Skip to content

Commit 39dc8cb

Browse files
committed
[ENG-1856] Store source identity metadata for Roam imported nodes
1 parent 0f0adf9 commit 39dc8cb

3 files changed

Lines changed: 234 additions & 16 deletions

File tree

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
import { beforeEach, describe, expect, it, vi } from "vitest";
2+
import { DISCOURSE_GRAPH_PROP_NAME } from "~/utils/createReifiedBlock";
3+
import {
4+
findImportedNodeUidBySourceRid,
5+
getImportedSourceRids,
6+
IMPORTED_FROM_PROP_KEY,
7+
parseImportedSourceIdentity,
8+
readImportedSourceIdentity,
9+
writeImportedSourceIdentity,
10+
} from "~/utils/importedSourceIdentity";
11+
import type { json } from "~/utils/getBlockProps";
12+
13+
const SOURCE_NODE_RID = "orn:obsidian.note:vault-a/node-1";
14+
const SOURCE_MODIFIED_AT = "2026-06-14T15:00:00.000Z";
15+
const PAGE_UID = "page-uid";
16+
17+
const propsByUid = new Map<string, Record<string, json>>();
18+
const query = vi.fn();
19+
20+
const setRoamAlphaApi = (): void => {
21+
(globalThis as { window: unknown }).window = {
22+
roamAlphaAPI: {
23+
data: {
24+
async: { q: query },
25+
block: {
26+
update: vi.fn(
27+
({
28+
block,
29+
}: {
30+
block: { props: Record<string, json>; uid: string };
31+
}) => {
32+
propsByUid.set(block.uid, block.props);
33+
},
34+
),
35+
},
36+
},
37+
pull: (_pattern: string, [, uid]: [string, string]) => ({
38+
":block/props": propsByUid.get(uid) ?? {},
39+
}),
40+
},
41+
};
42+
};
43+
44+
beforeEach(() => {
45+
propsByUid.clear();
46+
query.mockReset();
47+
setRoamAlphaApi();
48+
});
49+
50+
describe("imported source identity metadata", () => {
51+
it("reads the source RID without depending on display metadata", () => {
52+
const props = {
53+
[DISCOURSE_GRAPH_PROP_NAME]: {
54+
[IMPORTED_FROM_PROP_KEY]: {
55+
sourceModifiedAt: SOURCE_MODIFIED_AT,
56+
sourceNodeRid: SOURCE_NODE_RID,
57+
sourceTitle: "Legacy title that may change",
58+
},
59+
},
60+
};
61+
62+
expect(parseImportedSourceIdentity(props)).toEqual({
63+
sourceModifiedAt: SOURCE_MODIFIED_AT,
64+
sourceNodeRid: SOURCE_NODE_RID,
65+
});
66+
});
67+
68+
it("returns undefined for missing or malformed source identity", () => {
69+
expect(parseImportedSourceIdentity({})).toBeUndefined();
70+
expect(
71+
parseImportedSourceIdentity({
72+
[DISCOURSE_GRAPH_PROP_NAME]: {
73+
[IMPORTED_FROM_PROP_KEY]: { sourceNodeRid: 123 },
74+
},
75+
}),
76+
).toBeUndefined();
77+
});
78+
79+
it("writes the source RID and modified time while preserving sibling metadata", () => {
80+
propsByUid.set(PAGE_UID, {
81+
[DISCOURSE_GRAPH_PROP_NAME]: {
82+
"relation-migration": { relationUid: 1718000000000 },
83+
},
84+
"other-extension": { enabled: true },
85+
});
86+
87+
writeImportedSourceIdentity({
88+
pageUid: PAGE_UID,
89+
sourceModifiedAt: SOURCE_MODIFIED_AT,
90+
sourceNodeRid: SOURCE_NODE_RID,
91+
});
92+
93+
expect(readImportedSourceIdentity(PAGE_UID)).toEqual({
94+
sourceModifiedAt: SOURCE_MODIFIED_AT,
95+
sourceNodeRid: SOURCE_NODE_RID,
96+
});
97+
expect(propsByUid.get(PAGE_UID)).toEqual({
98+
[DISCOURSE_GRAPH_PROP_NAME]: {
99+
"relation-migration": { relationUid: 1718000000000 },
100+
[IMPORTED_FROM_PROP_KEY]: {
101+
sourceModifiedAt: SOURCE_MODIFIED_AT,
102+
sourceNodeRid: SOURCE_NODE_RID,
103+
},
104+
},
105+
"other-extension": { enabled: true },
106+
});
107+
});
108+
});
109+
110+
describe("imported source identity lookup", () => {
111+
it("returns the stored RID set used for duplicate prevention", async () => {
112+
query.mockResolvedValue([SOURCE_NODE_RID, 123, null]);
113+
114+
await expect(getImportedSourceRids()).resolves.toEqual(
115+
new Set([SOURCE_NODE_RID]),
116+
);
117+
expect(query).toHaveBeenCalledOnce();
118+
expect(query.mock.calls[0]?.[0]).toContain(":sourceNodeRid");
119+
});
120+
121+
it("finds the imported Roam page by source RID", async () => {
122+
query.mockResolvedValue([[PAGE_UID]]);
123+
124+
await expect(findImportedNodeUidBySourceRid(SOURCE_NODE_RID)).resolves.toBe(
125+
PAGE_UID,
126+
);
127+
expect(query).toHaveBeenCalledWith(
128+
expect.stringContaining(":sourceNodeRid"),
129+
SOURCE_NODE_RID,
130+
);
131+
});
132+
133+
it("returns null when the source RID has not been imported", async () => {
134+
query.mockResolvedValue([]);
135+
136+
await expect(
137+
findImportedNodeUidBySourceRid(SOURCE_NODE_RID),
138+
).resolves.toBeNull();
139+
});
140+
});

