Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/database/src/crossAppContracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export type CrossAppNodeSchema = CrossAppSchemaBase & {
label: string;
template?: string;
templateTitle?: string;
slotDefinitions?: Record<string, LocalId | undefined>;
};

// A relation type schema
Expand Down Expand Up @@ -75,6 +76,7 @@ type InlineCrossAppTypedContent = InlineCrossAppContent & {
// A node instance
export type CrossAppNode = CrossAppBase & {
nodeType: LocalId;
slots?: Record<string, LocalId>;
content: {
direct: InlineCrossAppContent;
full?: InlineCrossAppTypedContent;
Expand Down
70 changes: 63 additions & 7 deletions packages/database/src/lib/__tests__/dbToCrossAppConverters.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,31 +51,87 @@ describe("dbNodeSchemaToCrossApp", () => {
extra: "kept",
},
});
expect(dbNodeSchemaToCrossApp(schema, spaceMap, accountMap)).toEqual({
expect(
dbNodeSchemaToCrossApp({ schema, spaceMap, accountMap, schemaMap: {} }),
).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" },
slotDefinitions: {},
template: "template body",
templateTitle: "Template Title",
authorId: "account-local-1",
});
});

it("resolves slot definitions from roles and reference content", () => {
const schema = baseConcept({
literal_content: { roles: ["evidence", "claim"], extra: "kept" },
reference_content: { evidence: 10, claim: 20 },
});
const result = dbNodeSchemaToCrossApp({
schema,
spaceMap,
accountMap,
schemaMap: {
10: "orn:obsidian.schema:vault-a/evidence-type",
20: "orn:obsidian.schema:vault-a/claim-type",
},
});
// schemas are always local, so slots hold plain source local ids
expect(result.slotDefinitions).toEqual({
evidence: "evidence-type",
claim: "claim-type",
});
// roles drive the slot definitions, they are not kept as plain metadata
expect(result.metadata).toEqual({ extra: "kept" });
});

it("throws when a slot points at a schema in another space", () => {
const schema = baseConcept({
literal_content: { roles: ["evidence"] },
reference_content: { evidence: 10 },
});
expect(() =>
dbNodeSchemaToCrossApp({
schema,
spaceMap,
accountMap,
schemaMap: { 10: "orn:obsidian.schema:vault-b/evidence-type" },
}),
).toThrow("Unexpected spaceUri");
});

it("omits slots whose referenced schema cannot be resolved", () => {
const schema = baseConcept({
literal_content: { roles: ["evidence", "claim"] },
reference_content: { evidence: 10 },
});
expect(
dbNodeSchemaToCrossApp({
schema,
spaceMap,
accountMap,
schemaMap: { 10: "orn:obsidian.schema:vault-a/evidence-type" },
}).slotDefinitions,
).toEqual({ evidence: "evidence-type" });
});

it("throws when the author is unknown", () => {
const schema = baseConcept({ author_id: 999 });
expect(() => dbNodeSchemaToCrossApp(schema, spaceMap, accountMap)).toThrow(
"Missing author",
);
expect(() =>
dbNodeSchemaToCrossApp({ schema, spaceMap, accountMap, schemaMap: {} }),
).toThrow("Missing author");
});

it("throws when the space is unknown", () => {
const schema = baseConcept({ space_id: 999 });
expect(() => dbNodeSchemaToCrossApp(schema, spaceMap, accountMap)).toThrow(
"Missing space",
);
expect(() =>
dbNodeSchemaToCrossApp({ schema, spaceMap, accountMap, schemaMap: {} }),
).toThrow("Missing space");
});
});

