From 521741ee6ab5556ade4898b7966159b50e5b125a Mon Sep 17 00:00:00 2001 From: Marc-Antoine Parent Date: Wed, 15 Jul 2026 14:14:18 -0400 Subject: [PATCH 1/4] eng-2036-simplify-the-cross-app-node-contract --- .../convertRoamNodeToFullContent.example.ts | 2 +- .../src/utils/convertRoamNodeToFullContent.ts | 4 +- packages/database/src/crossAppContracts.ts | 27 ++++-------- .../src/crossAppNodeContract.example.ts | 20 ++++----- .../database/src/lib/crossAppConverters.ts | 43 +++---------------- 5 files changed, 26 insertions(+), 70 deletions(-) diff --git a/apps/roam/src/utils/convertRoamNodeToFullContent.example.ts b/apps/roam/src/utils/convertRoamNodeToFullContent.example.ts index fbc0324a9..f425d7d9f 100644 --- a/apps/roam/src/utils/convertRoamNodeToFullContent.example.ts +++ b/apps/roam/src/utils/convertRoamNodeToFullContent.example.ts @@ -229,6 +229,6 @@ export const roamClaimFullMarkdownExample: { full: { contentType: contentTypes.markdown, value: buildFullMarkdown({ title, blocks }), - author: { localId: "someone" }, + authorId: "someone", }, }; diff --git a/apps/roam/src/utils/convertRoamNodeToFullContent.ts b/apps/roam/src/utils/convertRoamNodeToFullContent.ts index 2a31825c8..59a056300 100644 --- a/apps/roam/src/utils/convertRoamNodeToFullContent.ts +++ b/apps/roam/src/utils/convertRoamNodeToFullContent.ts @@ -58,11 +58,11 @@ export const convertRoamNodeToFullContent = ({ const blocks = getFullTreeByParentUid(node.source_local_id).children; const viewType = getPageViewType(title) || "bullet"; const crossAppNode: CrossAppNode = { - author: { localId: node.author_local_id }, + authorId: node.author_local_id, localId: node.source_local_id, createdAt: new Date(node.created || Date.now()), modifiedAt: new Date(node.last_modified || Date.now()), - nodeType: { localId: node.node_type_id }, + nodeType: node.node_type_id, content: { direct: { localId: node.source_local_id, diff --git a/packages/database/src/crossAppContracts.ts b/packages/database/src/crossAppContracts.ts index fcf4bb679..f5c3185f5 100644 --- a/packages/database/src/crossAppContracts.ts +++ b/packages/database/src/crossAppContracts.ts @@ -1,24 +1,15 @@ import type { ContentType } from "@repo/content-model"; import { Enums, type Json } from "./dbTypes"; -export type LocalRef = { - // This localId is expected to be unique within the current space - localId: string; -}; - -type DbRef = { - // Some operations will refer to objects through their database Id - dbId: number; -}; - -// Generalized reference -export type Ref = LocalRef | DbRef; +// An identifier for objects in the platform. Expected to be unique within the platform. +export type LocalId = string; // Common attributes for most types -export type CrossAppBase = LocalRef & { +export type CrossAppBase = { + localId: LocalId; createdAt: Date; modifiedAt?: Date; - author: Ref; + authorId: LocalId; }; export type CrossAppSchemaBase = CrossAppBase & { @@ -48,13 +39,13 @@ export type CrossAppRelationTripleSchema = CrossAppSchemaBase & relation?: never; } | { - relation: Ref; + relation: LocalId; label?: never; complement?: never; } ) & { - sourceType: Ref; - destinationType: Ref; + sourceType: LocalId; + destinationType: LocalId; }; // An inline vector semantic embedding @@ -79,7 +70,7 @@ type InlineCrossAppTypedContent = InlineCrossAppContent & { // A node instance export type CrossAppNode = CrossAppBase & { - nodeType: Ref; + nodeType: LocalId; content: { direct: InlineCrossAppContent; full?: InlineCrossAppTypedContent; diff --git a/packages/database/src/crossAppNodeContract.example.ts b/packages/database/src/crossAppNodeContract.example.ts index b35a7fafe..33bdce9c3 100644 --- a/packages/database/src/crossAppNodeContract.example.ts +++ b/packages/database/src/crossAppNodeContract.example.ts @@ -13,23 +13,21 @@ Multiple studies show that sleep after learning strengthens memory traces. export const roamOriginNodeExample: CrossAppNode = { localId: ROAM_SOURCE_NODE_ID, - nodeType: { - localId: "rCLM0schema", - }, + nodeType: "rCLM0schema", content: { direct: { value: "Sleep improves memory consolidation", - author: { localId: "someone" }, + authorId: "someone", }, full: { contentType: contentTypes.markdown, value: roamFullMarkdown, - author: { localId: "someone" }, + authorId: "someone", }, }, createdAt: new Date("2026-06-12T14:00:00.000Z"), modifiedAt: new Date("2026-06-12T15:00:00.000Z"), - author: { localId: "maparent" }, + authorId: "maparent", }; // const OBSIDIAN_SOURCE_SPACE_ID = "obsidian:9a8b7c6d5e4f3210"; @@ -48,21 +46,19 @@ Participants with more REM sleep showed better next-day recall. export const obsidianOriginNodeExample: CrossAppNode = { localId: OBSIDIAN_SOURCE_NODE_ID, - nodeType: { - localId: OBSIDIAN_SOURCE_NODE_TYPE_ID, - }, + nodeType: OBSIDIAN_SOURCE_NODE_TYPE_ID, content: { direct: { value: "EVD - REM sleep and recall", - author: { localId: "someone" }, + authorId: "someone", }, full: { contentType: contentTypes.markdown, value: obsidianFullMarkdown, - author: { localId: "someone" }, + authorId: "someone", }, }, createdAt: new Date("2026-06-14T10:30:00.000Z"), modifiedAt: new Date("2026-06-14T15:00:00.000Z"), - author: { localId: "maparent" }, + authorId: "maparent", }; diff --git a/packages/database/src/lib/crossAppConverters.ts b/packages/database/src/lib/crossAppConverters.ts index e128bb4a0..00aadad67 100644 --- a/packages/database/src/lib/crossAppConverters.ts +++ b/packages/database/src/lib/crossAppConverters.ts @@ -1,43 +1,12 @@ import { - LocalRef, - Ref, CrossAppEmbedding, InlineCrossAppContent, - CrossAppBase, CrossAppNode, } from "../crossAppContracts"; import { LocalContentDataInput, LocalConceptDataInput } from "../inputTypes"; import { Enums, CompositeTypes } from "../dbTypes"; type InlineEmbeddingInput = CompositeTypes<"inline_embedding_input">; -type InlineAbstractBase = Partial; - -const decodeLocalRef = ( - ref: LocalRef | InlineAbstractBase | undefined, - localVarName: LocalVarName, -): Record | Record => { - if (ref === undefined) return {}; - if ("localId" in ref) { - return { - [localVarName]: ref.localId, - } as Record; - } - return {}; -}; - -const decodeRef = ( - ref: Ref | undefined, - dbVarName: DbVarName, - localVarName: LocalVarName, -): - | Record - | Record - | Record => { - if (ref === undefined) return {}; - if ("dbId" in ref) - return { [dbVarName]: ref.dbId } as Record; - return decodeLocalRef(ref, localVarName); -}; const crossAppEmbeddingToDbEmbedding = ( embedding: CrossAppEmbedding | undefined, @@ -64,14 +33,14 @@ const inlineCrossAppContentToDbContent = ( ): LocalContentDataInput | undefined => { if (content === undefined) return undefined; return filterUndefined({ - ...decodeLocalRef(content, "source_local_id"), + source_local_id: content.localId, text: content.value, scale: content.scale || "document", content_type: content.contentType || "text/plain", variant, created: content.createdAt?.toISOString(), last_modified: content.modifiedAt?.toISOString(), - ...decodeRef(content.author, "author_id", "author_local_id"), + author_local_id: content.authorId, embedding_inline: crossAppEmbeddingToDbEmbedding(content.embedding), }); }; @@ -88,7 +57,7 @@ export const crossAppNodeToDbContent = ( ...content, createdAt: content.createdAt || node.createdAt, modifiedAt: content.modifiedAt || node.modifiedAt, - author: content.author || node.author, + authorId: content.authorId || node.authorId, }, variant, ); @@ -98,10 +67,10 @@ export const crossAppNodeToDbConcept = ( node: CrossAppNode, ): LocalConceptDataInput => { return filterUndefined({ - ...decodeLocalRef(node, "source_local_id"), + source_local_id: node.localId, name: node.content.direct.value, - ...decodeRef(node.author, "author_id", "author_local_id"), - ...decodeRef(node.nodeType, "schema_id", "schema_represented_by_local_id"), + author_local_id: node.authorId, + schema_represented_by_local_id: node.nodeType, contents_inline: filterUndefinedArray([ crossAppNodeToDbContent(node, "direct"), crossAppNodeToDbContent(node, "full"), From b09e99fed77edcf6f4c206a8d030e8c7d05f7b53 Mon Sep 17 00:00:00 2001 From: Marc-Antoine Parent Date: Tue, 7 Jul 2026 20:41:08 -0400 Subject: [PATCH 2/4] eng-1909-add-no-op-content-upsert-endpoint --- .../api/internal/space/[id]/content/route.ts | 57 +++++++++++++++++++ apps/website/app/utils/supabase/account.ts | 17 ++++++ packages/database/src/crossAppContracts.ts | 5 ++ .../database/src/lib/crossAppConverters.ts | 34 +++++++++++ 4 files changed, 113 insertions(+) create mode 100644 apps/website/app/api/internal/space/[id]/content/route.ts diff --git a/apps/website/app/api/internal/space/[id]/content/route.ts b/apps/website/app/api/internal/space/[id]/content/route.ts new file mode 100644 index 000000000..0c4522694 --- /dev/null +++ b/apps/website/app/api/internal/space/[id]/content/route.ts @@ -0,0 +1,57 @@ +import { NextResponse, NextRequest } from "next/server"; + +import { createClient } from "~/utils/supabase/server"; +import { + createApiResponse, + handleRouteError, + defaultOptionsHandler, +} from "~/utils/supabase/apiUtils"; +import { asPostgrestFailure } from "@repo/database/lib/contextFunctions"; +import type { Json } from "@repo/database/dbTypes"; +import { StandaloneCrossAppContent } from "@repo/database/crossAppContracts"; +import { crossAppStandaloneContentToDbContent } from "@repo/database/lib/crossAppConverters"; +import { getAccountId } from "~/utils/supabase/account"; + +type ApiParams = Promise<{ id: string }>; +export type SegmentDataType = { params: ApiParams }; + +export const POST = async ( + request: NextRequest, + segmentData: SegmentDataType, +): Promise => { + const { id: spaceIdS } = await segmentData.params; + const spaceId = Number.parseInt(spaceIdS); + if (Number.isNaN(spaceId)) + return createApiResponse( + request, + asPostgrestFailure("Cannot parse space id", "invalid", 403), + ); + + const supabase = await createClient(); + + const userId = await getAccountId(supabase); + if (userId === undefined) + return createApiResponse( + request, + asPostgrestFailure("Please login", "invalid", 401), + ); + try { + const body = (await request.json()) as StandaloneCrossAppContent[]; + // TODO: Zed validator + const content = body + .map((c) => crossAppStandaloneContentToDbContent(c, spaceId)) + .filter((c) => c !== undefined); + if (content.length === 0) throw new Error("Could not translate content"); + const result = await supabase.rpc("upsert_content", { + data: content as Json, + v_space_id: spaceId, + v_creator_id: userId, + content_as_document: true, + }); + return createApiResponse(request, result); + } catch (e: unknown) { + return handleRouteError(request, e, "/api/supabase/space/[id]/content"); + } +}; + +export const OPTIONS = defaultOptionsHandler; diff --git a/apps/website/app/utils/supabase/account.ts b/apps/website/app/utils/supabase/account.ts index e7305f4c9..35c2d4efe 100644 --- a/apps/website/app/utils/supabase/account.ts +++ b/apps/website/app/utils/supabase/account.ts @@ -3,6 +3,23 @@ import type { DGSupabaseClient } from "@repo/database/lib/client"; type AgentType = Database["public"]["Enums"]["AgentType"] | "group"; +export const getAccountId = async ( + client: DGSupabaseClient, +): Promise => { + const { data, error } = await client.auth.getUser(); + if (error || !data?.user) return undefined; + const userData = data.user; + if (typeof userData.id !== "string") return undefined; + const id = userData.id; + const accountReq = await client + .from("PlatformAccount") + .select("id") + .eq("dg_account", id) + .eq("agent_type", "person") + .maybeSingle(); + return accountReq.data?.id; +}; + export const getSessionBaseUserData = async ( client: DGSupabaseClient, ): Promise<{ diff --git a/packages/database/src/crossAppContracts.ts b/packages/database/src/crossAppContracts.ts index f5c3185f5..ac9feccbd 100644 --- a/packages/database/src/crossAppContracts.ts +++ b/packages/database/src/crossAppContracts.ts @@ -68,6 +68,11 @@ type InlineCrossAppTypedContent = InlineCrossAppContent & { contentType: ContentType; }; +export type StandaloneCrossAppContent = InlineCrossAppTypedContent & + CrossAppBase & { + variant: Enums<"ContentVariant">; + }; + // A node instance export type CrossAppNode = CrossAppBase & { nodeType: LocalId; diff --git a/packages/database/src/lib/crossAppConverters.ts b/packages/database/src/lib/crossAppConverters.ts index 00aadad67..f532f95ee 100644 --- a/packages/database/src/lib/crossAppConverters.ts +++ b/packages/database/src/lib/crossAppConverters.ts @@ -1,11 +1,13 @@ import { CrossAppEmbedding, InlineCrossAppContent, + StandaloneCrossAppContent, CrossAppNode, } from "../crossAppContracts"; import { LocalContentDataInput, LocalConceptDataInput } from "../inputTypes"; import { Enums, CompositeTypes } from "../dbTypes"; +type ContentDataInput = CompositeTypes<"content_local_input">; type InlineEmbeddingInput = CompositeTypes<"inline_embedding_input">; const crossAppEmbeddingToDbEmbedding = ( @@ -45,6 +47,38 @@ const inlineCrossAppContentToDbContent = ( }); }; +export const crossAppStandaloneContentToDbContent = ( + content: StandaloneCrossAppContent | undefined, + spaceId: number, +): ContentDataInput | undefined => { + if (content === undefined) return undefined; + return { + source_local_id: content.localId, + author_local_id: content.authorId, + text: content.value, + scale: content.scale || "document", + content_type: content.contentType || "text/plain", + variant: content.variant, + created: content.createdAt.toISOString(), + last_modified: (content.modifiedAt || content.createdAt).toISOString(), + embedding_inline: crossAppEmbeddingToDbEmbedding(content.embedding) || null, + space_id: spaceId, + // provide other explicit null values for type completion + space_url: null, + author_inline: null, + author_id: null, + document_id: null, + creator_id: null, + creator_local_id: null, + creator_inline: null, + document_local_id: null, + document_inline: null, + part_of_id: null, + part_of_local_id: null, + metadata: null, + }; +}; + export const crossAppNodeToDbContent = ( node: CrossAppNode | undefined, variant: "full" | "direct", From 856b6fc65ea61a9226a995580b44e3261933ab9f Mon Sep 17 00:00:00 2001 From: Marc-Antoine Parent Date: Mon, 13 Jul 2026 10:13:29 -0400 Subject: [PATCH 3/4] Devin and graphite suggestions --- .../app/api/internal/space/[id]/content/route.ts | 11 ++++++++++- apps/website/app/utils/supabase/account.ts | 1 + 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/apps/website/app/api/internal/space/[id]/content/route.ts b/apps/website/app/api/internal/space/[id]/content/route.ts index 0c4522694..3cdb7ddcb 100644 --- a/apps/website/app/api/internal/space/[id]/content/route.ts +++ b/apps/website/app/api/internal/space/[id]/content/route.ts @@ -39,7 +39,16 @@ export const POST = async ( const body = (await request.json()) as StandaloneCrossAppContent[]; // TODO: Zed validator const content = body - .map((c) => crossAppStandaloneContentToDbContent(c, spaceId)) + .map((c) => + crossAppStandaloneContentToDbContent( + { + ...c, + createdAt: new Date(c.createdAt), + modifiedAt: new Date(c.modifiedAt || c.createdAt), + }, + spaceId, + ), + ) .filter((c) => c !== undefined); if (content.length === 0) throw new Error("Could not translate content"); const result = await supabase.rpc("upsert_content", { diff --git a/apps/website/app/utils/supabase/account.ts b/apps/website/app/utils/supabase/account.ts index 35c2d4efe..2a3d97d49 100644 --- a/apps/website/app/utils/supabase/account.ts +++ b/apps/website/app/utils/supabase/account.ts @@ -17,6 +17,7 @@ export const getAccountId = async ( .eq("dg_account", id) .eq("agent_type", "person") .maybeSingle(); + if (accountReq.error) throw accountReq.error; return accountReq.data?.id; }; From d73869a7944a53a4487a31862adb59765ce1b833 Mon Sep 17 00:00:00 2001 From: Marc-Antoine Parent Date: Thu, 16 Jul 2026 11:28:06 -0400 Subject: [PATCH 4/4] broaden try stanza to include getAccountId --- .../app/api/internal/space/[id]/content/route.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/website/app/api/internal/space/[id]/content/route.ts b/apps/website/app/api/internal/space/[id]/content/route.ts index 3cdb7ddcb..e56622ba9 100644 --- a/apps/website/app/api/internal/space/[id]/content/route.ts +++ b/apps/website/app/api/internal/space/[id]/content/route.ts @@ -29,13 +29,13 @@ export const POST = async ( const supabase = await createClient(); - const userId = await getAccountId(supabase); - if (userId === undefined) - return createApiResponse( - request, - asPostgrestFailure("Please login", "invalid", 401), - ); try { + const userId = await getAccountId(supabase); + if (userId === undefined) + return createApiResponse( + request, + asPostgrestFailure("Please login", "invalid", 401), + ); const body = (await request.json()) as StandaloneCrossAppContent[]; // TODO: Zed validator const content = body