diff --git a/inlang/source-code/sdk2/src/lix-plugin/detectConflicts.test.ts b/inlang/source-code/sdk2/src/lix-plugin/detectConflicts.test.ts index 8452aae6da..f09d71c35d 100644 --- a/inlang/source-code/sdk2/src/lix-plugin/detectConflicts.test.ts +++ b/inlang/source-code/sdk2/src/lix-plugin/detectConflicts.test.ts @@ -1,6 +1,7 @@ /* eslint-disable @typescript-eslint/no-non-null-assertion */ import { test, expect } from "vitest"; import { inlangLixPluginV1 } from "./inlangLixPluginV1.js"; + import { newLixFile, openLixInMemory, @@ -11,6 +12,13 @@ import { test("a create operation should not report a conflict given that the change does not exist in target", async () => { const targetLix = await openLixInMemory({ blob: await newLixFile() }); const sourceLix = await openLixInMemory({ blob: await newLixFile() }); + + const currentBranch = await sourceLix.db + .selectFrom("branch") + .selectAll() + .where("active", "=", true) + .executeTakeFirstOrThrow(); + const changes = await sourceLix.db .insertInto("change") .values([ @@ -26,6 +34,16 @@ test("a create operation should not report a conflict given that the change does ]) .returningAll() .execute(); + + await sourceLix.db + .insertInto("branch_change") + .values({ + branch_id: currentBranch.id, + change_id: "1", + seq: 1, + }) + .execute(); + const conflicts = await inlangLixPluginV1.detectConflicts!({ sourceLix, targetLix, @@ -90,6 +108,17 @@ test("it should report an UPDATE as a conflict if leaf changes are conflicting", const targetLix = await openLixInMemory({ blob: await newLixFile() }); const sourceLix = await openLixInMemory({ blob: await targetLix.toBlob() }); + const currentTargetBranch = await targetLix.db + .selectFrom("branch") + .selectAll() + .where("active", "=", true) + .executeTakeFirstOrThrow(); + const currentSourceBranch = await sourceLix.db + .selectFrom("branch") + .selectAll() + .where("active", "=", true) + .executeTakeFirstOrThrow(); + const commonChanges: NewChange[] = [ { id: "12s", @@ -137,11 +166,43 @@ test("it should report an UPDATE as a conflict if leaf changes are conflicting", .values([...commonChanges, ...changesOnlyInSource]) .execute(); + await sourceLix.db + .insertInto("branch_change") + .values([ + { + branch_id: currentSourceBranch.id, + change_id: "12s", + seq: 1, + }, + { + branch_id: currentSourceBranch.id, + change_id: "2qa", + seq: 2, + }, + ]) + .execute(); + await targetLix.db .insertInto("change") .values([...commonChanges, ...changesOnlyInTarget]) .execute(); + await targetLix.db + .insertInto("branch_change") + .values([ + { + branch_id: currentTargetBranch.id, + change_id: "12s", + seq: 1, + }, + { + branch_id: currentTargetBranch.id, + change_id: "3sd", + seq: 2, + }, + ]) + .execute(); + const conflicts = await inlangLixPluginV1.detectConflicts!({ leafChangesOnlyInSource: changesOnlyInSource as Change[], sourceLix: sourceLix, @@ -223,6 +284,13 @@ test("it should NOT report an UPDATE as a conflict if the common ancestor is the test("it should NOT report a DELETE as a conflict if the parent of the target and source are identical", async () => { const targetLix = await openLixInMemory({ blob: await newLixFile() }); + + const currentTargetBranch = await targetLix.db + .selectFrom("branch") + .where("active", "=", true) + .selectAll() + .executeTakeFirstOrThrow(); + await targetLix.db .insertInto("change") .values([ @@ -240,6 +308,15 @@ test("it should NOT report a DELETE as a conflict if the parent of the target an ]) .execute(); + await targetLix.db + .insertInto("branch_change") + .values({ + branch_id: currentTargetBranch.id, + change_id: "12s", + seq: 1, + }) + .execute(); + const sourceLix = await openLixInMemory({ blob: await targetLix.toBlob() }); const changesNotInTarget: NewChange[] = [ @@ -270,8 +347,26 @@ test("it should NOT report a DELETE as a conflict if the parent of the target an await sourceLix.db.insertInto("change").values(changesNotInTarget).execute(); + await sourceLix.db + .insertInto("branch_change") + .values({ + branch_id: currentTargetBranch.id, + change_id: "3sd", + seq: 2, + }) + .execute(); + await targetLix.db.insertInto("change").values(changesNotInSource).execute(); + await targetLix.db + .insertInto("branch_change") + .values({ + branch_id: currentTargetBranch.id, + change_id: "2qa", + seq: 2, + }) + .execute(); + const conflicts = await inlangLixPluginV1.detectConflicts!({ sourceLix, targetLix, diff --git a/inlang/source-code/sdk2/src/lix-plugin/merge.test.ts b/inlang/source-code/sdk2/src/lix-plugin/merge.test.ts index 90160e4c48..55fe7a64d4 100644 --- a/inlang/source-code/sdk2/src/lix-plugin/merge.test.ts +++ b/inlang/source-code/sdk2/src/lix-plugin/merge.test.ts @@ -7,7 +7,17 @@ import type { NewBundle, NewMessage, NewVariant } from "../database/schema.js"; test("it should update the variant to the source's value", async () => { const target = await loadProjectInMemory({ blob: await newProject() }); + const currentTargetBranch = await target.lix.db + .selectFrom("branch") + .selectAll() + .where("active", "=", true) + .executeTakeFirstOrThrow(); const source = await loadProjectInMemory({ blob: await target.toBlob() }); + const currentSourceBranch = await source.lix.db + .selectFrom("branch") + .selectAll() + .where("active", "=", true) + .executeTakeFirstOrThrow(); const dbFile = await target.lix.db .selectFrom("file") @@ -26,7 +36,6 @@ test("it should update the variant to the source's value", async () => { value: { id: "even_hour_mule_drum", } satisfies NewBundle, - commit_id: "c8ad005b-a834-4ca3-84fb-9627546f2eba", }, { id: "24f74fec-fc8a-4c68-b31c-d126417ce3af", @@ -41,7 +50,6 @@ test("it should update the variant to the source's value", async () => { locale: "en", selectors: [], } satisfies NewMessage, - commit_id: "c8ad005b-a834-4ca3-84fb-9627546f2eba", }, { id: "aaf0ec32-0c7f-4d07-af8c-922ce382aef1", @@ -61,7 +69,6 @@ test("it should update the variant to the source's value", async () => { }, ], } satisfies NewVariant, - commit_id: "c8ad005b-a834-4ca3-84fb-9627546f2eba", }, ]; @@ -88,7 +95,6 @@ test("it should update the variant to the source's value", async () => { meta: { id: "6a860f96-0cf3-477c-80ad-7893d8fde852", }, - commit_id: "df455c78-b5ed-4df0-9259-7bb694c9d755", }, ]; @@ -97,11 +103,58 @@ test("it should update the variant to the source's value", async () => { .values([...commonChanges, ...changesOnlyInSource]) .execute(); + await source.lix.db + .insertInto("branch_change") + .values([ + { + branch_id: currentSourceBranch.id, + change_id: "d92cdc2e-74cc-494c-8d51-958216272a17", + seq: 1, + }, + { + branch_id: currentSourceBranch.id, + change_id: "24f74fec-fc8a-4c68-b31c-d126417ce3af", + seq: 2, + }, + { + branch_id: currentSourceBranch.id, + change_id: "aaf0ec32-0c7f-4d07-af8c-922ce382aef1", + seq: 3, + }, + { + branch_id: currentSourceBranch.id, + change_id: "01c059f9-8476-4aaa-aa6d-53ea3158b374", + seq: 4, + }, + ]) + .execute(); + await target.lix.db .insertInto("change") .values([...commonChanges, ...changesOnlyInTarget]) .execute(); + await target.lix.db + .insertInto("branch_change") + .values([ + { + branch_id: currentTargetBranch.id, + change_id: "d92cdc2e-74cc-494c-8d51-958216272a17", + seq: 1, + }, + { + branch_id: currentTargetBranch.id, + change_id: "24f74fec-fc8a-4c68-b31c-d126417ce3af", + seq: 2, + }, + { + branch_id: currentTargetBranch.id, + change_id: "aaf0ec32-0c7f-4d07-af8c-922ce382aef1", + seq: 3, + }, + ]) + .execute(); + await merge({ sourceLix: source.lix, targetLix: target.lix }); await target.lix.settled(); @@ -112,7 +165,8 @@ test("it should update the variant to the source's value", async () => { }); const changes = await mergedProject.lix.db - .selectFrom("change") + .selectFrom("change_view") + .where("branch_id", "=", currentTargetBranch.id) .selectAll() .execute(); diff --git a/inlang/source-code/sdk2/src/lix-plugin/resolveConflictBySelecting.test.ts b/inlang/source-code/sdk2/src/lix-plugin/resolveConflictBySelecting.test.ts index d5601777f8..ff363ee9cf 100644 --- a/inlang/source-code/sdk2/src/lix-plugin/resolveConflictBySelecting.test.ts +++ b/inlang/source-code/sdk2/src/lix-plugin/resolveConflictBySelecting.test.ts @@ -2,10 +2,7 @@ import { test, expect } from "vitest"; import { newProject } from "../project/newProject.js"; import { loadProjectInMemory } from "../project/loadProjectInMemory.js"; -import { - isInSimulatedCurrentBranch, - resolveConflictBySelecting, -} from "@lix-js/sdk"; +import { resolveConflictBySelecting } from "@lix-js/sdk"; import { contentFromDatabase } from "sqlite-wasm-kysely"; test("it should resolve a conflict with the selected change", async () => { @@ -138,6 +135,48 @@ test("it should resolve a conflict with the selected change", async () => { .execute(); // --------------- + const currentBranch = await project.lix.db + .selectFrom("branch") + .select("id") + .where("active", "=", true) + .executeTakeFirstOrThrow(); + + await project.lix.db + .insertInto("branch_change") + .values([ + { + id: "branch-change-1", + change_id: changes[0]?.id, + branch_id: currentBranch.id, + seq: 0, + }, + { + id: "branch-change-2", + change_id: changes[1]?.id, + branch_id: currentBranch.id, + seq: 1, + }, + { + id: "branch-change-3", + change_id: changes[2]?.id, + branch_id: currentBranch.id, + seq: 2, + }, + { + id: "branch-change-4", + change_id: "samuels-change", + branch_id: currentBranch.id, + seq: 3, + }, + { + id: "branch-change-5", + change_id: "peters-change", + branch_id: currentBranch.id, + seq: 4, + }, + ]) + .execute(); + const conflicts = await project.lix.db .insertInto("conflict") .values([ @@ -145,24 +184,12 @@ test("it should resolve a conflict with the selected change", async () => { change_id: "samuels-change", conflicting_change_id: "peters-change", reason: "", + branch_id: currentBranch.id, }, ]) .returningAll() .execute(); - const changesInCurrentBranchBefore = await project.lix.db - .selectFrom("change") - .selectAll() - .where(isInSimulatedCurrentBranch) - .execute(); - - expect(changesInCurrentBranchBefore.map((c) => c.id)).toEqual([ - changes[0]?.id, - changes[1]?.id, - changes[2]?.id, - "samuels-change", - ]); - await resolveConflictBySelecting({ lix: project.lix, conflict: conflicts[0]!, @@ -172,15 +199,18 @@ test("it should resolve a conflict with the selected change", async () => { await project.lix.settled(); const changesInCurrentBranch = await project.lix.db - .selectFrom("change") + .selectFrom("branch_change") + .leftJoin("change", "change.id", "branch_change.change_id") .selectAll() - .where(isInSimulatedCurrentBranch) + .where("branch_id", "=", currentBranch.id) + .orderBy("seq") .execute(); expect(changesInCurrentBranch.map((c) => c.id)).toEqual([ changes[0]?.id, changes[1]?.id, changes[2]?.id, + "samuels-change", "peters-change", ]); }); diff --git a/lix/packages/csv-app2/src/state.ts b/lix/packages/csv-app2/src/state.ts index d28126b0c9..245cf3c1ee 100644 --- a/lix/packages/csv-app2/src/state.ts +++ b/lix/packages/csv-app2/src/state.ts @@ -4,7 +4,6 @@ import { atom } from "jotai"; import { atomWithStorage, createJSONStorage } from "jotai/utils"; import Papa from "papaparse"; // import { jsonObjectFrom } from "kysely/helpers/sqlite"; -import { isInSimulatedCurrentBranch } from "@lix-js/sdk"; import { plugin } from "./csv-plugin.js"; import { getOriginPrivateDirectory } from "native-file-system-adapter"; import { generateColor } from "./helper/gernerateUserColor/generateUserColor.ts"; @@ -402,7 +401,6 @@ export const uniqueColumnAtom = atom>(async (get) => { // .where("commit_id", "is not", null) // // TODO remove after sequence concept on lix // // https://linear.app/opral/issue/LIX-126/branching -// .where(isInSimulatedCurrentBranch) // .innerJoin("commit", "commit.id", "change.commit_id") // .orderBy("commit.created_at desc") // .execute(); diff --git a/lix/packages/sdk/src/branch/branch.test.ts b/lix/packages/sdk/src/branch/branch.test.ts new file mode 100644 index 0000000000..640d4ab74f --- /dev/null +++ b/lix/packages/sdk/src/branch/branch.test.ts @@ -0,0 +1,460 @@ +import { expect, test } from "vitest"; +import { openLixInMemory } from "../open/openLixInMemory.js"; +import type { LixPlugin } from "../plugin.js"; +import { newLixFile } from "../newLix.js"; + +test("should use branches correctly", async () => { + const mockPlugin: LixPlugin<{ + text: { id: string; text: string }; + }> = { + key: "mock-plugin", + glob: "*", + applyChanges: async ({ file, lix, changes }) => { + const enc = new TextEncoder(); + if (changes.length > 1) { + throw new Error("cannot apply more than one change per text file"); + } + + // console.log(JSON.stringify({ changes }, null, 4)); + + return { + fileData: enc.encode(changes[0]?.value!.text), + }; + }, + diff: { + file: async ({ old, neu }) => { + const dec = new TextDecoder(); + // console.log("diff", neu, old?.data, neu?.data); + const newText = dec.decode(neu?.data); + const oldText = dec.decode(old?.data); + + if (newText === oldText) { + return []; + } + + return await mockPlugin.diff.text({ + old: old + ? { + id: "test", + text: oldText, + } + : undefined, + neu: neu + ? { + id: "test", + text: newText, + } + : undefined, + }); + }, + text: async ({ old, neu }) => { + if (old?.text === neu?.text) { + return []; + } + + return [ + !old + ? { + type: "text", + operation: "create", + old: undefined, + neu: { + id: "test", + text: neu?.text, + }, + } + : { + type: "text", + operation: "update", + old: { + id: "test", + text: old?.text, + }, + neu: { + id: "test", + text: neu?.text, + }, + }, + ]; + }, + }, + }; + + const lix = await openLixInMemory({ + blob: await newLixFile(), + providePlugins: [mockPlugin], + }); + + const branches = await lix.db.selectFrom("branch").selectAll().execute(); + + expect(branches).toEqual([ + { + id: branches[0]?.id, + name: "main", + base_branch: null, + active: 1, + }, + ]); + + await lix.createBranch({ branchId: "test_branch", name: "test_branch" }); + + const branches2 = await lix.db.selectFrom("branch").selectAll().execute(); + + expect(branches2).toEqual([ + { + id: branches[0]?.id, + name: "main", + base_branch: null, + active: 1, + }, + { + active: 0, + base_branch: branches[0]?.id, + id: branches2[1]!.id, + name: "test_branch", + }, + ]); + + const enc = new TextEncoder(); + + await lix.db + .insertInto("file") + .values({ id: "test", path: "test.txt", data: enc.encode("test orig") }) + .execute(); + + await lix.settled(); + + expect( + (await lix.db.selectFrom("change_queue").selectAll().execute()).length, + ).toBe(0); + + const changes = await lix.db.selectFrom("change").selectAll().execute(); + + expect(changes).toEqual([ + { + id: changes[0]?.id, + author: null, + created_at: changes[0]?.created_at, + parent_id: null, + type: "text", + file_id: "test", + plugin_key: "mock-plugin", + value: { + id: "test", + text: "test orig", + }, + meta: null, + operation: "create", + }, + ]); + + const branchChanges = await lix.db + .selectFrom("branch_change") + .selectAll() + .execute(); + + expect(branchChanges).toEqual([ + { + branch_id: branches[0]?.id, + change_id: changes[0]?.id, + id: branchChanges[0]?.id, + seq: 1, + }, + ]); + + await lix.db + .updateTable("file") + .set({ data: enc.encode("test updated text") }) + .where("id", "=", "test") + .execute(); + + // repeat same update + await lix.db + .updateTable("file") + .set({ data: enc.encode("test updated text") }) + .where("id", "=", "test") + .execute(); + + // re apply same change + await lix.db + .updateTable("file") + .set({ data: enc.encode("test updated text") }) + .where("id", "=", "test") + .execute(); + + await lix.db + .updateTable("file") + .set({ data: enc.encode("test updated text second update") }) + .where("id", "=", "test") + .execute(); + + await lix.settled(); + + await lix.createBranch({ name: "test_branch_2", branchId: "test_branch_2" }); + + const branches3 = await lix.db + .selectFrom("branch") + .orderBy("name") + .selectAll() + .execute(); + + expect(branches3).toEqual([ + { + id: branches[0]?.id, + name: "main", + base_branch: null, + active: 1, + }, + { + active: 0, + base_branch: branches[0]?.id, + id: branches3[1]!.id, + name: "test_branch", + }, + { + active: 0, + base_branch: branches[0]?.id, + id: branches3[2]!.id, + name: "test_branch_2", + }, + ]); + + const updatedChanges = await lix.db + .selectFrom("change") + .selectAll() + .execute(); + + expect(updatedChanges).toEqual([ + { + id: changes[0]?.id, + author: null, + created_at: changes[0]?.created_at, + parent_id: null, + type: "text", + file_id: "test", + plugin_key: "mock-plugin", + value: { + id: "test", + text: "test orig", + }, + meta: null, + operation: "create", + }, + { + author: null, + id: updatedChanges[1]?.id, + created_at: updatedChanges[1]?.created_at, + parent_id: changes[0]?.id, + type: "text", + file_id: "test", + operation: "update", + plugin_key: "mock-plugin", + value: { + id: "test", + text: "test updated text", + }, + meta: null, + }, + { + id: updatedChanges[2]?.id, + author: null, + parent_id: updatedChanges[1]?.id, + type: "text", + file_id: "test", + plugin_key: "mock-plugin", + operation: "update", + value: { id: "test", text: "test updated text second update" }, + meta: null, + created_at: updatedChanges[2]?.created_at, + }, + ]); + + const branchChanges2 = await lix.db + .selectFrom("branch_change") + .orderBy(["branch_id", "seq"]) + .selectAll() + .execute(); + + expect(branchChanges2).toEqual([ + { + branch_id: branches[0]?.id, + change_id: updatedChanges[0]?.id, + id: branchChanges2[0]?.id, + seq: 1, + }, + { + branch_id: branches[0]?.id, + change_id: updatedChanges[1]?.id, + id: branchChanges2[1]?.id, + seq: 2, + }, + { + branch_id: branches[0]?.id, + change_id: updatedChanges[2]?.id, + id: branchChanges2[2]?.id, + seq: 3, + }, + + { + branch_id: branches3[2]?.id, + change_id: updatedChanges[0]?.id, + id: branchChanges2[0]?.id, + seq: 1, + }, + { + branch_id: branches3[2]?.id, + change_id: updatedChanges[1]?.id, + id: branchChanges2[1]?.id, + seq: 2, + }, + { + branch_id: branches3[2]?.id, + change_id: updatedChanges[2]?.id, + id: branchChanges2[2]?.id, + seq: 3, + }, + ]); + + // test change_view + const changesFromView = await lix.db + .selectFrom("change_view") + .where("branch_id", "=", branches[0]!.id) + .selectAll() + .execute(); + + expect(changesFromView).toEqual([ + { + author: null, + id: changesFromView[0]?.id, + parent_id: null, + type: "text", + file_id: "test", + plugin_key: "mock-plugin", + operation: "create", + value: { id: "test", text: "test orig" }, + meta: null, + created_at: changesFromView[0]?.created_at, + branch_id: branches[0]?.id, + seq: 1, + }, + { + author: null, + id: changesFromView[1]?.id, + file_id: "test", + parent_id: changesFromView[0]?.id, + type: "text", + plugin_key: "mock-plugin", + operation: "update", + value: { id: "test", text: "test updated text" }, + meta: null, + created_at: changesFromView[1]?.created_at, + branch_id: branches[0]?.id, + seq: 2, + }, + { + author: null, + id: changesFromView[2]?.id, + parent_id: changesFromView[1]?.id, + file_id: "test", + type: "text", + plugin_key: "mock-plugin", + operation: "update", + value: { id: "test", text: "test updated text second update" }, + meta: null, + created_at: changesFromView[2]?.created_at, + branch_id: branches[0]?.id, + seq: 3, + }, + ]); + + await lix.db + .updateTable("file") + .set({ data: enc.encode("test updated text xyz") }) + .where("id", "=", "test") + .execute(); + + await lix.settled(); + + const branchChanges3 = await lix.db + .selectFrom("branch_change") + .orderBy(["branch_id", "seq"]) + .selectAll() + .execute(); + + expect(branchChanges3).toEqual([ + { + branch_id: branches[0]?.id, + change_id: updatedChanges[0]?.id, + id: branchChanges2[0]?.id, + seq: 1, + }, + { + branch_id: branches[0]?.id, + change_id: updatedChanges[1]?.id, + id: branchChanges2[1]?.id, + seq: 2, + }, + { + branch_id: branches[0]?.id, + change_id: updatedChanges[2]?.id, + id: branchChanges2[2]?.id, + seq: 3, + }, + + { + branch_id: branches[0]?.id, + change_id: branchChanges3[3]?.change_id, + id: branchChanges3[3]?.id, + seq: 4, + }, + + { + branch_id: branches3[2]?.id, + change_id: updatedChanges[0]?.id, + id: branchChanges2[0]?.id, + seq: 1, + }, + { + branch_id: branches3[2]?.id, + change_id: updatedChanges[1]?.id, + id: branchChanges2[1]?.id, + seq: 2, + }, + { + branch_id: branches3[2]?.id, + change_id: updatedChanges[2]?.id, + id: branchChanges2[2]?.id, + seq: 3, + }, + ]); + + const newBranch = await lix.db + .selectFrom("branch") + .where("name", "=", "test_branch_2") + .select("id") + .executeTakeFirstOrThrow(); + + await lix.switchBranch({ branchId: newBranch.id }); + + await lix.settled(); + + const afterSwitchBranch = await lix.db + .selectFrom("branch") + .selectAll() + .where("active", "=", true) + .executeTakeFirstOrThrow(); + + const afterSwitchContent = await lix.db + .selectFrom("file") + .selectAll() + .where("path", "=", "test.txt") + .executeTakeFirstOrThrow(); + + expect(afterSwitchBranch.name).toEqual("test_branch_2"); + + const dec = new TextDecoder(); + + expect(dec.decode(afterSwitchContent.data)).toEqual( + "test updated text second update", + ); +}); diff --git a/lix/packages/sdk/src/branch/create.ts b/lix/packages/sdk/src/branch/create.ts new file mode 100644 index 0000000000..daf39af383 --- /dev/null +++ b/lix/packages/sdk/src/branch/create.ts @@ -0,0 +1,59 @@ +import { BranchExistsError } from "./errors.js"; +import type { initDb } from "../database/initDb.js"; + +export async function createBranch(args: { + db: ReturnType; + name: string; + branchId?: string; +}): Promise { + let branchId: string; + + await args.db.transaction().execute(async (trx) => { + const currentBranch = await trx + .selectFrom("branch") + .select("id") + .where("active", "=", true) + .executeTakeFirstOrThrow(); + + const existing = await trx + .selectFrom("branch") + .where("name", "=", args.name) + .select("id") + .executeTakeFirst(); + + if (existing) { + throw new BranchExistsError({ name: args.name, id: existing.id }); + } + + const newBranch = await trx + .insertInto("branch") + .values({ + name: args.name, + active: false, + base_branch: currentBranch.id, + id: args.branchId || undefined, + }) + .returning("id") + .executeTakeFirstOrThrow(); + + branchId = newBranch.id; + + await trx + .insertInto("branch_change") + .columns(["id", "branch_id", "change_id", "seq"]) + .expression((eb) => + eb + .selectFrom("branch_change") + .select((eb) => [ + "branch_change.id", + eb.val(newBranch!.id).as("branch_id"), + "branch_change.change_id", + "branch_change.seq", + ]) + .where("branch_id", "=", currentBranch.id), + ) + .execute(); + }); + + return branchId!; +} diff --git a/lix/packages/sdk/src/branch/errors.ts b/lix/packages/sdk/src/branch/errors.ts new file mode 100644 index 0000000000..b18d6ecdb8 --- /dev/null +++ b/lix/packages/sdk/src/branch/errors.ts @@ -0,0 +1,28 @@ +import dedent from "dedent"; + +export class BranchError extends Error { + constructor(message: string) { + super(message); + this.name = "BranchError"; + } +} + +export class BranchExistsError extends BranchError { + constructor(args: { name: string; id: string }) { + super(dedent` +The branch with name "${args.name}" already exists with id "${args.id}". + +`); + this.name = "BranchAlreadyExistsError"; + } +} + +export class PluginMissingError extends BranchError { + constructor(args: { type: string; handler: string }) { + super(dedent` +Plugin is missing the "${args.handler}" for type: "${args.type}". + +`); + this.name = "PluginMissingError"; + } +} diff --git a/lix/packages/sdk/src/branch/get-leaf-changes-diff.ts b/lix/packages/sdk/src/branch/get-leaf-changes-diff.ts new file mode 100644 index 0000000000..3a409ab08c --- /dev/null +++ b/lix/packages/sdk/src/branch/get-leaf-changes-diff.ts @@ -0,0 +1,117 @@ +import type { Change, LixReadonly } from "@lix-js/sdk"; +import type { LixPlugin } from "../plugin.js"; +import { PluginMissingError } from "./errors.js"; + +export async function getLeafChangesDiff(args: { + plugins: LixPlugin[]; + sourceDb: LixReadonly["db"]; + targetDb?: LixReadonly["db"]; + sourceBranchId: string; + targetBranchId: string; +}): Promise<{ change: Change; obsolete: boolean; valueId: string }[]> { + if (!args.targetDb) { + args.targetDb = args.sourceDb; + } + + const leafChangesInSource = await args.sourceDb + .selectFrom("change_view") + .selectAll() + .where("branch_id", "=", args.sourceBranchId) + .where( + "id", + "not in", + // @ts-ignore - no idea what the type issue is + args.sourceDb + .selectFrom("change_view") + .select("parent_id") + .where("parent_id", "is not", undefined) + .where("branch_id", "=", args.sourceBranchId) + .distinct(), + ) + .execute(); + + const sourceChangesByValueId: Record = {}; + for (const change of leafChangesInSource) { + if (!change.value) { + const parentChange = await args.sourceDb + .selectFrom("change") + .selectAll() + .where("id", "=", change.parent_id!) + .executeTakeFirstOrThrow(); + + sourceChangesByValueId[parentChange.value!.id]! = change; + } else { + sourceChangesByValueId[change.value.id] = change; + } + } + + const leafChangesInTarget = await args.targetDb + .selectFrom("change_view") + .selectAll() + .where("branch_id", "=", args.targetBranchId) + .where( + "id", + "not in", + // @ts-ignore - no idea what the type issue is + args.targetDb + .selectFrom("change_view") + .select("parent_id") + .where("parent_id", "is not", undefined) + .where("branch_id", "=", args.targetBranchId) + .distinct(), + ) + .execute(); + + const obsolete = []; + const targetChangesByValueId: Record = {}; + for (const change of leafChangesInTarget) { + let valueId: string; + if (!change.value) { + const parentChange = await args.targetDb + .selectFrom("change") + .selectAll() + .where("id", "=", change.parent_id!) + .executeTakeFirstOrThrow(); + + valueId = parentChange.value!.id; + } else { + valueId = change.value.id; + } + + targetChangesByValueId[valueId] = change; + + if (!sourceChangesByValueId[valueId]) { + obsolete.push({ change, obsolete: true, valueId }); + } + } + + const plugin = args.plugins[0]!; + const toChange = []; + + for (const [valueId, change] of Object.entries(sourceChangesByValueId)) { + // @ts-ignore + const targetChange = targetChangesByValueId[valueId]; + + if (change.id === targetChange?.id) { + continue; + } + + // @ts-ignore + const old = targetChangesByValueId[valueId] as Change | undefined; + + if (!plugin?.diff[change.type]) { + throw new PluginMissingError({ type: change.type, handler: "diff" }); + } + + const diff = await plugin.diff[change.type]!({ + neu: change.value, + old: old?.value, + }); + + if (diff.length) { + toChange.push({ change: targetChange!, obsolete: false, valueId }); + } + } + + return [...obsolete, ...toChange]; +} diff --git a/lix/packages/sdk/src/branch/switch.ts b/lix/packages/sdk/src/branch/switch.ts new file mode 100644 index 0000000000..7eee46e025 --- /dev/null +++ b/lix/packages/sdk/src/branch/switch.ts @@ -0,0 +1,77 @@ +import type { initDb } from "../database/initDb.js"; +import { getLeafChangesDiff } from "./get-leaf-changes-diff.js"; +import type { LixPlugin } from "../plugin.js"; +import type { Change } from "../database/schema.js"; + +export async function switchBranch(args: { + plugins: LixPlugin[]; + db: ReturnType; + branchId: string; +}) { + await args.db.transaction().execute(async (trx) => { + const currentBranchId = ( + await trx + .selectFrom("branch") + .where("active", "=", true) + .select("id") + .executeTakeFirstOrThrow() + ).id; + + await trx + .updateTable("branch") + .where("active", "=", true) + .set({ active: false }) + .execute(); + + await trx + .updateTable("branch") + .where("id", "=", args.branchId) + .set({ active: true }) + .execute(); + + const leafChangesDiff = await getLeafChangesDiff({ + sourceDb: trx, + plugins: args.plugins, + sourceBranchId: currentBranchId, + targetBranchId: args.branchId, + }); + + const plugin = args.plugins[0]; + + let changesByFileId: Record = {}; + + for (const { change, valueId, obsolete } of leafChangesDiff) { + if (!changesByFileId[change.file_id]) { + changesByFileId[change.file_id] = []; + } + changesByFileId[change.file_id]!.push({ change, valueId, obsolete }); + } + + for (const [fileId, changes] of Object.entries(changesByFileId)) { + const file = await trx + .selectFrom("file") + .selectAll() + .where("id", "=", fileId) + .executeTakeFirstOrThrow(); + + const { fileData } = await plugin!.applyChanges!({ + lix: { db: args.db, plugins: args.plugins }, + file, + // @ts-ignore -- FIXME: obsolete and valueid should be handled in plugins + changes: changes + .filter(({ obsolete }) => !obsolete) + .map(({ change }) => change), + // @ts-ignore + obsoleteChanges: changes + .filter(({ obsolete }) => !!obsolete) + .map(({ change }) => change), + }); + + await trx + .updateTable("file_internal") + .set("data", fileData!) + .where("id", "=", file.id) + .execute(); + } + }); +} diff --git a/lix/packages/sdk/src/change-queue.test.ts b/lix/packages/sdk/src/change-queue.test.ts index 4a2b9d50bd..6b0bf36826 100644 --- a/lix/packages/sdk/src/change-queue.test.ts +++ b/lix/packages/sdk/src/change-queue.test.ts @@ -75,10 +75,22 @@ test("should use queue and settled correctly", async () => { providePlugins: [mockPlugin], }); + const branches = await lix.db.selectFrom("branch").selectAll().execute(); + + expect(branches).toEqual([ + { + id: branches[0]?.id, + name: "main", + base_branch: null, + active: 1, + }, + ]); + 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("test orig") }) .execute(); const internalFiles = await lix.db @@ -89,6 +101,7 @@ test("should use queue and settled correctly", async () => { expect(internalFiles).toEqual([]); const queue = await lix.db.selectFrom("change_queue").selectAll().execute(); + expect(queue).toEqual([ { id: 1, @@ -131,15 +144,34 @@ test("should use queue and settled correctly", async () => { plugin_key: "mock-plugin", value: { id: "test", - text: "test", + text: "test orig", }, meta: null, - commit_id: null, operation: "create", }, ]); - // Test replacing uncommitted changes and multiple changes processing + const branchChanges = await lix.db + .selectFrom("branch_change") + .selectAll() + .execute(); + + expect(branchChanges).toEqual([ + { + branch_id: branches[0]?.id, + change_id: changes[0]?.id, + id: branchChanges[0]?.id, + seq: 1, + }, + ]); + + await lix.db + .updateTable("file") + .set({ data: enc.encode("test updated text") }) + .where("id", "=", "test") + .execute(); + + // repeat same update await lix.db .updateTable("file") .set({ data: enc.encode("test updated text") }) @@ -175,6 +207,13 @@ test("should use queue and settled correctly", async () => { metadata: null, data: queue2[1]?.data, }, + { + id: 5, + file_id: "test", + path: "test.txt", + metadata: null, + data: queue2[2]?.data, + }, ]); await lix.settled(); @@ -190,56 +229,129 @@ test("should use queue and settled correctly", async () => { expect(updatedChanges).toEqual([ { + id: changes[0]?.id, author: null, - id: updatedChanges[0]?.id, - created_at: updatedChanges[0]?.created_at, + created_at: changes[0]?.created_at, parent_id: null, type: "text", file_id: "test", - operation: "create", plugin_key: "mock-plugin", value: { id: "test", - text: "test", + text: "test orig", }, meta: null, - commit_id: null, + operation: "create", }, { author: null, - commit_id: null, + id: updatedChanges[1]?.id, created_at: updatedChanges[1]?.created_at, + parent_id: changes[0]?.id, + type: "text", file_id: "test", - id: updatedChanges[1]?.id, - meta: null, operation: "update", - parent_id: updatedChanges[0]?.id, plugin_key: "mock-plugin", - type: "text", value: { id: "test", text: "test updated text", }, + meta: null, }, { + id: updatedChanges[2]?.id, author: null, - commit_id: null, + parent_id: updatedChanges[1]?.id, + type: "text", + file_id: "test", + plugin_key: "mock-plugin", + operation: "update", + value: { id: "test", text: "test updated text second update" }, + meta: null, created_at: updatedChanges[2]?.created_at, + }, + ]); + + const branchChanges2 = await lix.db + .selectFrom("branch_change") + .selectAll() + .execute(); + + expect(branchChanges2).toEqual([ + { + branch_id: branches[0]?.id, + change_id: updatedChanges[0]?.id, + id: branchChanges2[0]?.id, + seq: 1, + }, + { + branch_id: branches[0]?.id, + change_id: updatedChanges[1]?.id, + id: branchChanges2[1]?.id, + seq: 2, + }, + { + branch_id: branches[0]?.id, + change_id: updatedChanges[2]?.id, + id: branchChanges2[2]?.id, + seq: 3, + }, + ]); + + // test change_view + + const changesFromView = await lix.db + .selectFrom("change_view") + .where("branch_id", "=", branches[0]!.id) + .selectAll() + .execute(); + + expect(changesFromView).toEqual([ + { + author: null, + id: changesFromView[0]?.id, + parent_id: null, + type: "text", file_id: "test", - id: updatedChanges[2]?.id, + plugin_key: "mock-plugin", + operation: "create", + value: { id: "test", text: "test orig" }, meta: null, - operation: "update", - parent_id: updatedChanges[1]?.id, + created_at: changesFromView[0]?.created_at, + branch_id: branches[0]?.id, + seq: 1, + }, + { + author: null, + id: changesFromView[1]?.id, + file_id: "test", + parent_id: changesFromView[0]?.id, + type: "text", plugin_key: "mock-plugin", + operation: "update", + value: { id: "test", text: "test updated text" }, + meta: null, + created_at: changesFromView[1]?.created_at, + branch_id: branches[0]?.id, + seq: 2, + }, + { + author: null, + id: changesFromView[2]?.id, + parent_id: changesFromView[1]?.id, + file_id: "test", type: "text", - value: { - id: "test", - text: "test updated text second update", - }, + plugin_key: "mock-plugin", + operation: "update", + value: { id: "test", text: "test updated text second update" }, + meta: null, + created_at: changesFromView[2]?.created_at, + branch_id: branches[0]?.id, + seq: 3, }, ]); - await lix.commit({ description: "test commit" }); + }); test("changes should contain the author", async () => { diff --git a/lix/packages/sdk/src/commit.test.ts b/lix/packages/sdk/src/commit.test.ts deleted file mode 100644 index 376a47ed7d..0000000000 --- a/lix/packages/sdk/src/commit.test.ts +++ /dev/null @@ -1,199 +0,0 @@ -/* eslint-disable @typescript-eslint/no-non-null-asserted-optional-chain */ -/* eslint-disable @typescript-eslint/no-non-null-assertion */ -import { expect, test } from "vitest"; -import { openLixInMemory } from "./open/openLixInMemory.js"; -import { newLixFile } from "./newLix.js"; -import type { LixPlugin } from "./plugin.js"; - -test("should be able to add and commit changes", async () => { - const mockPlugin: LixPlugin = { - key: "mock-plugin", - glob: "*", - diff: { - file: async ({ old }) => { - return [ - !old - ? { - type: "text", - operation: "create", - old: undefined, - neu: { - id: "test", - text: "inserted text", - }, - } - : { - type: "text", - operation: "update", - old: { - id: "test", - text: "inserted text", - }, - neu: { - id: "test", - text: "updated text", - }, - }, - ]; - }, - }, - }; - const lix = await openLixInMemory({ - blob: await newLixFile(), - providePlugins: [mockPlugin], - }); - - const firstRef = await lix.db - .selectFrom("ref") - .selectAll() - .where("name", "=", "current") - .executeTakeFirstOrThrow(); - expect(firstRef.commit_id).toBe("00000000-0000-0000-0000-000000000000"); - - const enc = new TextEncoder(); - - await lix.db - .insertInto("file") - .values({ id: "test", path: "test.txt", data: enc.encode("test") }) - .execute(); - - await lix.settled(); - - const changes = await lix.db.selectFrom("change").selectAll().execute(); - - // console.log(await lix.db.selectFrom("queue").selectAll().execute()); - - expect(changes).toEqual([ - { - id: changes[0]?.id, - author: null, - created_at: changes[0]?.created_at, - parent_id: null, - type: "text", - file_id: "test", - plugin_key: "mock-plugin", - value: { - id: "test", - text: "inserted text", - }, - meta: null, - commit_id: null, - operation: "create", - }, - ]); - - await lix.commit({ description: "test" }); - - const secondRef = await lix.db - .selectFrom("ref") - .selectAll() - .where("name", "=", "current") - .executeTakeFirstOrThrow(); - - expect(secondRef.commit_id).not.toBe("00000000-0000-0000-0000-000000000000"); - - const commits = await lix.db.selectFrom("commit").selectAll().execute(); - const commitedChanges = await lix.db - .selectFrom("change") - .selectAll() - .execute(); - - expect(commitedChanges).toEqual([ - { - id: commitedChanges[0]?.id, - author: null, - created_at: changes[0]?.created_at, - parent_id: null, - type: "text", - file_id: "test", - plugin_key: "mock-plugin", - value: { - id: "test", - text: "inserted text", - }, - meta: null, - commit_id: commits[0]?.id!, - operation: "create", - }, - ]); - - expect(commits).toEqual([ - { - id: commits[0]?.id!, - author: null, - created: commits[0]?.created!, - created_at: commits[0]?.created_at!, - description: "test", - parent_id: "00000000-0000-0000-0000-000000000000", - }, - ]); - - await lix.db - .updateTable("file") - .set({ data: enc.encode("test updated text") }) - .where("id", "=", "test") - .execute(); - - await lix.settled(); - - const updatedChanges = await lix.db - .selectFrom("change") - .selectAll() - .execute(); - - expect(updatedChanges).toEqual([ - { - id: updatedChanges[0]?.id!, - author: null, - created_at: updatedChanges[0]?.created_at, - parent_id: null, - type: "text", - file_id: "test", - plugin_key: "mock-plugin", - value: { - id: "test", - text: "inserted text", - }, - meta: null, - commit_id: commits[0]?.id, - operation: "create", - }, - { - id: updatedChanges[1]?.id!, - author: null, - parent_id: updatedChanges[0]?.id!, - created_at: updatedChanges[0]?.created_at, - type: "text", - file_id: "test", - plugin_key: "mock-plugin", - value: { - id: "test", - text: "updated text", - }, - meta: null, - commit_id: null, - operation: "update", - }, - ]); - - await lix.commit({ description: "test 2" }); - const newCommits = await lix.db.selectFrom("commit").selectAll().execute(); - expect(newCommits).toEqual([ - { - id: newCommits[0]?.id!, - author: null, - created: commits[0]?.created!, - created_at: newCommits[0]?.created_at!, - description: "test", - parent_id: "00000000-0000-0000-0000-000000000000", - }, - { - id: newCommits[1]?.id!, - author: null, - created: commits[0]?.created!, - created_at: newCommits[1]?.created_at!, - description: "test 2", - parent_id: newCommits[0]?.id!, - }, - ]); -}); diff --git a/lix/packages/sdk/src/commit.ts b/lix/packages/sdk/src/commit.ts deleted file mode 100644 index 9fe8425772..0000000000 --- a/lix/packages/sdk/src/commit.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { Kysely } from "kysely"; -import type { LixDatabaseSchema } from "./database/schema.js"; -import { v4 } from "uuid"; - -export async function commit(args: { - db: Kysely; - currentAuthor?: string; - // TODO remove description - description: string; -}) { - const newCommitId = v4(); - - return args.db.transaction().execute(async (trx) => { - const { commit_id: parent_id } = await trx - .selectFrom("ref") - .select("commit_id") - .where("name", "=", "current") - .executeTakeFirstOrThrow(); - - const commit = await trx - .insertInto("commit") - .values({ - id: newCommitId, - author: args.currentAuthor, - // todo - use zoned datetime - description: args.description, - parent_id, - }) - .returning("id") - .executeTakeFirstOrThrow(); - - await trx - .updateTable("ref") - .where("name", "=", "current") - .set({ commit_id: newCommitId }) - .execute(); - - // if (!args.merge) { - // // look for all conflicts and remove them if there is a new change that has same id - // // TODO: needs checking of type and plugin? and integrate into one sql statement - // const newChanges = ( - // await trx.selectFrom("change").where("commit_id", "is", null).selectAll().execute() - // ).map((change) => change.value.id) - - // await trx - // .updateTable("change") - // .set({ - // conflict: null, - // }) - // .where("conflict", "is not", null) - // .where((eb) => eb.ref("value", "->>").key("id"), "in", newChanges) - // .execute() - // } - - return await trx - .updateTable("change") - .where("commit_id", "is", null) - .set({ - commit_id: commit.id, - }) - .execute(); - }); -} diff --git a/lix/packages/sdk/src/database/createSchema.ts b/lix/packages/sdk/src/database/createSchema.ts index 24a147e828..01e9e9987c 100644 --- a/lix/packages/sdk/src/database/createSchema.ts +++ b/lix/packages/sdk/src/database/createSchema.ts @@ -1,14 +1,30 @@ import { sql, type Kysely } from "kysely"; +import { v4 } from "uuid"; export async function createSchema(args: { db: Kysely }) { + const mainUuid = v4(); + return await sql` - CREATE TABLE ref ( - name TEXT PRIMARY KEY, - commit_id TEXT - ); - CREATE TABLE file_internal ( + CREATE TABLE branch ( id TEXT PRIMARY KEY DEFAULT (uuid_v4()), + name TEXT NOT NULL UNIQUE, + active INTEGER DEFAULT FALSE, + base_branch TEXT + ) strict; + + CREATE TABLE branch_change ( + id TEXT DEFAULT (uuid_v4()), + change_id TEXT NOT NULL, + branch_id TEXT NOT NULL, + seq INTEGER NOT NULL + ) strict; + + -- js side uuid as the sqlite function is not available yet on creating the schema + INSERT INTO branch(id, name, active) values(${mainUuid}, 'main', true); + + CREATE TABLE file_internal ( + id TEXT PRIMARY KEY DEFAULT (uuid_v4()), path TEXT NOT NULL UNIQUE, data BLOB NOT NULL, metadata TEXT -- Added metadata field @@ -37,29 +53,20 @@ export async function createSchema(args: { db: Kysely }) { operation TEXT NOT NULL, value TEXT, meta TEXT, - commit_id TEXT, created_at TEXT DEFAULT CURRENT_TIMESTAMP NOT NULL ) strict; + CREATE VIEW change_view AS SELECT author, change.id as id, change.file_id as file_id, change.parent_id as parent_id, type, plugin_key, operation, value, meta, created_at, branch_change.branch_id as branch_id, branch_change.seq as seq FROM branch_change LEFT JOIN change ON branch_change.change_id = change.id ORDER BY seq; + CREATE TABLE conflict ( change_id TEXT NOT NULL, conflicting_change_id TEXT NOT NULL, reason TEXT, + branch_id TEXT NOT NULL, meta TEXT, resolved_with_change_id TEXT, PRIMARY KEY (change_id, conflicting_change_id) ) strict; - - CREATE TABLE 'commit' ( - id TEXT PRIMARY KEY DEFAULT (uuid_v4()), - author TEXT, - parent_id TEXT NOT NULL, - description TEXT NOT NULL, - created TEXT DEFAULT CURRENT_TIMESTAMP NOT NULL, - created_at TEXT DEFAULT CURRENT_TIMESTAMP NOT NULL - ) strict; - - INSERT INTO ref values ('current', '00000000-0000-0000-0000-000000000000'); CREATE TRIGGER file_update INSTEAD OF UPDATE ON file BEGIN @@ -111,4 +118,3 @@ export async function createSchema(args: { db: Kysely }) { `.execute(args.db); } - diff --git a/lix/packages/sdk/src/database/initDb.test.ts b/lix/packages/sdk/src/database/initDb.test.ts index d5d779398f..431d04d1e9 100644 --- a/lix/packages/sdk/src/database/initDb.test.ts +++ b/lix/packages/sdk/src/database/initDb.test.ts @@ -20,22 +20,6 @@ test("file ids should default to uuid", async () => { expect(validate(file.id)).toBe(true); }); -test("commit ids should default to uuid", async () => { - const sqlite = await createInMemoryDatabase({ - readOnly: false, - }); - const db = initDb({ sqlite }); - await createSchema({ db }); - - const commit = await db - .insertInto("commit") - .values({ parent_id: "mock", description: "mock" }) - .returningAll() - .executeTakeFirstOrThrow(); - - expect(validate(commit.id)).toBe(true); -}); - test("change ids should default to uuid", async () => { const sqlite = await createInMemoryDatabase({ readOnly: false, @@ -46,7 +30,6 @@ test("change ids should default to uuid", async () => { const change = await db .insertInto("change") .values({ - commit_id: "mock", type: "file", file_id: "mock", plugin_key: "mock-plugin", diff --git a/lix/packages/sdk/src/database/initDb.ts b/lix/packages/sdk/src/database/initDb.ts index 18876f8fca..07f9285da1 100644 --- a/lix/packages/sdk/src/database/initDb.ts +++ b/lix/packages/sdk/src/database/initDb.ts @@ -6,12 +6,14 @@ import type { LixDatabaseSchema } from "./schema.js"; export function initDb(args: { sqlite: SqliteDatabase }) { initDefaultValueFunctions({ sqlite: args.sqlite }); + const db = new Kysely({ dialect: createDialect({ database: args.sqlite, }), plugins: [new ParseJSONResultsPlugin(), new SerializeJsonPlugin()], }); + return db; } diff --git a/lix/packages/sdk/src/database/schema.ts b/lix/packages/sdk/src/database/schema.ts index 2cb3de6be4..30d2b47aa6 100644 --- a/lix/packages/sdk/src/database/schema.ts +++ b/lix/packages/sdk/src/database/schema.ts @@ -4,8 +4,9 @@ import type { LixPlugin } from "../plugin.js"; export type LixDatabaseSchema = { file: LixFileTable; change: ChangeTable; - commit: CommitTable; - ref: RefTable; + change_view: ChangeView; + branch: BranchTable; + branch_change: BranchChangeMappingTable; file_internal: LixFileTable; change_queue: ChangeQueueTable; conflict: ConflictTable; @@ -16,12 +17,21 @@ export type LixDatabaseSchema = { discussion_change_map: DiscussionChangeMapTable; }; -export type Ref = Selectable; -export type NewRef = Insertable; -export type RefUpdate = Updateable; -type RefTable = { +export type Ref = Selectable; +export type NewRef = Insertable; +export type RefUpdate = Updateable; +type BranchTable = { + id: Generated; name: string; - commit_id: string; + base_branch?: string; + active: boolean; +}; + +type BranchChangeMappingTable = { + id: Generated; + change_id: ChangeTable["id"]; + branch_id: BranchTable["id"]; + seq: number; }; export type ChangeQueueEntry = Selectable; @@ -46,25 +56,6 @@ type LixFileTable = { metadata: Record | null; }; -export type Commit = Selectable; -export type NewCommit = Insertable; -export type CommitUpdate = Updateable; -type CommitTable = { - id: Generated; - // todo: - // multiple authors can commit one change - // think of real-time collaboration scenarios - author?: string; - description: string; - /** - * @deprecated use created_at instead - * todo remove before release - */ - created: Generated; - created_at: Generated; - parent_id: string; -}; - export type Change = Selectable; export type NewChange = Insertable; export type ChangeUpdate = Updateable; @@ -73,11 +64,6 @@ type ChangeTable = { parent_id?: ChangeTable["id"]; author?: string; file_id: string; - /** - * If no commit id exists on a change, - * the change is considered uncommitted. - */ - commit_id?: CommitTable["id"]; /** * The plugin key that contributed the change. * @@ -122,6 +108,11 @@ type ChangeTable = { created_at: Generated; }; +export type ChangeView = ChangeTable & { + branch_id: BranchTable["id"]; + seq: BranchChangeMappingTable["seq"]; +}; + export type Conflict = Selectable; export type NewConflict = Insertable; export type ConflictUpdate = Updateable; @@ -129,6 +120,7 @@ type ConflictTable = { meta?: Record; reason?: string; change_id: ChangeTable["id"]; + branch_id: BranchTable["id"]; conflicting_change_id: ChangeTable["id"]; /** * The change id that the conflict was resolved with. @@ -142,7 +134,6 @@ type ConflictTable = { // ------ discussions ------ export type Discussion = Selectable; -export type NewDiscussion = Insertable; export type DiscussionUpdate = Updateable; type DiscussionTable = { id: Generated; diff --git a/lix/packages/sdk/src/discussion/discussion.test.ts b/lix/packages/sdk/src/discussion/discussion.test.ts index 2167d2f6bd..1b5ea83948 100644 --- a/lix/packages/sdk/src/discussion/discussion.test.ts +++ b/lix/packages/sdk/src/discussion/discussion.test.ts @@ -74,7 +74,6 @@ test("should be able to start a discussion on changes", async () => { text: "inserted text", }, meta: null, - commit_id: null, operation: "create", }, ]); @@ -153,7 +152,6 @@ test("should fail to create a disussion on non existing changes", async () => { text: "inserted text", }, meta: null, - commit_id: null, operation: "create", }, ]); diff --git a/lix/packages/sdk/src/file-handlers.ts b/lix/packages/sdk/src/file-handlers.ts index 581afebeef..5ec78d5296 100644 --- a/lix/packages/sdk/src/file-handlers.ts +++ b/lix/packages/sdk/src/file-handlers.ts @@ -2,8 +2,7 @@ import { v4 } from "uuid"; import type { LixDatabaseSchema, LixFile } from "./database/schema.js"; import type { LixPlugin } from "./plugin.js"; import { minimatch } from "minimatch"; -import { Kysely } from "kysely"; -import { getLeafChange } from "./query-utilities/get-leaf-change.js"; +import { Kysely, sql } from "kysely"; // start a new normalize path function that has the absolute minimum implementation. function normalizePath(path: string) { @@ -23,7 +22,6 @@ export async function handleFileInsert(args: { }) { const pluginDiffs: any[] = []; - // console.log({ args }); for (const plugin of args.plugins) { // glob expressions are expressed relative without leading / but path has leading / if (!minimatch(normalizePath(args.neu.path), "/" + plugin.glob)) { @@ -34,20 +32,26 @@ export async function handleFileInsert(args: { old: undefined, neu: args.neu, }); - // console.log({ diffs }); pluginDiffs.push({ diffs, pluginKey: plugin.key }); } await args.db.transaction().execute(async (trx) => { + const currentBranch = await trx + .selectFrom("branch") + .selectAll() + .where("active", "=", true) + .executeTakeFirstOrThrow(); + for (const { diffs, pluginKey } of pluginDiffs) { for (const diff of diffs ?? []) { const value = diff.neu ?? diff.old; + const changeId = v4(); await trx .insertInto("change") .values({ - id: v4(), + id: changeId, type: diff.type, file_id: args.neu.id, author: args.currentAuthor, @@ -60,6 +64,25 @@ export async function handleFileInsert(args: { // add queueId interesting for debugging or knowning what changes were generated in same worker run }) .execute(); + + const previousBranchChange = await trx + .selectFrom("branch_change") + .selectAll() + .where("branch_id", "=", currentBranch.id) + .orderBy("seq", "desc") + .executeTakeFirst(); + + const lastSeq = previousBranchChange?.seq ?? 0; + + await trx + .insertInto("branch_change") + .values({ + id: v4(), + seq: lastSeq + 1, + branch_id: currentBranch.id, + change_id: changeId, + }) + .execute(); } } @@ -102,6 +125,12 @@ export async function handleFileChange(args: { } await args.db.transaction().execute(async (trx) => { + const currentBranch = await trx + .selectFrom("branch") + .selectAll() + .where("active", "=", true) + .executeTakeFirstOrThrow(); + for (const { diffs, pluginKey, pluginDiffFunction } of pluginDiffs) { for (const diff of diffs ?? []) { // assume an insert or update operation as the default @@ -110,27 +139,17 @@ export async function handleFileChange(args: { // TODO: save hash of changed fles in every commit to discover inconsistent commits with blob? - const previousChanges = await trx - .selectFrom("change") - .selectAll() - .where("file_id", "=", fileId) - .where("plugin_key", "=", pluginKey) - .where("type", "=", diff.type) + const previousChange = await trx + .selectFrom("branch_change") + .leftJoin("change", "branch_change.change_id", "change.id") + .orderBy("branch_change.seq", "desc") + .where("change.file_id", "=", fileId) + .where("change.plugin_key", "=", pluginKey) + .where("change.type", "=", diff.type) + .where("branch_change.branch_id", "=", currentBranch.id) .where((eb) => eb.ref("value", "->>").key("id"), "=", value.id) - // TODO don't rely on created at. a plugin should report the parent id. - .execute(); - - // we need to finde the real leaf change as multiple changes can be set in the same created timestamp second or clockskew - let previousChange; // = previousChanges.at(-1); - for (let i = previousChanges.length - 1; i >= 0; i--) { - for (const change of previousChanges) { - if (change.parent_id === previousChanges[i]?.id) { - break; - } - } - previousChange = previousChanges[i]; - break; - } + .selectAll() + .executeTakeFirst(); // working change exists but is different from previously committed change // -> update the working change or delete if it is the same as previous uncommitted change @@ -141,30 +160,48 @@ export async function handleFileChange(args: { // working change exists but is identical to previously committed change if (previousChange) { previousCommittedDiff = await pluginDiffFunction?.[diff.type]?.({ - old: previousChange?.value, + old: previousChange.value, neu: diff.neu, }); if (previousCommittedDiff?.length === 0) { - // drop the change because it's identical continue; } } + const changeId = v4(); + await trx .insertInto("change") .values({ - id: v4(), + id: changeId, + value: value, + operation: diff.operation, + meta: diff.meta, type: diff.type, file_id: fileId, plugin_key: pluginKey, author: args.currentAuthor, - parent_id: previousChange?.id, - // @ts-expect-error - database expects stringified json - value: JSON.stringify(value), - // @ts-expect-error - database expects stringified json - meta: JSON.stringify(diff.meta), - operation: diff.operation, + parent_id: previousChange?.id || undefined, + }) + .execute(); + + const previousBranchChange = await trx + .selectFrom("branch_change") + .selectAll() + .where("branch_id", "=", currentBranch.id) + .orderBy("seq", "desc") + .executeTakeFirst(); + + const lastSeq = previousBranchChange?.seq ?? 0; + + await trx + .insertInto("branch_change") + .values({ + id: v4(), + seq: lastSeq + 1, + branch_id: currentBranch.id, + change_id: changeId, }) .execute(); } diff --git a/lix/packages/sdk/src/merge/merge.test.ts b/lix/packages/sdk/src/merge/merge.test.ts index ff2918cb32..2be7b935fb 100644 --- a/lix/packages/sdk/src/merge/merge.test.ts +++ b/lix/packages/sdk/src/merge/merge.test.ts @@ -3,7 +3,7 @@ import { test, expect, vi } from "vitest"; import { openLixInMemory } from "../open/openLixInMemory.js"; import { newLixFile } from "../newLix.js"; import { merge } from "./merge.js"; -import type { NewChange, NewCommit, NewConflict } from "../database/schema.js"; +import type { NewChange, NewConflict } from "../database/schema.js"; import type { LixPlugin } from "../plugin.js"; test("it should copy changes from the sourceLix into the targetLix that do not exist in targetLix yet", async () => { @@ -49,18 +49,61 @@ test("it should copy changes from the sourceLix into the targetLix that do not e providePlugins: [mockPlugin], }); + const currentSourceBranch = await sourceLix.db + .selectFrom("branch") + .selectAll() + .where("active", "=", true) + .executeTakeFirstOrThrow(); + const targetLix = await openLixInMemory({ blob: await newLixFile(), providePlugins: [mockPlugin], }); + const currentTargetBranch = await targetLix.db + .selectFrom("branch") + .selectAll() + .where("active", "=", true) + .executeTakeFirstOrThrow(); await sourceLix.db .insertInto("change") .values([mockChanges[0]!, mockChanges[1]!, mockChanges[2]!]) .execute(); + await sourceLix.db + .insertInto("branch_change") + .values([ + { + branch_id: currentSourceBranch.id, + change_id: mockChanges[0]!.id, + seq: 1, + }, + { + branch_id: currentSourceBranch.id, + change_id: mockChanges[1]!.id, + seq: 2, + }, + { + branch_id: currentSourceBranch.id, + change_id: mockChanges[2]!.id, + seq: 3, + }, + ]) + .execute(); + await targetLix.db.insertInto("change").values([mockChanges[0]!]).execute(); + await targetLix.db + .insertInto("branch_change") + .values([ + { + branch_id: currentTargetBranch.id, + change_id: mockChanges[0]!.id, + seq: 1, + }, + ]) + .execute(); + await targetLix.db .insertInto("file") .values({ @@ -134,22 +177,63 @@ test("it should save change conflicts", async () => { blob: await newLixFile(), providePlugins: [mockPlugin], }); + const currentSourceBranch = await sourceLix.db + .selectFrom("branch") + .selectAll() + .where("active", "=", true) + .executeTakeFirstOrThrow(); const targetLix = await openLixInMemory({ blob: await newLixFile(), providePlugins: [mockPlugin], }); + const currentTargetBranch = await targetLix.db + .selectFrom("branch") + .selectAll() + .executeTakeFirstOrThrow(); await sourceLix.db .insertInto("change") .values([mockChanges[0]!, mockChanges[2]!]) .execute(); + await sourceLix.db + .insertInto("branch_change") + .values([ + { + branch_id: currentSourceBranch.id, + change_id: mockChanges[0]!.id, + seq: 1, + }, + { + branch_id: currentSourceBranch.id, + change_id: mockChanges[2]!.id, + seq: 2, + }, + ]) + .execute(); + await targetLix.db .insertInto("change") .values([mockChanges[0]!, mockChanges[1]!]) .execute(); + await targetLix.db + .insertInto("branch_change") + .values([ + { + branch_id: currentTargetBranch.id, + change_id: mockChanges[0]!.id, + seq: 1, + }, + { + branch_id: currentTargetBranch.id, + change_id: mockChanges[1]!.id, + seq: 2, + }, + ]) + .execute(); + await targetLix.db .insertInto("file") .values({ @@ -221,22 +305,59 @@ test("diffing should not be invoked to prevent the generation of duplicate chang blob: await newLixFile(), providePlugins: [mockPluginInSourceLix], }); + const currentSourceBranch = await sourceLix.db + .selectFrom("branch") + .where("active", "=", true) + .selectAll() + .executeTakeFirstOrThrow(); const targetLix = await openLixInMemory({ blob: await newLixFile(), providePlugins: [mockPluginInTargetLix], }); + const currentTargetBranch = await targetLix.db + .selectFrom("branch") + .where("active", "=", true) + .selectAll() + .executeTakeFirstOrThrow(); await sourceLix.db .insertInto("change") .values([...commonChanges, ...changesOnlyInSourceLix]) .execute(); + await sourceLix.db + .insertInto("branch_change") + .values([ + { + branch_id: currentSourceBranch.id, + change_id: commonChanges[0]!.id, + seq: 1, + }, + { + branch_id: currentSourceBranch.id, + change_id: changesOnlyInSourceLix[0]!.id, + seq: 2, + }, + ]) + .execute(); + await targetLix.db .insertInto("change") .values([...commonChanges, ...changesOnlyInTargetLix]) .execute(); + await targetLix.db + .insertInto("branch_change") + .values([ + { + branch_id: currentTargetBranch.id, + change_id: commonChanges[0]!.id, + seq: 1, + }, + ]) + .execute(); + await targetLix.db .insertInto("file") .values({ @@ -300,18 +421,54 @@ test("it should apply changes that are not conflicting", async () => { blob: await newLixFile(), providePlugins: [mockPlugin], }); + const currentSourceBranch = await sourceLix.db + .selectFrom("branch") + .where("active", "=", true) + .selectAll() + .executeTakeFirstOrThrow(); const targetLix = await openLixInMemory({ blob: await newLixFile(), providePlugins: [mockPlugin], }); + const currentTargetBranch = await targetLix.db + .selectFrom("branch") + .where("active", "=", true) + .selectAll() + .executeTakeFirstOrThrow(); await sourceLix.db .insertInto("change") .values([mockChanges[0]!, mockChanges[1]!]) .execute(); + await sourceLix.db + .insertInto("branch_change") + .values([ + { + branch_id: currentSourceBranch.id, + change_id: mockChanges[0]!.id, + seq: 1, + }, + { + branch_id: currentSourceBranch.id, + change_id: mockChanges[1]!.id, + seq: 2, + }, + ]) + .execute(); + await targetLix.db.insertInto("change").values([mockChanges[0]!]).execute(); + await targetLix.db + .insertInto("branch_change") + .values([ + { + branch_id: currentTargetBranch.id, + change_id: mockChanges[0]!.id, + seq: 1, + }, + ]) + .execute(); await targetLix.db .insertInto("file") @@ -387,22 +544,59 @@ test("subsequent merges should not lead to duplicate changes and/or conflicts", blob: await newLixFile(), providePlugins: [mockPlugin], }); + const currentSourceBranch = await sourceLix.db + .selectFrom("branch") + .where("active", "=", true) + .selectAll() + .executeTakeFirstOrThrow(); const targetLix = await openLixInMemory({ blob: await newLixFile(), providePlugins: [mockPlugin], }); + const currentTargetBranch = await targetLix.db + .selectFrom("branch") + .where("active", "=", true) + .selectAll() + .executeTakeFirstOrThrow(); await sourceLix.db .insertInto("change") .values([...commonChanges, ...changesOnlyInSourceLix]) .execute(); + await sourceLix.db + .insertInto("branch_change") + .values([ + { + branch_id: currentSourceBranch.id, + change_id: commonChanges[0]!.id, + seq: 1, + }, + { + branch_id: currentSourceBranch.id, + change_id: changesOnlyInSourceLix[0]!.id, + seq: 2, + }, + ]) + .execute(); + await targetLix.db .insertInto("change") .values([...commonChanges, ...changesOnlyInTargetLix]) .execute(); + await targetLix.db + .insertInto("branch_change") + .values([ + { + branch_id: currentTargetBranch.id, + change_id: commonChanges[0]!.id, + seq: 1, + }, + ]) + .execute(); + await targetLix.db .insertInto("file") .values({ @@ -445,7 +639,6 @@ test("it should naively copy changes from the sourceLix into the targetLix that { id: "2", operation: "update", - commit_id: "commit-1", type: "mock", value: { id: "mock-id", color: "blue" }, file_id: "mock-file", @@ -453,14 +646,6 @@ test("it should naively copy changes from the sourceLix into the targetLix that }, ]; - const commitsOnlyInSourceLix: NewCommit[] = [ - { - id: "commit-1", - description: "", - parent_id: "0", - }, - ]; - const mockPlugin: LixPlugin = { key: "mock-plugin", glob: "*", @@ -475,11 +660,21 @@ test("it should naively copy changes from the sourceLix into the targetLix that blob: await newLixFile(), providePlugins: [mockPlugin], }); + const currentSourceBranch = await sourceLix.db + .selectFrom("branch") + .where("active", "=", true) + .selectAll() + .executeTakeFirstOrThrow(); const targetLix = await openLixInMemory({ blob: await newLixFile(), providePlugins: [mockPlugin], }); + const currentTargetBranch = await targetLix.db + .selectFrom("branch") + .where("active", "=", true) + .selectAll() + .executeTakeFirstOrThrow(); await targetLix.db .insertInto("file") @@ -490,20 +685,22 @@ test("it should naively copy changes from the sourceLix into the targetLix that .insertInto("change") .values(changesOnlyInSourceLix) .execute(); - await sourceLix.db - .insertInto("commit") - .values(commitsOnlyInSourceLix) + .insertInto("branch_change") + .values([ + { + branch_id: currentSourceBranch.id, + change_id: changesOnlyInSourceLix[0]!.id, + seq: 1, + }, + ]) .execute(); await merge({ sourceLix, targetLix }); const changes = await targetLix.db.selectFrom("change").selectAll().execute(); - const commits = await targetLix.db.selectFrom("commit").selectAll().execute(); expect(changes.length).toBe(1); - expect(commits.length).toBe(1); - expect(changes[0]?.commit_id).toBe("commit-1"); }); test("it should copy discussion and related comments and mappings", async () => { @@ -583,7 +780,6 @@ test("it should copy discussion and related comments and mappings", async () => text: "inserted text", }, meta: null, - commit_id: null, operation: "create", }, ]); @@ -636,4 +832,4 @@ test("it should copy discussion and related comments and mappings", async () => await merge({ sourceLix: lix1, targetLix: lix2 }); // TODO add test for discussions and discussion maps -}); \ No newline at end of file +}); diff --git a/lix/packages/sdk/src/merge/merge.ts b/lix/packages/sdk/src/merge/merge.ts index 6c2ebb536e..852705b66b 100644 --- a/lix/packages/sdk/src/merge/merge.ts +++ b/lix/packages/sdk/src/merge/merge.ts @@ -8,24 +8,41 @@ import { getLeafChangesOnlyInSource } from "../query-utilities/get-leaf-changes- * Combined the changes of the source lix into the target lix. */ export async function merge(args: { - targetLix: Lix; sourceLix: Lix; + targetLix: Lix; + sourceBranchId?: string; + targetBranchId?: string; // TODO selectively merge changes // onlyTheseChanges }): Promise { // TODO increase performance by using attach mode // and only get the changes and commits that // are not in target. - const sourceChanges = await args.sourceLix.db - .selectFrom("change") - .selectAll() - .execute(); - // TODO increase performance by only getting commits - // that are not in target in the future. - const sourceCommits = await args.sourceLix.db - .selectFrom("commit") + const sourceBranchId = + args.sourceBranchId || + ( + await args.sourceLix.db + .selectFrom("branch") + .select("id") + .where("active", "=", true) + .executeTakeFirstOrThrow() + ).id; + + const targetBranchId = + args.targetBranchId || + ( + await args.targetLix.db + .selectFrom("branch") + .select("id") + .where("active", "=", true) + .executeTakeFirstOrThrow() + ).id; + + const sourceChanges = await args.sourceLix.db + .selectFrom("change_view") .selectAll() + .where("branch_id", "=", sourceBranchId) .execute(); // TODO don't query the changes again. inefficient. @@ -33,7 +50,6 @@ export async function merge(args: { sourceLix: args.sourceLix, targetLix: args.targetLix, }); - // 2. Let the plugin detect conflicts const plugin = args.sourceLix.plugins[0] as LixPlugin; @@ -44,24 +60,28 @@ export async function merge(args: { } else if (plugin.detectConflicts === undefined) { throw new Error("Plugin does not support conflict detection"); } - const conflicts = await plugin.detectConflicts({ - sourceLix: args.sourceLix, - targetLix: args.targetLix, - leafChangesOnlyInSource, - }); + const conflicts = ( + await plugin.detectConflicts({ + sourceLix: args.sourceLix, + targetLix: args.targetLix, + leafChangesOnlyInSource, + }) + ).map((c) => ({ ...c, branch_id: targetBranchId })); const changesPerFile: Record = {}; const fileIds = new Set(sourceChanges.map((c) => c.file_id)); + const nonConflictingLeafChangesInSource = leafChangesOnlyInSource.filter( + (c) => + conflicts.every((conflict) => conflict.conflicting_change_id !== c.id), + ); + for (const fileId of fileIds) { // 3. apply non conflicting leaf changes // TODO inefficient double looping - const nonConflictingLeafChangesInSourceForFile = leafChangesOnlyInSource - .filter((c) => - conflicts.every((conflict) => conflict.conflicting_change_id !== c.id), - ) - .filter((c) => c.file_id === fileId); + const nonConflictingLeafChangesInSourceForFile = + nonConflictingLeafChangesInSource.filter((c) => c.file_id === fileId); let file = await args.targetLix.db .selectFrom("file") @@ -77,15 +97,14 @@ export async function merge(args: { .where("id", "=", fileId) .executeTakeFirstOrThrow(); - const fileToInsert = { - id: file.id, - path: file.path, - data: file.data, - metadata: file.metadata, - }; await args.targetLix.db .insertInto("file_internal") - .values(fileToInsert) + .values({ + id: file.id, + path: file.path, + data: file.data, + metadata: file.metadata, + }) .executeTakeFirst(); } @@ -128,30 +147,53 @@ export async function merge(args: { await args.targetLix.db.transaction().execute(async (trx) => { if (sourceChanges.length > 0) { // 1. copy the changes from source - await trx - .insertInto("change") - .values( - // @ts-expect-error - todo auto serialize values - // https://github.com/opral/inlang-message-sdk/issues/123 - sourceChanges.map((change) => ({ - ...change, - value: JSON.stringify(change.value), - meta: JSON.stringify(change.meta), - })), - ) - // ignore if already exists - .onConflict((oc) => oc.doNothing()) - .execute(); - } + let lastSeq = await trx + .selectFrom("branch_change") + .select("seq") + .where("branch_change.branch_id", "=", targetBranchId) + .executeTakeFirst(); - // 2. copy the commits from source - if (sourceCommits.length > 0) { - await trx - .insertInto("commit") - .values(sourceCommits) - // ignore if already exists - .onConflict((oc) => oc.doNothing()) - .execute(); + for (const toCopyChange of sourceChanges.map((change) => ({ + ...change, + branch_id: undefined, + seq: undefined, + value: JSON.stringify(change.value), + meta: JSON.stringify(change.meta), + }))) { + const existing = await trx + .selectFrom("change") + .select("id") + .where("id", "=", toCopyChange.id) + .executeTakeFirst(); + + if (!existing) { + await trx + .insertInto("change") + .values( + // @ts-expect-error - todo auto serialize values + // https://github.com/opral/inlang-message-sdk/issues/123 + toCopyChange, + ) + .execute(); + } + + if ( + !existing && + nonConflictingLeafChangesInSource.find( + (entry) => entry.id === toCopyChange.id, + ) + ) { + await trx + .insertInto("branch_change") + .values({ + id: toCopyChange.id, + branch_id: targetBranchId, // todo: only for leaf change + change_id: toCopyChange.id, + seq: (lastSeq?.seq || 0) + 1, + }) + .execute(); + } + } } // 3. insert the conflicts of those changes diff --git a/lix/packages/sdk/src/newLix.test.ts b/lix/packages/sdk/src/newLix.test.ts index 6f0cbda3be..0f7c165863 100644 --- a/lix/packages/sdk/src/newLix.test.ts +++ b/lix/packages/sdk/src/newLix.test.ts @@ -9,7 +9,6 @@ test("inserting a change should auto fill the created_at column", async () => { .insertInto("change") .values({ id: "test", - commit_id: "test", type: "file", file_id: "mock", plugin_key: "mock-plugin", @@ -21,21 +20,3 @@ test("inserting a change should auto fill the created_at column", async () => { expect(changes).lengthOf(1); expect(changes[0]?.created_at).toBeDefined(); }); - -test("inserting a commit should auto fill the created_at column", async () => { - const lix = await openLixInMemory({ blob: await newLixFile() }); - - await lix.db - .insertInto("commit") - .values({ - id: "test", - parent_id: "test", - description: "test", - }) - .execute(); - - const commits = await lix.db.selectFrom("commit").selectAll().execute(); - - expect(commits).lengthOf(1); - expect(commits[0]?.created_at).toBeDefined(); -}); \ No newline at end of file diff --git a/lix/packages/sdk/src/open/openLix.ts b/lix/packages/sdk/src/open/openLix.ts index b55e82b242..159ebb1caa 100644 --- a/lix/packages/sdk/src/open/openLix.ts +++ b/lix/packages/sdk/src/open/openLix.ts @@ -1,11 +1,12 @@ import type { LixPlugin } from "../plugin.js"; -import { commit } from "../commit.js"; import { createDiscussion } from "../discussion/create-discussion.js"; import { addComment } from "../discussion/add-comment.js"; import { handleFileChange, handleFileInsert } from "../file-handlers.js"; import { loadPlugins } from "../load-plugin.js"; import { contentFromDatabase, type SqliteDatabase } from "sqlite-wasm-kysely"; import { initDb } from "../database/initDb.js"; +import { createBranch } from "../branch/create.js"; +import { switchBranch } from "../branch/switch.js"; // TODO: fix in fink to not use time ordering! // .orderBy("commit.created desc") @@ -156,7 +157,7 @@ export async function openLix(args: { await pending; } - return { + const lix = { db, settled, currentAuthor: { @@ -177,9 +178,6 @@ export async function openLix(args: { args.database.close(); await db.destroy(); }, - commit: (args: { description: string }) => { - return commit({ ...args, db, currentAuthor }); - }, createDiscussion: (args: { changeIds?: string[]; body: string }) => { if (currentAuthor === undefined) { throw new Error("current author not set"); @@ -196,7 +194,21 @@ export async function openLix(args: { } return addComment({ ...args, db, currentAuthor }); }, + createBranch: ({ branchId, name }: { branchId?: string; name: string }) => + createBranch({ + db, + name, + branchId, + }), + switchBranch: ({ branchId }: { branchId: string }) => + switchBranch({ + db, + plugins: plugins, + branchId: branchId, + }), }; + + return lix; } // // TODO register on behalf of apps or leave it up to every app? @@ -205,7 +217,7 @@ export async function openLix(args: { // for (const plugin of plugins) { // for (const type in plugin.diffComponent) { // const component = plugin.diffComponent[type]?.() -// const name = "lix-plugin-" + plugin.key + "-diff-" + type +// const named = "lix-plugin-" + plugin.key + "-diff-" + type // if (customElements.get(name) === undefined) { // // @ts-ignore // customElements.define(name, component) diff --git a/lix/packages/sdk/src/plugin.ts b/lix/packages/sdk/src/plugin.ts index 79d982e753..2bc606b82a 100644 --- a/lix/packages/sdk/src/plugin.ts +++ b/lix/packages/sdk/src/plugin.ts @@ -34,6 +34,7 @@ export type LixPlugin< lix: LixReadonly; file: LixFile; changes: Array; + obsoleteChanges?: Array; }) => Promise<{ fileData: LixFile["data"]; }>; 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 33a655ae71..a63f00c446 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 @@ -41,6 +41,33 @@ test("it should find the latest child of a given change", async () => { }, ]; + const currentBranch = await lix.db + .selectFrom("branch") + .selectAll() + .where("active", "=", true) + .executeTakeFirstOrThrow(); + + await lix.db + .insertInto("branch_change") + .values([ + { + branch_id: currentBranch.id, + change_id: "1", + seq: 1, + }, + { + branch_id: currentBranch.id, + change_id: "2", + seq: 2, + }, + { + branch_id: currentBranch.id, + change_id: "3", + seq: 3, + }, + ]) + .execute(); + await lix.db.insertInto("change").values(mockChanges).executeTakeFirst(); const leafOfChange01 = await getLeafChange({ 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 d1eacb34ad..4c4c6440f2 100644 --- a/lix/packages/sdk/src/query-utilities/get-leaf-change.ts +++ b/lix/packages/sdk/src/query-utilities/get-leaf-change.ts @@ -10,19 +10,38 @@ export async function getLeafChange(args: { const _true = true; let nextChange = args.change; + const currentBranch = await args.lix.db + .selectFrom("branch") + .selectAll() + .where("active", "=", _true) + .executeTakeFirstOrThrow(); while (_true) { const childChange = await args.lix.db - .selectFrom("change") + .selectFrom("branch_change") + .fullJoin("change", "branch_change.change_id", "change.id") .selectAll() - .where("parent_id", "=", nextChange.id) + .select([ + "author", + "change.id as id", + "change.parent_id as parent_id", + "type", + "file_id", + "plugin_key", + "operation", + "value", + "meta", + "created_at", + ]) + .where("branch_id", "=", currentBranch.id) + .where("change.parent_id", "=", nextChange?.id) .executeTakeFirst(); if (!childChange) { break; } - nextChange = childChange; + nextChange = childChange as Change; } return nextChange; 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 a02d5860a9..782fa924f3 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 @@ -8,9 +8,19 @@ test("it should get the leaf changes that only exist in source", async () => { const sourceLix = await openLixInMemory({ blob: await newLixFile(), }); + const currentSourceBranch = await sourceLix.db + .selectFrom("branch") + .selectAll() + .where("active", "=", true) + .executeTakeFirstOrThrow(); const targetLix = await openLixInMemory({ blob: await newLixFile(), }); + const currentTargetBranch = await targetLix.db + .selectFrom("branch") + .selectAll() + .where("active", "=", true) + .executeTakeFirstOrThrow(); const commonChanges: NewChange[] = [ { id: "c1", @@ -69,11 +79,63 @@ test("it should get the leaf changes that only exist in source", async () => { .values([...commonChanges, ...changesOnlyInTarget]) .execute(); + await targetLix.db + .insertInto("branch_change") + .values([ + { + branch_id: currentTargetBranch.id, + change_id: "c1", + seq: 1, + }, + { + branch_id: currentTargetBranch.id, + change_id: "c2", + seq: 2, + }, + { + branch_id: currentTargetBranch.id, + change_id: "t1", + seq: 3, + }, + ]) + .execute(); + await sourceLix.db .insertInto("change") .values([...commonChanges, ...changesOnlyInSource]) .execute(); + await sourceLix.db + .insertInto("branch_change") + .values([ + { + branch_id: currentSourceBranch.id, + change_id: "c1", + seq: 1, + }, + { + branch_id: currentSourceBranch.id, + change_id: "c2", + seq: 2, + }, + { + branch_id: currentSourceBranch.id, + change_id: "s1", + seq: 3, + }, + { + branch_id: currentSourceBranch.id, + change_id: "s2", + seq: 4, + }, + { + branch_id: currentSourceBranch.id, + change_id: "s3", + seq: 5, + }, + ]) + .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 f742ddb67f..2cb486f069 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 @@ -11,30 +11,56 @@ import type { Change, LixReadonly } from "@lix-js/sdk"; // TODO optimize later to use a single sql query (attach mode) export async function getLeafChangesOnlyInSource(args: { sourceLix: LixReadonly; - targetLix: LixReadonly; + targetLix?: LixReadonly; + sourceBranchId?: string; + targetBranchId?: string; }): Promise { + if (!args.targetLix) { + args.targetLix = args.sourceLix; + } + if (!args.sourceBranchId) { + args.sourceBranchId = ( + await args.sourceLix.db + .selectFrom("branch") + .select("id") + .where("active", "=", true) + .executeTakeFirstOrThrow() + ).id; + } + if (!args.targetBranchId) { + args.targetBranchId = ( + await args.targetLix.db + .selectFrom("branch") + .select("id") + .where("active", "=", true) + .executeTakeFirstOrThrow() + ).id; + } const result: Change[] = []; const leafChangesInSource = await args.sourceLix.db - .selectFrom("change") + .selectFrom("change_view") .selectAll() + .where("branch_id", "=", args.sourceBranchId) .where( "id", "not in", // @ts-ignore - no idea what the type issue is args.sourceLix.db - .selectFrom("change") + .selectFrom("change_view") .select("parent_id") .where("parent_id", "is not", undefined) + .where("branch_id", "=", args.sourceBranchId) .distinct(), ) .execute(); for (const change of leafChangesInSource) { const changeExistsInTarget = await args.targetLix.db - .selectFrom("change") + .selectFrom("change_view") .select("id") .where("id", "=", change.id) + .where("branch_id", "=", args.targetBranchId) .executeTakeFirst(); if (!changeExistsInTarget) { 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 66f4529510..faa9ae2f8a 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 @@ -7,9 +7,19 @@ test("it should find the common parent of two changes recursively", async () => const sourceLix = await openLixInMemory({ blob: await newLixFile(), }); + const currentSourceBranch = await sourceLix.db + .selectFrom("branch") + .selectAll() + .where("active", "=", true) + .executeTakeFirstOrThrow(); const targetLix = await openLixInMemory({ blob: await newLixFile(), }); + const currentTargetBranch = await targetLix.db + .selectFrom("branch") + .selectAll() + .where("active", "=", true) + .executeTakeFirstOrThrow(); const mockChanges: Change[] = [ { @@ -49,12 +59,44 @@ test("it should find the common parent of two changes recursively", async () => .values([mockChanges[0]!]) .executeTakeFirst(); + await targetLix.db + .insertInto("branch_change") + .values([ + { + branch_id: currentTargetBranch.id, + change_id: "0", + seq: 1, + }, + ]) + .execute(); + await sourceLix.db .insertInto("change") // lix b has two update changes .values([mockChanges[0]!, mockChanges[1]!, mockChanges[2]!]) .executeTakeFirst(); + await sourceLix.db + .insertInto("branch_change") + .values([ + { + branch_id: currentSourceBranch.id, + change_id: "0", + seq: 1, + }, + { + branch_id: currentSourceBranch.id, + change_id: "1", + seq: 2, + }, + { + branch_id: currentSourceBranch.id, + change_id: "2", + seq: 3, + }, + ]) + .execute(); + const commonParent = await getLowestCommonAncestor({ sourceChange: mockChanges[2]!, sourceLix, @@ -128,9 +170,20 @@ test("it should return the source change if its the common parent", async () => const sourceLix = await openLixInMemory({ blob: await newLixFile(), }); + const currentSourceBranch = await sourceLix.db + .selectFrom("branch") + .selectAll() + .where("active", "=", true) + .executeTakeFirstOrThrow(); + const targetLix = await openLixInMemory({ blob: await newLixFile(), }); + const currentTargetBranch = await targetLix.db + .selectFrom("branch") + .selectAll() + .where("active", "=", true) + .executeTakeFirstOrThrow(); const mockChanges: Change[] = [ { @@ -160,11 +213,43 @@ test("it should return the source change if its the common parent", async () => .values([mockChanges[0]!, mockChanges[1]!]) .executeTakeFirst(); + await targetLix.db + .insertInto("branch_change") + .values([ + { + branch_id: currentTargetBranch.id, + change_id: "0", + seq: 1, + }, + { + branch_id: currentTargetBranch.id, + change_id: "1", + seq: 2, + }, + ]) + .execute(); + await sourceLix.db .insertInto("change") .values([mockChanges[0]!, mockChanges[1]!]) .executeTakeFirst(); + await sourceLix.db + .insertInto("branch_change") + .values([ + { + branch_id: currentSourceBranch.id, + change_id: "0", + seq: 1, + }, + { + branch_id: currentSourceBranch.id, + change_id: "1", + seq: 2, + }, + ]) + .execute(); + const commonParent = await getLowestCommonAncestor({ sourceChange: mockChanges[1]!, targetLix, 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 86daaa862c..c12386e023 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 @@ -16,38 +16,95 @@ export async function getLowestCommonAncestor(args: { return undefined; } - const changeExistsInTarget = await args.targetLix.db - .selectFrom("change") + const currentTargetBranch = await args.targetLix.db + .selectFrom("branch") + .selectAll() + .where("active", "=", true) + .executeTakeFirstOrThrow(); + + const currentSourceBranch = await args.sourceLix.db + .selectFrom("branch") .selectAll() - .where("id", "=", args.sourceChange.id) + .where("active", "=", true) + .executeTakeFirstOrThrow(); + + const changeExistsInTarget = await args.targetLix.db + .selectFrom("branch_change") + .leftJoin("change", "branch_change.change_id", "change.id") + .select([ + "author", + "change.id as id", + "change.parent_id as parent_id", + "type", + "file_id", + "plugin_key", + "operation", + "value", + "meta", + "created_at", + ]) + .orderBy("seq", "desc") + .where("branch_id", "=", currentTargetBranch.id) + .where("change.id", "=", args.sourceChange.id) .executeTakeFirst(); if (changeExistsInTarget) { - return changeExistsInTarget; + return changeExistsInTarget as Change; } - let nextChange: Change | undefined; + let nextChange: Change | undefined = args.sourceChange; const _true = true; while (_true) { - nextChange = await args.sourceLix.db - .selectFrom("change") - .selectAll() - .where("id", "=", nextChange?.parent_id ?? args.sourceChange.parent_id) - .executeTakeFirst(); + if (!nextChange?.parent_id) { + // end of the change sequence. No common parent found. + return undefined; + } + nextChange = (await args.sourceLix.db + .selectFrom("branch_change") + .leftJoin("change", "branch_change.change_id", "change.id") + .select([ + "author", + "change.id as id", + "change.parent_id as parent_id", + "type", + "file_id", + "plugin_key", + "operation", + "value", + "meta", + "created_at", + ]) + .where("branch_id", "=", currentSourceBranch.id) + .orderBy("seq", "desc") + .where("change.id", "=", nextChange.parent_id) + .executeTakeFirst()) as Change; - if (!nextChange || !nextChange.parent_id) { + if (!nextChange) { // end of the change sequence. No common parent found. return undefined; } const changeExistsInTarget = await args.targetLix.db - .selectFrom("change") - .selectAll() - .where("id", "=", nextChange.id) + .selectFrom("branch_change") + .leftJoin("change", "branch_change.change_id", "change.id") + .select([ + "author", + "change.id as id", + "change.parent_id as parent_id", + "type", + "file_id", + "plugin_key", + "operation", + "value", + "meta", + "created_at", + ]) + .where("branch_id", "=", currentTargetBranch.id) + .where("change.id", "=", nextChange.id) .executeTakeFirst(); if (changeExistsInTarget) { - return changeExistsInTarget; + return changeExistsInTarget as Change; } } return; diff --git a/lix/packages/sdk/src/query-utilities/index.ts b/lix/packages/sdk/src/query-utilities/index.ts index a0678eea2b..d853132b77 100644 --- a/lix/packages/sdk/src/query-utilities/index.ts +++ b/lix/packages/sdk/src/query-utilities/index.ts @@ -1,4 +1,3 @@ export { getLeafChange } from "./get-leaf-change.js"; export { getLowestCommonAncestor } from "./get-lowest-common-ancestor.js"; export { getLeafChangesOnlyInSource } from "./get-leaf-changes-only-in-source.js"; -export { isInSimulatedCurrentBranch } from "./is-in-simulated-branch.js"; \ No newline at end of file diff --git a/lix/packages/sdk/src/query-utilities/is-in-simulated-branch.test.ts b/lix/packages/sdk/src/query-utilities/is-in-simulated-branch.test.ts deleted file mode 100644 index 79e30606f0..0000000000 --- a/lix/packages/sdk/src/query-utilities/is-in-simulated-branch.test.ts +++ /dev/null @@ -1,174 +0,0 @@ -/* eslint-disable unicorn/no-null */ -import { test, expect } from "vitest"; -import { openLixInMemory } from "../open/openLixInMemory.js"; -import { newLixFile } from "../newLix.js"; -import { isInSimulatedCurrentBranch } from "./is-in-simulated-branch.js"; - -test("as long as a conflict is unresolved, the conflicting change should not appear in the current branch", async () => { - const lix = await openLixInMemory({ blob: await newLixFile() }); - - const changes = await lix.db - .insertInto("change") - .values([ - { - id: "change0", - file_id: "mock", - operation: "create", - plugin_key: "mock", - type: "mock", - }, - { - id: "change1", - file_id: "mock", - operation: "create", - plugin_key: "mock", - type: "mock", - }, - { - id: "change2", - file_id: "mock", - operation: "create", - plugin_key: "mock", - type: "mock", - }, - ]) - .returningAll() - .execute(); - - await lix.db - .insertInto("conflict") - .values([ - { - change_id: "change0", - conflicting_change_id: "change2", - resolved_with_change_id: null, - }, - ]) - .returningAll() - .execute(); - - const changesInCurrentBranch = await lix.db - .selectFrom("change") - .selectAll() - .where(isInSimulatedCurrentBranch) - .execute(); - - expect(changesInCurrentBranch.map((c) => c.id)).toEqual([ - changes[0]?.id, - changes[1]?.id, - ]); -}); - -test(`if the conflict has been resolved by selecting the 'original' change, - the 'conflicting' change should not be in the current branch`, async () => { - const lix = await openLixInMemory({ blob: await newLixFile() }); - - const changes = await lix.db - .insertInto("change") - .values([ - { - id: "change0", - file_id: "mock", - operation: "create", - plugin_key: "mock", - type: "mock", - }, - { - id: "change1", - file_id: "mock", - operation: "create", - plugin_key: "mock", - type: "mock", - }, - { - id: "change2", - file_id: "mock", - operation: "create", - plugin_key: "mock", - type: "mock", - }, - ]) - .returningAll() - .execute(); - - await lix.db - .insertInto("conflict") - .values([ - { - change_id: "change0", - conflicting_change_id: "change2", - resolved_with_change_id: "change0", - }, - ]) - .returningAll() - .execute(); - - const changesInCurrentBranch = await lix.db - .selectFrom("change") - .selectAll() - .where(isInSimulatedCurrentBranch) - .execute(); - - expect(changesInCurrentBranch.map((c) => c.id)).toEqual([ - changes[0]?.id, - changes[1]?.id, - ]); -}); - -test(` - if the conflict has been resolved by selecting the conflicting change, - and rejecting the original change, the conflicting change should appear - in the branch while the original change is excluded`, async () => { - const lix = await openLixInMemory({ blob: await newLixFile() }); - - const changes = await lix.db - .insertInto("change") - .values([ - { - id: "change0", - file_id: "mock", - operation: "create", - plugin_key: "mock", - type: "mock", - }, - { - id: "change1", - file_id: "mock", - operation: "create", - plugin_key: "mock", - type: "mock", - }, - { - id: "change2", - file_id: "mock", - operation: "create", - plugin_key: "mock", - type: "mock", - }, - ]) - .returningAll() - .execute(); - - await lix.db - .insertInto("conflict") - .values([ - { - change_id: "change0", - conflicting_change_id: "change2", - resolved_with_change_id: "change2", - }, - ]) - .returningAll() - .execute(); - - const changesInCurrentBranch = await lix.db - .selectFrom("change") - .selectAll() - .where(isInSimulatedCurrentBranch) - .execute(); - - expect(changesInCurrentBranch.map((c) => c.id)).toEqual([ - changes[1]?.id, - changes[2]?.id, - ]); -}); diff --git a/lix/packages/sdk/src/query-utilities/is-in-simulated-branch.ts b/lix/packages/sdk/src/query-utilities/is-in-simulated-branch.ts deleted file mode 100644 index ddf134b223..0000000000 --- a/lix/packages/sdk/src/query-utilities/is-in-simulated-branch.ts +++ /dev/null @@ -1,47 +0,0 @@ -/* eslint-disable unicorn/no-null */ -import type { ExpressionBuilder } from "kysely"; -import type { LixDatabaseSchema } from "../database/schema.js"; - -/** - * This is a simulated current branch until the - * branching concept is implemented https://linear.app/opral/issue/LIX-126/branching. - * - * Feel free to use your own simulated branch implementation. - * - * @example - * const changesInCurrentBranch = await lix.db - * .selectFrom("change") - * .selectAll() - * .where(isInSimulatedBranch) - * .execute(); - */ -export function isInSimulatedCurrentBranch( - eb: ExpressionBuilder, -) { - return eb.or([ - // change is not in a conflict - eb("change.id", "not in", (subquery) => - subquery.selectFrom("conflict").select("conflict.change_id").unionAll( - // @ts-expect-error - no idea why - subquery - .selectFrom("conflict") - .select("conflict.conflicting_change_id"), - ), - ), - // change is in a conflict that has not been resolved - // AND the change is not the conflicting one - eb("change.id", "in", (subquery) => - subquery - .selectFrom("conflict") - .select("conflict.change_id") - .where("conflict.resolved_with_change_id", "is", null), - ), - // change is in a conflict and is the resolved one - // @ts-expect-error - no idea why - eb("change.id", "in", (subquery) => - subquery - .selectFrom("conflict") - .select("conflict.resolved_with_change_id"), - ), - ]); -} diff --git a/lix/packages/sdk/src/resolve-conflict/resolve-conflict-by-selecting.test.ts b/lix/packages/sdk/src/resolve-conflict/resolve-conflict-by-selecting.test.ts index 05147470fd..9b57d6665e 100644 --- a/lix/packages/sdk/src/resolve-conflict/resolve-conflict-by-selecting.test.ts +++ b/lix/packages/sdk/src/resolve-conflict/resolve-conflict-by-selecting.test.ts @@ -46,6 +46,12 @@ test("it should resolve a conflict by applying the change and marking the confli providePlugins: [mockPlugin], }); + const currentBranch = await lix.db + .selectFrom("branch") + .where("active", "=", true) + .selectAll() + .executeTakeFirst(); + await lix.db .insertInto("file") .values({ id: "mock", path: "mock", data: new Uint8Array() }) @@ -57,11 +63,23 @@ test("it should resolve a conflict by applying the change and marking the confli .returningAll() .execute(); + await lix.db + .insertInto("branch_change") + .values([ + { + seq: 1, + branch_id: currentBranch!.id, + change_id: changes[0]!.id, + }, + ]) + .execute(); + const conflict = await lix.db .insertInto("conflict") .values({ change_id: changes[0]!.id, conflicting_change_id: changes[1]!.id, + branch_id: currentBranch!.id, }) .returningAll() .executeTakeFirstOrThrow(); @@ -96,6 +114,7 @@ test("it should throw if the change id does not belong to the conflict", async ( resolveConflictBySelecting({ lix: {} as any, conflict: { + branch_id: "my-branch", change_id: "change1", conflicting_change_id: "change2", meta: undefined, 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 34ac0bda18..3ca91cd7ba 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 @@ -50,6 +50,12 @@ test("it should throw if the to be resolved with change already exists", async ( providePlugins: [mockPlugin], }); + const currentBranch = await lix.db + .selectFrom("branch") + .where("active", "=", true) + .selectAll() + .executeTakeFirstOrThrow(); + await lix.db .insertInto("file") .values({ id: "mock", path: "mock", data: new Uint8Array() }) @@ -61,11 +67,28 @@ test("it should throw if the to be resolved with change already exists", async ( .returningAll() .execute(); + await lix.db + .insertInto("branch_change") + .values([ + { + change_id: changes[0]!.id, + branch_id: currentBranch.id, + seq: 0, + }, + { + change_id: changes[1]!.id, + branch_id: currentBranch.id, + seq: 1, + }, + ]) + .execute(); + const conflict = await lix.db .insertInto("conflict") .values({ change_id: changes[0]!.id, conflicting_change_id: changes[1]!.id, + branch_id: currentBranch!.id, }) .returningAll() .executeTakeFirstOrThrow(); @@ -121,6 +144,12 @@ test("resolving a conflict should throw if the to be resolved with change is not providePlugins: [mockPlugin], }); + const currentBranch = await lix.db + .selectFrom("branch") + .where("active", "=", true) + .selectAll() + .executeTakeFirstOrThrow(); + await lix.db .insertInto("file") .values({ id: "mock", path: "mock", data: new Uint8Array() }) @@ -132,11 +161,28 @@ test("resolving a conflict should throw if the to be resolved with change is not .returningAll() .execute(); + await lix.db + .insertInto("branch_change") + .values([ + { + change_id: changes[0]!.id, + branch_id: currentBranch.id, + seq: 0, + }, + { + change_id: changes[1]!.id, + branch_id: currentBranch.id, + seq: 1, + }, + ]) + .execute(); + const conflict = await lix.db .insertInto("conflict") .values({ change_id: changes[0]!.id, conflicting_change_id: changes[1]!.id, + branch_id: currentBranch.id, }) .returningAll() .executeTakeFirstOrThrow(); @@ -198,6 +244,12 @@ test("resolving a conflict should throw if the change to resolve with does not b providePlugins: [mockPlugin], }); + const currentBranch = await lix.db + .selectFrom("branch") + .where("active", "=", true) + .selectAll() + .executeTakeFirstOrThrow(); + await lix.db .insertInto("file") .values({ id: "mock", path: "mock", data: new Uint8Array() }) @@ -209,11 +261,28 @@ test("resolving a conflict should throw if the change to resolve with does not b .returningAll() .execute(); + await lix.db + .insertInto("branch_change") + .values([ + { + change_id: changes[0]!.id, + branch_id: currentBranch.id, + seq: 0, + }, + { + change_id: changes[1]!.id, + branch_id: currentBranch.id, + seq: 1, + }, + ]) + .execute(); + const conflict = await lix.db .insertInto("conflict") .values({ change_id: changes[0]!.id, conflicting_change_id: changes[1]!.id, + branch_id: currentBranch.id, }) .returningAll() .executeTakeFirstOrThrow(); @@ -274,6 +343,12 @@ test("resolving a conflict with a new change should insert the change and mark t providePlugins: [mockPlugin], }); + const currentBranch = await lix.db + .selectFrom("branch") + .where("active", "=", true) + .selectAll() + .executeTakeFirstOrThrow(); + await lix.db .insertInto("file") .values({ id: "mock", path: "mock", data: new Uint8Array() }) @@ -285,11 +360,28 @@ test("resolving a conflict with a new change should insert the change and mark t .returningAll() .execute(); + await lix.db + .insertInto("branch_change") + .values([ + { + change_id: changes[0]!.id, + branch_id: currentBranch.id, + seq: 0, + }, + { + change_id: changes[1]!.id, + branch_id: currentBranch.id, + seq: 1, + }, + ]) + .execute(); + const conflict = await lix.db .insertInto("conflict") .values({ change_id: changes[0]!.id, conflicting_change_id: changes[1]!.id, + branch_id: currentBranch.id, }) .returningAll() .executeTakeFirstOrThrow();