From 9f6732e72b6a3ae398c0e99bb56fe0f14fcdc4b8 Mon Sep 17 00:00:00 2001 From: Marc-Antoine Parent Date: Sat, 25 Jul 2026 11:50:43 -0400 Subject: [PATCH 1/3] eng-1865-stage1-avoid-db-loops --- apps/roam/src/utils/publishNodesToGroups.ts | 111 +++++++----------- packages/database/src/inputTypes.ts | 2 +- packages/database/src/lib/contextFunctions.ts | 5 + packages/database/src/lib/groups.ts | 56 +++++++++ 4 files changed, 107 insertions(+), 67 deletions(-) diff --git a/apps/roam/src/utils/publishNodesToGroups.ts b/apps/roam/src/utils/publishNodesToGroups.ts index b873fc2be..05eb0db5c 100644 --- a/apps/roam/src/utils/publishNodesToGroups.ts +++ b/apps/roam/src/utils/publishNodesToGroups.ts @@ -2,12 +2,19 @@ import { CrossAppNode } from "@repo/database/crossAppContracts"; import type { DGSupabaseClient } from "@repo/database/lib/client"; import { getAvailableGroupIds } from "@repo/database/lib/groups"; import { nodeUidsWithTypeToCrossApp } from "./roamToCrossAppConverters"; +import { ensurePartialSpaceAccess } from "@repo/database/lib/groups"; +import { isIgnorableUpsertError } from "@repo/database/lib/contextFunctions"; +import { difference, intersection } from "@repo/utils/setOperations"; +import internalError from "./internalError"; export type NodeUidWithType = { uid: string; type: string; }; +const onlyStrings = (values: (string | null)[]): string[] => + values.filter((value): value is string => typeof value === "string"); + type PublishNodesResult = { publishedNodeUids: string[]; skippedUnsyncedUids: string[]; @@ -15,13 +22,6 @@ type PublishNodesResult = { failedGroupIds: string[]; }; -// 23505 = unique_violation: the grant already exists, which counts as success. -const isIgnorableUpsertError = (error: { code?: string } | null): boolean => - !error || error.code === "23505"; - -const onlyStrings = (values: (string | null)[]): string[] => - values.filter((value): value is string => typeof value === "string"); - // Grants a group access to already-synced discourse nodes by mirroring the // Obsidian publish-to-group access model (SpaceAccess + ResourceAccess), // without its file/frontmatter/relation/asset coupling. @@ -50,83 +50,62 @@ export const publishNodesToGroups = async ({ if (nodes.length === 0 || groupIds.length === 0) return result; const availableGroupIds = new Set(await getAvailableGroupIds(client)); - const requestedGroupIds = [...new Set(groupIds)]; - const targetGroupIds = requestedGroupIds.filter((groupId) => - availableGroupIds.has(groupId), - ); - result.failedGroupIds = requestedGroupIds.filter( - (groupId) => !availableGroupIds.has(groupId), - ); - if (targetGroupIds.length === 0) return result; + const requestedGroupIds = new Set(groupIds); + groupIds = [...intersection(requestedGroupIds, availableGroupIds)]; + result.failedGroupIds = [...difference(requestedGroupIds, availableGroupIds)]; + if (groupIds.length === 0) return result; + + const { existing: existingSpaceAccess, missing: missingSpaceAccess } = + await ensurePartialSpaceAccess({ + client, + groupIds, + spaceId, + }); + if (missingSpaceAccess && Object.keys(missingSpaceAccess).length) { + result.failedGroupIds = groupIds.filter( + (id) => !(id in existingSpaceAccess), + ); + groupIds = Object.keys(existingSpaceAccess); + } + if (groupIds.length === 0) return result; - const uids = [...new Set(nodes.map((node) => node.localId))]; + const nodeUids = [...new Set(nodes.map((node) => node.localId))]; const syncedRes = await client .from("my_concepts") .select("source_local_id") .eq("space_id", spaceId) - .eq("is_schema", false) - .in("source_local_id", uids); - if (syncedRes.error) throw syncedRes.error; + .in("source_local_id", nodeUids); + if (syncedRes.error) { + internalError({ error: syncedRes.error }); + return result; + } const syncedUids = new Set( onlyStrings((syncedRes.data ?? []).map((row) => row.source_local_id)), ); + result.skippedUnsyncedUids = nodeUids.filter((uid) => !syncedUids.has(uid)); - result.skippedUnsyncedUids = uids.filter((uid) => !syncedUids.has(uid)); - const syncedNodeUids = uids.filter((uid) => syncedUids.has(uid)); - if (syncedNodeUids.length === 0) return result; - - // Required dependency: the node-type schema concept, when it is synced too. - const types = [ - ...new Set( - nodes - .filter((node) => syncedUids.has(node.localId)) - .map((node) => node.nodeType), - ), - ]; - const schemaRes = await client - .from("my_concepts") - .select("source_local_id") - .eq("space_id", spaceId) - .eq("is_schema", true) - .in("source_local_id", types); - if (schemaRes.error) throw schemaRes.error; - const syncedSchemaIds = onlyStrings( - (schemaRes.data ?? []).map((row) => row.source_local_id), - ); - - const resourceIds = [...syncedNodeUids, ...syncedSchemaIds]; - - for (const groupId of targetGroupIds) { - // Existing reader/editor access is broader than partial, so leave it intact. - const spaceAccessRes = await client - .from("SpaceAccess") - .upsert( - { account_uid: groupId, space_id: spaceId, permissions: "partial" }, - { ignoreDuplicates: true }, - ); - if (!isIgnorableUpsertError(spaceAccessRes.error)) { - result.failedGroupIds.push(groupId); - continue; - } - - const grantRes = await client.from("ResourceAccess").upsert( - resourceIds.map((sourceLocalId) => ({ + const resourceAccesses = []; + for (const groupId of groupIds) { + resourceAccesses.push( + ...nodeUids.map((sourceLocalId) => ({ account_uid: groupId, source_local_id: sourceLocalId, space_id: spaceId, })), - { ignoreDuplicates: true }, ); - if (!isIgnorableUpsertError(grantRes.error)) { - result.failedGroupIds.push(groupId); - continue; - } + } - result.okGroupIds.push(groupId); + const grantRes = await client + .from("ResourceAccess") + .upsert(resourceAccesses, { ignoreDuplicates: true }); + if (!isIgnorableUpsertError(grantRes.error)) { + internalError({ error: grantRes.error }); + return result; } - result.publishedNodeUids = result.okGroupIds.length > 0 ? syncedNodeUids : []; + result.publishedNodeUids = nodeUids; + return result; }; diff --git a/packages/database/src/inputTypes.ts b/packages/database/src/inputTypes.ts index 5ff4b76a8..64f69c635 100644 --- a/packages/database/src/inputTypes.ts +++ b/packages/database/src/inputTypes.ts @@ -6,7 +6,7 @@ export type LocalAccountDataInput = Partial< export type LocalDocumentDataInput = Partial< Omit< Database["public"]["CompositeTypes"]["document_local_input"], - "author_inline" + "author_inline" | "contents" > & { author_inline: LocalAccountDataInput } >; export type LocalContentDataInput = Partial< diff --git a/packages/database/src/lib/contextFunctions.ts b/packages/database/src/lib/contextFunctions.ts index b6213a0ef..1eda4f3ad 100644 --- a/packages/database/src/lib/contextFunctions.ts +++ b/packages/database/src/lib/contextFunctions.ts @@ -47,6 +47,11 @@ export const asPostgrestFailure = ( export class FatalError extends Error {} +// 23505 = unique_violation: the grant already exists, which counts as success. +export const isIgnorableUpsertError = ( + error: { code?: string } | null, +): boolean => !error || error.code === "23505"; + export const spaceValidator = (space: SpaceCreationInput): string | null => { if (!space || typeof space !== "object") return "Invalid request body: expected a JSON object."; diff --git a/packages/database/src/lib/groups.ts b/packages/database/src/lib/groups.ts index 6a9b13f89..10eef6e6e 100644 --- a/packages/database/src/lib/groups.ts +++ b/packages/database/src/lib/groups.ts @@ -1,4 +1,6 @@ import type { DGSupabaseClient } from "./client"; +import type { Tables, Enums } from "../dbTypes"; +import { isIgnorableUpsertError } from "./contextFunctions"; export type MyGroup = { id: string; @@ -47,3 +49,57 @@ export const getMyGroups = async ( name: row.my_groups.name ?? row.group_id, })); }; + +type SpaceAccessPermissions = Enums<"SpaceAccessPermissions">; + +export const ensurePartialSpaceAccess = async ({ + client, + groupIds, + spaceId, +}: { + client: DGSupabaseClient; + groupIds: string[]; + spaceId: number; +}): Promise<{ + existing: Record; + missing?: Record; +}> => { + const existingAccessResult = await client + .from("SpaceAccess") + .select() + .eq("space_id", spaceId) + .in("group_id", groupIds); + const existingAccessByGroupId = existingAccessResult.data + ? Object.fromEntries( + existingAccessResult.data.map((sa) => [sa.account_uid, sa.permissions]), + ) + : {}; + const missingAccess: Tables<"SpaceAccess">[] = []; + for (const groupId of groupIds) { + if (existingAccessByGroupId[groupId] === undefined) { + missingAccess.push({ + space_id: spaceId, + permissions: "partial", + account_uid: groupId, + }); + } + } + if (missingAccess.length > 0) { + const upsertAccessResult = await client + .from("SpaceAccess") + .upsert(missingAccess, { ignoreDuplicates: true }); + if (!isIgnorableUpsertError(upsertAccessResult.error)) { + // allow partial results + return { + existing: existingAccessByGroupId, + missing: Object.fromEntries( + missingAccess.map((a) => [a.space_id, a.permissions]), + ), + }; + } + } + missingAccess.forEach((a) => { + existingAccessByGroupId[a.account_uid] = "partial"; + }); + return { existing: existingAccessByGroupId }; +}; From 10b16d0aef0d2cabaec4d2e07e8013a17783f18c Mon Sep 17 00:00:00 2001 From: Marc-Antoine Parent Date: Sat, 25 Jul 2026 11:58:52 -0400 Subject: [PATCH 2/3] eng-1865-stage2-node-schemas --- apps/roam/src/utils/publishNodesToGroups.ts | 38 +++++++++++++++++-- .../src/utils/roamToCrossAppConverters.ts | 26 ++++++++++++- 2 files changed, 60 insertions(+), 4 deletions(-) diff --git a/apps/roam/src/utils/publishNodesToGroups.ts b/apps/roam/src/utils/publishNodesToGroups.ts index 05eb0db5c..a80aa6341 100644 --- a/apps/roam/src/utils/publishNodesToGroups.ts +++ b/apps/roam/src/utils/publishNodesToGroups.ts @@ -2,8 +2,11 @@ import { CrossAppNode } from "@repo/database/crossAppContracts"; import type { DGSupabaseClient } from "@repo/database/lib/client"; import { getAvailableGroupIds } from "@repo/database/lib/groups"; import { nodeUidsWithTypeToCrossApp } from "./roamToCrossAppConverters"; +import { nodeSchemaToCrossApp } from "./roamToCrossAppConverters"; +import { crossAppNodeSchemaToDbConcept } from "@repo/database/lib/crossAppConverters"; import { ensurePartialSpaceAccess } from "@repo/database/lib/groups"; import { isIgnorableUpsertError } from "@repo/database/lib/contextFunctions"; +import getDiscourseNodes from "./getDiscourseNodes"; import { difference, intersection } from "@repo/utils/setOperations"; import internalError from "./internalError"; @@ -16,7 +19,9 @@ const onlyStrings = (values: (string | null)[]): string[] => values.filter((value): value is string => typeof value === "string"); type PublishNodesResult = { + publishedNodeSchemaUids: string[]; publishedNodeUids: string[]; + syncedNodeSchemaUids: string[]; skippedUnsyncedUids: string[]; okGroupIds: string[]; failedGroupIds: string[]; @@ -42,7 +47,9 @@ export const publishNodesToGroups = async ({ nodes: CrossAppNode[]; }): Promise => { const result: PublishNodesResult = { + publishedNodeSchemaUids: [], publishedNodeUids: [], + syncedNodeSchemaUids: [], skippedUnsyncedUids: [], okGroupIds: [], failedGroupIds: [], @@ -70,12 +77,19 @@ export const publishNodesToGroups = async ({ if (groupIds.length === 0) return result; const nodeUids = [...new Set(nodes.map((node) => node.localId))]; + const nodeSchemaUids = new Set(nodes.map((node) => node.nodeType)); + const nodeSchemas = getDiscourseNodes() + .filter((s) => nodeSchemaUids.has(s.type)) + .map((s) => nodeSchemaToCrossApp(s)) + .filter((s) => s !== null); + + const neededUids = [...nodeSchemaUids, ...nodeUids]; const syncedRes = await client .from("my_concepts") .select("source_local_id") .eq("space_id", spaceId) - .in("source_local_id", nodeUids); + .in("source_local_id", neededUids); if (syncedRes.error) { internalError({ error: syncedRes.error }); return result; @@ -83,12 +97,19 @@ export const publishNodesToGroups = async ({ const syncedUids = new Set( onlyStrings((syncedRes.data ?? []).map((row) => row.source_local_id)), ); - result.skippedUnsyncedUids = nodeUids.filter((uid) => !syncedUids.has(uid)); + const missingNodeSchemas = nodeSchemas.filter( + (s) => !syncedUids.has(s.localId), + ); + result.skippedUnsyncedUids = neededUids.filter((uid) => !syncedUids.has(uid)); + const upsertConcepts = [ + ...missingNodeSchemas.map((s) => crossAppNodeSchemaToDbConcept(s)), + ].filter((r) => r !== undefined); const resourceAccesses = []; + const resourceIds = [...nodeUids, ...nodeSchemaUids]; for (const groupId of groupIds) { resourceAccesses.push( - ...nodeUids.map((sourceLocalId) => ({ + ...resourceIds.map((sourceLocalId) => ({ account_uid: groupId, source_local_id: sourceLocalId, space_id: spaceId, @@ -96,6 +117,16 @@ export const publishNodesToGroups = async ({ ); } + const response = await client.rpc("upsert_concepts", { + v_space_id: spaceId, + data: upsertConcepts, + }); + if (response.error) { + internalError({ error: response.error }); + return result; + } + + result.syncedNodeSchemaUids = missingNodeSchemas.map((s) => s.localId); const grantRes = await client .from("ResourceAccess") .upsert(resourceAccesses, { ignoreDuplicates: true }); @@ -104,6 +135,7 @@ export const publishNodesToGroups = async ({ return result; } + result.publishedNodeSchemaUids = [...nodeSchemaUids]; result.publishedNodeUids = nodeUids; return result; diff --git a/apps/roam/src/utils/roamToCrossAppConverters.ts b/apps/roam/src/utils/roamToCrossAppConverters.ts index bc7b9b77d..6f1d4be76 100644 --- a/apps/roam/src/utils/roamToCrossAppConverters.ts +++ b/apps/roam/src/utils/roamToCrossAppConverters.ts @@ -1,4 +1,7 @@ -import type { CrossAppNode } from "@repo/database/crossAppContracts"; +import type { + CrossAppNode, + CrossAppNodeSchema, +} from "@repo/database/crossAppContracts"; import type { RoamFullContentNode } from "./convertRoamNodeToFullContent"; import type { DiscourseNode } from "./getDiscourseNodes"; import type { TreeNode, ViewType } from "roamjs-components/types"; @@ -116,3 +119,24 @@ export const nodeUidsWithTypeToCrossApp = async ( }); return results; }; + +export const nodeSchemaToCrossApp = ( + s: DiscourseNode, +): CrossAppNodeSchema | null => { + const relData = window.roamAlphaAPI.pull( + "[:create/time :edit/time {:create/user [:user/uid]}]", + `[:block/uid "${s.type}"]`, + ) as unknown as { + ":create/time": number; + ":edit/time": number; + ":create/user": { ":user/uid": string }; + }; + if (!relData) return null; + const userUid = relData[":create/user"][":user/uid"]; + return { + localId: s.type, + label: s.text, + authorId: userUid, + createdAt: new Date(relData[":create/time"]), + }; +}; From 9f6663dd7b71b1e80173151548b648f310b0ed98 Mon Sep 17 00:00:00 2001 From: Marc-Antoine Parent Date: Fri, 10 Jul 2026 17:00:10 -0400 Subject: [PATCH 3/3] eng-1865 stage 3 sync and publish relations --- apps/roam/src/utils/createReifiedBlock.ts | 30 +++ apps/roam/src/utils/publishNodesToGroups.ts | 228 +++++++++++++++++- .../src/utils/roamToCrossAppConverters.ts | 63 +++++ 3 files changed, 316 insertions(+), 5 deletions(-) diff --git a/apps/roam/src/utils/createReifiedBlock.ts b/apps/roam/src/utils/createReifiedBlock.ts index 642ddb12a..ad78975f7 100644 --- a/apps/roam/src/utils/createReifiedBlock.ts +++ b/apps/roam/src/utils/createReifiedBlock.ts @@ -102,6 +102,36 @@ export const countReifiedRelations = async (): Promise => { return (r[0] || [0])[0] as number; }; +export type ReifiedRelationData = { + sourceUid: string; + destinationUid: string; + hasSchema: string; + importedFromRid?: string; +}; + +export type ReifiedRelationDataWithRelId = ReifiedRelationData & { + relationId: string; +}; + +export const getReifiedRelations = async (): Promise< + ReifiedRelationDataWithRelId[] +> => { + const pageUid = getExistingRelationPageUid(); + if (pageUid === undefined) return []; + const r = await window.roamAlphaAPI.data.async.q( + `[:find ?ruid ?rdata :where + [?p :block/uid "${pageUid}"] + [?p :block/children ?c] + [?c :block/uid ?ruid] + [?c :block/props ?pr] + [(get ?pr :${DISCOURSE_GRAPH_PROP_NAME}) ?rdata] ]`, + ); + return r.map((x) => ({ + relationId: x[0] as string, + ...(x[1] as ReifiedRelationData), + })); +}; + export const createReifiedRelation = async ({ sourceUid, relationBlockUid, diff --git a/apps/roam/src/utils/publishNodesToGroups.ts b/apps/roam/src/utils/publishNodesToGroups.ts index a80aa6341..6ce948650 100644 --- a/apps/roam/src/utils/publishNodesToGroups.ts +++ b/apps/roam/src/utils/publishNodesToGroups.ts @@ -1,9 +1,23 @@ -import { CrossAppNode } from "@repo/database/crossAppContracts"; +import { + CrossAppNode, + CrossAppRelation, + CrossAppRelationTripleSchema, +} from "@repo/database/crossAppContracts"; import type { DGSupabaseClient } from "@repo/database/lib/client"; import { getAvailableGroupIds } from "@repo/database/lib/groups"; import { nodeUidsWithTypeToCrossApp } from "./roamToCrossAppConverters"; -import { nodeSchemaToCrossApp } from "./roamToCrossAppConverters"; -import { crossAppNodeSchemaToDbConcept } from "@repo/database/lib/crossAppConverters"; +import { + reifiedRelationToCrossApp, + relationTripleSchemaToCrossApp, + nodeSchemaToCrossApp, +} from "./roamToCrossAppConverters"; +import getDiscourseRelations from "./getDiscourseRelations"; +import { getReifiedRelations } from "./createReifiedBlock"; +import { + crossAppNodeSchemaToDbConcept, + crossAppRelationToDbConcept, + crossAppRelationTripleSchemaToDbConcept, +} from "@repo/database/lib/crossAppConverters"; import { ensurePartialSpaceAccess } from "@repo/database/lib/groups"; import { isIgnorableUpsertError } from "@repo/database/lib/contextFunctions"; import getDiscourseNodes from "./getDiscourseNodes"; @@ -15,13 +29,171 @@ export type NodeUidWithType = { type: string; }; +const getAllPublishedIdsByGroup = async ( + client: DGSupabaseClient, + spaceId: number, + groupIds: string[], +): Promise>> => { + const response = await client + .from("ResourceAccess") + .select("account_uid, source_local_id") + .eq("space_id", spaceId) + .in("account_uid", groupIds); + if (response.error) throw response.error; + const publishedIdsByGroupId = Object.fromEntries( + groupIds.map((gid) => [gid, new Set()]), + ); + response.data.forEach(({ account_uid, source_local_id }) => { + publishedIdsByGroupId[account_uid].add(source_local_id); + }); + + return publishedIdsByGroupId; +}; + +const getSpaceIdAndUrlsByGroupId = async ( + client: DGSupabaseClient, + groupIds: string[], +): Promise<{ + spaceUrlById: Record; + spaceIdsByGroupId: Record>; +}> => { + const response = await client + .from("SpaceAccess") + .select("account_uid, space_id") + .in("account_uid", groupIds); + if (response.error) throw response.error; + const spaceIds = response.data.map((r) => r.space_id); + const response2 = await client + .from("Space") + .select("id, url") + .in("id", spaceIds); + if (response2.error) throw response2.error; + const spaceUrlById = Object.fromEntries( + response2.data.map(({ id, url }) => [id, url]), + ); + const spaceIdsByGroupId = Object.fromEntries( + groupIds.map((gid) => [gid, new Set()]), + ); + response.data.forEach(({ account_uid, space_id }) => { + spaceIdsByGroupId[account_uid].add(space_id); + }); + return { + spaceUrlById, + spaceIdsByGroupId, + }; +}; + +// Use readImportedSourceIdentity from eng-1859 when it's merged. +// eslint-disable-next-line @typescript-eslint/no-unused-vars +const importedFromSpaceId = (nodeId: string): number | undefined => undefined; + +export const gatherCorrespondingRelations = async ({ + client, + spaceId, + groupIds, + forNodeIds, +}: { + client: DGSupabaseClient; + spaceId: number; + groupIds: string[]; + forNodeIds?: Set; +}): Promise<{ + relations: CrossAppRelation[]; + relationTripleSchemas: CrossAppRelationTripleSchema[]; + relevantRelationIdsPerGroupId: Record; +}> => { + const allRelationsSchemas = getDiscourseRelations(); + const allRelationSchemasById = Object.fromEntries( + allRelationsSchemas.map((s) => [s.id, s]), + ); + // Should we even handle non-reified relations? Assuming not. + // I need a way to know if a relation is imported, see importedFromSpaceId + const allRelations = await getReifiedRelations(); + const spaceIdOfNodes: Record = {}; + const isImportedFrom = (nodeLocalId: string): number => { + let cached = spaceIdOfNodes[nodeLocalId]; + if (cached === undefined) { + cached = spaceIdOfNodes[nodeLocalId] = + importedFromSpaceId(nodeLocalId) || spaceId; + } + return cached === spaceId ? 0 : cached; + }; + const relations = + forNodeIds !== undefined + ? allRelations.filter( + (r) => + r.importedFromRid === undefined && + (forNodeIds.has(r.sourceUid) || forNodeIds.has(r.destinationUid)), + ) + : allRelations.filter((r) => r.importedFromRid === undefined); + const { spaceIdsByGroupId, spaceUrlById } = await getSpaceIdAndUrlsByGroupId( + client, + groupIds, + ); + const isImportedFromSpaceUri = (uid: string) => + spaceUrlById[isImportedFrom(uid) || 0]; + const publishedIdsByGroup = await getAllPublishedIdsByGroup( + client, + spaceId, + groupIds, + ); + // calculate separately to avoid case of a relation between nodes published to or from different groups + const relevantRelationIdsPerGroupId = Object.fromEntries( + groupIds.map((groupId) => { + const groupSpaceIds = spaceIdsByGroupId[groupId]; + const publishedIds = publishedIdsByGroup[groupId]; + return [ + groupId, + relations + .filter( + (r) => + (publishedIds.has(r.sourceUid) || + groupSpaceIds.has(isImportedFrom(r.sourceUid) || 0)) && + (publishedIds.has(r.destinationUid) || + groupSpaceIds.has(isImportedFrom(r.destinationUid) || 0)), + ) + .map((r) => r.relationId), + ]; + }), + ); + const allRelevantRelationIds = new Set( + Object.values(relevantRelationIdsPerGroupId).flat(), + ); + let allRelevantRelations = relations.filter((r) => + allRelevantRelationIds.has(r.relationId), + ); + const relationSchemaIds = new Set( + allRelevantRelations + .map((r) => r.hasSchema) + // filter out deleted schemas + .filter((id) => id in allRelationSchemasById), + ); + allRelevantRelations = allRelevantRelations.filter((r) => + relationSchemaIds.has(r.hasSchema), + ); + + return { + relations: allRelevantRelations + .map((r) => reifiedRelationToCrossApp(r, isImportedFromSpaceUri)) + .filter((r) => r !== null), + relationTripleSchemas: allRelationsSchemas + .map((rs3) => relationTripleSchemaToCrossApp(rs3)) + .filter((rs3) => rs3 !== null), + relevantRelationIdsPerGroupId, + }; +}; + const onlyStrings = (values: (string | null)[]): string[] => values.filter((value): value is string => typeof value === "string"); type PublishNodesResult = { publishedNodeSchemaUids: string[]; publishedNodeUids: string[]; + publishedRelationTripleSchemaUids: string[]; + publishedRelationUids: string[]; syncedNodeSchemaUids: string[]; + syncedRelationTripleSchemaUids: string[]; + syncedRelationUids: string[]; skippedUnsyncedUids: string[]; okGroupIds: string[]; failedGroupIds: string[]; @@ -49,7 +221,11 @@ export const publishNodesToGroups = async ({ const result: PublishNodesResult = { publishedNodeSchemaUids: [], publishedNodeUids: [], + publishedRelationTripleSchemaUids: [], + publishedRelationUids: [], + syncedRelationUids: [], syncedNodeSchemaUids: [], + syncedRelationTripleSchemaUids: [], skippedUnsyncedUids: [], okGroupIds: [], failedGroupIds: [], @@ -82,8 +258,23 @@ export const publishNodesToGroups = async ({ .filter((s) => nodeSchemaUids.has(s.type)) .map((s) => nodeSchemaToCrossApp(s)) .filter((s) => s !== null); + const { relations, relationTripleSchemas, relevantRelationIdsPerGroupId } = + await gatherCorrespondingRelations({ + client, + spaceId, + groupIds, + forNodeIds: new Set(nodeUids), + }); + + const relationUids = relations.map((r) => r.localId); + const relationTripleSchemaUids = relationTripleSchemas.map((r) => r.localId); - const neededUids = [...nodeSchemaUids, ...nodeUids]; + const neededUids = [ + ...nodeSchemaUids, + ...nodeUids, + ...relationTripleSchemaUids, + ...relationUids, + ]; const syncedRes = await client .from("my_concepts") @@ -100,16 +291,37 @@ export const publishNodesToGroups = async ({ const missingNodeSchemas = nodeSchemas.filter( (s) => !syncedUids.has(s.localId), ); + const missingRelationTripleSchemas = relationTripleSchemas.filter( + (s) => !syncedUids.has(s.localId), + ); + const missingRelations = relations.filter((s) => !syncedUids.has(s.localId)); + result.skippedUnsyncedUids = neededUids.filter((uid) => !syncedUids.has(uid)); const upsertConcepts = [ ...missingNodeSchemas.map((s) => crossAppNodeSchemaToDbConcept(s)), + ...missingRelationTripleSchemas.map((rs3) => + crossAppRelationTripleSchemaToDbConcept(rs3), + ), + ...missingRelations.map((r) => crossAppRelationToDbConcept(r)), ].filter((r) => r !== undefined); const resourceAccesses = []; const resourceIds = [...nodeUids, ...nodeSchemaUids]; for (const groupId of groupIds) { + const groupRelationIds = new Set(relevantRelationIdsPerGroupId[groupId]); + const groupRelations = relations.filter((r) => + groupRelationIds.has(r.localId), + ); + const groupRelationTripleSchemaIds = new Set( + groupRelations.map((r) => r.relationType), + ); + const groupResourceIds = [ + ...resourceIds, + ...groupRelationIds, + ...groupRelationTripleSchemaIds, + ]; resourceAccesses.push( - ...resourceIds.map((sourceLocalId) => ({ + ...groupResourceIds.map((sourceLocalId) => ({ account_uid: groupId, source_local_id: sourceLocalId, space_id: spaceId, @@ -126,6 +338,10 @@ export const publishNodesToGroups = async ({ return result; } + result.syncedRelationTripleSchemaUids = missingRelationTripleSchemas.map( + (s) => s.localId, + ); + result.syncedRelationUids = missingRelations.map((s) => s.localId); result.syncedNodeSchemaUids = missingNodeSchemas.map((s) => s.localId); const grantRes = await client .from("ResourceAccess") @@ -135,6 +351,8 @@ export const publishNodesToGroups = async ({ return result; } + result.publishedRelationTripleSchemaUids = relationTripleSchemaUids; + result.publishedRelationUids = relationUids; result.publishedNodeSchemaUids = [...nodeSchemaUids]; result.publishedNodeUids = nodeUids; diff --git a/apps/roam/src/utils/roamToCrossAppConverters.ts b/apps/roam/src/utils/roamToCrossAppConverters.ts index 6f1d4be76..7cc8b6ba7 100644 --- a/apps/roam/src/utils/roamToCrossAppConverters.ts +++ b/apps/roam/src/utils/roamToCrossAppConverters.ts @@ -1,16 +1,21 @@ import type { CrossAppNode, CrossAppNodeSchema, + CrossAppRelation, + CrossAppRelationTripleSchema, } from "@repo/database/crossAppContracts"; import type { RoamFullContentNode } from "./convertRoamNodeToFullContent"; import type { DiscourseNode } from "./getDiscourseNodes"; import type { TreeNode, ViewType } from "roamjs-components/types"; import type { NodeUidWithType } from "~/utils/publishNodesToGroups"; import type { Json } from "@repo/database/dbTypes"; +import type { ReifiedRelationDataWithRelId } from "./createReifiedBlock"; +import type { DiscourseRelation } from "./getDiscourseRelations"; import { toMarkdown } from "./pageToMarkdown"; import getFullTreeByParentUid from "roamjs-components/queries/getFullTreeByParentUid"; import getPageViewType from "roamjs-components/queries/getPageViewType"; import { contentTypes } from "@repo/content-model"; +import { spaceUriAndLocalIdToRid } from "@repo/database/lib/rid"; const FULL_MARKDOWN_OPTS = { refs: true, @@ -120,6 +125,64 @@ export const nodeUidsWithTypeToCrossApp = async ( return results; }; +export const reifiedRelationToCrossApp = ( + r: ReifiedRelationDataWithRelId, + isImportedFromSpaceUri: (nodeUid: string) => string | undefined, +): CrossAppRelation | null => { + const sourceSpaceUri = isImportedFromSpaceUri(r.sourceUid); + const destinationSpaceUri = isImportedFromSpaceUri(r.destinationUid); + const sourceId = + sourceSpaceUri === undefined + ? r.sourceUid + : spaceUriAndLocalIdToRid(sourceSpaceUri, r.sourceUid); + const destinationId = + destinationSpaceUri === undefined + ? r.destinationUid + : spaceUriAndLocalIdToRid(destinationSpaceUri, r.destinationUid); + const relData = window.roamAlphaAPI.pull( + "[:create/time :edit/time {:create/user [:user/uid]}]", + `[:block/uid "${r.relationId}"]`, + ) as Record; + if (relData == undefined || !relData[":create/user"]) return null; + const userUid = (relData[":create/user"] as Record)[ + ":user/uid" + ]; + + return { + localId: r.relationId, + relationType: r.hasSchema, + source: sourceId, + destination: destinationId, + authorId: userUid, + createdAt: new Date(relData[":create/time"] as number), + modifiedAt: new Date(relData[":edit/time"] as number), + }; +}; + +export const relationTripleSchemaToCrossApp = ( + r: DiscourseRelation, +): CrossAppRelationTripleSchema | null => { + const relData = window.roamAlphaAPI.pull( + "[:create/time :edit/time {:create/user [:user/uid]}]", + `[:block/uid "${r.id}"]`, + ) as Record; + if (!relData) return null; + const userUid = (relData[":create/user"] as Record)[ + ":user/uid" + ]; + + return { + localId: r.id, + sourceType: r.source, + destinationType: r.destination, + label: r.label, + complement: r.complement, + authorId: userUid, + createdAt: new Date(relData[":create/time"] as number), + modifiedAt: new Date(relData[":edit/time"] as number), + }; +}; + export const nodeSchemaToCrossApp = ( s: DiscourseNode, ): CrossAppNodeSchema | null => {