diff --git a/lix/packages/sdk/src/change-queue.test.ts b/lix/packages/sdk/src/change-queue.test.ts index 3495496062..f7dbc92817 100644 --- a/lix/packages/sdk/src/change-queue.test.ts +++ b/lix/packages/sdk/src/change-queue.test.ts @@ -37,7 +37,7 @@ test("should use queue and settled correctly", async () => { const enc = new TextEncoder(); await lix.db .insertInto("file") - .values({ id: "test", path: "test.txt", data: enc.encode("test") }) + .values({ id: "test", path: "test.txt", data: enc.encode("insert text") }) .execute(); const internalFiles = await lix.db @@ -85,19 +85,15 @@ test("should use queue and settled correctly", async () => { .execute(); expect(changes).toEqual([ - { - id: changes[0]?.id, - created_at: changes[0]?.created_at, - snapshot_id: changes[0]?.snapshot_id, - parent_id: null, + expect.objectContaining({ entity_id: "test", type: "text", file_id: "test", plugin_key: "mock-plugin", content: { - text: "test", + text: "insert text", }, - }, + }), ]); await lix.db @@ -150,46 +146,46 @@ test("should use queue and settled correctly", async () => { .select("snapshot.content") .execute(); + const updatedEdges = await lix.db + .selectFrom("change_edge") + .selectAll() + .execute(); + expect(updatedChanges).toEqual([ - { - id: updatedChanges[0]?.id, - created_at: updatedChanges[0]?.created_at, - snapshot_id: updatedChanges[0]?.snapshot_id, - parent_id: null, + expect.objectContaining({ entity_id: "test", type: "text", file_id: "test", plugin_key: "mock-plugin", content: { - text: "test", + text: "insert text", }, - }, - { + }), + expect.objectContaining({ entity_id: "test", - created_at: updatedChanges[1]?.created_at, - snapshot_id: updatedChanges[1]?.snapshot_id, file_id: "test", - id: updatedChanges[1]?.id, - parent_id: updatedChanges[0]?.id, plugin_key: "mock-plugin", type: "text", content: { text: "test updated text", }, - }, - { - created_at: updatedChanges[2]?.created_at, - snapshot_id: updatedChanges[2]?.snapshot_id, + }), + expect.objectContaining({ file_id: "test", - id: updatedChanges[2]?.id, - parent_id: updatedChanges[1]?.id, entity_id: "test", plugin_key: "mock-plugin", type: "text", content: { text: "test updated text second update", }, - }, + }), + ]); + + expect(updatedEdges).toEqual([ + // 0 is the parent of 1 + // 1 is the parent of 2 + { parent_id: updatedChanges[0]?.id, child_id: updatedChanges[1]?.id }, + { parent_id: updatedChanges[1]?.id, child_id: updatedChanges[2]?.id }, ]); }); diff --git a/lix/packages/sdk/src/database/applySchema.ts b/lix/packages/sdk/src/database/applySchema.ts index 2d8f536cf8..e98a410614 100644 --- a/lix/packages/sdk/src/database/applySchema.ts +++ b/lix/packages/sdk/src/database/applySchema.ts @@ -28,7 +28,6 @@ export async function applySchema(args: { sqlite: SqliteDatabase }) { CREATE TABLE IF NOT EXISTS change ( id TEXT PRIMARY KEY DEFAULT (uuid_v4()), - parent_id TEXT, entity_id TEXT NOT NULL, type TEXT NOT NULL, file_id TEXT NOT NULL, @@ -37,7 +36,15 @@ export async function applySchema(args: { sqlite: SqliteDatabase }) { created_at TEXT DEFAULT CURRENT_TIMESTAMP NOT NULL ) strict; - CREATE INDEX IF NOT EXISTS idx_change_parent_id ON change (parent_id); + CREATE TABLE IF NOT EXISTS change_edge ( + parent_id TEXT NOT NULL, + child_id TEXT NOT NULL, + PRIMARY KEY (parent_id, child_id), + FOREIGN KEY(parent_id) REFERENCES change(id), + FOREIGN KEY(child_id) REFERENCES change(id), + -- Prevent self referencing edges + CHECK (parent_id != child_id) + ) strict; CREATE TABLE IF NOT EXISTS snapshot ( id TEXT GENERATED ALWAYS AS (sha256(content)) STORED UNIQUE, diff --git a/lix/packages/sdk/src/database/initDb.test.ts b/lix/packages/sdk/src/database/initDb.test.ts index 994cb6a7b6..51aed454a7 100644 --- a/lix/packages/sdk/src/database/initDb.test.ts +++ b/lix/packages/sdk/src/database/initDb.test.ts @@ -91,3 +91,23 @@ test("files should be able to have metadata", async () => { expect(updatedFile.metadata?.primary_key).toBe("something-else"); }); + +test("change edges can't reference themselves", async () => { + const sqlite = await createInMemoryDatabase({ + readOnly: false, + }); + const db = initDb({ sqlite }); + + await expect( + db + .insertInto("change_edge") + .values({ + parent_id: "change1", + child_id: "change1", + }) + .returningAll() + .execute(), + ).rejects.toThrowErrorMatchingInlineSnapshot( + `[SQLite3Error: SQLITE_CONSTRAINT_CHECK: sqlite3 result code 275: CHECK constraint failed: parent_id != child_id]`, + ); +}); \ No newline at end of file diff --git a/lix/packages/sdk/src/database/schema.ts b/lix/packages/sdk/src/database/schema.ts index c8f1f31539..39333e016b 100644 --- a/lix/packages/sdk/src/database/schema.ts +++ b/lix/packages/sdk/src/database/schema.ts @@ -7,6 +7,7 @@ export type LixDatabaseSchema = { change: ChangeTable; file_internal: LixFileTable; change_queue: ChangeQueueTable; + change_edge: ChangeEdgeTable; conflict: ConflictTable; snapshot: SnapshotTable; @@ -42,7 +43,6 @@ export type Change = Selectable; export type NewChange = Insertable; type ChangeTable = { id: Generated; - parent_id: Generated | null; /** * The entity the change refers to. */ @@ -71,6 +71,13 @@ type ChangeTable = { created_at: Generated; }; +export type ChangeEdge = Selectable; +export type NewChangeEdge = Insertable; +type ChangeEdgeTable = { + parent_id: ChangeTable["id"]; + child_id: ChangeTable["id"]; +}; + export type Snapshot = Selectable; export type NewSnapshot = Insertable; type SnapshotTable = { diff --git a/lix/packages/sdk/src/file-handlers.ts b/lix/packages/sdk/src/file-handlers.ts index eaaa27aa65..2537083aee 100644 --- a/lix/packages/sdk/src/file-handlers.ts +++ b/lix/packages/sdk/src/file-handlers.ts @@ -120,9 +120,11 @@ export async function handleFileChange(args: { // heuristic to find the previous change // there is no guarantee that the previous change is the leaf change // because sorting by time is unreliable in a distributed system - const previousChange = await trx + const maybeParentChange = await trx .selectFrom("change") - .selectAll() + .innerJoin("snapshot", "snapshot.id", "change.snapshot_id") + .selectAll("change") + .select("snapshot.content") .where("file_id", "=", fileId) .where("type", "=", detectedChange.type) .where("entity_id", "=", detectedChange.entity_id) @@ -132,11 +134,11 @@ export async function handleFileChange(args: { .executeTakeFirst(); // get the leaf change of the assumed previous change - const leafChange = !previousChange + const parentChange = !maybeParentChange ? undefined : await getLeafChange({ lix: { db: trx }, - change: previousChange, + change: maybeParentChange, }); const snapshot = await trx @@ -155,17 +157,28 @@ export async function handleFileChange(args: { .returning("id") .executeTakeFirstOrThrow(); - await trx + const insertedChange = await trx .insertInto("change") .values({ type: detectedChange.type, file_id: fileId, plugin_key: detectedChange.pluginKey, entity_id: detectedChange.entity_id, - parent_id: leafChange?.id, snapshot_id: snapshot.id, }) - .execute(); + .returning("id") + .executeTakeFirstOrThrow(); + + // If a parent exists, the change is a child of the parent + if (parentChange) { + await trx + .insertInto("change_edge") + .values({ + parent_id: parentChange.id, + child_id: insertedChange.id, + }) + .execute(); + } } await trx diff --git a/lix/packages/sdk/src/merge/merge.test.ts b/lix/packages/sdk/src/merge/merge.test.ts index 0624203bcf..e870c12c6a 100644 --- a/lix/packages/sdk/src/merge/merge.test.ts +++ b/lix/packages/sdk/src/merge/merge.test.ts @@ -3,6 +3,7 @@ import { openLixInMemory } from "../open/openLixInMemory.js"; import { newLixFile } from "../newLix.js"; import { merge } from "./merge.js"; import type { + ChangeEdge, NewChange, NewConflict, NewSnapshot, @@ -45,6 +46,8 @@ test("it should copy changes from the sourceLix into the targetLix that do not e }, ]; + const mockEdges: ChangeEdge[] = [{ parent_id: "2", child_id: "3" }]; + const mockPlugin: LixPlugin = { key: "mock-plugin", detectConflicts: vi.fn().mockResolvedValue([]), @@ -76,6 +79,8 @@ test("it should copy changes from the sourceLix into the targetLix that do not e .values([mockChanges[0]!, mockChanges[1]!, mockChanges[2]!]) .execute(); + await sourceLix.db.insertInto("change_edge").values([mockEdges[0]]).execute(); + await targetLix.db .insertInto("snapshot") .values( @@ -119,6 +124,13 @@ test("it should copy changes from the sourceLix into the targetLix that do not e mockSnapshots[2]?.id, ]); + const edges = await targetLix.db + .selectFrom("change_edge") + .selectAll() + .execute(); + + expect(edges).toEqual(mockEdges); + expect(mockPlugin.applyChanges).toHaveBeenCalledTimes(1); expect(mockPlugin.detectConflicts).toHaveBeenCalledTimes(1); }); @@ -345,7 +357,6 @@ test("it should apply changes that are not conflicting", async () => { }, { id: "2", - parent_id: "1", entity_id: "value1", type: "mock", snapshot_id: mockSnapshots[1]!.id, @@ -354,6 +365,8 @@ test("it should apply changes that are not conflicting", async () => { }, ]; + const edges: ChangeEdge[] = [{ parent_id: "1", child_id: "2" }]; + const mockPlugin: LixPlugin = { key: "mock-plugin", applyChanges: async ({ changes }) => { @@ -389,6 +402,8 @@ test("it should apply changes that are not conflicting", async () => { .values([mockChanges[0]!, mockChanges[1]!]) .execute(); + await sourceLix.db.insertInto("change_edge").values([edges[0]]).execute(); + await targetLix.db .insertInto("snapshot") .values( @@ -397,6 +412,7 @@ test("it should apply changes that are not conflicting", async () => { }), ) .execute(); + await targetLix.db.insertInto("change").values([mockChanges[0]!]).execute(); await targetLix.db @@ -657,7 +673,6 @@ test("it should copy discussion and related comments and mappings", async () => id: changes[0]?.id, created_at: changes[0]?.created_at, snapshot_id: changes[0]?.snapshot_id, - parent_id: null, type: "text", file_id: "test", entity_id: "test", diff --git a/lix/packages/sdk/src/merge/merge.ts b/lix/packages/sdk/src/merge/merge.ts index 41964ba2c0..efc39cf3d3 100644 --- a/lix/packages/sdk/src/merge/merge.ts +++ b/lix/packages/sdk/src/merge/merge.ts @@ -117,9 +117,14 @@ export async function merge(args: { .selectAll() .execute(); + const sourceEdges = await args.sourceLix.db + .selectFrom("change_edge") + .selectAll() + .execute(); + await args.targetLix.db.transaction().execute(async (trx) => { if (sourceChangesWithSnapshot.length > 0) { - // 1. copy the snapshots from source + // copy the snapshots from source await trx .insertInto("snapshot") .values( @@ -146,7 +151,7 @@ export async function merge(args: { .execute(); } - // 2. insert the conflicts of those changes + // insert the conflicts of those changes if (conflicts.length > 0) { await trx .insertInto("conflict") @@ -157,7 +162,7 @@ export async function merge(args: { } for (const [fileId, fileData] of Object.entries(changesPerFile)) { - // 3. update the file data with the applied changes + // update the file data with the applied changes await trx .updateTable("file_internal") .set("data", fileData) @@ -165,7 +170,17 @@ export async function merge(args: { .execute(); } - // 4. add discussions, comments and discsussion_change_mappings + // copy edges + if (sourceEdges.length > 0) { + await trx + .insertInto("change_edge") + .values(sourceEdges) + // ignore if already exists + .onConflict((oc) => oc.doNothing()) + .execute(); + } + + // add discussions, comments and discsussion_change_mappings if (sourceDiscussions.length > 0) { await trx diff --git a/lix/packages/sdk/src/mock/mock-csv-plugin.test.ts b/lix/packages/sdk/src/mock/mock-csv-plugin.test.ts index 9c7e4e6e4f..f5fdf35bc6 100644 --- a/lix/packages/sdk/src/mock/mock-csv-plugin.test.ts +++ b/lix/packages/sdk/src/mock/mock-csv-plugin.test.ts @@ -1,5 +1,4 @@ -import { newLixFile } from "../newLix.js"; -import { openLixInMemory } from "../open/openLixInMemory.js"; +import type { ChangeWithSnapshot } from "../database/schema.js"; import type { DetectedChange } from "../plugin.js"; import { mockCsvPlugin } from "./mock-csv-plugin.js"; import { describe, expect, test } from "vitest"; @@ -40,39 +39,18 @@ describe("applyChanges()", () => { test("it should apply a delete change", async () => { const before = new TextEncoder().encode("Name,Age\nAnna,20\n,50"); const after = new TextEncoder().encode("Name,Age\nAnna,20"); - const lix = await openLixInMemory({ blob: await newLixFile() }); - - const snapshot = await lix.db - .insertInto("snapshot") - .values({ - // @ts-expect-error - database expects stringified json - content: JSON.stringify({ - columnIndex: 1, - rowIndex: 2, - text: "50", - }), - }) - .returningAll() - .executeTakeFirstOrThrow(); - - await lix.db - .insertInto("change") - .values({ - id: "parent_change_id", - entity_id: "value1", - file_id: "random", - plugin_key: "csv", + const changes: Partial[] = [ + { + entity_id: "2-1", type: "cell", - snapshot_id: snapshot.id, - }) - .execute(); - - const changes = [{ parent_id: "parent_change_id" }]; + content: undefined, + }, + ]; const { fileData } = await mockCsvPlugin.applyChanges!({ file: { id: "mock", path: "x.csv", data: before, metadata: null }, changes: changes as any, - lix, + lix: {} as any, }); expect(fileData).toEqual(after); }); @@ -112,14 +90,14 @@ describe("detectChanges()", () => { ] satisfies DetectedChange[]); }); - test("it should an update diff", async () => { + test("it detect update changes", async () => { const before = new TextEncoder().encode("Name,Age\nAnna,20\nPeter,50"); const after = new TextEncoder().encode("Name,Age\nAnna,21\nPeter,50"); - const diffs = await mockCsvPlugin.detectChanges?.({ + const detectedChanges = await mockCsvPlugin.detectChanges?.({ before: { id: "random", path: "x.csv", data: before, metadata: null }, after: { id: "random", path: "x.csv", data: after, metadata: null }, }); - expect(diffs).toEqual([ + expect(detectedChanges).toEqual([ { type: "cell", entity_id: "1-1", @@ -128,14 +106,14 @@ describe("detectChanges()", () => { ] satisfies DetectedChange[]); }); - test("it should report a delete diff", async () => { + test("it should detect a deletion", async () => { const before = new TextEncoder().encode("Name,Age\nAnna,20\nPeter,50"); const after = new TextEncoder().encode("Name,Age\nAnna,20"); - const diffs = await mockCsvPlugin.detectChanges?.({ + const detectedChanges = await mockCsvPlugin.detectChanges?.({ before: { id: "random", path: "x.csv", data: before, metadata: null }, after: { id: "random", path: "x.csv", data: after, metadata: null }, }); - expect(diffs).toEqual([ + expect(detectedChanges).toEqual([ { entity_id: "2-0", type: "cell", snapshot: undefined }, { entity_id: "2-1", type: "cell", snapshot: undefined }, ] satisfies DetectedChange[]); diff --git a/lix/packages/sdk/src/mock/mock-csv-plugin.ts b/lix/packages/sdk/src/mock/mock-csv-plugin.ts index 4ab1e155aa..497eb3dd27 100644 --- a/lix/packages/sdk/src/mock/mock-csv-plugin.ts +++ b/lix/packages/sdk/src/mock/mock-csv-plugin.ts @@ -10,7 +10,7 @@ type Cell = { rowIndex: number; columnIndex: number; text: string }; export const mockCsvPlugin: LixPlugin = { key: "csv", glob: "*.csv", - applyChanges: async ({ file, changes, lix }) => { + applyChanges: async ({ file, changes }) => { const parsed = papaparse.parse(new TextDecoder().decode(file.data)); for (const change of changes) { if (change.content) { @@ -32,27 +32,13 @@ export const mockCsvPlugin: LixPlugin = { // update the cell (parsed.data[rowIndex] as any)[columnIndex] = text; } else { - if (change.parent_id === undefined) { - throw new Error( - "Expected a previous change to exist if a value is undefined (a deletion)", - ); - } - // TODO possibility to avoid querying the parent change? - const parent = await lix.db - .selectFrom("change") - .innerJoin("snapshot", "snapshot.id", "change.snapshot_id") - .selectAll("change") - .select("snapshot.content") - .where("change.id", "=", change.parent_id) - .executeTakeFirstOrThrow(); - - const { rowIndex, columnIndex } = parent.content as unknown as Cell; - (parsed.data as any)[rowIndex][columnIndex] = ""; - // if the row is empty after deleting the cell, remove it + const [rowIndex, columnIndex] = change.entity_id.split("-").map(Number); + (parsed.data as any)[rowIndex!][columnIndex!] = ""; + // if the row is empty after deleting the cell, delete the row if ( - (parsed.data[rowIndex] as any).every((cell: string) => cell === "") + (parsed.data[rowIndex!] as any).every((cell: string) => cell === "") ) { - parsed.data.splice(rowIndex, 1); + parsed.data.splice(rowIndex!, 1); } } } diff --git a/lix/packages/sdk/src/query-utilities/get-leaf-change.bench.ts b/lix/packages/sdk/src/query-utilities/get-leaf-change.bench.ts index 893a8c4984..550855b227 100644 --- a/lix/packages/sdk/src/query-utilities/get-leaf-change.bench.ts +++ b/lix/packages/sdk/src/query-utilities/get-leaf-change.bench.ts @@ -6,6 +6,7 @@ import { newLixFile, openLixInMemory, type Change, + type ChangeEdge, type NewChange, type NewSnapshot, } from "../index.js"; @@ -14,16 +15,15 @@ const createChange = ( type: "bundle" | "message" | "variant", payload: any, parentChangeId: string | null, -): { change: NewChange; snapshot: NewSnapshot } => { +): { change: NewChange; snapshot: NewSnapshot; edges: ChangeEdge[] } => { const entityId = payload[type].id; const snapshotId = v4(); const snapshot: NewSnapshot = { id: snapshotId, content: payload[type], }; - const change: NewChange = { + const change: Change = { id: v4(), - parent_id: parentChangeId, file_id: "mock", plugin_key: "inlang", type: type, @@ -31,9 +31,20 @@ const createChange = ( entity_id: entityId, created_at: "", }; + + const edges: ChangeEdge[] = []; + + if (parentChangeId) { + edges.push({ + parent_id: parentChangeId, + child_id: change.id, + }); + } + return { change, snapshot, + edges, }; }; @@ -42,7 +53,11 @@ const setupLix = async (nMessages: number) => { blob: await newLixFile(), }); - const mockChanges: { change: NewChange; snapshot: NewSnapshot }[] = []; + const mockChanges: { + change: NewChange; + snapshot: NewSnapshot; + edges: ChangeEdge[]; + }[] = []; for (let i = 0; i < nMessages; i++) { const payloads = getPayloadsForId(v4()); @@ -93,11 +108,13 @@ const setupLix = async (nMessages: number) => { .slice(i, i + batchSize) .map((item) => item.snapshot); - await lix.db - .insertInto("snapshot") - .values(snapshotsArray) - .executeTakeFirst(); - await lix.db.insertInto("change").values(changesArray).executeTakeFirst(); + const edgesArray = mockChanges + .slice(i, i + batchSize) + .flatMap((item) => item.edges); + + await lix.db.insertInto("snapshot").values(snapshotsArray).execute(); + await lix.db.insertInto("change").values(changesArray).execute(); + await lix.db.insertInto("change_edge").values(edgesArray).execute(); } // console.log("setting up lix with " + nMessages + ".... done"); diff --git a/lix/packages/sdk/src/query-utilities/get-leaf-change.test.ts b/lix/packages/sdk/src/query-utilities/get-leaf-change.test.ts index 77f97430f6..3318fa2ac9 100644 --- a/lix/packages/sdk/src/query-utilities/get-leaf-change.test.ts +++ b/lix/packages/sdk/src/query-utilities/get-leaf-change.test.ts @@ -2,7 +2,7 @@ import { test, expect } from "vitest"; import { getLeafChange } from "./get-leaf-change.js"; import { openLixInMemory } from "../open/openLixInMemory.js"; import { newLixFile } from "../newLix.js"; -import type { NewChange } from "../database/schema.js"; +import type { ChangeEdge, NewChange } from "../database/schema.js"; import { mockJsonSnapshot } from "./mock-json-snapshot.js"; test("it should find the latest child of a given change", async () => { @@ -19,7 +19,6 @@ test("it should find the latest child of a given change", async () => { const mockChanges: NewChange[] = [ { id: "1", - parent_id: undefined, entity_id: "value1", file_id: "mock", plugin_key: "mock", @@ -28,7 +27,6 @@ test("it should find the latest child of a given change", async () => { }, { id: "2", - parent_id: "1", entity_id: "value1", file_id: "mock", plugin_key: "mock", @@ -37,7 +35,6 @@ test("it should find the latest child of a given change", async () => { }, { id: "3", - parent_id: "2", entity_id: "value1", file_id: "mock", plugin_key: "mock", @@ -45,15 +42,18 @@ test("it should find the latest child of a given change", async () => { snapshot_id: mockSnapshots[2]!.id, }, ]; + + const edges: ChangeEdge[] = [ + { parent_id: "1", child_id: "2" }, + { parent_id: "2", child_id: "3" }, + ]; + await lix.db .insertInto("snapshot") - .values( - mockSnapshots.map((s) => { - return { content: s.content }; - }), - ) + .values(mockSnapshots.map((s) => ({ content: s.content }))) .execute(); await lix.db.insertInto("change").values(mockChanges).execute(); + await lix.db.insertInto("change_edge").values(edges).execute(); const changes = await lix.db.selectFrom("change").selectAll().execute(); diff --git a/lix/packages/sdk/src/query-utilities/get-leaf-change.ts b/lix/packages/sdk/src/query-utilities/get-leaf-change.ts index cf786ea2b8..83a25c8335 100644 --- a/lix/packages/sdk/src/query-utilities/get-leaf-change.ts +++ b/lix/packages/sdk/src/query-utilities/get-leaf-change.ts @@ -17,8 +17,9 @@ export async function getLeafChange(args: { const childChange = await args.lix.db .selectFrom("change") .selectAll() - .where(isInSimulatedCurrentBranch) + .innerJoin("change_edge", "change_edge.child_id", "change.id") .where("parent_id", "=", nextChange.id) + .where(isInSimulatedCurrentBranch) .executeTakeFirst(); if (!childChange) { diff --git a/lix/packages/sdk/src/query-utilities/get-leaf-changes-only-in-source.test.ts b/lix/packages/sdk/src/query-utilities/get-leaf-changes-only-in-source.test.ts index a8afd79e54..bd12691317 100644 --- a/lix/packages/sdk/src/query-utilities/get-leaf-changes-only-in-source.test.ts +++ b/lix/packages/sdk/src/query-utilities/get-leaf-changes-only-in-source.test.ts @@ -31,13 +31,14 @@ test("it should get the leaf changes that only exist in source", async () => { id: "c2", file_id: "mock", entity_id: "value1", - parent_id: "c1", plugin_key: "mock", type: "mock", snapshot_id: commonSnapshots[1]!.id, }, ]; + const commonChangesEdges = [{ parent_id: "c1", child_id: "c2" }]; + const snapshotsOnlyInSource = [ mockJsonSnapshot({ id: "mock-id", color: "pink" }), mockJsonSnapshot({ id: "mock-id", color: "orange" }), @@ -55,7 +56,6 @@ test("it should get the leaf changes that only exist in source", async () => { }, { id: "s2", - parent_id: "s1", entity_id: "value1", file_id: "mock", plugin_key: "mock", @@ -64,7 +64,6 @@ test("it should get the leaf changes that only exist in source", async () => { }, { id: "s3", - parent_id: "s2", entity_id: "value1", file_id: "mock", plugin_key: "mock", @@ -73,6 +72,11 @@ test("it should get the leaf changes that only exist in source", async () => { }, ]; + const changesOnlyInSourceEdges = [ + { parent_id: "s1", child_id: "s2" }, + { parent_id: "s2", child_id: "s3" }, + ]; + const snapshotsOnlyInTarget = [ mockJsonSnapshot({ id: "mock-id", color: "black" }), ]; @@ -80,7 +84,6 @@ test("it should get the leaf changes that only exist in source", async () => { const changesOnlyInTarget: NewChange[] = [ { id: "t1", - parent_id: "c2", entity_id: "value1", file_id: "mock", plugin_key: "mock", @@ -89,6 +92,8 @@ test("it should get the leaf changes that only exist in source", async () => { }, ]; + const changesOnlyInTargetEdges = [{ parent_id: "c2", child_id: "t1" }]; + await targetLix.db .insertInto("snapshot") .values( @@ -103,6 +108,11 @@ test("it should get the leaf changes that only exist in source", async () => { .values([...commonChanges, ...changesOnlyInTarget]) .execute(); + await targetLix.db + .insertInto("change_edge") + .values([...commonChangesEdges, ...changesOnlyInTargetEdges]) + .execute(); + await sourceLix.db .insertInto("snapshot") .values( @@ -117,6 +127,11 @@ test("it should get the leaf changes that only exist in source", async () => { .values([...commonChanges, ...changesOnlyInSource]) .execute(); + await sourceLix.db + .insertInto("change_edge") + .values([...commonChangesEdges, ...changesOnlyInSourceEdges]) + .execute(); + const result = await getLeafChangesOnlyInSource({ sourceLix: sourceLix, targetLix: targetLix, diff --git a/lix/packages/sdk/src/query-utilities/get-leaf-changes-only-in-source.ts b/lix/packages/sdk/src/query-utilities/get-leaf-changes-only-in-source.ts index 1903c8b350..a76c1a4ac0 100644 --- a/lix/packages/sdk/src/query-utilities/get-leaf-changes-only-in-source.ts +++ b/lix/packages/sdk/src/query-utilities/get-leaf-changes-only-in-source.ts @@ -26,6 +26,7 @@ export async function getLeafChangesOnlyInSource(args: { "not in", args.sourceLix.db .selectFrom("change") + .innerJoin("change_edge", "change_edge.child_id", "change.id") .select("parent_id") .where("parent_id", "is not", null) .distinct(), diff --git a/lix/packages/sdk/src/query-utilities/get-lowest-common-ancestor.test.ts b/lix/packages/sdk/src/query-utilities/get-lowest-common-ancestor.test.ts index f5eb8979c9..164107ae3b 100644 --- a/lix/packages/sdk/src/query-utilities/get-lowest-common-ancestor.test.ts +++ b/lix/packages/sdk/src/query-utilities/get-lowest-common-ancestor.test.ts @@ -22,7 +22,6 @@ test("it should find the common parent of two changes recursively", async () => const mockChanges: NewChange[] = [ { id: "0", - parent_id: undefined, entity_id: "value1", file_id: "mock", plugin_key: "mock", @@ -31,7 +30,6 @@ test("it should find the common parent of two changes recursively", async () => }, { id: "1", - parent_id: "0", entity_id: "value1", file_id: "mock", plugin_key: "mock", @@ -40,7 +38,6 @@ test("it should find the common parent of two changes recursively", async () => }, { id: "2", - parent_id: "1", file_id: "mock", entity_id: "value1", plugin_key: "mock", @@ -49,6 +46,11 @@ test("it should find the common parent of two changes recursively", async () => }, ]; + const edges = [ + { parent_id: "0", child_id: "1" }, + { parent_id: "1", child_id: "2" }, + ]; + await targetLix.db .insertInto("snapshot") .values( @@ -79,6 +81,11 @@ test("it should find the common parent of two changes recursively", async () => .values([mockChanges[0]!, mockChanges[1]!, mockChanges[2]!]) .execute(); + await sourceLix.db + .insertInto("change_edge") + .values([edges[0], edges[1]]) + .execute(); + const secondChange = await sourceLix.db .selectFrom("change") .selectAll() @@ -111,8 +118,6 @@ test("it should return undefined if no common parent exists", async () => { const mockChanges: NewChange[] = [ { id: "0", - // no parent == create change - parent_id: undefined, entity_id: "value1", file_id: "mock", plugin_key: "mock", @@ -121,7 +126,6 @@ test("it should return undefined if no common parent exists", async () => { }, { id: "1", - parent_id: "0", entity_id: "value1", file_id: "mock", plugin_key: "mock", @@ -131,8 +135,6 @@ test("it should return undefined if no common parent exists", async () => { { id: "2", entity_id: "value2", - // no parent == create change - parent_id: undefined, file_id: "mock", plugin_key: "mock", type: "mock", @@ -154,6 +156,11 @@ test("it should return undefined if no common parent exists", async () => { ) .execute(); + await targetLix.db + .insertInto("change_edge") + .values([{ parent_id: "0", child_id: "1" }]) + .execute(); + await sourceLix.db .insertInto("change") .values([mockChanges[0]!, mockChanges[1]!, mockChanges[2]!]) @@ -168,6 +175,11 @@ test("it should return undefined if no common parent exists", async () => { ) .execute(); + await sourceLix.db + .insertInto("change_edge") + .values([{ parent_id: "0", child_id: "1" }]) + .execute(); + const insertedChange = await sourceLix.db .selectFrom("change") .selectAll() @@ -201,7 +213,6 @@ test("it should return the source change if its the common parent", async () => { id: "0", entity_id: "value1", - parent_id: undefined, file_id: "mock", plugin_key: "mock", type: "mock", @@ -210,7 +221,6 @@ test("it should return the source change if its the common parent", async () => { id: "1", entity_id: "value1", - parent_id: "0", file_id: "mock", plugin_key: "mock", type: "mock", @@ -218,6 +228,8 @@ test("it should return the source change if its the common parent", async () => }, ]; + const mockEdges = [{ parent_id: "0", child_id: "1" }]; + await targetLix.db .insertInto("snapshot") .values( @@ -232,6 +244,8 @@ test("it should return the source change if its the common parent", async () => .values([mockChanges[0]!, mockChanges[1]!]) .executeTakeFirst(); + await targetLix.db.insertInto("change_edge").values(mockEdges).execute(); + await sourceLix.db .insertInto("snapshot") .values( @@ -246,6 +260,8 @@ test("it should return the source change if its the common parent", async () => .values([mockChanges[0]!, mockChanges[1]!]) .executeTakeFirst(); + await sourceLix.db.insertInto("change_edge").values(mockEdges).execute(); + const changeOne = await sourceLix.db .selectFrom("change") .selectAll() diff --git a/lix/packages/sdk/src/query-utilities/get-lowest-common-ancestor.ts b/lix/packages/sdk/src/query-utilities/get-lowest-common-ancestor.ts index 99ee6704bc..9569d8fd4f 100644 --- a/lix/packages/sdk/src/query-utilities/get-lowest-common-ancestor.ts +++ b/lix/packages/sdk/src/query-utilities/get-lowest-common-ancestor.ts @@ -12,8 +12,16 @@ export async function getLowestCommonAncestor(args: { sourceLix: LixReadonly; targetLix: LixReadonly; }): Promise { - // the change has no parent (it is the root change) - if (!args.sourceChange?.parent_id) { + const parentEdges = await args.sourceLix.db + .selectFrom("change_edge") + .selectAll() + .where("child_id", "=", args.sourceChange.id) + // TODO https://github.com/opral/lix-sdk/issues/105 + // .where(isInSimulatedCurrentBranch) + .execute(); + + // the change has no parents (it is the root change) + if (parentEdges.length === 0) { return undefined; } @@ -28,10 +36,12 @@ export async function getLowestCommonAncestor(args: { } let nextChange: Change | undefined; - let parentId: string | undefined | null = args.sourceChange.parent_id; + // TODO assumes that only one parent exists + // https://github.com/opral/lix-sdk/issues/105 + let parentId = parentEdges[0]?.parent_id; if (!parentId) { - return; // ok the change was not part of the target but also has no parent (no common ancestor!) + return undefined; } while (parentId) { @@ -50,13 +60,26 @@ export async function getLowestCommonAncestor(args: { .selectFrom("change") .selectAll() .where("id", "=", nextChange.id) + // TODO account for multiple parents .executeTakeFirst(); if (changeExistsInTarget) { return changeExistsInTarget; } - parentId = nextChange.parent_id; + const parentEdges = await args.sourceLix.db + .selectFrom("change_edge") + .selectAll() + .where("child_id", "=", nextChange.id) + .execute(); + // TODO assumes that only one parent exists + if (parentEdges.length > 1) { + // https://github.com/opral/inlang-sdk/issues/134 + throw new Error( + "Unimplemented. Multiple parents not supported until the branch feature exists.", + ); + } + parentId = parentEdges[0]?.parent_id; } return; } diff --git a/lix/packages/sdk/src/query-utilities/get-parent-change.test.ts b/lix/packages/sdk/src/query-utilities/get-parent-change.test.ts new file mode 100644 index 0000000000..5fe7d01a2c --- /dev/null +++ b/lix/packages/sdk/src/query-utilities/get-parent-change.test.ts @@ -0,0 +1,60 @@ +import { test, expect } from "vitest"; +import { openLixInMemory } from "../open/openLixInMemory.js"; +import { newLixFile } from "../newLix.js"; +import type { ChangeEdge, NewChange, NewConflict } from "../database/schema.js"; +import { getParentChange } from "./get-parent-change.js"; + +test("it should return the parent change of a change", async () => { + const lix = await openLixInMemory({ + blob: await newLixFile(), + }); + + const mockChanges: NewChange[] = [ + { + id: "change1", + file_id: "mock", + entity_id: "value1", + plugin_key: "mock", + snapshot_id: "sn1", + type: "mock", + }, + { + id: "change2", + file_id: "mock", + entity_id: "value1", + plugin_key: "mock", + snapshot_id: "sn2", + type: "mock", + }, + { + id: "change3", + file_id: "mock", + entity_id: "value1", + plugin_key: "mock", + snapshot_id: "sn3", + type: "mock", + }, + ]; + + const mockEdges: ChangeEdge[] = [ + { parent_id: "change1", child_id: "change3" }, + { parent_id: "change2", child_id: "change3" }, + ]; + + const mockConflicts: NewConflict[] = [ + { change_id: "change1", conflicting_change_id: "change2" }, + ]; + + await lix.db.insertInto("change").values(mockChanges).execute(); + await lix.db.insertInto("change_edge").values(mockEdges).execute(); + await lix.db.insertInto("conflict").values(mockConflicts).execute(); + + const parentChange = await getParentChange({ + lix, + change: { id: "change3" }, + }); + + // expecting change 1 because while change 2 exists, it's conflicting + // and is not applied. + expect(parentChange?.id).toEqual("change1"); +}); diff --git a/lix/packages/sdk/src/query-utilities/get-parent-change.ts b/lix/packages/sdk/src/query-utilities/get-parent-change.ts new file mode 100644 index 0000000000..de8bfdfda0 --- /dev/null +++ b/lix/packages/sdk/src/query-utilities/get-parent-change.ts @@ -0,0 +1,40 @@ +import type { Change } from "../database/schema.js"; +import type { LixReadonly } from "../types.js"; +import { isInSimulatedCurrentBranch } from "./is-in-simulated-branch.js"; + +/** + * Gets the parent change of a given change. + * + * A change can have multiple parents. This function + * returns the only parent of a change for a given + * branch. + * + * @example + * ```ts + * const parent = await getParentChange({ lix, change }); + * ``` + * + * Additional Information + * + * - If you want to get all the parents of a change, use + * `getParentChanges` instead. + */ +export async function getParentChange(args: { + lix: Pick & Partial; + change: Pick & Partial; +}): Promise { + const parents = await args.lix.db + .selectFrom("change_edge") + .innerJoin("change", "change.id", "change_edge.parent_id") + .selectAll("change") + .where("change_edge.child_id", "=", args.change.id) + .where(isInSimulatedCurrentBranch) + .execute(); + if (parents.length > 1) { + throw new Error( + "Change has more than a single parent. This is an internal bug in lix. Please report it.", + ); + } + // undefined if no parents exist + return parents[0]; +} diff --git a/lix/packages/sdk/src/resolve-conflict/resolve-conflict-with-new-change.test.ts b/lix/packages/sdk/src/resolve-conflict/resolve-conflict-with-new-change.test.ts index dc1294b09f..260a059325 100644 --- a/lix/packages/sdk/src/resolve-conflict/resolve-conflict-with-new-change.test.ts +++ b/lix/packages/sdk/src/resolve-conflict/resolve-conflict-with-new-change.test.ts @@ -86,9 +86,9 @@ test("it should throw if the to be resolved with change already exists", async ( resolveConflictWithNewChange({ lix: lix, conflict: conflict, + parentIds: [changes[0]!.id], newChange: { ...changes[0]!, - parent_id: changes[0]!.id, content: mockSnapshots[0]?.content, }, }), @@ -171,9 +171,9 @@ test("resolving a conflict should throw if the to be resolved with change is not resolveConflictWithNewChange({ lix: lix, conflict: conflict, + parentIds: [], newChange: { file_id: "mock", - parent_id: null, plugin_key: "plugin1", type: "mock", entity_id: "value3", @@ -250,9 +250,9 @@ test("resolving a conflict should throw if the change to resolve with does not b resolveConflictWithNewChange({ lix: lix, conflict: conflict, + parentIds: [changes[0]!.id], newChange: { file_id: "other-mock-file", - parent_id: changes[0]!.id, plugin_key: "plugin1", type: "mock", entity_id: "value3", @@ -336,9 +336,9 @@ test("resolving a conflict with a new change should insert the change and mark t await resolveConflictWithNewChange({ lix: lix, conflict: conflict, + parentIds: [changes[0]!.id], newChange: { file_id: "mock", - parent_id: changes[0]!.id, plugin_key: "plugin1", entity_id: "value3", type: "mock", diff --git a/lix/packages/sdk/src/resolve-conflict/resolve-conflict-with-new-change.ts b/lix/packages/sdk/src/resolve-conflict/resolve-conflict-with-new-change.ts index 8e85b30ba8..41e99f29d8 100644 --- a/lix/packages/sdk/src/resolve-conflict/resolve-conflict-with-new-change.ts +++ b/lix/packages/sdk/src/resolve-conflict/resolve-conflict-with-new-change.ts @@ -14,6 +14,7 @@ export async function resolveConflictWithNewChange(args: { lix: Lix; conflict: Conflict; newChange: NewChangeWithSnapshot; + parentIds: string[]; }): Promise { if (args.lix.plugins.length !== 1) { throw new Error("Unimplemented. Only one plugin is supported for now"); @@ -34,7 +35,7 @@ export async function resolveConflictWithNewChange(args: { if (change.file_id !== args.newChange.file_id) { throw new ChangeDoesNotBelongToFileError(); - } else if (change.id !== args.newChange.parent_id) { + } else if (args.parentIds.includes(change.id) === false) { throw new ChangeNotDirectChildOfConflictError(); } @@ -97,6 +98,16 @@ export async function resolveConflictWithNewChange(args: { .returning("id") .executeTakeFirstOrThrow(); + for (const id of args.parentIds) { + await trx + .insertInto("change_edge") + .values({ + parent_id: id, + child_id: insertedChange.id, + }) + .execute(); + } + await trx .updateTable("conflict") .where((eb) =>