-
Notifications
You must be signed in to change notification settings - Fork 6
ENG-2019: Extract shared cross-space node discovery into packages/database #1216
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
da4d214
b805ddf
6d2b17a
aba903d
3b91d1b
6dccc48
69fa231
0f0adf9
9f7f93e
2fa228c
d70f4d1
f35b7ca
a587350
d4071b4
9d2f8c6
d4d437d
9ab3603
7542d59
c4e68cd
74b6de3
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2,6 +2,7 @@ import type { Json } from "@repo/database/dbTypes"; | |
| import matter from "gray-matter"; | ||
| import { App, Notice, TFile } from "obsidian"; | ||
| import type { DGSupabaseClient } from "@repo/database/lib/client"; | ||
| import { listGroupSharedNodes } from "@repo/database/lib/sharedNodes"; | ||
| import type DiscourseGraphPlugin from "~/index"; | ||
| import { getLoggedInClient, getSupabaseContext } from "./supabaseContext"; | ||
| import type { DiscourseNode, ImportableNode } from "~/types"; | ||
|
|
@@ -40,84 +41,34 @@ export const getPublishedNodesForGroups = async ({ | |
| groupIds: string[]; | ||
| currentSpaceId: number; | ||
| }): Promise<Array<PublishedNode>> => { | ||
| if (groupIds.length === 0) { | ||
| return []; | ||
| } | ||
|
|
||
| // Query my_contents (RLS applied); exclude current space. Get both variants so we can use | ||
| // the latest last_modified per node and prefer "direct" for text (title). | ||
| const { data, error } = await client | ||
| .from("my_contents") | ||
| .select( | ||
| "source_local_id, space_id, text, created, last_modified, variant, metadata, author_id", | ||
| ) | ||
| .neq("space_id", currentSpaceId); | ||
|
|
||
| if (error) { | ||
| const candidates = await listGroupSharedNodes({ | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Obsidian swap — the PR's one behavior change lands here (see description). Same |
||
| client, | ||
| currentSpaceId, | ||
| groupIds, | ||
| }).catch((error: { message?: string }) => { | ||
| console.error("Error fetching published nodes:", error); | ||
| throw new Error(`Failed to fetch published nodes: ${error.message}`); | ||
| } | ||
|
|
||
| if (!data || data.length === 0) { | ||
| return []; | ||
| } | ||
|
|
||
| type Row = { | ||
| source_local_id: string | null; | ||
| space_id: number | null; | ||
| text: string | null; | ||
| created: string | null; | ||
| last_modified: string | null; | ||
| variant: string | null; | ||
| author_id: number | null; | ||
| metadata: Json; | ||
| }; | ||
|
|
||
| const key = (r: Row) => `${r.space_id ?? ""}\t${r.source_local_id ?? ""}`; | ||
| const groups = new Map<string, Row[]>(); | ||
| for (const row of data as Row[]) { | ||
| if (row.source_local_id == null || row.space_id == null) continue; | ||
| const k = key(row); | ||
| if (!groups.has(k)) groups.set(k, []); | ||
| groups.get(k)!.push(row); | ||
| } | ||
|
|
||
| const nodes: Array<PublishedNode> = []; | ||
| }); | ||
|
|
||
| for (const rows of groups.values()) { | ||
| const withDate = rows.filter( | ||
| (r) => r.last_modified != null && r.text != null, | ||
| ); | ||
| if (withDate.length === 0) continue; | ||
| const latest = withDate.reduce((a, b) => | ||
| (a.last_modified ?? "") >= (b.last_modified ?? "") ? a : b, | ||
| ); | ||
| const direct = rows.find((r) => r.variant === "direct"); | ||
| const text = direct?.text ?? latest.text ?? ""; | ||
| const createdAt = latest.created | ||
| ? new Date(latest.created + "Z").valueOf() | ||
| : 0; | ||
| const modifiedAt = latest.last_modified | ||
| ? new Date(latest.last_modified + "Z").valueOf() | ||
| : 0; | ||
| return candidates.map((candidate) => { | ||
| const metadata = candidate.directMetadata; | ||
| const filePath: string | undefined = | ||
| direct && | ||
| typeof direct.metadata === "object" && | ||
| typeof (direct.metadata as Record<string, any>).filePath === "string" | ||
| ? (direct.metadata as Record<string, any>).filePath | ||
| metadata !== null && | ||
| typeof metadata === "object" && | ||
| !Array.isArray(metadata) && | ||
| typeof metadata.filePath === "string" | ||
| ? metadata.filePath | ||
| : undefined; | ||
| nodes.push({ | ||
| source_local_id: latest.source_local_id!, | ||
| space_id: latest.space_id!, | ||
| text, | ||
| createdAt, | ||
| modifiedAt, | ||
| return { | ||
| source_local_id: candidate.sourceLocalId, | ||
| space_id: candidate.spaceId, | ||
| text: candidate.title, | ||
| createdAt: candidate.created ? new Date(candidate.created).valueOf() : 0, | ||
| modifiedAt: new Date(candidate.lastModified).valueOf(), | ||
| filePath, | ||
| authorId: latest.author_id ?? undefined, | ||
| }); | ||
| } | ||
|
|
||
| return nodes; | ||
| authorId: candidate.authorId, | ||
| }; | ||
| }); | ||
| }; | ||
|
|
||
| export const getLocalNodeInstanceIds = ( | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,179 +1,48 @@ | ||
| import { describe, expect, it } from "vitest"; | ||
| import { buildDiscoveredSharedNodes } from "~/utils/discoverSharedNodes"; | ||
|
|
||
| type BuildArgs = Parameters<typeof buildDiscoveredSharedNodes>[0]; | ||
|
|
||
| const resources: BuildArgs["resources"] = [ | ||
| { space_id: 20, source_local_id: "node-1" }, | ||
| { space_id: 20, source_local_id: "schema-1" }, | ||
| ]; | ||
| const spaces: BuildArgs["spaces"] = [ | ||
| { | ||
| id: 20, | ||
| name: "Research vault", | ||
| platform: "Obsidian", | ||
| url: "obsidian:vault-a", | ||
| }, | ||
| ]; | ||
| const concepts: BuildArgs["concepts"] = [ | ||
| { | ||
| is_schema: false, | ||
| last_modified: "2026-06-14T12:00:00", | ||
| schema_id: 200, | ||
| source_local_id: "node-1", | ||
| space_id: 20, | ||
| }, | ||
| ]; | ||
| const contents: BuildArgs["contents"] = [ | ||
| { | ||
| content_type: "text/plain", | ||
| last_modified: "2026-06-14T13:00:00", | ||
| source_local_id: "node-1", | ||
| space_id: 20, | ||
| text: "EVD - REM sleep and recall", | ||
| variant: "direct", | ||
| }, | ||
| { | ||
| content_type: "text/markdown", | ||
| last_modified: "2026-06-14T15:00:00", | ||
| source_local_id: "node-1", | ||
| space_id: 20, | ||
| text: "# EVD - REM sleep and recall", | ||
| variant: "full", | ||
| }, | ||
| ]; | ||
| const sourceNodeRid = "orn:obsidian.note:vault-a/node-1"; | ||
|
|
||
| const build = ({ | ||
| conceptsOverride = concepts, | ||
| contentsOverride = contents, | ||
| currentSpaceId = 10, | ||
| importedSourceRids = new Set<string>(), | ||
| resourcesOverride = resources, | ||
| spacesOverride = spaces, | ||
| }: { | ||
| conceptsOverride?: typeof concepts; | ||
| contentsOverride?: typeof contents; | ||
| currentSpaceId?: number; | ||
| importedSourceRids?: ReadonlySet<string>; | ||
| resourcesOverride?: typeof resources; | ||
| spacesOverride?: typeof spaces; | ||
| } = {}) => | ||
| buildDiscoveredSharedNodes({ | ||
| concepts: conceptsOverride, | ||
| contents: contentsOverride, | ||
| currentSpaceId, | ||
| importedSourceRids, | ||
| resources: resourcesOverride, | ||
| spaces: spacesOverride, | ||
| }); | ||
|
|
||
| describe("buildDiscoveredSharedNodes", () => { | ||
| it("builds a group-shared contract node with stable source identity", () => { | ||
| expect(build({ importedSourceRids: new Set([sourceNodeRid]) })).toEqual([ | ||
| import { toDiscoveredSharedNodes } from "~/utils/discoverSharedNodes"; | ||
| import type { SharedNodeCandidate } from "@repo/database/lib/sharedNodes"; | ||
|
|
||
| const candidate: SharedNodeCandidate = { | ||
| rid: "orn:obsidian.note:vault-a/node-1", | ||
| sourceLocalId: "node-1", | ||
| spaceId: 20, | ||
| spaceName: "Research vault", | ||
| spaceUri: "obsidian:vault-a", | ||
| platform: "Obsidian", | ||
| title: "EVD - REM sleep and recall", | ||
| created: "2026-06-14T12:30:00.000Z", | ||
| lastModified: "2026-06-14T15:00:00.000Z", | ||
| authorId: 7, | ||
| directMetadata: null, | ||
| }; | ||
|
|
||
| describe("toDiscoveredSharedNodes", () => { | ||
| it("maps a candidate to the exact discovered shared node shape", () => { | ||
| expect( | ||
| toDiscoveredSharedNodes({ | ||
| candidates: [candidate], | ||
| importedSourceRids: new Set([candidate.rid]), | ||
| }), | ||
| ).toEqual([ | ||
| { | ||
| alreadyImported: true, | ||
| modifiedAt: "2026-06-14T15:00:00.000Z", | ||
| sourceApp: "Obsidian", | ||
| sourceNodeId: "node-1", | ||
| sourceNodeRid, | ||
| sourceNodeRid: "orn:obsidian.note:vault-a/node-1", | ||
| sourceSpaceId: "obsidian:vault-a", | ||
| sourceSpaceName: "Research vault", | ||
| title: "EVD - REM sleep and recall", | ||
| }, | ||
| ]); | ||
| }); | ||
|
|
||
| it("does not discover shared resources from the current space", () => { | ||
| expect(build({ currentSpaceId: 20 })).toEqual([]); | ||
| }); | ||
|
|
||
| it("requires the exact shared resource identity", () => { | ||
| expect( | ||
| build({ | ||
| resourcesOverride: [{ space_id: 21, source_local_id: "node-1" }], | ||
| }), | ||
| ).toEqual([]); | ||
| }); | ||
|
|
||
| it.each([ | ||
| { | ||
| name: "schema concept", | ||
| conceptsOverride: [{ ...concepts[0], is_schema: true }], | ||
| contentsOverride: contents, | ||
| }, | ||
| { | ||
| name: "missing node type", | ||
| conceptsOverride: [{ ...concepts[0], schema_id: null }], | ||
| contentsOverride: contents, | ||
| }, | ||
| { | ||
| name: "missing direct content", | ||
| conceptsOverride: concepts, | ||
| contentsOverride: [contents[1]], | ||
| }, | ||
| { | ||
| name: "missing full content", | ||
| conceptsOverride: concepts, | ||
| contentsOverride: [contents[0]], | ||
| }, | ||
| { | ||
| name: "untyped full content", | ||
| conceptsOverride: concepts, | ||
| contentsOverride: [contents[0], { ...contents[1], content_type: null }], | ||
| }, | ||
| ])("filters a node with $name", ({ conceptsOverride, contentsOverride }) => { | ||
| expect(build({ conceptsOverride, contentsOverride })).toEqual([]); | ||
| }); | ||
|
|
||
| it("matches imports by RID rather than source-local ID alone", () => { | ||
| expect( | ||
| build({ | ||
| toDiscoveredSharedNodes({ | ||
| candidates: [candidate], | ||
| importedSourceRids: new Set(["orn:obsidian.note:another-vault/node-1"]), | ||
| })[0]?.alreadyImported, | ||
| ).toBe(false); | ||
| }); | ||
|
|
||
| it.each([ | ||
| "orn:obsidian.note:vault-a/node-1", | ||
| "https://example.com/shared/node-1", | ||
| ])("preserves a RID-shaped source-local ID: %s", (rid) => { | ||
| expect( | ||
| build({ | ||
| conceptsOverride: [{ ...concepts[0], source_local_id: rid }], | ||
| contentsOverride: contents.map((content) => ({ | ||
| ...content, | ||
| source_local_id: rid, | ||
| })), | ||
| resourcesOverride: [{ space_id: 20, source_local_id: rid }], | ||
| })[0]?.sourceNodeRid, | ||
| ).toBe(rid); | ||
| }); | ||
|
|
||
| it("sorts newest nodes first", () => { | ||
| const olderConcept = { | ||
| ...concepts[0], | ||
| last_modified: "2026-06-10T12:00:00", | ||
| source_local_id: "node-2", | ||
| }; | ||
| const olderContents = contents.map((content) => ({ | ||
| ...content, | ||
| last_modified: "2026-06-10T12:00:00", | ||
| source_local_id: "node-2", | ||
| text: | ||
| content.variant === "direct" | ||
| ? "Older shared node" | ||
| : "# Older shared node", | ||
| })); | ||
| expect( | ||
| build({ | ||
| conceptsOverride: [olderConcept, concepts[0]], | ||
| contentsOverride: [...olderContents, ...contents], | ||
| resourcesOverride: [ | ||
| ...resources, | ||
| { space_id: 20, source_local_id: "node-2" }, | ||
| ], | ||
| }).map((node) => node.sourceNodeId), | ||
| ).toEqual(["node-1", "node-2"]); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Not moved — replaced. This query's semantics (any cross-space row, RLS-only, unpaginated) are superseded by the shared module's gated listing; the variant-grouping/direct-preference logic it did by hand is what
buildSharedNodeCandidatesdoes behind the gate.