Expand Down
32 changes: 32 additions & 0 deletions packages/database/src/lib/__tests__/sharedNodes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ const nodes: BuildArgs["nodes"] = [
schema_id: 200,
source_local_id: "node-1",
space_id: 20,
reference_content: {},
concepts_of_relation: [],
},
];
const directContents: BuildArgs["directContents"] = [
Expand Down Expand Up @@ -167,6 +169,36 @@ describe("buildSharedNodes", () => {
expect(build({ nodesOverride, directOverride })).toEqual([]);
});

it("resolves slots to local ids in the same space and rids elsewhere", () => {
const otherSpace: BuildArgs["spaces"][number] = {
id: 21,
name: "Other vault",
platform: "Obsidian",
url: "obsidian:vault-b",
};
const nodeWithSlots: BuildArgs["nodes"][number] = {
...nodes[0]!,
reference_content: { evidence: 5, claim: 6, dangling: 7 },
concepts_of_relation: [
{ id: 5, space_id: 20, source_local_id: "node-5" },
{ id: 6, space_id: 21, source_local_id: "node-6" },
],
};
expect(
build({
nodesOverride: [nodeWithSlots],
spacesOverride: [...spaces, otherSpace],
})[0]?.slots,
).toEqual({
evidence: "node-5",
claim: "orn:obsidian:vault-b/node-6",
});
});

it("leaves slots undefined when the node references nothing", () => {
expect(build()[0]?.slots).toBeUndefined();
});

it("sorts newest nodes first", () => {
const olderNode = {
...nodes[0]!,
Expand Down
5 changes: 5 additions & 0 deletions packages/database/src/lib/crossAppConverters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,16 +86,20 @@ export const crossAppNodeToDbConcept = (
]),
created: node.createdAt?.toISOString(),
last_modified: node.modifiedAt?.toISOString(),
local_reference_content: node.slots,
});
};