apps/roam/src/utils/discoverSharedNodes.ts

Lines changed: 1 addition & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,7 @@ import {
33
listGroupSharedNodes,
44
type SharedNodeCandidate,
55
} from "@repo/database/lib/sharedNodes";
6-
import { DISCOURSE_GRAPH_PROP_NAME } from "./createReifiedBlock";
7-
8-
const IMPORTED_FROM_PROP_KEY = "importedFrom";
6+
import { getImportedSourceRids } from "./importedSourceIdentity";
97

108
export type DiscoveredSharedNode = {
119
alreadyImported: boolean;
@@ -36,19 +34,6 @@ export const toDiscoveredSharedNodes = ({
3634
title: candidate.title,
3735
}));
3836

39-
const getImportedSourceRids = async (): Promise<Set<string>> => {
40-
const query = `[:find [?rid ...]
41-
:where
42-
[?page :block/props ?props]
43-
[(get ?props :${DISCOURSE_GRAPH_PROP_NAME}) ?dgData]
44-
[(get ?dgData :${IMPORTED_FROM_PROP_KEY}) ?imported]
45-
[(get ?imported :sourceNodeRid) ?rid]]`;
46-
const result = (await window.roamAlphaAPI.data.async.q(query)) as unknown[];
47-
return new Set(
48-
result.filter((rid): rid is string => typeof rid === "string"),
49-
);
50-
};
51-
5237
export const discoverSharedNodes = async ({
5338
client,
5439
currentSpaceId,
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import { DISCOURSE_GRAPH_PROP_NAME } from "./createReifiedBlock";
2+
import getBlockProps, { type json } from "./getBlockProps";
3+
import setBlockProps from "./setBlockProps";
4+
5+
export type ImportedSourceIdentity = {
6+
sourceModifiedAt: string;
7+
sourceNodeRid: string;
8+
};
9+
10+
export const IMPORTED_FROM_PROP_KEY = "importedFrom";
11+
12+
const isJsonObject = (value: json): value is Record<string, json> =>
13+
typeof value === "object" && value !== null && !Array.isArray(value);
14+
15+
export const parseImportedSourceIdentity = (
16+
props: Record<string, json>,
17+
): ImportedSourceIdentity | undefined => {
18+
const discourseGraphProps = props[DISCOURSE_GRAPH_PROP_NAME];
19+
if (!isJsonObject(discourseGraphProps)) return undefined;
20+
21+
const importedFrom = discourseGraphProps[IMPORTED_FROM_PROP_KEY];
22+
if (!isJsonObject(importedFrom)) return undefined;
23+
24+
const { sourceModifiedAt, sourceNodeRid } = importedFrom;
25+
if (typeof sourceModifiedAt !== "string" || typeof sourceNodeRid !== "string")
26+
return undefined;
27+
28+
return { sourceModifiedAt, sourceNodeRid };
29+
};
30+
31+
export const readImportedSourceIdentity = (
32+
pageUid: string,
33+
): ImportedSourceIdentity | undefined =>
34+
parseImportedSourceIdentity(getBlockProps(pageUid));
35+
36+
export const writeImportedSourceIdentity = ({
37+
pageUid,
38+
sourceModifiedAt,
39+
sourceNodeRid,
40+
}: {
41+
pageUid: string;
42+
sourceModifiedAt: string;
43+
sourceNodeRid: string;
44+
}): void => {
45+
const existing = getBlockProps(pageUid)[DISCOURSE_GRAPH_PROP_NAME];
46+
const discourseGraphProps = isJsonObject(existing) ? existing : {};
47+
48+
setBlockProps(pageUid, {
49+
[DISCOURSE_GRAPH_PROP_NAME]: {
50+
...discourseGraphProps,
51+
[IMPORTED_FROM_PROP_KEY]: { sourceModifiedAt, sourceNodeRid },
52+
},
53+
});
54+
};
55+
56+
export const getImportedSourceRids = async (): Promise<Set<string>> => {
57+
const query = `[:find [?rid ...]
58+
:where
59+
[?page :block/props ?props]
60+
[(get ?props :${DISCOURSE_GRAPH_PROP_NAME}) ?dgData]
61+
[(get ?dgData :${IMPORTED_FROM_PROP_KEY}) ?importedFrom]
62+
[(get ?importedFrom :sourceNodeRid) ?rid]]`;
63+
const result = (await window.roamAlphaAPI.data.async.q(query)) as unknown[];
64+
65+
return new Set(
66+
result.filter((rid): rid is string => typeof rid === "string"),
67+
);
68+
};
69+
70+
export const findImportedNodeUidBySourceRid = async (
71+
sourceNodeRid: string,
72+
): Promise<string | null> => {
73+
const query = `[:find ?uid
74+
:in $ ?sourceNodeRid
75+
:where
76+
[?page :block/uid ?uid]
77+
[?page :block/props ?props]
78+
[(get ?props :${DISCOURSE_GRAPH_PROP_NAME}) ?dgData]
79+
[(get ?dgData :${IMPORTED_FROM_PROP_KEY}) ?importedFrom]
80+
[(get ?importedFrom :sourceNodeRid) ?sourceNodeRid]]`;
81+
const result = (await window.roamAlphaAPI.data.async.q(
82+
query,
83+
sourceNodeRid,
84+
)) as [string][];
85+
86+
if (result.length > 1) {
87+
console.warn(
88+
`findImportedNodeUidBySourceRid: ${result.length} pages share source RID '${sourceNodeRid}'`,
89+
);
90+
}
91+
92+
return result[0]?.[0] ?? null;
93+
};

0 commit comments

Comments
 (0)