Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
da4d214
[ENG-1855] Add Roam shared-node import discovery
sid597 Jul 10, 2026
b805ddf
[ENG-1855] Keep discovery dialog open
sid597 Jul 10, 2026
6d2b17a
[ENG-1855] Fix discovery dialog dismissing on keyboard launch
sid597 Jul 10, 2026
aba903d
[ENG-1855] Use getAllPages for ResourceAccess pagination
sid597 Jul 10, 2026
3b91d1b
[ENG-2019] Add shared cross-space node discovery module to packages/d…
sid597 Jul 10, 2026
6dccc48
[ENG-2019] Swap Roam shared-node discovery onto the shared module
sid597 Jul 10, 2026
69fa231
[ENG-2019] Swap Obsidian import listing onto the shared module
sid597 Jul 10, 2026
0f0adf9
[ENG-2019] Narrow Json metadata without any-casts in Obsidian mapper
sid597 Jul 10, 2026
9f7f93e
Avoid racing Supabase context initialization
sid597 Jul 12, 2026
2fa228c
Merge remote-tracking branch 'origin/main' into eng-1855-add-roam-sha…
sid597 Jul 15, 2026
d70f4d1
[ENG-1855] Address shared-node discovery review feedback
sid597 Jul 15, 2026
f35b7ca
[ENG-1855] Match UTC parsing with Obsidian
sid597 Jul 15, 2026
a587350
[ENG-1855] Defer shared discovery refactor to ENG-2019
sid597 Jul 15, 2026
d4071b4
[ENG-1855] Exclude relations from shared-node discovery
sid597 Jul 16, 2026
9d2f8c6
Merge remote-tracking branch 'origin/eng-1855-add-roam-shared-node-im…
sid597 Jul 16, 2026
d4d437d
[ENG-2019] Use the Platform enum for shared discovery types
sid597 Jul 16, 2026
9ab3603
[ENG-2019] Stop fetching full text during shared-node discovery
sid597 Jul 16, 2026
7542d59
[ENG-2019] Filter current space and order uniquely in the ResourceAcc…
sid597 Jul 16, 2026
c4e68cd
[ENG-2019] Alias the Platform enum locally to match existing usage
sid597 Jul 16, 2026
74b6de3
Merge remote-tracking branch 'origin/main' into eng-2019-extract-shar…
sid597 Jul 16, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ jobs:
- name: Run Tests
run: npx turbo run test --filter=roam --ui stream

- name: Run Package Unit Tests
run: npx turbo run test:unit --ui stream

lint-changed-files:
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
Expand Down
95 changes: 23 additions & 72 deletions apps/obsidian/src/utils/importNodes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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

Copy link
Copy Markdown
Collaborator Author

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 buildSharedNodeCandidates does behind the gate.

.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({

@sid597 sid597 Jul 10, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The 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 PublishedNode shape out. The old + "Z" conversions are gone on purpose: the shared module now returns canonical toISOString() values for created/lastModified, so appending another Z would produce NaN. Two hardening deltas vs the old code: null-metadata guard (old code threw on metadata: null) and re-wrapping PostgrestError so the modal Notice shows a real message.

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 = (
Expand Down
187 changes: 28 additions & 159 deletions apps/roam/src/utils/__tests__/discoverSharedNodes.test.ts
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"]);
});
});
Loading