export const crossAppNodeSchemaToDbConcept = (
node: CrossAppNodeSchema,
): LocalConceptDataInput => {
const slots = Object.keys(node.slotDefinitions ?? {});
const literalInfo = filterUndefined({
template: node.templateTitle,
template_content: node.template,
roles: slots.length > 0 ? slots : undefined,
});
const referenceContent = slots.length ? node.slotDefinitions! : undefined;
const spaceUri = node.rid
? ridToSpaceUriAndLocalId(node.rid).spaceUri
: undefined;
Expand All @@ -107,6 +111,7 @@ export const crossAppNodeSchemaToDbConcept = (
is_schema: true,
literal_content:
Object.keys(literalInfo).length > 0 ? literalInfo : undefined,
local_reference_content: referenceContent,
created: node.createdAt?.toISOString(),
last_modified: node.modifiedAt?.toISOString(),
});
Expand Down
38 changes: 31 additions & 7 deletions packages/database/src/lib/dbToCrossAppConverters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ const getConceptMap = async (
conceptIds: number[],
spaceMap: Record<number, string>,
): Promise<Record<number, string>> => {
if (conceptIds.length === 0) return {};
const request = await client
.from("my_concepts")
.select("id, space_id, source_local_id")
Expand Down Expand Up @@ -79,12 +80,22 @@ const asSimpleLocalId = (
return rid;
};

export const dbNodeSchemaToCrossApp = (
schema: Concept,
spaceMap: Record<number, string>,
accountMap: Record<number, string>,
): CrossAppNodeSchema => {
const { template, template_content, ...other } =
export const dbNodeSchemaToCrossApp = ({
schema,
spaceMap,
accountMap,
schemaMap,
}: {
schema: Concept;
spaceMap: Record<number, string>;
accountMap: Record<number, string>;
schemaMap: Record<number, string>;
}): CrossAppNodeSchema => {
const referenceContent = (schema.reference_content ?? {}) as Record<
string,
number
>;
const { template, template_content, roles, ...other } =
schema.literal_content as Record<string, Json>;
const authorId = accountMap[schema.author_id || 0];
if (authorId === undefined) throw new Error("Missing author");
Expand All @@ -95,6 +106,14 @@ export const dbNodeSchemaToCrossApp = (
schema.source_local_id!,
"schema",
);
const slotDefinitions: Record<string, string> = Object.fromEntries(
((roles as string[] | undefined) ?? [])
.map((r) => [
r,
asSimpleLocalId(schemaMap[referenceContent[r] ?? 0], spaceUrl),
])
.filter(([, s]) => s !== undefined) as [string, string][],
);
return {
rid,
localId: schema.source_local_id!,
Expand All @@ -105,6 +124,7 @@ export const dbNodeSchemaToCrossApp = (
template: template_content as string | undefined,
templateTitle: template as string | undefined,
authorId,
slotDefinitions,
};
};

Expand All @@ -126,7 +146,11 @@ export const dbNodeSchemasToCrossApp = async ({
);
accountMap = await getAccountMap(client, [...authorIds]);
}
return schemas.map((r) => dbNodeSchemaToCrossApp(r, spaceMap, accountMap));
const referredSchemaIds = schemas.flatMap((schema) => schema.refs);
const schemaMap = await getConceptMap(client, referredSchemaIds, spaceMap);
return schemas.map((schema) =>
dbNodeSchemaToCrossApp({ schema, spaceMap, accountMap, schemaMap }),
);
};

export const dbRelationTypeSchemaToCrossApp = (
Expand Down
43 changes: 37 additions & 6 deletions packages/database/src/lib/sharedNodes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,19 @@ import type { Enums, Json, Tables } from "../dbTypes";

type SharedConcept = Pick<
Tables<"my_concepts">,
"is_schema" | "last_modified" | "schema_id" | "source_local_id" | "space_id"
>;
| "is_schema"
| "last_modified"
| "schema_id"
| "source_local_id"
| "space_id"
| "reference_content"
> & {
concepts_of_relation: {
id: number | null;
space_id: number | null;
source_local_id: string | null;
}[];
};
type SharedContent = Pick<
Tables<"my_contents">,
| "author_id"
Expand Down Expand Up @@ -45,6 +56,7 @@ export type SharedNode = {
lastModified: string;
authorId?: number;
directMetadata: Json;
slots?: Record<string, string>;
};

export type SharedNodeRows = {
Expand All @@ -54,8 +66,8 @@ export type SharedNodeRows = {
spaces: SharedSpace[];
};

const CONCEPT_COLUMNS =
"is_schema, last_modified, schema_id, source_local_id, space_id";
const CONCEPT_COLUMNS_WITH_SLOTS =
"is_schema, last_modified, schema_id, source_local_id, space_id, reference_content, concepts_of_relation(id, space_id, source_local_id)";
const DIRECT_CONTENT_COLUMNS =
"author_id, created, last_modified, metadata, source_local_id, space_id, text, variant";
const FULL_CONTENT_SUMMARY_COLUMNS = "last_modified, source_local_id, space_id";
Expand Down Expand Up @@ -184,6 +196,24 @@ export const buildSharedNodes = ({
return [];
}

const nodeRidById = Object.fromEntries(
node.concepts_of_relation.map((c) => {
if (c.space_id === node.space_id) return [c.id, c.source_local_id];
const space = spacesById.get(c.space_id || 0);
if (!space || !c.source_local_id || !c.id) return [c.id, undefined];
return [c.id, spaceUriAndLocalIdToRid(space.url, c.source_local_id)];
}) as [number, string | undefined][],
);
const referenceContent = (node.reference_content ?? {}) as Record<
string,
number
>;
const slots = Object.fromEntries(
Object.entries(referenceContent ?? {})
.map(([k, v]) => [k, nodeRidById[v]])
.filter(([, v]) => v !== undefined) as [string, string][],
);
Comment thread
maparent marked this conversation as resolved.

return [
{
rid,
Expand All @@ -197,6 +227,7 @@ export const buildSharedNodes = ({
lastModified,
authorId: direct.author_id ?? undefined,
directMetadata: direct.metadata,
slots: Object.keys(slots).length > 0 ? slots : undefined,
},
];
})
Expand All @@ -218,7 +249,7 @@ const getSharedNodeRows = async ({
await Promise.all([
client
.from("my_concepts")
.select(CONCEPT_COLUMNS)
.select(CONCEPT_COLUMNS_WITH_SLOTS)
.neq("space_id", currentSpaceId)
.eq("is_schema", false)
.eq("is_relation", false),
Expand Down Expand Up @@ -278,7 +309,7 @@ export const getSharedNodeByRid = async ({
const [conceptsResponse, directResponse, fullResponse] = await Promise.all([
client
.from("my_concepts")
.select(CONCEPT_COLUMNS)
.select(CONCEPT_COLUMNS_WITH_SLOTS)
.eq("space_id", space.id)
.eq("source_local_id", sourceLocalId)
.eq("is_schema", false)
Expand Down