Skip to content

Commit 9e93709

Browse files
committed
eng-1865 sync and publish relations related to published nodes, WIP
1 parent 83cd593 commit 9e93709

3 files changed

Lines changed: 364 additions & 0 deletions

File tree

apps/roam/src/utils/createReifiedBlock.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,36 @@ export const countReifiedRelations = async (): Promise<number> => {
102102
return (r[0] || [0])[0] as number;
103103
};
104104

105+
export type ReifiedRelationData = {
106+
sourceUid: string;
107+
destinationUid: string;
108+
hasSchema: string;
109+
importedFromRid?: string;
110+
};
111+
112+
export type ReifiedRelationDataWithRelId = ReifiedRelationData & {
113+
relationId: string;
114+
};
115+
116+
export const getReifiedRelations = async (): Promise<
117+
ReifiedRelationDataWithRelId[]
118+
> => {
119+
const pageUid = getExistingRelationPageUid();
120+
if (pageUid === undefined) return [];
121+
const r = await window.roamAlphaAPI.data.async.q(
122+
`[:find ?ruid ?rdata :where
123+
[?p :block/uid "${pageUid}"]
124+
[?p :block/children ?c]
125+
[?c :block/uid ?ruid]
126+
[?c :block/props ?pr]
127+
[(get ?pr :${DISCOURSE_GRAPH_PROP_NAME}) ?rdata] ]`,
128+
);
129+
return r.map((x) => ({
130+
relationId: x[0] as string,
131+
...(x[1] as ReifiedRelationData),
132+
}));
133+
};
134+
105135
export const createReifiedRelation = async ({
106136
sourceUid,
107137
relationBlockUid,

apps/roam/src/utils/publishNodesToGroups.ts

Lines changed: 328 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,22 @@
11
import type { DGSupabaseClient } from "@repo/database/lib/client";
22
import { getAvailableGroupIds } from "@repo/database/lib/groups";
3+
import getDiscourseRelations from "./getDiscourseRelations";
4+
import getDiscourseNodes from "./getDiscourseNodes";
5+
import {
6+
getReifiedRelations,
7+
type ReifiedRelationDataWithRelId,
8+
} from "./createReifiedBlock";
9+
import type {
10+
CrossAppNodeSchema,
11+
CrossAppRelation,
12+
CrossAppRelationTripleSchema,
13+
} from "@repo/database/crossAppContracts";
14+
import type { DiscourseRelation } from "./getDiscourseRelations";
15+
import {
16+
crossAppRelationToDbConcept,
17+
crossAppRelationTripleSchemaToDbConcept,
18+
} from "@repo/database/lib/crossAppConverters";
19+
import { spaceUriAndLocalIdToRid } from "@repo/database/lib/rid";
320

421
export type PublishNode = {
522
uid: string;
@@ -13,6 +30,311 @@ type PublishNodesResult = {
1330
failedGroupIds: string[];
1431
};
1532

33+
const getAllPublishedIds = async (
34+
client: DGSupabaseClient,
35+
spaceId: number,
36+
groupId: string,
37+
): Promise<string[]> => {
38+
const response = await client
39+
.from("ResourceAccess")
40+
.select("source_local_id")
41+
.eq("space_id", spaceId)
42+
.eq("account_uid", groupId);
43+
if (response.error) throw response.error;
44+
return response.data.map((r) => r.source_local_id);
45+
};
46+
47+
const getGroupSpaceIdAndUrls = async (
48+
client: DGSupabaseClient,
49+
groupId: string,
50+
): Promise<Record<number, string>> => {
51+
const response = await client
52+
.from("SpaceAccess")
53+
.select("space_id")
54+
.eq("account_uid", groupId);
55+
if (response.error) throw response.error;
56+
const spaceIds = response.data.map((r) => r.space_id);
57+
const response2 = await client
58+
.from("Space")
59+
.select("id, url")
60+
.in("id", spaceIds);
61+
if (response2.error) throw response2.error;
62+
return Object.fromEntries(response2.data.map(({ id, url }) => [id, url]));
63+
};
64+
65+
// Use readImportedSourceIdentity when it's integrated.
66+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
67+
const getSpaceIdOf = (nodeId: string): number | undefined => undefined;
68+
69+
const getSyncedRelationLocalIds = async (
70+
client: DGSupabaseClient,
71+
spaceId: number,
72+
): Promise<string[]> => {
73+
const response = await client
74+
.from("my_concepts")
75+
.select("source_local_id")
76+
.eq("space_id", spaceId)
77+
.eq("arity", 2); // intentionally include both schemas and instances
78+
if (response.error) throw response.error;
79+
return response.data
80+
.map((r) => r.source_local_id)
81+
.filter((id) => id !== null);
82+
};
83+
84+
const ensureRelationsSynced = async ({
85+
client,
86+
spaceId,
87+
relations,
88+
existing,
89+
isImportedFrom,
90+
spaceUids,
91+
}: {
92+
client: DGSupabaseClient;
93+
spaceId: number;
94+
relations: ReifiedRelationDataWithRelId[];
95+
existing: Set<string>;
96+
isImportedFrom: (id: string) => number | undefined;
97+
spaceUids: Record<number, string>;
98+
}): Promise<void> => {
99+
const currentSpaceUri = spaceUids[spaceId];
100+
const missing = relations.filter((r) => !existing.has(r.relationId));
101+
const asCrossApp: CrossAppRelation[] = missing
102+
.map((r) => {
103+
const sourceSpaceUid = spaceUids[isImportedFrom(r.sourceUid) || 0];
104+
const destinationSpaceUid =
105+
spaceUids[isImportedFrom(r.destinationUid) || 0];
106+
const sourceId =
107+
sourceSpaceUid === undefined
108+
? r.sourceUid
109+
: spaceUriAndLocalIdToRid(sourceSpaceUid, r.sourceUid);
110+
const destinationId =
111+
destinationSpaceUid === undefined
112+
? r.destinationUid
113+
: spaceUriAndLocalIdToRid(destinationSpaceUid, r.destinationUid);
114+
const relData = window.roamAlphaAPI.pull(
115+
"[:create/time :edit/time {:create/user [:user/uid]}]",
116+
`[:block/uid "${r.relationId}"]`,
117+
) as unknown as {
118+
":create/time": number;
119+
":edit/time": number;
120+
":create/user": { ":user/uid": string };
121+
};
122+
if (relData == undefined) return null;
123+
const userUid = relData[":create/user"][":user/uid"];
124+
125+
return {
126+
localId: r.relationId,
127+
relationType: { localId: r.hasSchema },
128+
source: { localId: sourceId },
129+
destination: { localId: destinationId },
130+
author: { localId: userUid },
131+
createdAt: new Date(relData[":create/time"]),
132+
modifiedAt: new Date(relData[":edit/time"]),
133+
};
134+
})
135+
.filter((s) => s !== null);
136+
if (asCrossApp.length == 0) return;
137+
const asDbData = asCrossApp.map((r) =>
138+
crossAppRelationToDbConcept(r, currentSpaceUri),
139+
);
140+
const response = await client.rpc("upsert_concepts", {
141+
v_space_id: spaceId,
142+
data: asDbData,
143+
});
144+
if (response.error) throw response.error;
145+
relations.map((r) => {
146+
existing.add(r.relationId);
147+
});
148+
};
149+
150+
const ensureRelationSchemasSynced = async ({
151+
client,
152+
spaceId,
153+
relationSchemas,
154+
existing,
155+
nodeSchemaById,
156+
}: {
157+
client: DGSupabaseClient;
158+
spaceId: number;
159+
relationSchemas: DiscourseRelation[];
160+
existing: Set<string>;
161+
nodeSchemaById: Record<string, CrossAppNodeSchema>;
162+
}): Promise<void> => {
163+
const missing = relationSchemas.filter((r) => !existing.has(r.id));
164+
const asCrossApp: CrossAppRelationTripleSchema[] = missing
165+
.map((r) => {
166+
const relData = window.roamAlphaAPI.pull(
167+
"[:create/time :edit/time {:create/user [:user/uid]}]",
168+
`[:block/uid "${r.id}"]`,
169+
) as unknown as {
170+
":create/time": number;
171+
":edit/time": number;
172+
":create/user": { ":user/uid": string };
173+
};
174+
if (!relData) return null;
175+
const userUid = relData[":create/user"][":user/uid"];
176+
177+
return {
178+
localId: r.id,
179+
sourceType: { localId: r.source },
180+
destinationType: { localId: r.destination },
181+
label: r.label,
182+
complement: r.complement,
183+
author: { localId: userUid },
184+
createdAt: new Date(relData[":create/time"]),
185+
modifiedAt: new Date(relData[":edit/time"]),
186+
};
187+
})
188+
.filter((x) => x !== null);
189+
if (asCrossApp.length == 0) return;
190+
const asDbData = asCrossApp
191+
.map((r) => {
192+
const sourceNodeSchema =
193+
"localId" in r.sourceType
194+
? nodeSchemaById[r.sourceType.localId]
195+
: undefined;
196+
const destinationNodeSchema =
197+
"localId" in r.destinationType
198+
? nodeSchemaById[r.destinationType.localId]
199+
: undefined;
200+
return sourceNodeSchema !== undefined &&
201+
destinationNodeSchema !== undefined
202+
? crossAppRelationTripleSchemaToDbConcept({
203+
node: r,
204+
sourceNodeSchema,
205+
destinationNodeSchema,
206+
})
207+
: undefined;
208+
})
209+
.filter((d) => d !== undefined);
210+
const response = await client.rpc("upsert_concepts", {
211+
v_space_id: spaceId,
212+
data: asDbData,
213+
});
214+
if (response.error) throw response.error;
215+
asDbData.map((d) => {
216+
if (d.source_local_id) existing.add(d.source_local_id);
217+
});
218+
};
219+
220+
const getCrossAppNodeSchemaById = (): Record<string, CrossAppNodeSchema> => {
221+
const allNodeSchemas = getDiscourseNodes();
222+
const result: CrossAppNodeSchema[] = allNodeSchemas
223+
.map((s) => {
224+
const relData = window.roamAlphaAPI.pull(
225+
"[:create/time :edit/time {:create/user [:user/uid]}]",
226+
`[:block/uid "${s.type}"]`,
227+
) as unknown as {
228+
":create/time": number;
229+
":edit/time": number;
230+
":create/user": { ":user/uid": string };
231+
};
232+
if (!relData) return null;
233+
const userUid = relData[":create/user"][":user/uid"];
234+
return {
235+
localId: s.type,
236+
label: s.text,
237+
author: { localId: userUid },
238+
createdAt: new Date(relData[":create/time"]),
239+
};
240+
})
241+
.filter((s) => s !== null);
242+
return Object.fromEntries(result.map((r) => [r.localId, r]));
243+
};
244+
245+
export const publishCorrespondingRelations = async ({
246+
client,
247+
spaceId,
248+
groupIds,
249+
forNodeIds,
250+
}: {
251+
client: DGSupabaseClient;
252+
spaceId: number;
253+
groupIds: string[];
254+
forNodeIds?: Set<string>;
255+
}): Promise<string[] | null> => {
256+
const allRelationsSchemas = getDiscourseRelations();
257+
const nodeSchemaById = getCrossAppNodeSchemaById();
258+
// Should we even handle non-reified relations?
259+
// I need a way to know if a relation is imported.
260+
const allRelations = await getReifiedRelations();
261+
const failedGroupIds: string[] = [];
262+
const sourceIdOfNodes: Record<string, number> = {};
263+
const syncedRelationIds = new Set(
264+
await getSyncedRelationLocalIds(client, spaceId),
265+
);
266+
const isImportedFrom = (nodeLocalId: string): number => {
267+
let cached = sourceIdOfNodes[nodeLocalId];
268+
if (cached === undefined) {
269+
cached = sourceIdOfNodes[nodeLocalId] =
270+
getSpaceIdOf(nodeLocalId) || spaceId;
271+
}
272+
return cached === spaceId ? 0 : cached;
273+
};
274+
const relations =
275+
forNodeIds !== undefined
276+
? allRelations.filter(
277+
(r) =>
278+
r.importedFromRid === undefined &&
279+
(forNodeIds.has(r.sourceUid) || forNodeIds.has(r.destinationUid)),
280+
)
281+
: allRelations.filter((r) => r.importedFromRid === undefined);
282+
for (const groupId of groupIds) {
283+
const groupSpaces = await getGroupSpaceIdAndUrls(client, groupId);
284+
const publishedIds = new Set(
285+
await getAllPublishedIds(client, spaceId, groupId),
286+
);
287+
const relsBetweenPublished = relations.filter(
288+
(r) =>
289+
(publishedIds.has(r.sourceUid) ||
290+
(isImportedFrom(r.sourceUid) || 0) in groupSpaces) &&
291+
(publishedIds.has(r.destinationUid) ||
292+
(isImportedFrom(r.destinationUid) || 0) in groupSpaces),
293+
);
294+
await ensureRelationsSynced({
295+
client,
296+
spaceId,
297+
relations: relsBetweenPublished,
298+
existing: syncedRelationIds,
299+
isImportedFrom,
300+
spaceUids: groupSpaces,
301+
});
302+
const relationSchemaIds = new Set(
303+
relsBetweenPublished.map((r) => r.hasSchema),
304+
);
305+
const relationSchemaTriples = allRelationsSchemas.filter((r) =>
306+
relationSchemaIds.has(r.id),
307+
);
308+
await ensureRelationSchemasSynced({
309+
client,
310+
spaceId,
311+
relationSchemas: relationSchemaTriples,
312+
existing: syncedRelationIds,
313+
nodeSchemaById,
314+
});
315+
const missingIds = [
316+
...relsBetweenPublished
317+
.map((r) => r.relationId)
318+
.filter((id) => !publishedIds.has(id)),
319+
...relationSchemaTriples
320+
.map((r) => r.id)
321+
.filter((id) => !publishedIds.has(id)),
322+
];
323+
const grantRes = await client.from("ResourceAccess").upsert(
324+
missingIds.map((sourceLocalId) => ({
325+
account_uid: groupId,
326+
source_local_id: sourceLocalId,
327+
space_id: spaceId,
328+
})),
329+
{ ignoreDuplicates: true },
330+
);
331+
if (!isIgnorableUpsertError(grantRes.error)) {
332+
failedGroupIds.push(groupId);
333+
}
334+
}
335+
return failedGroupIds.length ? failedGroupIds : null;
336+
};
337+
16338
// 23505 = unique_violation: the grant already exists, which counts as success.
17339
const isIgnorableUpsertError = (error: { code?: string } | null): boolean =>
18340
!error || error.code === "23505";
@@ -119,6 +441,12 @@ export const publishNodesToGroups = async ({
119441
continue;
120442
}
121443

444+
await publishCorrespondingRelations({
445+
client,
446+
spaceId,
447+
groupIds: targetGroupIds,
448+
forNodeIds: new Set(syncedNodeUids),
449+
});
122450
result.okGroupIds.push(groupId);
123451
}
124452

packages/database/src/lib/crossAppConverters.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -291,8 +291,14 @@ export const crossAppRelationToDbConcept = (
291291
) as Record<string, number>;
292292

293293
return filterUndefined<LocalConceptDataInput>({
294+
name: node.localId,
294295
...decodeLocalRef(node, "source_local_id"),
295296
...decodeRef(node.author, "author_id", "author_local_id"),
297+
...decodeRef(
298+
node.relationType,
299+
"schema_id",
300+
"schema_represented_by_local_id",
301+
),
296302
local_reference_content: refLocalIds,
297303
reference_content: refIds,
298304
created: node.createdAt?.toISOString(),

0 commit comments

Comments
 (0)