Skip to content
Merged
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
41 changes: 41 additions & 0 deletions apps/obsidian/src/utils/__tests__/repairTripleRids.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { describe, expect, it } from "vitest";
import type { DiscourseRelation } from "~/types";
import { findTriplesWithRelationRid } from "~/utils/repairTripleRids";

const triple = (id: string, importedFromRid?: string): DiscourseRelation => ({
id,
sourceId: "clm",
destinationId: "evd",
relationshipTypeId: "sup",
created: 0,
modified: 0,
importedFromRid,
});

describe("findTriplesWithRelationRid", () => {
it("finds a triple whose RID has the relation subtype", () => {
const bad = triple("t1", "orn:obsidian.relation:vault-a/rel-1");
expect(findTriplesWithRelationRid([bad], new Set())).toEqual([bad]);
});

it("finds a triple whose RID is an imported relation instance's", () => {
const roamRid = "https://roamresearch.com/#/app/graph/page/relUid";
const bad = triple("t1", roamRid);
expect(findTriplesWithRelationRid([bad], new Set([roamRid]))).toEqual([
bad,
]);
});

it("leaves local triples and triples with a schema RID alone", () => {
expect(
findTriplesWithRelationRid(
[
triple("t1"),
triple("t2", "orn:obsidian.schema:vault-a/triple-1"),
triple("t3", "https://roamresearch.com/#/app/graph/page/tripleUid"),
],
new Set(["orn:obsidian.relation:vault-a/other"]),
),
).toEqual([]);
});
});
147 changes: 147 additions & 0 deletions apps/obsidian/src/utils/__tests__/sourceTriple.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import { describe, expect, it } from "vitest";
import type { RemoteRelationInstance } from "~/utils/importRelations";
import { type SchemaRow, selectSourceTripleRids } from "~/utils/sourceTriple";

const A = 10;
const B = 20;
const spaceUriById = new Map([
[A, "obsidian:vault-a"],
[B, "obsidian:vault-b"],
]);

const schema = (
row: Pick<SchemaRow, "id" | "space_id" | "source_local_id" | "name"> &
Partial<SchemaRow>,
): SchemaRow => ({ literal_content: {}, reference_content: {}, ...row });

const claim = schema({
id: 1,
space_id: A,
source_local_id: "clm",
name: "Claim",
});
const evidenceA = schema({
id: 2,
space_id: A,
source_local_id: "evd-a",
name: "Evidence",
literal_content: { importedFromRid: "orn:obsidian.schema:vault-b/evd-b" },
});
const evidenceB = schema({
id: 3,
space_id: B,
source_local_id: "evd-b",
name: "Evidence",
});
const supports = schema({
id: 4,
space_id: A,
source_local_id: "sup",
name: "Supports",
literal_content: { label: "supports" },
});
const triple = (id: number, localId: string, refs: Record<string, number>) =>
schema({
id,
space_id: A,
source_local_id: localId,
name: "Claim -supports-> Evidence",
reference_content: { relation_type: supports.id, ...refs },
});

// X is A's own claim; Y is B's evidence node, which A imported.
const relation = (schemaId: number): RemoteRelationInstance => ({
id: 100,
source_local_id: "rel-1",
schema_id: schemaId,
reference_content: { source: 50, destination: 60 },
refs: [50, 60],
created: null,
last_modified: null,
author_id: null,
concepts_of_relation: [
{ id: 50, space_id: A, source_local_id: "x", schema_id: claim.id },
{ id: 60, space_id: B, source_local_id: "y", schema_id: evidenceB.id },
],
});

const byId = (...rows: SchemaRow[]) => new Map(rows.map((r) => [r.id, r]));

describe("selectSourceTripleRids", () => {
it("uses the schema itself when the relation's schema is a triple", () => {
const roamTriple = triple(5, "roam-triple", { source: 1, destination: 3 });
expect(
selectSourceTripleRids({
relations: [relation(roamTriple.id)],
schemasById: byId(roamTriple, claim, evidenceB),
candidateTriples: [],
spaceUriById,
}),
).toEqual(new Map([[100, "orn:obsidian.schema:vault-a/roam-triple"]]));
});

it("picks the relation space's triple whose ends match the relation's end types", () => {
const fits = triple(5, "t-fits", { source: 1, destination: 2 });
const other = triple(6, "t-other", { source: 1, destination: 1 });
expect(
selectSourceTripleRids({
relations: [relation(supports.id)],
schemasById: byId(supports, claim, evidenceA, evidenceB),
candidateTriples: [fits, other],
spaceUriById,
}),
).toEqual(new Map([[100, "orn:obsidian.schema:vault-a/t-fits"]]));
});

it("matches a hidden end type by the name in the triple's name", () => {
const hiddenEnd = triple(5, "t-hidden", { source: 1, destination: 2 });
expect(
selectSourceTripleRids({
relations: [relation(supports.id)],
schemasById: byId(supports, claim, evidenceB),
candidateTriples: [hiddenEnd],
spaceUriById,
}),
).toEqual(new Map([[100, "orn:obsidian.schema:vault-a/t-hidden"]]));
});

it("ignores triples from other spaces", () => {
const elsewhere = {
...triple(5, "t-b", { source: 1, destination: 3 }),
space_id: B,
};
expect(
selectSourceTripleRids({
relations: [relation(supports.id)],
schemasById: byId(supports, claim, evidenceB),
candidateTriples: [elsewhere],
spaceUriById,
}).size,
).toBe(0);
});

it("records nothing when two triples match equally", () => {
expect(
selectSourceTripleRids({
relations: [relation(supports.id)],
schemasById: byId(supports, claim, evidenceB),
candidateTriples: [
triple(5, "t-one", { source: 1, destination: 90 }),
triple(6, "t-two", { source: 1, destination: 91 }),
],
spaceUriById,
}).size,
).toBe(0);
});

it("records nothing when the relation's schema is not visible", () => {
expect(
selectSourceTripleRids({
relations: [relation(supports.id)],
schemasById: byId(claim, evidenceB),
candidateTriples: [triple(5, "t", { source: 1, destination: 2 })],
spaceUriById,
}).size,
).toBe(0);
});
});
89 changes: 65 additions & 24 deletions apps/obsidian/src/utils/importRelations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,13 @@ import {
findExistingTriple,
findLocalRelationTypeMatch,
} from "./schemaMatching";
import { fetchSourceTripleRids } from "./sourceTriple";

