Skip to content

Commit 0bb9a97

Browse files
committed
eng-1865 sync and publish relations related to published nodes, WIP
1 parent 180590b commit 0bb9a97

3 files changed

Lines changed: 357 additions & 1 deletion

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: 326 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,309 @@ 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 missing = relations.filter((r) => !existing.has(r.relationId));
100+
const asCrossApp: CrossAppRelation[] = missing
101+
.map((r) => {
102+
const sourceSpaceUid = spaceUids[isImportedFrom(r.sourceUid) || 0];
103+
const destinationSpaceUid =
104+
spaceUids[isImportedFrom(r.destinationUid) || 0];
105+
const sourceId =
106+
sourceSpaceUid === undefined
107+
? r.sourceUid
108+
: spaceUriAndLocalIdToRid(sourceSpaceUid, r.sourceUid);
109+
const destinationId =
110+
destinationSpaceUid === undefined
111+
? r.destinationUid
112+
: spaceUriAndLocalIdToRid(destinationSpaceUid, r.destinationUid);
113+
const relData = window.roamAlphaAPI.pull(
114+
"[:create/time :edit/time {:create/user [:user/uid]}]",
115+
`[:block/uid "${r.relationId}"]`,
116+
) as unknown as {
117+
":create/time": number;
118+
":edit/time": number;
119+
":create/user": { ":user/uid": string };
120+
};
121+
if (relData == undefined) return null;
122+
const userUid = relData[":create/user"][":user/uid"];
123+
124+
return {
125+
localId: r.relationId,
126+
relationType: r.hasSchema,
127+
source: sourceId,
128+
destination: destinationId,
129+
authorId: userUid,
130+
createdAt: new Date(relData[":create/time"]),
131+
modifiedAt: new Date(relData[":edit/time"]),
132+
};
133+
})
134+
.filter((s) => s !== null);
135+
if (asCrossApp.length == 0) return;
136+
const asDbData = asCrossApp.map((r) => crossAppRelationToDbConcept(r));
137+
const response = await client.rpc("upsert_concepts", {
138+
v_space_id: spaceId,
139+
data: asDbData,
140+
});
141+
if (response.error) throw response.error;
142+
asCrossApp.forEach((r) => {
143+
existing.add(r.localId);
144+
});
145+
};
146+
147+
const ensureRelationSchemasSynced = async ({
148+
client,
149+
spaceId,
150+
relationSchemas,
151+
existing,
152+
nodeSchemaById,
153+
}: {
154+
client: DGSupabaseClient;
155+
spaceId: number;
156+
relationSchemas: DiscourseRelation[];
157+
existing: Set<string>;
158+
nodeSchemaById: Record<string, CrossAppNodeSchema>;
159+
}): Promise<void> => {
160+
const missing = relationSchemas.filter((r) => !existing.has(r.id));
161+
const asCrossApp: CrossAppRelationTripleSchema[] = missing
162+
.map((r) => {
163+
const relData = window.roamAlphaAPI.pull(
164+
"[:create/time :edit/time {:create/user [:user/uid]}]",
165+
`[:block/uid "${r.id}"]`,
166+
) as unknown as {
167+
":create/time": number;
168+
":edit/time": number;
169+
":create/user": { ":user/uid": string };
170+
};
171+
if (!relData) return null;
172+
const userUid = relData[":create/user"][":user/uid"];
173+
174+
return {
175+
localId: r.id,
176+
sourceType: r.source,
177+
destinationType: r.destination,
178+
label: r.label,
179+
complement: r.complement,
180+
authorId: userUid,
181+
createdAt: new Date(relData[":create/time"]),
182+
modifiedAt: new Date(relData[":edit/time"]),
183+
};
184+
})
185+
.filter((x) => x !== null);
186+
if (asCrossApp.length == 0) return;
187+
const asDbData = asCrossApp
188+
.map((r) => {
189+
const sourceSchema = nodeSchemaById[r.sourceType];
190+
const destinationSchema = nodeSchemaById[r.destinationType];
191+
if (!sourceSchema || !destinationSchema) return undefined;
192+
return crossAppRelationTripleSchemaToDbConcept({
193+
node: r,
194+
sourceNodeSchema: sourceSchema,
195+
destinationNodeSchema: destinationSchema,
196+
});
197+
})
198+
.filter((d) => d !== undefined);
199+
const response = await client.rpc("upsert_concepts", {
200+
v_space_id: spaceId,
201+
data: asDbData,
202+
});
203+
if (response.error) throw response.error;
204+
asDbData.map((d) => {
205+
if (d.source_local_id) existing.add(d.source_local_id);
206+
});
207+
};
208+
209+
const getCrossAppNodeSchemaById = (): Record<string, CrossAppNodeSchema> => {
210+
const allNodeSchemas = getDiscourseNodes();
211+
const result: CrossAppNodeSchema[] = allNodeSchemas
212+
.map((s) => {
213+
const relData = window.roamAlphaAPI.pull(
214+
"[:create/time :edit/time {:create/user [:user/uid]}]",
215+
`[:block/uid "${s.type}"]`,
216+
) as unknown as {
217+
":create/time": number;
218+
":edit/time": number;
219+
":create/user": { ":user/uid": string };
220+
};
221+
if (!relData) return null;
222+
const userUid = relData[":create/user"][":user/uid"];
223+
return {
224+
localId: s.type,
225+
label: s.text,
226+
authorId: userUid,
227+
createdAt: new Date(relData[":create/time"]),
228+
};
229+
})
230+
.filter((s) => s !== null);
231+
return Object.fromEntries(result.map((r) => [r.localId, r]));
232+
};
233+
234+
export const publishCorrespondingRelations = async ({
235+
client,
236+
spaceId,
237+
groupIds,
238+
forNodeIds,
239+
}: {
240+
client: DGSupabaseClient;
241+
spaceId: number;
242+
groupIds: string[];
243+
forNodeIds?: Set<string>;
244+
}): Promise<string[] | null> => {
245+
const allRelationsSchemas = getDiscourseRelations();
246+
const allRelationSchemasById = Object.fromEntries(
247+
allRelationsSchemas.map((s) => [s.id, s]),
248+
);
249+
const nodeSchemaById = getCrossAppNodeSchemaById();
250+
// Should we even handle non-reified relations?
251+
// I need a way to know if a relation is imported.
252+
const allRelations = await getReifiedRelations();
253+
const failedGroupIds: string[] = [];
254+
const sourceIdOfNodes: Record<string, number> = {};
255+
const syncedRelationIds = new Set(
256+
await getSyncedRelationLocalIds(client, spaceId),
257+
);
258+
const isImportedFrom = (nodeLocalId: string): number => {
259+
let cached = sourceIdOfNodes[nodeLocalId];
260+
if (cached === undefined) {
261+
cached = sourceIdOfNodes[nodeLocalId] =
262+
getSpaceIdOf(nodeLocalId) || spaceId;
263+
}
264+
return cached === spaceId ? 0 : cached;
265+
};
266+
const relations =
267+
forNodeIds !== undefined
268+
? allRelations.filter(
269+
(r) =>
270+
r.importedFromRid === undefined &&
271+
(forNodeIds.has(r.sourceUid) || forNodeIds.has(r.destinationUid)),
272+
)
273+
: allRelations.filter((r) => r.importedFromRid === undefined);
274+
for (const groupId of groupIds) {
275+
const groupSpaces = await getGroupSpaceIdAndUrls(client, groupId);
276+
const publishedIds = new Set(
277+
await getAllPublishedIds(client, spaceId, groupId),
278+
);
279+
const relsBetweenPublishedAll = relations.filter(
280+
(r) =>
281+
(publishedIds.has(r.sourceUid) ||
282+
(isImportedFrom(r.sourceUid) || 0) in groupSpaces) &&
283+
(publishedIds.has(r.destinationUid) ||
284+
(isImportedFrom(r.destinationUid) || 0) in groupSpaces),
285+
);
286+
const relationSchemaIds = new Set(
287+
relsBetweenPublishedAll
288+
.map((r) => r.hasSchema)
289+
// filter out deleted schemas
290+
.filter((id) => id in allRelationSchemasById),
291+
);
292+
const relationSchemaTriples = allRelationsSchemas.filter((r) =>
293+
relationSchemaIds.has(r.id),
294+
);
295+
const relsBetweenPublished = relsBetweenPublishedAll.filter((r) =>
296+
relationSchemaIds.has(r.hasSchema),
297+
);
298+
await ensureRelationSchemasSynced({
299+
client,
300+
spaceId,
301+
relationSchemas: relationSchemaTriples,
302+
existing: syncedRelationIds,
303+
nodeSchemaById,
304+
});
305+
await ensureRelationsSynced({
306+
client,
307+
spaceId,
308+
relations: relsBetweenPublished,
309+
existing: syncedRelationIds,
310+
isImportedFrom,
311+
spaceUids: groupSpaces,
312+
});
313+
const missingIds = [
314+
...relsBetweenPublished
315+
.map((r) => r.relationId)
316+
.filter((id) => !publishedIds.has(id)),
317+
...relationSchemaTriples
318+
.map((r) => r.id)
319+
.filter((id) => !publishedIds.has(id)),
320+
];
321+
const grantRes = await client.from("ResourceAccess").upsert(
322+
missingIds.map((sourceLocalId) => ({
323+
account_uid: groupId,
324+
source_local_id: sourceLocalId,
325+
space_id: spaceId,
326+
})),
327+
{ ignoreDuplicates: true },
328+
);
329+
if (!isIgnorableUpsertError(grantRes.error)) {
330+
failedGroupIds.push(groupId);
331+
}
332+
}
333+
return failedGroupIds.length ? failedGroupIds : null;
334+
};
335+
16336
// 23505 = unique_violation: the grant already exists, which counts as success.
17337
const isIgnorableUpsertError = (error: { code?: string } | null): boolean =>
18338
!error || error.code === "23505";
@@ -121,6 +441,12 @@ export const publishNodesToGroups = async ({
121441

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

125451
result.publishedNodeUids = result.okGroupIds.length > 0 ? syncedNodeUids : [];
126452
return result;

packages/database/src/inputTypes.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ export type LocalAccountDataInput = Partial<
66
export type LocalDocumentDataInput = Partial<
77
Omit<
88
Database["public"]["CompositeTypes"]["document_local_input"],
9-
"author_inline"
9+
"author_inline" | "contents"
1010
> & { author_inline: LocalAccountDataInput }
1111
>;
1212
export type LocalContentDataInput = Partial<

0 commit comments

Comments
 (0)