diff --git a/packages/lix-sdk/src/branch/create-branch.test.ts b/packages/lix-sdk/src/branch/create-branch.test.ts new file mode 100644 index 0000000000..9511fc38ae --- /dev/null +++ b/packages/lix-sdk/src/branch/create-branch.test.ts @@ -0,0 +1,63 @@ +import { test, expect } from "vitest"; +import { updateBranchPointers } from "../branch/update-branch-pointers.js"; +import { openLixInMemory } from "../open/openLixInMemory.js"; +import { createBranch } from "./create-branch.js"; + +test("it should copy the change pointers from the parent branch", async () => { + const lix = await openLixInMemory({}); + + const mainBranch = await lix.db + .selectFrom("branch") + .selectAll() + .where("name", "=", "main") + .executeTakeFirstOrThrow(); + + await lix.db.transaction().execute(async (trx) => { + const changes = await trx + .insertInto("change") + .values([ + { + type: "file", + entity_id: "value1", + file_id: "mock", + plugin_key: "mock-plugin", + snapshot_id: "sn1", + }, + { + type: "file", + entity_id: "value2", + file_id: "mock", + plugin_key: "mock-plugin", + snapshot_id: "sn2", + }, + ]) + .returningAll() + .execute(); + + await updateBranchPointers({ + lix: { db: trx }, + branch: mainBranch, + changes, + }); + }); + + const branch = await createBranch({ + lix, + from: mainBranch, + name: "feature-branch", + }); + + const branchChangePointers = await lix.db + .selectFrom("branch_change_pointer") + .selectAll() + .execute(); + + // main and feature branch should have the same change pointers + expect(branchChangePointers.length).toBe(4); + expect(branchChangePointers.map((pointer) => pointer.branch_id)).toEqual([ + mainBranch.id, + mainBranch.id, + branch.id, + branch.id, + ]); +}); diff --git a/packages/lix-sdk/src/branch/create-branch.ts b/packages/lix-sdk/src/branch/create-branch.ts new file mode 100644 index 0000000000..de35d9c7f9 --- /dev/null +++ b/packages/lix-sdk/src/branch/create-branch.ts @@ -0,0 +1,64 @@ +import type { Branch } from "../database/schema.js"; +import type { Lix } from "../open/openLix.js"; + +/** + * Creates a new branch. + * + * @example + * ```ts + * const branch = await createBranch({ lix, from: otherBranch }); + * ``` + */ +export async function createBranch(args: { + lix: Pick; + from: Pick; + name?: Branch["name"]; +}): Promise { + const executeInTransaction = async (trx: Lix["db"]) => { + const branch = await trx + .insertInto("branch") + .defaultValues() + .returningAll() + .executeTakeFirstOrThrow(); + + if (args.name) { + await trx + .updateTable("branch") + .set({ name: args.name }) + .where("id", "=", branch.id) + .execute(); + } + + // copy the change pointers from the parent branch + await trx + .insertInto("branch_change_pointer") + .columns([ + "branch_id", + "change_id", + "change_file_id", + "change_entity_id", + "change_type", + ]) + .expression((eb) => + trx + .selectFrom("branch_change_pointer") + .select([ + eb.val(branch.id).as("branch_id"), + "change_id", + "change_file_id", + "change_entity_id", + "change_type", + ]) + .where("branch_id", "=", args.from.id), + ) + .execute(); + + return branch; + }; + + if (args.lix.db.isTransaction) { + return executeInTransaction(args.lix.db); + } else { + return args.lix.db.transaction().execute(executeInTransaction); + } +} diff --git a/packages/lix-sdk/src/branch/index.ts b/packages/lix-sdk/src/branch/index.ts new file mode 100644 index 0000000000..8f6a7962c0 --- /dev/null +++ b/packages/lix-sdk/src/branch/index.ts @@ -0,0 +1,7 @@ +/** + * Public API for the branches. + */ + +export { createBranch } from "./create-branch.js"; +export { switchBranch } from "./switch-branch.js"; +export { updateBranchPointers } from "./update-branch-pointers.js"; diff --git a/packages/lix-sdk/src/branch/merge-branch.test.ts b/packages/lix-sdk/src/branch/merge-branch.test.ts new file mode 100644 index 0000000000..3b343d83a2 --- /dev/null +++ b/packages/lix-sdk/src/branch/merge-branch.test.ts @@ -0,0 +1,254 @@ +import { test, expect, vi } from "vitest"; +import { openLixInMemory } from "../open/openLixInMemory.js"; +import type { LixPlugin } from "../plugin/lix-plugin.js"; +import { mergeBranch } from "./merge-branch.js"; + +test("it should update the branch pointers for non-conflicting changes and insert detected conflicts", async () => { + const lix = await openLixInMemory({}); + + // Initialize source and target branches + const sourceBranch = await lix.db + .insertInto("branch") + .values({ name: "source-branch" }) + .returningAll() + .executeTakeFirstOrThrow(); + + const targetBranch = await lix.db + .insertInto("branch") + .values({ name: "target-branch" }) + .returningAll() + .executeTakeFirstOrThrow(); + + // Insert changes into `change` table and `branch_change_pointer` for source branch + const [change1, change2, change3] = await lix.db + .insertInto("change") + .values([ + { + id: "change1", + type: "file", + entity_id: "entity1", + file_id: "file1", + plugin_key: "mock-plugin", + snapshot_id: "no-content", + }, + { + id: "change2", + type: "file", + entity_id: "entity2", + file_id: "file2", + plugin_key: "mock-plugin", + snapshot_id: "no-content", + }, + { + id: "change3", + type: "file", + entity_id: "entity3", + file_id: "file3", + plugin_key: "mock-plugin", + snapshot_id: "no-content", + }, + ]) + .returningAll() + .execute(); + + await lix.db + .insertInto("branch_change_pointer") + .values([ + { + branch_id: sourceBranch.id, + change_id: change1!.id, + change_entity_id: change1!.entity_id, + change_file_id: change1!.file_id, + change_type: change1!.type, + }, + { + branch_id: sourceBranch.id, + change_id: change2!.id, + change_entity_id: change2!.entity_id, + change_file_id: change2!.file_id, + change_type: change2!.type, + }, + { + branch_id: sourceBranch.id, + change_id: change3!.id, + change_entity_id: change3!.entity_id, + change_file_id: change3!.file_id, + change_type: change3!.type, + }, + ]) + .execute(); + + const mockPlugin: LixPlugin = { + key: "mock", + detectConflictsV2: async () => { + // simulating a conflict between change2 and change3 + return [ + { + change_id: change2!.id, + conflicting_change_id: change3!.id, + }, + ]; + }, + }; + + lix.plugin.getAll = vi.fn().mockResolvedValue([mockPlugin]); + + // Execute the mergeBranch function + await mergeBranch({ lix, sourceBranch, targetBranch }); + + // Validate results in `branch_change_pointer` and `conflict` tables + const targetPointers = await lix.db + .selectFrom("branch_change_pointer") + .selectAll() + .where("branch_id", "=", targetBranch.id) + .execute(); + + const conflicts = await lix.db.selectFrom("conflict").selectAll().execute(); + + // Ensure that non-conflicting pointers (change1 and change2) are in target branch + expect(targetPointers.map((pointer) => pointer.change_id)).toContain( + change1?.id, + ); + expect(targetPointers.map((pointer) => pointer.change_id)).toContain( + change2?.id, + ); + + // Ensure that conflicting pointer (change3) is not in target branch + expect(targetPointers.map((pointer) => pointer.change_id)).not.toContain( + change3?.id, + ); + + // Verify that a conflict for change2 was added to the `conflict` table + expect(conflicts.map((conflict) => conflict.change_id)).toContain( + change2?.id, + ); +}); + +// it is reasonable to assume that a conflict exists if the same (entity, file, type) change is updated in both branches. +// in case a plugin does not detect a conflict, the system should automatically detect it. +test("it should automatically detect a conflict if a change exists that differs updates in both branches despite having a common ancestor", async () => { + const lix = await openLixInMemory({}); + + // Initialize source and target branches + const sourceBranch = await lix.db + .insertInto("branch") + .values({ name: "source-branch" }) + .returningAll() + .executeTakeFirstOrThrow(); + + const targetBranch = await lix.db + .insertInto("branch") + .values({ name: "target-branch" }) + .returningAll() + .executeTakeFirstOrThrow(); + + const ancestorChange = await lix.db + .insertInto("change") + .values({ + id: "ancestor-change", + type: "type1", + entity_id: "entity1", + file_id: "file1", + plugin_key: "mock", + snapshot_id: "no-content", + }) + .returningAll() + .executeTakeFirstOrThrow(); + + // Simulate updates to the entity in both branches + const sourceChange = await lix.db + .insertInto("change") + .values({ + id: "source-change", + type: "type1", + entity_id: "entity1", + file_id: "file1", + plugin_key: "mock", + snapshot_id: "no-content", + }) + .returningAll() + .executeTakeFirstOrThrow(); + + const targetChange = await lix.db + .insertInto("change") + .values({ + id: "target-change", + type: "type1", + entity_id: "entity1", + file_id: "file1", + plugin_key: "mock", + snapshot_id: "no-content", + }) + .returningAll() + .executeTakeFirstOrThrow(); + + // insert edges to ancestor change + await lix.db + .insertInto("change_graph_edge") + .values([ + { parent_id: ancestorChange.id, child_id: sourceChange.id }, + { parent_id: ancestorChange.id, child_id: targetChange.id }, + ]) + .execute(); + + // Insert head pointers for source and target branches + await lix.db + .insertInto("branch_change_pointer") + .values([ + { + branch_id: sourceBranch.id, + change_id: sourceChange.id, + change_entity_id: "entity1", + change_file_id: "file1", + change_type: "type1", + }, + { + branch_id: targetBranch.id, + change_id: targetChange.id, + change_entity_id: "entity1", + change_file_id: "file1", + change_type: "type1", + }, + ]) + .execute(); + + const mockPlugin: LixPlugin = { + key: "mock-plugin", + detectConflictsV2: async () => { + // Simulate no manual conflicts; system should detect automatically + return []; + }, + }; + lix.plugin.getAll = vi.fn().mockResolvedValue([mockPlugin]); + + await mergeBranch({ lix, sourceBranch, targetBranch }); + + // Validate results in `conflict` table + const conflicts = await lix.db + .selectFrom("conflict") + .selectAll() + .where("change_id", "=", sourceChange.id) + .execute(); + + // Ensure that the change from `sourceBranch` is detected as a conflict + expect(conflicts).toEqual([ + expect.objectContaining({ + change_id: sourceChange.id, + conflicting_change_id: targetChange.id, + }), + ]); + + // ensure that the branch change pointer hasn't been updated + const targetPointers = await lix.db + .selectFrom("branch_change_pointer") + .selectAll() + .where("branch_id", "=", targetBranch.id) + .execute(); + + expect(targetPointers.map((pointer) => pointer.change_id)).not.toContain( + sourceChange.id, + ); + expect(targetPointers.map((pointer) => pointer.change_id)).toContain( + targetChange.id, + ); +}); diff --git a/packages/lix-sdk/src/branch/merge-branch.ts b/packages/lix-sdk/src/branch/merge-branch.ts new file mode 100644 index 0000000000..b4d8ce5558 --- /dev/null +++ b/packages/lix-sdk/src/branch/merge-branch.ts @@ -0,0 +1,175 @@ +import type { Branch, Change } from "../database/schema.js"; +import type { Lix } from "../open/openLix.js"; +import type { DetectedConflict } from "../plugin/lix-plugin.js"; +import { getLowestCommonAncestorV2 } from "../query-utilities/get-lowest-common-ancestor-v2.js"; + +export async function mergeBranch(args: { + lix: Lix; + sourceBranch: Branch; + targetBranch: Branch; +}) { + const executeInTransaction = async (trx: Lix["db"]) => { + const diffingPointers = await getBranchChangePointerDiff(trx, args); + + // could be queried in a single query with a join + // but decided to keep it simpler for now + const diffingChanges = await trx + .selectFrom("change") + .select(["id"]) + .where( + "id", + "in", + diffingPointers.map((pointer) => pointer.source_change_id), + ) + .selectAll() + .execute(); + + const plugins = await args.lix.plugin.getAll(); + const detectedConflicts: DetectedConflict[] = []; + + // let plugin detect conflicts + await Promise.all( + plugins.map(async (plugin) => { + if (plugin.detectConflictsV2) { + const conflicts = await plugin.detectConflictsV2({ + lix: args.lix, + changes: diffingChanges, + }); + detectedConflicts.push(...conflicts); + } + }), + ); + + // update the branch change pointers for non-conflicting changes + await Promise.all( + diffingPointers.map(async (pointer) => { + // potentially expensive query + // TODO remove the meaning of detected "this one conflicts with that one" + // to allow "bi-directional" conflicts that can be resolved on + // one branch and reflected on others branches that have the same + // conflict. + const pluginDetectedConflict = detectedConflicts.find( + (conflict) => + conflict.conflicting_change_id === pointer.target_change_id || + conflict.conflicting_change_id === pointer.source_change_id, + ); + if (pluginDetectedConflict) { + // don't update the branch change pointer + return; + } + + // if the entity change doesn't exist in the target + // it can't conflict (except if a plugin detected + // a semantic conflict) + const hasConflictingEntityChanges = !pointer.target_change_id + ? false + : await childOfCommonAncestorDiffers({ + lix: { db: trx }, + changeA: { id: pointer.source_change_id }, + changeB: { id: pointer.target_change_id }, + }); + + if (hasConflictingEntityChanges) { + detectedConflicts.push({ + change_id: pointer.source_change_id, + conflicting_change_id: pointer.target_change_id!, + }); + return; + } + + await trx + .insertInto("branch_change_pointer") + .values([ + { + branch_id: args.targetBranch.id, + change_id: pointer.source_change_id, + change_file_id: pointer.change_file_id, + change_entity_id: pointer.change_entity_id, + change_type: pointer.change_type, + }, + ]) + .onConflict((oc) => + oc.doUpdateSet({ branch_id: args.targetBranch.id }), + ) + .execute(); + }), + ); + + // insert the detected conflicts + // (ignore if the conflict already exists) + if (detectedConflicts.length > 0) { + await trx + .insertInto("conflict") + .values(detectedConflicts) + .onConflict((oc) => oc.doNothing()) + .execute(); + } + }; + + if (args.lix.db.isTransaction) { + return executeInTransaction(args.lix.db); + } else { + return args.lix.db.transaction().execute(executeInTransaction); + } +} + +// could be optimized by returning the diffing changes +async function getBranchChangePointerDiff( + trx: Lix["db"], + args: { + sourceBranch: Branch; + targetBranch: Branch; + }, +) { + return await trx + // select all change pointers from the source branch + .selectFrom("branch_change_pointer as source") + .where("source.branch_id", "=", args.sourceBranch.id) + // and join them with the change pointers from the target branch + .leftJoin("branch_change_pointer as target", (join) => + join + // join related change pointers + // related = same entity, file, and type + .onRef("source.change_entity_id", "=", "target.change_entity_id") + .onRef("source.change_file_id", "=", "target.change_file_id") + .onRef("source.change_type", "=", "target.change_type") + .on("target.branch_id", "=", args.targetBranch.id), + ) + .where((eb) => + eb.or([ + // Doesn't exist in targetBranch (new entity change) + eb("target.change_id", "is", null), + // Differs in targetBranch (different pointer) + eb("source.change_id", "!=", "target.change_id"), + ]), + ) + .select([ + "source.change_id as source_change_id", + "target.change_id as target_change_id", + "source.change_entity_id", + "source.change_file_id", + "source.change_type", + ]) + .execute(); +} + +async function childOfCommonAncestorDiffers(args: { + lix: Pick; + changeA: Pick; + changeB: Pick; +}): Promise { + const lowestCommonAncestor = await getLowestCommonAncestorV2(args); + if (lowestCommonAncestor === undefined) { + return false; + } + // change a or b is the lowest common ancestor, aka no divergence + if ( + args.changeA.id === lowestCommonAncestor.id || + args.changeB.id === lowestCommonAncestor.id + ) { + return false; + } + // neither change a or b is the lowest common ancestor. + // hence, one change is diverged + return true; +} diff --git a/packages/lix-sdk/src/branch/switch-branch.test.ts b/packages/lix-sdk/src/branch/switch-branch.test.ts new file mode 100644 index 0000000000..02ef39d399 --- /dev/null +++ b/packages/lix-sdk/src/branch/switch-branch.test.ts @@ -0,0 +1,30 @@ +import { test, expect } from "vitest"; +import { openLixInMemory } from "../open/openLixInMemory.js"; +import { switchBranch } from "./switch-branch.js"; +import { createBranch } from "./create-branch.js"; + +test("switching branches should update the branch pointer", async () => { + const lix = await openLixInMemory({}); + + const mainBranch = await lix.db + .selectFrom("branch") + .selectAll() + .where("name", "=", "main") + .executeTakeFirstOrThrow(); + + const newBranch = await lix.db.transaction().execute(async (trx) => { + const newBranch = await createBranch({ + lix: { db: trx }, + from: mainBranch, + }); + await switchBranch({ lix: { db: trx }, to: newBranch }); + return newBranch; + }); + + const currentBranch = await lix.db + .selectFrom("current_branch") + .selectAll() + .executeTakeFirstOrThrow(); + + expect(currentBranch.id).toBe(newBranch?.id); +}); diff --git a/packages/lix-sdk/src/branch/switch-branch.ts b/packages/lix-sdk/src/branch/switch-branch.ts new file mode 100644 index 0000000000..b4974eae1f --- /dev/null +++ b/packages/lix-sdk/src/branch/switch-branch.ts @@ -0,0 +1,37 @@ +import type { Branch } from "../database/schema.js"; +import type { Lix } from "../open/openLix.js"; + +/** + * Switches the current branch to the given branch. + * + * The branch must already exist before calling this function. + * + * @example + * ```ts + * await switchBranch({ lix, to: { id: "branch-1" } }); + * ``` + * + * @example + * Switching branches to a newly created branch. + * + * ```ts + * await lix.db.transaction().execute(async (trx) => { + * const newBranch = await createBranch({ lix: { db: trx }, from: currentBranch }); + * await switchBranch({ lix: { db: trx }, to: newBranch }); + * }); + * ``` + */ +export async function switchBranch(args: { + lix: Pick; + to: Pick; +}): Promise { + const executeInTransaction = async (trx: Lix["db"]) => { + await trx.updateTable("current_branch").set({ id: args.to.id }).execute(); + }; + + if (args.lix.db.isTransaction) { + return executeInTransaction(args.lix.db); + } else { + return args.lix.db.transaction().execute(executeInTransaction); + } +} diff --git a/packages/lix-sdk/src/branch/update-branch-pointers.test.ts b/packages/lix-sdk/src/branch/update-branch-pointers.test.ts new file mode 100644 index 0000000000..75460daa2e --- /dev/null +++ b/packages/lix-sdk/src/branch/update-branch-pointers.test.ts @@ -0,0 +1,108 @@ +import { expect, test } from "vitest"; +import { openLixInMemory } from "../open/openLixInMemory.js"; +import { updateBranchPointers } from "./update-branch-pointers.js"; + +test("the branch pointer for a change should be updated", async () => { + const lix = await openLixInMemory({}); + + const mainBranch = await lix.db + .selectFrom("branch") + .selectAll() + .where("name", "=", "main") + .executeTakeFirstOrThrow(); + + await lix.db.transaction().execute(async (trx) => { + const changes = await trx + .insertInto("change") + .values({ + id: "change-1", + type: "file", + entity_id: "value1", + file_id: "mock", + plugin_key: "mock-plugin", + snapshot_id: "no-content", + }) + .returningAll() + .execute(); + + await updateBranchPointers({ + lix: { db: trx }, + branch: mainBranch, + changes, + }); + }); + + const branchChangePointers = await lix.db + .selectFrom("branch_change_pointer") + .selectAll() + .where("branch_id", "=", mainBranch.id) + .execute(); + + // the head of the change is change-1 + expect(branchChangePointers.length).toBe(1); + expect(branchChangePointers[0]?.change_id).toBe("change-1"); + + await lix.db.transaction().execute(async (trx) => { + const changes = await trx + .insertInto("change") + .values({ + id: "change-2", + type: "file", + entity_id: "value1", + file_id: "mock", + plugin_key: "mock-plugin", + snapshot_id: "no-content", + }) + .returningAll() + .execute(); + + await updateBranchPointers({ + lix: { db: trx }, + branch: mainBranch, + changes, + }); + }); + + const updatedBranchChangePointers = await lix.db + .selectFrom("branch_change_pointer") + .selectAll() + .where("branch_id", "=", mainBranch.id) + .execute(); + + // the head of the change is updated to change-2 + expect(updatedBranchChangePointers.length).toBe(1); + expect(updatedBranchChangePointers[0]?.change_id).toBe("change-2"); + + await lix.db.transaction().execute(async (trx) => { + const changes = await trx + .insertInto("change") + .values({ + id: "change-3", + type: "file", + entity_id: "value2", + file_id: "mock", + plugin_key: "mock-plugin", + snapshot_id: "no-content", + }) + .returningAll() + .execute(); + + await updateBranchPointers({ + lix: { db: trx }, + branch: mainBranch, + changes, + }); + }); + + const updatedBranchChangePointers2 = await lix.db + .selectFrom("branch_change_pointer") + .selectAll() + .where("branch_id", "=", mainBranch.id) + .execute(); + + // inserting a new entity should add a new change pointer + // while not updating the old one + expect(updatedBranchChangePointers2.length).toBe(2); + expect(updatedBranchChangePointers2[0]?.change_id).toBe("change-2"); + expect(updatedBranchChangePointers2[1]?.change_id).toBe("change-3"); +}); diff --git a/packages/lix-sdk/src/branch/update-branch-pointers.ts b/packages/lix-sdk/src/branch/update-branch-pointers.ts new file mode 100644 index 0000000000..6a0c2f49b6 --- /dev/null +++ b/packages/lix-sdk/src/branch/update-branch-pointers.ts @@ -0,0 +1,39 @@ +import type { Branch, Change } from "../database/schema.js"; +import type { Lix } from "../open/openLix.js"; + +/** + * Updates the branch pointers for the given branch with the given changes. + */ +export async function updateBranchPointers(args: { + lix: Pick; + branch: Pick; + changes: Change[]; +}): Promise { + const executeInTransaction = async (trx: Lix["db"]) => { + await trx + .insertInto("branch_change_pointer") + .values( + args.changes.map((change) => ({ + branch_id: args.branch.id, + change_id: change.id, + change_entity_id: change.entity_id, + change_file_id: change.file_id, + change_type: change.type, + })), + ) + // pointer for this branch and change_entity, change_file, change_type + // already exists, then update the change_id + .onConflict((oc) => + oc.doUpdateSet((eb) => ({ + change_id: eb.ref("excluded.change_id"), + })), + ) + .execute(); + }; + + if (args.lix.db.isTransaction) { + return executeInTransaction(args.lix.db); + } else { + return args.lix.db.transaction().execute(executeInTransaction); + } +} diff --git a/packages/lix-sdk/src/change-queue.test.ts b/packages/lix-sdk/src/change-queue.test.ts index 481e398ee3..22fa25bfcf 100644 --- a/packages/lix-sdk/src/change-queue.test.ts +++ b/packages/lix-sdk/src/change-queue.test.ts @@ -34,6 +34,11 @@ test("should use queue and settled correctly", async () => { providePlugins: [mockPlugin], }); + const currentBranch = await lix.db + .selectFrom("current_branch") + .selectAll() + .executeTakeFirstOrThrow(); + const enc = new TextEncoder(); await lix.db .insertInto("file") @@ -151,6 +156,11 @@ test("should use queue and settled correctly", async () => { .selectAll() .execute(); + const branchChangePointers = await lix.db + .selectFrom("branch_change_pointer") + .selectAll() + .execute(); + expect(updatedChanges).toEqual([ expect.objectContaining({ entity_id: "test", @@ -187,6 +197,14 @@ test("should use queue and settled correctly", async () => { { parent_id: updatedChanges[0]?.id, child_id: updatedChanges[1]?.id }, { parent_id: updatedChanges[1]?.id, child_id: updatedChanges[2]?.id }, ]); + + // the branch change pointers points to the last change + expect(branchChangePointers).toEqual([ + expect.objectContaining({ + branch_id: currentBranch.id, + change_id: updatedChanges[2]?.id, + }), + ]); }); test.todo("changes should contain the author", async () => { diff --git a/packages/lix-sdk/src/database/applySchema.ts b/packages/lix-sdk/src/database/applySchema.ts index be7bf6ffe7..7090818d7e 100644 --- a/packages/lix-sdk/src/database/applySchema.ts +++ b/packages/lix-sdk/src/database/applySchema.ts @@ -33,7 +33,9 @@ export async function applySchema(args: { sqlite: SqliteDatabase }) { file_id TEXT NOT NULL, plugin_key TEXT NOT NULL, snapshot_id TEXT NOT NULL, - created_at TEXT DEFAULT CURRENT_TIMESTAMP NOT NULL + created_at TEXT DEFAULT CURRENT_TIMESTAMP NOT NULL, + + UNIQUE (id, entity_id, file_id, type) ) strict; CREATE TABLE IF NOT EXISTS change_graph_edge ( @@ -62,7 +64,10 @@ export async function applySchema(args: { sqlite: SqliteDatabase }) { reason TEXT, metadata TEXT, resolved_change_id TEXT, - PRIMARY KEY (change_id, conflicting_change_id) + + PRIMARY KEY (change_id, conflicting_change_id), + -- Prevent self referencing conflicts + CHECK (change_id != conflicting_change_id) ) strict; CREATE TRIGGER IF NOT EXISTS file_update INSTEAD OF UPDATE ON file @@ -134,6 +139,46 @@ export async function applySchema(args: { sqlite: SqliteDatabase }) { INSERT OR IGNORE INTO label (name) VALUES ('confirmed'); + -- branches + + CREATE TABLE IF NOT EXISTS branch ( + id TEXT PRIMARY KEY DEFAULT (uuid_v4()), + + -- name is optional. + -- + -- "anonymous" branches can ease workflows. + -- For example, a user can create a branch + -- without a name to experiment with + -- changes with no mental overhead of + -- naming the branch. + name TEXT + + ) strict; + + CREATE TABLE IF NOT EXISTS branch_change_pointer ( + branch_id TEXT NOT NULL, + change_id TEXT NOT NULL, + change_file_id TEXT NOT NULL, + change_entity_id TEXT NOT NULL, + change_type TEXT NOT NULL, + + PRIMARY KEY(branch_id, change_file_id, change_entity_id, change_type), + + FOREIGN KEY(branch_id) REFERENCES branch(id), + FOREIGN KEY(change_id, change_file_id, change_entity_id, change_type) REFERENCES change(change_id, change_file_id, change_entity_id, change_type) + ) strict; + + -- only one branch can be active at a time + -- hence, the table has only one row + CREATE TABLE IF NOT EXISTS current_branch ( + id TEXT NOT NULL PRIMARY KEY, + + FOREIGN KEY(id) REFERENCES branch(id) + ) strict; + + -- Create a default branch (using a pre-defined id to avoid duplicate inserts) + INSERT OR IGNORE INTO branch (id, name) VALUES ('00000000-0000-0000-0000-000000000000','main'); + INSERT OR IGNORE INTO current_branch (id) VALUES ('00000000-0000-0000-0000-000000000000'); `; } diff --git a/packages/lix-sdk/src/database/initDb.test.ts b/packages/lix-sdk/src/database/initDb.test.ts index 3dbd72f6e6..5ee2415e55 100644 --- a/packages/lix-sdk/src/database/initDb.test.ts +++ b/packages/lix-sdk/src/database/initDb.test.ts @@ -207,4 +207,40 @@ test("the confirmed label should be created if it doesn't exist", async () => { expect(tag).toMatchObject({ name: "confirmed", }); +}); + +test("a default main branch should exist", async () => { + const sqlite = await createInMemoryDatabase({ + readOnly: false, + }); + const db = initDb({ sqlite }); + + const branch = await db + .selectFrom("branch") + .selectAll() + .where("name", "=", "main") + .executeTakeFirst(); + + expect(branch).toBeDefined(); +}); + + +test("conflicts should not be able to reference themselves", async () => { + const sqlite = await createInMemoryDatabase({ + readOnly: false, + }); + const db = initDb({ sqlite }); + + expect( + db + .insertInto("conflict") + .values({ + change_id: "change1", + conflicting_change_id: "change1", + }) + .returningAll() + .execute(), + ).rejects.toThrowErrorMatchingInlineSnapshot( + `[SQLite3Error: SQLITE_CONSTRAINT_CHECK: sqlite3 result code 275: CHECK constraint failed: change_id != conflicting_change_id]`, + ); }); \ No newline at end of file diff --git a/packages/lix-sdk/src/database/schema.ts b/packages/lix-sdk/src/database/schema.ts index f38d6d1303..5a5a391fb5 100644 --- a/packages/lix-sdk/src/database/schema.ts +++ b/packages/lix-sdk/src/database/schema.ts @@ -19,6 +19,11 @@ export type LixDatabaseSchema = { // discussion discussion: DiscussionTable; comment: CommentTable; + + // branch + current_branch: CurrentBranchTable; + branch: BranchTable; + branch_change_pointer: BranchChangePointerTable; }; export type ChangeQueueEntry = Selectable; @@ -169,4 +174,32 @@ export type ChangeSetLabelUpdate = Updateable; type ChangeSetLabelTable = { change_set_id: string; label_id: string; +}; + +// ------ branches ------ + +export type Branch = Selectable; +export type NewBranch = Insertable; +export type BranchUpdate = Updateable; +type BranchTable = { + id: Generated; + name: string | null; +}; + +export type BranchChangePointer = Selectable; +export type NewBranchChangePointer = Insertable; +export type BranchChangePointerUpdate = Updateable; +type BranchChangePointerTable = { + branch_id: string; + change_id: string; + change_file_id: string; + change_entity_id: string; + change_type: string; +}; + +export type CurrentBranch = Selectable; +export type NewCurrentBranch = Insertable; +export type CurrentBranchUpdate = Updateable; +type CurrentBranchTable = { + id: string; }; \ No newline at end of file diff --git a/packages/lix-sdk/src/file-handlers.ts b/packages/lix-sdk/src/file-handlers.ts index 3e80f7636c..93eeac6454 100644 --- a/packages/lix-sdk/src/file-handlers.ts +++ b/packages/lix-sdk/src/file-handlers.ts @@ -5,6 +5,7 @@ import { minimatch } from "minimatch"; import { Kysely } from "kysely"; import { isInSimulatedCurrentBranch } from "./query-utilities/is-in-simulated-branch.js"; import { getLeafChange } from "./query-utilities/get-leaf-change.js"; +import { updateBranchPointers } from "./branch/update-branch-pointers.js"; // start a new normalize path function that has the absolute minimum implementation. function normalizePath(path: string) { @@ -51,6 +52,11 @@ export async function handleFileInsert(args: { } await args.db.transaction().execute(async (trx) => { + const currentBranch = await trx + .selectFrom("current_branch") + .selectAll() + .executeTakeFirstOrThrow(); + for (const detectedChange of detectedChanges) { const snapshot = await trx .insertInto("snapshot") @@ -62,7 +68,7 @@ export async function handleFileInsert(args: { .returning("id") .executeTakeFirstOrThrow(); - await trx + const insertedChange = await trx .insertInto("change") .values({ type: detectedChange.type, @@ -71,7 +77,14 @@ export async function handleFileInsert(args: { plugin_key: detectedChange.pluginKey, snapshot_id: snapshot.id, }) - .execute(); + .returningAll() + .executeTakeFirstOrThrow(); + + await updateBranchPointers({ + lix: { db: trx }, + changes: [insertedChange], + branch: currentBranch, + }); } // TODO: decide if TRIGGER or in js land with await trx.insertInto('file_internal').values({ id: args.fileId, blob: args.newBlob, path: args.newPath }).execute() @@ -120,6 +133,10 @@ export async function handleFileChange(args: { } await args.db.transaction().execute(async (trx) => { + const currentBranch = await trx + .selectFrom("current_branch") + .selectAll() + .executeTakeFirstOrThrow(); for (const detectedChange of detectedChanges) { // heuristic to find the previous change // there is no guarantee that the previous change is the leaf change @@ -170,9 +187,15 @@ export async function handleFileChange(args: { entity_id: detectedChange.entity_id, snapshot_id: snapshot.id, }) - .returning("id") + .returningAll() .executeTakeFirstOrThrow(); + await updateBranchPointers({ + lix: { db: trx }, + changes: [insertedChange], + branch: currentBranch, + }); + // If a parent exists, the change is a child of the parent if (parentChange) { await trx diff --git a/packages/lix-sdk/src/index.ts b/packages/lix-sdk/src/index.ts index af2be962b7..f5b6bd4e5d 100644 --- a/packages/lix-sdk/src/index.ts +++ b/packages/lix-sdk/src/index.ts @@ -1,8 +1,11 @@ export { newLixFile } from "./newLix.js"; + export * from "./plugin/index.js"; export * from "./open/index.js"; export * from "./database/index.js"; export * from "./query-utilities/index.js"; +export * from "./branch/index.js"; + export { jsonObjectFrom, jsonArrayFrom } from "kysely/helpers/sqlite"; export { v4 as uuidv4 } from "uuid"; export * from "./resolve-conflict/errors.js"; diff --git a/packages/lix-sdk/src/merge/merge.test.ts b/packages/lix-sdk/src/merge/merge.test.ts index 3ea800cca9..efea910421 100644 --- a/packages/lix-sdk/src/merge/merge.test.ts +++ b/packages/lix-sdk/src/merge/merge.test.ts @@ -805,6 +805,8 @@ test("it should copy change sets and merge memberships", async () => { const changeSets = await targetLix.db .selectFrom("change_set") .selectAll() + // the initial change set for a branch + .where("id", "is not", "00000000-0000-0000-0000-000000000000") .execute(); const changeSet1Items = await targetLix.db diff --git a/packages/lix-sdk/src/plugin/lix-plugin.ts b/packages/lix-sdk/src/plugin/lix-plugin.ts index d6f92b4686..f848f7737c 100644 --- a/packages/lix-sdk/src/plugin/lix-plugin.ts +++ b/packages/lix-sdk/src/plugin/lix-plugin.ts @@ -37,6 +37,10 @@ export type LixPlugin = { sourceLix: LixReadonly; targetLix: LixReadonly; }) => Promise; + detectConflictsV2?: (args: { + lix: LixReadonly; + changes: Array; + }) => Promise; applyChanges?: (args: { lix: LixReadonly; file: LixFile; @@ -76,5 +80,6 @@ export type DetectedConflict = { export type LixReadonly = Pick & { db: { selectFrom: Lix["db"]["selectFrom"]; + withRecursive: Lix["db"]["withRecursive"]; }; }; diff --git a/packages/lix-sdk/src/plugin/with-transaction.ts b/packages/lix-sdk/src/plugin/with-transaction.ts index e7ff2c6a63..fa5585d7ca 100644 --- a/packages/lix-sdk/src/plugin/with-transaction.ts +++ b/packages/lix-sdk/src/plugin/with-transaction.ts @@ -15,6 +15,7 @@ export function withTransaction( return { db: { selectFrom: trx.selectFrom, + withRecursive: trx.withRecursive, }, plugin: lix.plugin, }; diff --git a/packages/lix-sdk/src/query-utilities/change-in-branch.test.ts b/packages/lix-sdk/src/query-utilities/change-in-branch.test.ts new file mode 100644 index 0000000000..57e1ec7f8f --- /dev/null +++ b/packages/lix-sdk/src/query-utilities/change-in-branch.test.ts @@ -0,0 +1,89 @@ +import { expect, test } from "vitest"; +import { openLixInMemory } from "../open/openLixInMemory.js"; +import { changeInBranch } from "./change-in-branch.js"; + +test("selectChangeInBranch should retrieve all changes in the branch including ancestors", async () => { + const lix = await openLixInMemory({}); + + const branch = await lix.db + .insertInto("branch") + .values({ name: "test-branch" }) + .returningAll() + .executeTakeFirstOrThrow(); + + // Insert changes and create a parent-child chain in change_graph_edge + const [, , changeC] = await lix.db + .insertInto("change") + .values([ + { + id: "changeA", + entity_id: "entity1", + type: "type1", + file_id: "file1", + plugin_key: "plugin1", + snapshot_id: "no-content", + }, + { + id: "changeB", + entity_id: "entity1", + type: "type1", + file_id: "file1", + plugin_key: "plugin1", + snapshot_id: "no-content", + }, + { + id: "changeC", + entity_id: "entity1", + type: "type1", + file_id: "file1", + plugin_key: "plugin1", + snapshot_id: "no-content", + }, + { + id: "changeD", + entity_id: "entity1", + type: "type1", + file_id: "file1", + plugin_key: "plugin1", + snapshot_id: "no-content", + }, + ]) + .returningAll() + .execute(); + + // Link changes in change_graph_edge (C <- B <- A) + await lix.db + .insertInto("change_graph_edge") + .values([ + { parent_id: "changeA", child_id: "changeB" }, + { parent_id: "changeB", child_id: "changeC" }, + ]) + .execute(); + + // Point the branch to changeC, which should include changeA and changeB as ancestors + await lix.db + .insertInto("branch_change_pointer") + .values({ + branch_id: branch.id, + change_id: "changeC", + change_file_id: changeC!.file_id, + change_entity_id: changeC!.entity_id, + change_type: changeC!.type, + }) + .execute(); + + const changes = await lix.db + .selectFrom("change") + .where(changeInBranch(branch.id)) + .selectAll() + .execute(); + + // Verify the returned changes include changeC and its ancestors changeA and changeB + const changeIds = changes.map((change) => change.id); + + // change D is not pointed at in th branch, so it should not be included + expect(changes).toHaveLength(3); + expect(changeIds).toContain("changeA"); + expect(changeIds).toContain("changeB"); + expect(changeIds).toContain("changeC"); +}); diff --git a/packages/lix-sdk/src/query-utilities/change-in-branch.ts b/packages/lix-sdk/src/query-utilities/change-in-branch.ts new file mode 100644 index 0000000000..f4d3a02496 --- /dev/null +++ b/packages/lix-sdk/src/query-utilities/change-in-branch.ts @@ -0,0 +1,35 @@ +import { ExpressionWrapper, sql } from "kysely"; +import type { Branch, LixDatabaseSchema } from "../database/schema.js"; +import type { SqlBool } from "kysely"; + +/** + * Filters if a change is in the given branch. + * + * @example + * ```ts + * const changes = await lix.db.selectFrom("change") + * .where(changeInBranch(currentBranch.id)) + * .selectAll() + * .execute(); + * ``` + */ +export function changeInBranch(branchId: Branch["id"]) { + // Kysely does not support WITH RECURSIVE in a subquery, so we have to + // use a raw SQL expression here and map the type for TypeScript. + // + // The return type cast was figured out by looking at another filter. + return sql` + change.id IN ( + WITH RECURSIVE recursive_changes(id) AS ( + SELECT change_id AS id + FROM branch_change_pointer + WHERE branch_id = ${branchId} + UNION ALL + SELECT change_graph_edge.parent_id AS id + FROM change_graph_edge + INNER JOIN recursive_changes ON recursive_changes.id = change_graph_edge.child_id + ) + SELECT id FROM recursive_changes + ) +` as unknown as ExpressionWrapper; +} diff --git a/packages/lix-sdk/src/query-utilities/get-lowest-common-ancestor-v2.test.ts b/packages/lix-sdk/src/query-utilities/get-lowest-common-ancestor-v2.test.ts new file mode 100644 index 0000000000..9af0c3493a --- /dev/null +++ b/packages/lix-sdk/src/query-utilities/get-lowest-common-ancestor-v2.test.ts @@ -0,0 +1,177 @@ +import { test, expect } from "vitest"; +import { openLixInMemory } from "../open/openLixInMemory.js"; +import { newLixFile } from "../newLix.js"; +import type { Change, ChangeGraphEdge, NewChange } from "../database/schema.js"; +import { getLowestCommonAncestorV2 } from "./get-lowest-common-ancestor-v2.js"; + +test("it should find the common parent of two changes recursively", async () => { + const lix = await openLixInMemory({ + blob: await newLixFile(), + }); + + const mockChanges = [ + { + id: "common", + entity_id: "value1", + file_id: "mock", + plugin_key: "mock", + type: "mock", + created_at: "mock", + snapshot_id: "no-content", + }, + { + id: "1", + entity_id: "value1", + file_id: "mock", + plugin_key: "mock", + type: "mock", + created_at: "mock", + snapshot_id: "no-content", + }, + { + id: "2", + file_id: "mock", + entity_id: "value1", + plugin_key: "mock", + type: "mock", + created_at: "mock", + snapshot_id: "no-content", + }, + { + id: "3", + file_id: "mock", + entity_id: "value1", + plugin_key: "mock", + type: "mock", + created_at: "mock", + snapshot_id: "no-content", + }, + ] as const satisfies Change[]; + + const edges = [ + { parent_id: "common", child_id: "1" }, + { parent_id: "common", child_id: "3" }, + // for re-assurance that the function is not + // just yielding the first parent + { parent_id: "1", child_id: "2" }, + ]; + + await lix.db.insertInto("change").values(mockChanges).execute(); + + await lix.db.insertInto("change_graph_edge").values(edges).execute(); + + const commonAncestor = await getLowestCommonAncestorV2({ + lix, + changeA: mockChanges[1], + changeB: mockChanges[3], + }); + + expect(commonAncestor?.id).toBe("common"); +}); + +test("it should return undefind if no common parent exists", async () => { + const lix = await openLixInMemory({ + blob: await newLixFile(), + }); + + const mockChanges: NewChange[] = [ + { + id: "0", + entity_id: "value1", + file_id: "mock", + plugin_key: "mock", + type: "mock", + snapshot_id: "no-content", + }, + { + id: "1", + entity_id: "value1", + file_id: "mock", + plugin_key: "mock", + type: "mock", + snapshot_id: "no-content", + }, + { + id: "2", + entity_id: "value2", + file_id: "mock", + plugin_key: "mock", + type: "mock", + snapshot_id: "no-content", + }, + ]; + + await lix.db + .insertInto("change") + .values([mockChanges[0]!, mockChanges[1]!, mockChanges[2]!]) + .execute(); + + await lix.db + .insertInto("change_graph_edge") + .values([{ parent_id: "0", child_id: "1" }]) + .execute(); + + const changeA = await lix.db + .selectFrom("change") + .selectAll() + .where("id", "=", mockChanges[2]!.id!) + .executeTakeFirstOrThrow(); + + const changeB = await lix.db + .selectFrom("change") + .selectAll() + .where("id", "=", mockChanges[1]!.id!) + .executeTakeFirstOrThrow(); + + const commonAncestor = await getLowestCommonAncestorV2({ + lix, + changeA, + changeB, + }); + + expect(commonAncestor).toBe(undefined); +}); + +test("it should succeed if one of the given changes is the common ancestor", async () => { + const lix = await openLixInMemory({ + blob: await newLixFile(), + }); + + const mockChanges = [ + { + id: "0", + entity_id: "value1", + file_id: "mock", + plugin_key: "mock", + type: "mock", + snapshot_id: "no-content", + created_at: "mock", + }, + { + id: "1", + entity_id: "value1", + file_id: "mock", + plugin_key: "mock", + type: "mock", + snapshot_id: "no-content", + created_at: "mock", + }, + ] as const satisfies Change[]; + + const edges: ChangeGraphEdge[] = [{ parent_id: "0", child_id: "1" }]; + + await lix.db + .insertInto("change") + .values([mockChanges[0]!, mockChanges[1]!]) + .execute(); + + await lix.db.insertInto("change_graph_edge").values(edges).execute(); + + const commonAncestor = await getLowestCommonAncestorV2({ + lix, + changeA: mockChanges[0], + changeB: mockChanges[1], + }); + + expect(commonAncestor?.id).toBe("0"); +}); diff --git a/packages/lix-sdk/src/query-utilities/get-lowest-common-ancestor-v2.ts b/packages/lix-sdk/src/query-utilities/get-lowest-common-ancestor-v2.ts new file mode 100644 index 0000000000..bda23e5db7 --- /dev/null +++ b/packages/lix-sdk/src/query-utilities/get-lowest-common-ancestor-v2.ts @@ -0,0 +1,110 @@ +import type { Change } from "../database/schema.js"; +import type { LixReadonly } from "../plugin/lix-plugin.js"; + +/** + * Returns the lowest common ancestor of two changes. + * + * @returns Either the lowest common ancestor of the two changes or `undefined` if they do not share a common ancestor. + */ +export async function getLowestCommonAncestorV2(args: { + lix: Pick; + changeA: Pick; + changeB: Pick; +}): Promise { + // check for direct ancestors first before running the recursive query + const directAncestorA = await args.lix.db + .selectFrom("change_graph_edge") + .where("parent_id", "=", args.changeA.id) + .where("child_id", "=", args.changeB.id) + .select("parent_id") + .executeTakeFirst(); + + if (directAncestorA) { + return args.lix.db + .selectFrom("change") + .selectAll() + .where("id", "=", args.changeA.id) + .executeTakeFirstOrThrow(); + } + + const directAncestorB = await args.lix.db + .selectFrom("change_graph_edge") + .where("parent_id", "=", args.changeB.id) + .where("child_id", "=", args.changeA.id) + .select("parent_id") + .executeTakeFirst(); + + if (directAncestorB) { + return args.lix.db + .selectFrom("change") + .selectAll() + .where("id", "=", args.changeB.id) + .executeTakeFirstOrThrow(); + } + + // run the recursive query + + const result = await args.lix.db + .withRecursive("changeA_ancestors", (eb) => + eb + .selectFrom("change_graph_edge") + .select(["parent_id", "child_id"]) + .where("child_id", "=", args.changeA.id) + .unionAll( + eb + .selectFrom("change_graph_edge") + .select([ + "change_graph_edge.parent_id", + "change_graph_edge.child_id", + ]) + .innerJoin( + "changeA_ancestors", + "changeA_ancestors.parent_id", + "change_graph_edge.child_id", + ), + ), + ) + .withRecursive("changeB_ancestors", (eb) => + eb + .selectFrom("change_graph_edge") + .select(["parent_id", "child_id"]) + .where("child_id", "=", args.changeB.id) + .unionAll( + eb + .selectFrom("change_graph_edge") + .select([ + "change_graph_edge.parent_id", + "change_graph_edge.child_id", + ]) + .innerJoin( + "changeB_ancestors", + "changeB_ancestors.parent_id", + "change_graph_edge.child_id", + ), + ), + ) + // Compare ancestors at each recursive step + .selectFrom("changeA_ancestors") + .innerJoin( + "changeB_ancestors", + "changeA_ancestors.parent_id", + "changeB_ancestors.parent_id", + ) + // Return only if both changes directly diverge from a common ancestor + .where("changeA_ancestors.child_id", "=", args.changeA.id) + .where("changeB_ancestors.child_id", "=", args.changeB.id) + .select("changeA_ancestors.parent_id as common_ancestor_id") + .executeTakeFirst(); + + if (result === undefined) { + return undefined; + } + + const commonAncestor = await args.lix.db + .selectFrom("change") + .selectAll() + .where("id", "=", result.common_ancestor_id) + .executeTakeFirstOrThrow(); + + return commonAncestor; +} diff --git a/packages/lix-sdk/src/query-utilities/get-parent-change.ts b/packages/lix-sdk/src/query-utilities/get-parent-change.ts index 3375741428..be8476bd40 100644 --- a/packages/lix-sdk/src/query-utilities/get-parent-change.ts +++ b/packages/lix-sdk/src/query-utilities/get-parent-change.ts @@ -4,6 +4,8 @@ import { isInSimulatedCurrentBranch } from "./is-in-simulated-branch.js"; /** * Gets the parent change of a given change. + * + * @deprecated Use `getParentChangesV2` instead. * * A change can have multiple parents. This function * returns the only parent of a change for a given diff --git a/packages/lix-sdk/src/query-utilities/get-parent-changes-v2.test.ts b/packages/lix-sdk/src/query-utilities/get-parent-changes-v2.test.ts new file mode 100644 index 0000000000..b81a54d78c --- /dev/null +++ b/packages/lix-sdk/src/query-utilities/get-parent-changes-v2.test.ts @@ -0,0 +1,76 @@ +import { test, expect } from "vitest"; +import { openLixInMemory } from "../open/openLixInMemory.js"; +import type { ChangeGraphEdge, NewChange } from "../database/schema.js"; +import { getParentChangesV2 } from "./get-parent-changes-v2.js"; + +test("it should return the parent change of a change", async () => { + const lix = await openLixInMemory({}); + + const mockChanges: NewChange[] = [ + { + id: "change0", + file_id: "mock", + entity_id: "value0", + plugin_key: "mock", + snapshot_id: "no-content", + type: "mock", + }, + { + id: "change1", + file_id: "mock", + entity_id: "value1", + plugin_key: "mock", + snapshot_id: "no-content", + type: "mock", + }, + { + id: "change2", + file_id: "mock", + entity_id: "value1", + plugin_key: "mock", + snapshot_id: "no-content", + type: "mock", + }, + { + id: "change3", + file_id: "mock", + entity_id: "value1", + plugin_key: "mock", + snapshot_id: "no-content", + type: "mock", + }, + { + id: "change4", + file_id: "mock", + entity_id: "value1", + plugin_key: "mock", + snapshot_id: "no-content", + type: "mock", + }, + ]; + + const mockEdges: ChangeGraphEdge[] = [ + // including a grandparent to ensure the function is + // not including this as parent of change3 + { parent_id: "change0", child_id: "change1" }, + // actual parents of change 3 + { parent_id: "change1", child_id: "change3" }, + { parent_id: "change2", child_id: "change3" }, + // included for re-assurance that the function is + // not including this as parent of change3 + { parent_id: "change3", child_id: "change4" }, + ]; + + await lix.db.insertInto("change").values(mockChanges).execute(); + await lix.db.insertInto("change_graph_edge").values(mockEdges).execute(); + + const parentChanges = await getParentChangesV2({ + lix, + change: { id: "change3" }, + }); + + expect(parentChanges.map((change) => change.id)).toStrictEqual([ + "change1", + "change2", + ]); +}); diff --git a/packages/lix-sdk/src/query-utilities/get-parent-changes-v2.ts b/packages/lix-sdk/src/query-utilities/get-parent-changes-v2.ts new file mode 100644 index 0000000000..f2a2ff5eba --- /dev/null +++ b/packages/lix-sdk/src/query-utilities/get-parent-changes-v2.ts @@ -0,0 +1,24 @@ +import type { Change } from "../database/schema.js"; +import type { LixReadonly } from "../plugin/lix-plugin.js"; + +/** + * Gets the parent change of a given change. + * + * @example + * ```ts + * const parents = await getParentChanges({ lix, change }); + * ``` + */ +export async function getParentChangesV2(args: { + lix: Pick; + change: Pick; +}): Promise { + const parents = await args.lix.db + .selectFrom("change_graph_edge") + .innerJoin("change", "change.id", "change_graph_edge.parent_id") + .selectAll("change") + .where("change_graph_edge.child_id", "=", args.change.id) + .execute(); + + return parents; +} diff --git a/packages/lix-sdk/src/query-utilities/index.ts b/packages/lix-sdk/src/query-utilities/index.ts index 8d4a05ab5d..90f1d724f3 100644 --- a/packages/lix-sdk/src/query-utilities/index.ts +++ b/packages/lix-sdk/src/query-utilities/index.ts @@ -8,4 +8,7 @@ export { changeHasLabel } from "./change-has-label.js"; export { changeIsLeafChange } from "./change-is-leaf-change.js"; export { createDiscussion } from "./create-discussion.js"; export { createChangeSet } from "./create-change-set.js"; -export { createComment } from "./create-comment.js"; \ No newline at end of file +export { createComment } from "./create-comment.js"; +export { changeInBranch } from "./change-in-branch.js"; +export { getLowestCommonAncestorV2 } from "./get-lowest-common-ancestor-v2.js"; +export { getParentChangesV2 } from "./get-parent-changes-v2.js"; \ No newline at end of file