type ConceptInRelation = {
id: number;
space_id: number;
source_local_id: string;
schema_id: number | null;
};

export type RemoteRelationInstance = {
Expand Down Expand Up @@ -168,24 +170,29 @@ const findOrCreateTriple = async ({
};

/**
* Fetch relation instances from a remote space. Relation instances are concepts with
* is_schema=false and schema_id pointing to a relation type
* Fetch relation instances from a remote space, or only those with the given local ids.
* Relation instances are concepts with is_schema=false and schema_id pointing to a relation
* type (Obsidian) or a triple (Roam).
*/
export const fetchRelationInstancesFromSpace = async ({
client,
spaceId,
sourceLocalIds,
}: {
client: DGSupabaseClient;
spaceId: number;
sourceLocalIds?: string[];
}): Promise<RemoteRelationInstance[]> => {
const { data: instances, error } = await client
let query = client
.from("my_concepts")
.select(
"id, source_local_id, schema_id, reference_content, refs, created, last_modified, author_id, concepts_of_relation!inner(id, space_id, source_local_id)",
"id, source_local_id, schema_id, reference_content, refs, created, last_modified, author_id, concepts_of_relation!inner(id, space_id, source_local_id, schema_id)",
)
.eq("space_id", spaceId)
.eq("is_schema", false)
.eq("is_relation", true);
if (sourceLocalIds) query = query.in("source_local_id", sourceLocalIds);
const { data: instances, error } = await query;

if (error || !instances) {
console.warn("Error fetching relation instances:", error);
Expand All @@ -195,6 +202,36 @@ export const fetchRelationInstancesFromSpace = async ({
return instances as unknown as RemoteRelationInstance[];
};

const resolveRelationEnds = (
rel: RemoteRelationInstance,
keyToRelationEndpointId: Map<string, string>,
):
| {
sourceData: ConceptInRelation;
destData: ConceptInRelation;
sourceEndpointId: string;
destEndpointId: string;
}
| undefined => {
const refs = rel.reference_content as Record<string, number | number[]>;
const sourceData = rel.concepts_of_relation.find(
(cor) => cor.id === refs.source,
);
const destData = rel.concepts_of_relation.find(
(cor) => cor.id === refs.destination,
);
if (!sourceData || !destData) return undefined;

const sourceEndpointId = keyToRelationEndpointId.get(
`${sourceData.space_id}:${sourceData.source_local_id}`,
);
const destEndpointId = keyToRelationEndpointId.get(
`${destData.space_id}:${destData.source_local_id}`,
);
if (!sourceEndpointId || !destEndpointId) return undefined;
return { sourceData, destData, sourceEndpointId, destEndpointId };
};

/**
* Import relations where both source and destination resolve in this vault (imported or local).
* keyToRelationEndpointId maps "spaceId:source_local_id" -> endpoint id (RID or nodeInstanceId) to store in RelationInstance.
Expand Down Expand Up @@ -248,27 +285,28 @@ export const importRelationsForImportedNodes = async ({
}
}

for (const rel of relationInstances) {
const sourceData = rel.concepts_of_relation.find(
(cor) =>
cor.id ===
(rel.reference_content as Record<string, number | number[]>).source,
);
const destData = rel.concepts_of_relation.find(
(cor) =>
cor.id ===
(rel.reference_content as Record<string, number | number[]>)
.destination,
);
if (!sourceData || !destData) continue;

const sourceKey = `${sourceData.space_id}:${sourceData.source_local_id}`;
const destKey = `${destData.space_id}:${destData.source_local_id}`;
const importable = relationInstances.flatMap((rel) => {
const ends = resolveRelationEnds(rel, keyToRelationEndpointId);
return ends ? [{ rel, ...ends }] : [];
});

const sourceEndpointId = keyToRelationEndpointId.get(sourceKey);
const destEndpointId = keyToRelationEndpointId.get(destKey);
if (!sourceEndpointId || !destEndpointId) continue;
let sourceTripleRids = new Map<number, string>();
try {
sourceTripleRids = await fetchSourceTripleRids({
client,
relations: importable.map(({ rel }) => rel),
});
} catch (error) {
console.warn("Could not look up the source triples of relations:", error);
}

for (const {
rel,
sourceData,
destData,
sourceEndpointId,
destEndpointId,
} of importable) {
if (!rel.schema_id) continue;

const sourceRelationTypeId = schemaMap.get(rel.schema_id);
Expand Down Expand Up @@ -360,7 +398,10 @@ export const importRelationsForImportedNodes = async ({
relationTypeId: mappedTypeId,
importedCreatedAt,
importedModifiedAt,
importedFromRid: relationImportedFromRid,
// Without a unique source triple, keep the relation's RID: an imported triple
// needs some importedFromRid, or isAcceptedSchema treats it as a local one.
importedFromRid:
sourceTripleRids.get(rel.id) ?? relationImportedFromRid,
authorId,
});
}
Expand Down
Loading
Loading