diff --git a/packages/database/src/lib/__tests__/dbToCrossAppConverters.test.ts b/packages/database/src/lib/__tests__/dbToCrossAppConverters.test.ts new file mode 100644 index 000000000..86ee73ba7 --- /dev/null +++ b/packages/database/src/lib/__tests__/dbToCrossAppConverters.test.ts @@ -0,0 +1,250 @@ +import { describe, expect, it } from "vitest"; +import { + dbNodeSchemaToCrossApp, + dbRelationTypeSchemaToCrossApp, + dbRelationTripleSchemaToCrossApp, + dbRelationToCrossApp, +} from "../dbToCrossAppConverters"; +import { Tables, Json } from "../../dbTypes"; + +type Concept = Tables<"Concept">; + +const spaceMap: Record = { 1: "obsidian:vault-a" }; +const accountMap: Record = { 42: "account-local-1" }; + +/* eslint-disable @typescript-eslint/naming-convention */ +const baseConcept = ({ + id = 1, + literal_content = {}, + reference_content = {}, + ...overrides +}: Partial & { + literal_content?: Json; + reference_content?: Json; +} = {}): Concept => ({ + id, + arity: 0, + is_relation: false, + author_id: 42, + created: "2026-06-14T11:00:00", + description: null, + epistemic_status: "certainly-not" as Concept["epistemic_status"], + is_schema: true, + last_modified: "2026-06-14T13:00:00", + literal_content, + name: "Some concept", + reference_content, + refs: [], + schema_id: null, + source_local_id: "concept-1", + space_id: 1, + ...overrides, +}); +/* eslint-enable @typescript-eslint/naming-convention */ + +describe("dbNodeSchemaToCrossApp", () => { + it("converts a node schema, splitting out template fields from metadata", () => { + const schema = baseConcept({ + literal_content: { + template: "Template Title", + template_content: "template body", + extra: "kept", + }, + }); + expect(dbNodeSchemaToCrossApp(schema, spaceMap, accountMap)).toEqual({ + rid: "orn:obsidian.schema:vault-a/concept-1", + localId: "concept-1", + createdAt: new Date("2026-06-14T11:00:00Z"), + modifiedAt: new Date("2026-06-14T13:00:00Z"), + label: "Some concept", + metadata: { extra: "kept" }, + template: "template body", + templateTitle: "Template Title", + authorId: "account-local-1", + }); + }); + + it("throws when the author is unknown", () => { + const schema = baseConcept({ author_id: 999 }); + expect(() => dbNodeSchemaToCrossApp(schema, spaceMap, accountMap)).toThrow( + "Missing author", + ); + }); + + it("throws when the space is unknown", () => { + const schema = baseConcept({ space_id: 999 }); + expect(() => dbNodeSchemaToCrossApp(schema, spaceMap, accountMap)).toThrow( + "Missing space", + ); + }); +}); + +describe("dbRelationTypeSchemaToCrossApp", () => { + it("converts a relation type schema, splitting out label/complement from metadata", () => { + const schema = baseConcept({ + literal_content: { + roles: ["source", "destination"], + label: "supports", + complement: "is supported by", + extra: "kept", + }, + }); + expect( + dbRelationTypeSchemaToCrossApp(schema, spaceMap, accountMap), + ).toEqual({ + rid: "orn:obsidian.schema:vault-a/concept-1", + localId: "concept-1", + createdAt: new Date("2026-06-14T11:00:00Z"), + modifiedAt: new Date("2026-06-14T13:00:00Z"), + metadata: { extra: "kept" }, + label: "supports", + complement: "is supported by", + authorId: "account-local-1", + }); + }); +}); + +describe("dbRelationTripleSchemaToCrossApp", () => { + const conceptMap: Record = { + 10: "orn:obsidian.schema:vault-a/relation-type-1", + 20: "orn:obsidian.schema:vault-a/source-type-1", + 30: "orn:obsidian.schema:vault-a/destination-type-1", + }; + + it("uses the relation_type reference when present, resolved to a local id", () => { + const schema = baseConcept({ + reference_content: { relation_type: 10, source: 20, destination: 30 }, + }); + const result = dbRelationTripleSchemaToCrossApp({ + schema, + spaceMap, + accountMap, + conceptMap, + }); + expect(result).toMatchObject({ + relation: "relation-type-1", + sourceType: "source-type-1", + destinationType: "destination-type-1", + }); + expect(result).not.toHaveProperty("label"); + }); + + it("falls back to literal label/complement when no relation_type reference exists", () => { + const schema = baseConcept({ + literal_content: { label: "supports", complement: "is supported by" }, + reference_content: { source: 20, destination: 30 }, + }); + const result = dbRelationTripleSchemaToCrossApp({ + schema, + spaceMap, + accountMap, + conceptMap, + }); + expect(result).toMatchObject({ + label: "supports", + complement: "is supported by", + }); + expect(result).not.toHaveProperty("relation"); + }); + + it("throws when neither relation_type nor label/complement are present", () => { + const schema = baseConcept({ + reference_content: { source: 20, destination: 30 }, + }); + expect(() => + dbRelationTripleSchemaToCrossApp({ + schema, + spaceMap, + accountMap, + conceptMap, + }), + ).toThrow("Missing either relation_type or relation_type data"); + }); + + it("throws when the source type is missing", () => { + const schema = baseConcept({ + reference_content: { relation_type: 10, destination: 30 }, + }); + expect(() => + dbRelationTripleSchemaToCrossApp({ + schema, + spaceMap, + accountMap, + conceptMap, + }), + ).toThrow("Missing source type"); + }); + + it("throws when the destination type is missing", () => { + const schema = baseConcept({ + reference_content: { relation_type: 10, source: 20 }, + }); + expect(() => + dbRelationTripleSchemaToCrossApp({ + schema, + spaceMap, + accountMap, + conceptMap, + }), + ).toThrow("Missing destination type"); + }); +}); + +describe("dbRelationToCrossApp", () => { + const conceptMap: Record = { + 10: "orn:obsidian.schema:vault-a/relation-type-1", + 20: "orn:obsidian.node:vault-a/source-node-1", + 30: "orn:obsidian.node:vault-a/destination-node-1", + }; + + it("converts a relation, resolving type/source/destination to local ids", () => { + const relation = baseConcept({ + is_schema: false, + schema_id: 10, + reference_content: { source: 20, destination: 30 }, + }); + expect( + dbRelationToCrossApp({ relation, spaceMap, accountMap, conceptMap }), + ).toEqual({ + rid: "orn:obsidian.relation:vault-a/concept-1", + localId: "concept-1", + authorId: "account-local-1", + createdAt: new Date("2026-06-14T11:00:00Z"), + modifiedAt: new Date("2026-06-14T13:00:00Z"), + source: "source-node-1", + destination: "destination-node-1", + relationType: "relation-type-1", + }); + }); + + it("keeps source/destination as a full rid when it points to a different space", () => { + const relation = baseConcept({ + is_schema: false, + schema_id: 10, + reference_content: { source: 20, destination: 30 }, + }); + const foreignConceptMap = { + ...conceptMap, + 20: "orn:obsidian.node:vault-b/source-node-1", + }; + expect( + dbRelationToCrossApp({ + relation, + spaceMap, + accountMap, + conceptMap: foreignConceptMap, + }).source, + ).toBe("orn:obsidian.node:vault-b/source-node-1"); + }); + + it("throws when the relation type is missing", () => { + const relation = baseConcept({ + is_schema: false, + schema_id: 999, + reference_content: { source: 20, destination: 30 }, + }); + expect(() => + dbRelationToCrossApp({ relation, spaceMap, accountMap, conceptMap }), + ).toThrow("Missing relationType"); + }); +}); diff --git a/packages/database/src/lib/dbToCrossAppConverters.ts b/packages/database/src/lib/dbToCrossAppConverters.ts new file mode 100644 index 000000000..4fbfbede8 --- /dev/null +++ b/packages/database/src/lib/dbToCrossAppConverters.ts @@ -0,0 +1,395 @@ +import { + CrossAppNodeSchema, + CrossAppRelationTypeSchema, + CrossAppRelationTripleSchema, + CrossAppRelation, +} from "../crossAppContracts"; +import { Tables, Json } from "../dbTypes"; +import type { DGSupabaseClient } from "./client"; +import { ridToSpaceUriAndLocalId, spaceUriAndLocalIdToRid, isRid } from "./rid"; + +type Concept = Tables<"Concept">; + +const getConceptMap = async ( + client: DGSupabaseClient, + conceptIds: number[], + spaceMap: Record, +): Promise> => { + const request = await client + .from("my_concepts") + .select("id, space_id, source_local_id") + .in("id", conceptIds) + .not("source_local_id", "is", null); + if (request.error) throw request.error; + return Object.fromEntries( + (request.data || []) + .map(({ id, source_local_id, space_id }) => { + const spaceUri: string | undefined = spaceMap[space_id ?? 0]; + return [ + id!, + spaceUri !== undefined + ? spaceUriAndLocalIdToRid(spaceUri, source_local_id) + : undefined, + ]; + }) + .filter(([, rid]) => rid !== undefined) as [number, string][], + ); +}; + +export const getAccountMap = async ( + client: DGSupabaseClient, + accountIds: number[], +): Promise> => { + const request = await client + .from("my_accounts") + .select("id,account_local_id") + .in("id", accountIds); + if (request.error) throw request.error; + return Object.fromEntries( + (request.data || []).map(({ id, account_local_id }) => [ + id!, + account_local_id!, + ]), + ); +}; + +export const getSpaceMap = async ( + client: DGSupabaseClient, + spaceIds?: number[], +): Promise> => { + let query = client.from("my_spaces").select("id, url"); + if (spaceIds !== undefined) query = query.in("id", [...spaceIds]); + + const { data, error } = await query; + if (error) throw error; + if (!data) throw new Error("Missing spaces"); + return Object.fromEntries(data.map(({ id, url }) => [id!, url!])); +}; + +const asSimpleLocalId = ( + rid: string | undefined, + spaceUrl: string | undefined, + optional?: boolean, +): string | undefined => { + if (rid === undefined) return undefined; + if (!isRid(rid)) return rid; + const { spaceUri, sourceLocalId } = ridToSpaceUriAndLocalId(rid); + if (spaceUrl === spaceUri) return sourceLocalId; + if (optional !== true) throw new Error("Unexpected spaceUri"); + return rid; +}; + +export const dbNodeSchemaToCrossApp = ( + schema: Concept, + spaceMap: Record, + accountMap: Record, +): CrossAppNodeSchema => { + const { template, template_content, ...other } = + schema.literal_content as Record; + const authorId = accountMap[schema.author_id || 0]; + if (authorId === undefined) throw new Error("Missing author"); + const spaceUrl = spaceMap[schema.space_id]; + if (spaceUrl === undefined) throw new Error("Missing space"); + const rid = spaceUriAndLocalIdToRid( + spaceUrl, + schema.source_local_id!, + "schema", + ); + return { + rid, + localId: schema.source_local_id!, + createdAt: new Date(schema.created + "Z"), + modifiedAt: new Date(schema.last_modified + "Z"), + label: schema.name, + metadata: other, + template: template_content as string | undefined, + templateTitle: template as string | undefined, + authorId, + }; +}; + +export const dbNodeSchemasToCrossApp = async ({ + client, + schemas, + spaceMap, + accountMap, +}: { + client: DGSupabaseClient; + schemas: Concept[]; + spaceMap?: Record; + accountMap?: Record; +}): Promise => { + if (spaceMap === undefined) spaceMap = await getSpaceMap(client); + if (accountMap === undefined) { + const authorIds = new Set( + schemas.map((r) => r.author_id).filter((id) => typeof id === "number"), + ); + accountMap = await getAccountMap(client, [...authorIds]); + } + return schemas.map((r) => dbNodeSchemaToCrossApp(r, spaceMap, accountMap)); +}; + +export const dbRelationTypeSchemaToCrossApp = ( + schema: Concept, + spaceMap: Record, + accountMap: Record, +): CrossAppRelationTypeSchema => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { roles, label, complement, ...other } = + schema.literal_content as Record; + const authorId = accountMap[schema.author_id || 0]; + if (authorId === undefined) throw new Error("Missing author"); + const spaceUrl = spaceMap[schema.space_id]; + if (spaceUrl === undefined) throw new Error("Missing space"); + const rid = spaceUriAndLocalIdToRid( + spaceUrl, + schema.source_local_id!, + "schema", + ); + return { + rid, + localId: schema.source_local_id!, + createdAt: new Date(schema.created + "Z"), + modifiedAt: new Date(schema.last_modified + "Z"), + metadata: other, + label: label as string, + complement: complement as string, + authorId, + }; +}; + +export const dbRelationTypeSchemasToCrossApp = async ({ + client, + schemas, + spaceMap, + accountMap, +}: { + client: DGSupabaseClient; + schemas: Concept[]; + spaceMap?: Record; + accountMap?: Record; +}): Promise => { + if (spaceMap === undefined) spaceMap = await getSpaceMap(client); + if (accountMap === undefined) { + const authorIds = new Set( + schemas.map((r) => r.author_id).filter((id) => typeof id === "number"), + ); + accountMap = await getAccountMap(client, [...authorIds]); + } + + return schemas.map((r) => + dbRelationTypeSchemaToCrossApp(r, spaceMap, accountMap), + ); +}; + +export const dbRelationTripleSchemaToCrossApp = ({ + schema, + spaceMap, + accountMap, + conceptMap, +}: { + schema: Concept; + spaceMap: Record; + accountMap: Record; + conceptMap: Record; +}): CrossAppRelationTripleSchema => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { roles, label, complement, ...other } = + schema.literal_content as Record; + const authorId = accountMap[schema.author_id || 0]; + if (authorId === undefined) throw new Error("Missing author"); + const references = (schema.reference_content ?? {}) as Record; + const spaceUrl = spaceMap[schema.space_id]; + if (spaceUrl === undefined) throw new Error("Missing space"); + const rid = spaceUriAndLocalIdToRid( + spaceUrl, + schema.source_local_id!, + "schema", + ); + const relation = asSimpleLocalId( + conceptMap[references["relation_type"] ?? 0], + spaceUrl, + ); + const sourceType = asSimpleLocalId( + conceptMap[references["source"] ?? 0], + spaceUrl, + ); + const destinationType = asSimpleLocalId( + conceptMap[references["destination"] ?? 0], + spaceUrl, + ); + if (sourceType === undefined) throw new Error("Missing source type"); + if (destinationType === undefined) + throw new Error("Missing destination type"); + const base = { + rid, + localId: schema.source_local_id!, + createdAt: new Date(schema.created + "Z"), + modifiedAt: new Date(schema.last_modified + "Z"), + metadata: other, + authorId, + sourceType, + destinationType, + }; + if (relation) { + return { + ...base, + relation, + }; + } else { + if (typeof label !== "string" || typeof complement !== "string") + throw new Error("Missing either relation_type or relation_type data"); + return { + ...base, + label, + complement, + }; + } +}; + +export const dbRelationTripleSchemasToCrossApp = async ({ + client, + schemas, + spaceMap, + accountMap, + conceptMap, +}: { + client: DGSupabaseClient; + schemas: Concept[]; + spaceMap?: Record; + accountMap?: Record; + conceptMap?: Record; +}): Promise => { + if (spaceMap === undefined) spaceMap = await getSpaceMap(client); + if (accountMap === undefined) { + const authorIds = new Set( + schemas.map((r) => r.author_id).filter((id) => typeof id === "number"), + ); + accountMap = await getAccountMap(client, [...authorIds]); + } + if (conceptMap === undefined) { + const schemaIds = schemas + .map((r) => { + const refs = (r.reference_content ?? {}) as Record< + string, + number | number[] + >; + return [ + refs["source"] ?? [], + refs["destination"] ?? [], + refs["relation_type"] ?? [], + ]; + }) + .flat(2); + conceptMap = await getConceptMap(client, [...new Set(schemaIds)], spaceMap); + } + + return schemas.map((schema) => + dbRelationTripleSchemaToCrossApp({ + schema, + spaceMap, + accountMap, + conceptMap, + }), + ); +}; + +export const dbRelationToCrossApp = ({ + relation, + spaceMap, + accountMap, + conceptMap, +}: { + relation: Concept; + spaceMap: Record; + accountMap: Record; + conceptMap: Record; +}): CrossAppRelation => { + const authorId = accountMap[relation.author_id || 0]; + if (authorId === undefined) throw new Error("Missing author"); + const references = (relation.reference_content ?? {}) as Record< + string, + number + >; + const spaceUrl = spaceMap[relation.space_id]; + if (spaceUrl === undefined) throw new Error("Missing space"); + const rid = spaceUriAndLocalIdToRid( + spaceUrl, + relation.source_local_id!, + "relation", + ); + const relationType = asSimpleLocalId( + conceptMap[relation.schema_id || 0], + spaceUrl, + ); + if (relationType === undefined) throw new Error("Missing relationType"); + const source = asSimpleLocalId( + conceptMap[references["source"] || 0], + spaceUrl, + true, + ); + if (source === undefined) throw new Error("Missing source"); + const destination = asSimpleLocalId( + conceptMap[references["destination"] || 0], + spaceUrl, + true, + ); + if (destination === undefined) throw new Error("Missing destination"); + + return { + rid, + localId: relation.source_local_id!, + authorId, + createdAt: new Date(relation.created + "Z"), + modifiedAt: new Date(relation.last_modified + "Z"), + source, + destination, + relationType, + }; +}; + +export const dbRelationsToCrossApp = async ({ + client, + relations, + accountMap, + conceptMap, + spaceMap, +}: { + client: DGSupabaseClient; + relations: Concept[]; + accountMap?: Record; + conceptMap?: Record; + spaceMap?: Record; +}): Promise => { + if (accountMap === undefined) { + const authorIds = new Set( + relations.map((r) => r.author_id).filter((id) => typeof id === "number"), + ); + accountMap = await getAccountMap(client, [...authorIds]); + } + if (spaceMap === undefined) { + spaceMap = await getSpaceMap(client); + } + if (conceptMap === undefined) { + const nodeIds = relations + .map((r) => { + const refs = (r.reference_content ?? {}) as Record< + string, + number | number[] + >; + return [refs["source"] ?? [], refs["destination"] ?? []]; + }) + .flat(2); + const schemaIds = relations + .map((r) => r.schema_id) + .filter((id) => id !== null); + conceptMap = await getConceptMap( + client, + [...new Set([...schemaIds, ...nodeIds])], + spaceMap, + ); + } + return relations.map((relation) => + dbRelationToCrossApp({ relation, spaceMap, accountMap, conceptMap }), + ); +};