From 05047f754631eaf13e1b45e3092a7a31fb6bb755 Mon Sep 17 00:00:00 2001 From: Samuel Stroschein <35429197+samuelstroschein@users.noreply.github.com> Date: Fri, 25 Oct 2024 13:06:32 +0200 Subject: [PATCH 1/2] add: change sets --- .../src/change-set/create-change-set.test.ts | 44 ++++++++++ .../src/change-set/create-change-set.ts | 26 ++++++ packages/lix-sdk/src/database/applySchema.ts | 19 ++++- packages/lix-sdk/src/database/initDb.test.ts | 34 ++++++++ packages/lix-sdk/src/database/schema.ts | 21 +++++ packages/lix-sdk/src/index.ts | 1 + packages/lix-sdk/src/merge/merge.test.ts | 82 +++++++++++++++++++ packages/lix-sdk/src/merge/merge.ts | 41 ++++++++-- 8 files changed, 261 insertions(+), 7 deletions(-) create mode 100644 packages/lix-sdk/src/change-set/create-change-set.test.ts create mode 100644 packages/lix-sdk/src/change-set/create-change-set.ts diff --git a/packages/lix-sdk/src/change-set/create-change-set.test.ts b/packages/lix-sdk/src/change-set/create-change-set.test.ts new file mode 100644 index 0000000000..d9892faeef --- /dev/null +++ b/packages/lix-sdk/src/change-set/create-change-set.test.ts @@ -0,0 +1,44 @@ +import { test, expect } from "vitest"; +import { openLixInMemory } from "../open/openLixInMemory.js"; +import { createChangeSet } from "./create-change-set.js"; + +test("creating a change set should succeed", async () => { + const lix = await openLixInMemory({}); + + const mockChanges = await lix.db + .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(); + + const changeSet = await createChangeSet({ + lix, + changeIds: mockChanges.map((change) => change.id), + }); + + const changeSetMembers = await lix.db + .selectFrom("change_set_membership") + .selectAll() + .where("change_set_id", "=", changeSet.id) + .execute(); + + expect(changeSetMembers.map((member) => member.change_id)).toStrictEqual([ + mockChanges[0]?.id, + mockChanges[1]?.id, + ]); +}); diff --git a/packages/lix-sdk/src/change-set/create-change-set.ts b/packages/lix-sdk/src/change-set/create-change-set.ts new file mode 100644 index 0000000000..c6be3e9175 --- /dev/null +++ b/packages/lix-sdk/src/change-set/create-change-set.ts @@ -0,0 +1,26 @@ +import type { ChangeSet } from "../database/schema.js"; +import type { Lix } from "../types.js"; + +export async function createChangeSet(args: { + lix: Lix; + changeIds: string[]; +}): Promise { + return await args.lix.db.transaction().execute(async (trx) => { + const changeSet = await trx + .insertInto("change_set") + .defaultValues() + .returningAll() + .executeTakeFirstOrThrow(); + + for (const changeId of args.changeIds) { + await trx + .insertInto("change_set_membership") + .values({ + change_id: changeId, + change_set_id: changeSet.id, + }) + .execute(); + } + return changeSet; + }); +} diff --git a/packages/lix-sdk/src/database/applySchema.ts b/packages/lix-sdk/src/database/applySchema.ts index 4c6a477319..f8046bbe6e 100644 --- a/packages/lix-sdk/src/database/applySchema.ts +++ b/packages/lix-sdk/src/database/applySchema.ts @@ -51,6 +51,8 @@ export async function applySchema(args: { sqlite: SqliteDatabase }) { content TEXT ) strict; + -- conflicts + CREATE INDEX IF NOT EXISTS idx_content_hash ON snapshot (id); CREATE TABLE IF NOT EXISTS conflict ( @@ -79,11 +81,25 @@ export async function applySchema(args: { sqlite: SqliteDatabase }) { insert or replace into file_internal(id, path, data, metadata) values(OLD.file_id, OLD.path, OLD.data, OLD.metadata); END; + -- change sets + + CREATE TABLE IF NOT EXISTS change_set ( + id TEXT PRIMARY KEY DEFAULT (uuid_v4()) + ) strict; + + CREATE TABLE IF NOT EXISTS change_set_membership ( + change_set_id TEXT NOT NULL, + change_id TEXT NOT NULL, + + UNIQUE(change_set_id, change_id), + FOREIGN KEY(change_set_id) REFERENCES change_set(id), + FOREIGN KEY(change_id) REFERENCES change(id) + ) strict; + -- change discussions CREATE TABLE IF NOT EXISTS discussion ( - -- TODO https://github.com/opral/lix-sdk/issues/74 replace with uuid_v7 id TEXT PRIMARY KEY DEFAULT (uuid_v4()) ) strict; @@ -99,7 +115,6 @@ export async function applySchema(args: { sqlite: SqliteDatabase }) { CREATE TABLE IF NOT EXISTS comment ( - --- TODO in inlang i saw we replace uuid_v3 with uuid_v7 any reason we use v4 in lix? id TEXT PRIMARY KEY DEFAULT (uuid_v4()), parent_id TEXT, discussion_id TEXT NULL, diff --git a/packages/lix-sdk/src/database/initDb.test.ts b/packages/lix-sdk/src/database/initDb.test.ts index f5e2333115..8bed3622b3 100644 --- a/packages/lix-sdk/src/database/initDb.test.ts +++ b/packages/lix-sdk/src/database/initDb.test.ts @@ -127,3 +127,37 @@ test("change edges can't reference themselves", async () => { `[SQLite3Error: SQLITE_CONSTRAINT_CHECK: sqlite3 result code 275: CHECK constraint failed: parent_id != child_id]`, ); }); + +test("change set memberships must be unique", async () => { + const sqlite = await createInMemoryDatabase({ + readOnly: false, + }); + const db = initDb({ sqlite }); + + await db + .insertInto("change_set") + .defaultValues() + .returningAll() + .executeTakeFirstOrThrow(); + + await db + .insertInto("change_set_membership") + .values({ + change_set_id: "change-set-1", + change_id: "change-1", + }) + .execute(); + + await expect( + db + .insertInto("change_set_membership") + .values({ + change_set_id: "change-set-1", + change_id: "change-1", + }) + .returningAll() + .execute(), + ).rejects.toThrowErrorMatchingInlineSnapshot( + `[SQLite3Error: SQLITE_CONSTRAINT_UNIQUE: sqlite3 result code 2067: UNIQUE constraint failed: change_set_membership.change_set_id, change_set_membership.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 042cbd0583..121e5d194e 100644 --- a/packages/lix-sdk/src/database/schema.ts +++ b/packages/lix-sdk/src/database/schema.ts @@ -9,6 +9,9 @@ export type LixDatabaseSchema = { change_edge: ChangeEdgeTable; conflict: ConflictTable; snapshot: SnapshotTable; + // change set + change_set: ChangeSetTable; + change_set_membership: ChangeSetMembershipTable; // discussion discussion: DiscussionTable; @@ -116,6 +119,24 @@ type ConflictTable = { resolved_change_id: string | null; }; +// ------ change sets ------ + +export type ChangeSet = Selectable; +export type NewChangeSet = Insertable; +export type ChangeSetUpdate = Updateable; +type ChangeSetTable = { + id: Generated; +}; + +export type ChangeSetMembership = Selectable; +export type NewChangeSetMembership = Insertable; +export type ChangeSetMembershipUpdate = Updateable; +type ChangeSetMembershipTable = { + change_set_id: string; + change_id: string; +}; + + // ------ discussions ------ export type Discussion = Selectable; diff --git a/packages/lix-sdk/src/index.ts b/packages/lix-sdk/src/index.ts index e4704b9f3d..389235de75 100644 --- a/packages/lix-sdk/src/index.ts +++ b/packages/lix-sdk/src/index.ts @@ -8,6 +8,7 @@ export * from "./types.js"; export * from "./query-utilities/index.js"; export * from "./resolve-conflict/errors.js"; export { merge } from "./merge/merge.js"; +export { createChangeSet } from "./change-set/create-change-set.js"; // TODO maybe move to `lix.*` api // https://github.com/opral/lix-sdk/issues/58 diff --git a/packages/lix-sdk/src/merge/merge.test.ts b/packages/lix-sdk/src/merge/merge.test.ts index db313a2901..9bef618fe3 100644 --- a/packages/lix-sdk/src/merge/merge.test.ts +++ b/packages/lix-sdk/src/merge/merge.test.ts @@ -11,6 +11,7 @@ import type { } from "../database/schema.js"; import type { LixPlugin } from "../plugin.js"; import { mockJsonSnapshot } from "../query-utilities/mock-json-snapshot.js"; +import { createChangeSet } from "../change-set/create-change-set.js"; test("it should copy changes from the sourceLix into the targetLix that do not exist in targetLix yet", async () => { const mockSnapshots = [ @@ -735,3 +736,84 @@ test("it should copy discussion and related comments and mappings", async () => // TODO add test for discussions and discussion maps }); + +test("it should copy change sets and merge memberships", async () => { + const targetLix = await openLixInMemory({}); + + const mockChanges = await targetLix.db + .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(); + + const changeSet1 = await createChangeSet({ + lix: targetLix, + changeIds: [mockChanges[0]!.id], + }); + + const sourceLix = await openLixInMemory({ + blob: await targetLix.toBlob(), + }); + + // expand the change set to contain another change + // to test if the sets are merged + await targetLix.db + .insertInto("change_set_membership") + .values({ + change_set_id: changeSet1.id, + change_id: mockChanges[1]!.id, + }) + .execute(); + + // create a new set just for change [1] + const changeSet2 = await createChangeSet({ + lix: targetLix, + changeIds: [mockChanges[1]!.id], + }); + + await merge({ sourceLix, targetLix }); + + const changeSets = await targetLix.db + .selectFrom("change_set") + .selectAll() + .execute(); + + const changeSet1Members = await targetLix.db + .selectFrom("change_set_membership") + .selectAll() + .where("change_set_id", "=", changeSet1.id) + .execute(); + const changeSet2Members = await targetLix.db + .selectFrom("change_set_membership") + .selectAll() + .where("change_set_id", "=", changeSet2.id) + .execute(); + + // expect two change sets + expect(changeSets.length).toBe(2); + + // expect merger of the change set to contain both changes + expect(changeSet1Members.map((member) => member.change_id)).toEqual( + expect.arrayContaining([mockChanges[0]?.id, mockChanges[1]?.id]), + ); + + // expect the second change set to contain only the second change + expect(changeSet2Members.map((member) => member.change_id)).toEqual( + expect.arrayContaining([mockChanges[1]?.id]), + ); +}); \ No newline at end of file diff --git a/packages/lix-sdk/src/merge/merge.ts b/packages/lix-sdk/src/merge/merge.ts index 8d416a42eb..1399bc27dc 100644 --- a/packages/lix-sdk/src/merge/merge.ts +++ b/packages/lix-sdk/src/merge/merge.ts @@ -1,4 +1,3 @@ -import type { LixPlugin } from "../plugin.js"; import type { Lix } from "../types.js"; import { getLeafChangesOnlyInSource } from "../query-utilities/get-leaf-changes-only-in-source.js"; @@ -28,15 +27,15 @@ export async function merge(args: { // 2. Let the plugin detect conflicts - const plugin = args.sourceLix.plugins[0] as LixPlugin; + const plugin = args.sourceLix.plugins[0]; // TODO function assumes that all changes belong to the same file - if (args.sourceLix.plugins.length !== 1) { + if (args.sourceLix.plugins.length > 1) { throw new Error("Unimplemented. Only one plugin is supported for now"); } const conflicts = - (await plugin.detectConflicts?.({ + (await plugin?.detectConflicts?.({ sourceLix: args.sourceLix, targetLix: args.targetLix, })) ?? []; @@ -80,7 +79,7 @@ export async function merge(args: { .executeTakeFirst(); } - if (!plugin.applyChanges) { + if (!plugin?.applyChanges) { throw new Error("Plugin does not support applying changes"); } @@ -116,11 +115,25 @@ export async function merge(args: { .selectAll() .execute(); + // change graph + const sourceEdges = await args.sourceLix.db .selectFrom("change_edge") .selectAll() .execute(); + // change sets + + const sourceChangeSets = await args.sourceLix.db + .selectFrom("change_set") + .selectAll() + .execute(); + + const sourceChangeSetMemberships = await args.sourceLix.db + .selectFrom("change_set_membership") + .selectAll() + .execute(); + await args.targetLix.db.transaction().execute(async (trx) => { if (sourceChangesWithSnapshot.length > 0) { // copy the snapshots from source @@ -178,6 +191,24 @@ export async function merge(args: { .execute(); } + // add change sets and change_set_memberships + if (sourceChangeSets.length > 0) { + await trx + .insertInto("change_set") + .values(sourceChangeSets) + // ignore if already exists + .onConflict((oc) => oc.doNothing()) + .execute(); + } + if (sourceChangeSetMemberships.length > 0) { + await trx + .insertInto("change_set_membership") + .values(sourceChangeSetMemberships) + // ignore if already exists + .onConflict((oc) => oc.doNothing()) + .execute(); + } + // add discussions, comments and discsussion_change_mappings if (sourceDiscussions.length > 0) { From 0beaeef30c76c402cecc9cfdc2ea9540865f8bd1 Mon Sep 17 00:00:00 2001 From: Samuel Stroschein <35429197+samuelstroschein@users.noreply.github.com> Date: Fri, 25 Oct 2024 18:37:23 +0200 Subject: [PATCH 2/2] rename `change_set_membership` to `change_set_item` --- .../src/change-set/create-change-set.test.ts | 9 ++++----- .../lix-sdk/src/change-set/create-change-set.ts | 2 +- packages/lix-sdk/src/database/applySchema.ts | 2 +- packages/lix-sdk/src/database/initDb.test.ts | 8 ++++---- packages/lix-sdk/src/database/schema.ts | 10 +++++----- packages/lix-sdk/src/merge/merge.test.ts | 14 +++++++------- packages/lix-sdk/src/merge/merge.ts | 10 +++++----- 7 files changed, 27 insertions(+), 28 deletions(-) diff --git a/packages/lix-sdk/src/change-set/create-change-set.test.ts b/packages/lix-sdk/src/change-set/create-change-set.test.ts index d9892faeef..eda3042111 100644 --- a/packages/lix-sdk/src/change-set/create-change-set.test.ts +++ b/packages/lix-sdk/src/change-set/create-change-set.test.ts @@ -32,13 +32,12 @@ test("creating a change set should succeed", async () => { }); const changeSetMembers = await lix.db - .selectFrom("change_set_membership") + .selectFrom("change_set_item") .selectAll() .where("change_set_id", "=", changeSet.id) .execute(); - expect(changeSetMembers.map((member) => member.change_id)).toStrictEqual([ - mockChanges[0]?.id, - mockChanges[1]?.id, - ]); + expect(changeSetMembers.map((member) => member.change_id)).toEqual( + expect.arrayContaining([mockChanges[0]?.id, mockChanges[1]?.id]), + ); }); diff --git a/packages/lix-sdk/src/change-set/create-change-set.ts b/packages/lix-sdk/src/change-set/create-change-set.ts index c6be3e9175..efbaf0bafe 100644 --- a/packages/lix-sdk/src/change-set/create-change-set.ts +++ b/packages/lix-sdk/src/change-set/create-change-set.ts @@ -14,7 +14,7 @@ export async function createChangeSet(args: { for (const changeId of args.changeIds) { await trx - .insertInto("change_set_membership") + .insertInto("change_set_item") .values({ change_id: changeId, change_set_id: changeSet.id, diff --git a/packages/lix-sdk/src/database/applySchema.ts b/packages/lix-sdk/src/database/applySchema.ts index f8046bbe6e..b26ac21351 100644 --- a/packages/lix-sdk/src/database/applySchema.ts +++ b/packages/lix-sdk/src/database/applySchema.ts @@ -87,7 +87,7 @@ export async function applySchema(args: { sqlite: SqliteDatabase }) { id TEXT PRIMARY KEY DEFAULT (uuid_v4()) ) strict; - CREATE TABLE IF NOT EXISTS change_set_membership ( + CREATE TABLE IF NOT EXISTS change_set_item ( change_set_id TEXT NOT NULL, change_id TEXT NOT NULL, diff --git a/packages/lix-sdk/src/database/initDb.test.ts b/packages/lix-sdk/src/database/initDb.test.ts index 8bed3622b3..c6a28a720a 100644 --- a/packages/lix-sdk/src/database/initDb.test.ts +++ b/packages/lix-sdk/src/database/initDb.test.ts @@ -128,7 +128,7 @@ test("change edges can't reference themselves", async () => { ); }); -test("change set memberships must be unique", async () => { +test("change set items must be unique", async () => { const sqlite = await createInMemoryDatabase({ readOnly: false, }); @@ -141,7 +141,7 @@ test("change set memberships must be unique", async () => { .executeTakeFirstOrThrow(); await db - .insertInto("change_set_membership") + .insertInto("change_set_item") .values({ change_set_id: "change-set-1", change_id: "change-1", @@ -150,7 +150,7 @@ test("change set memberships must be unique", async () => { await expect( db - .insertInto("change_set_membership") + .insertInto("change_set_item") .values({ change_set_id: "change-set-1", change_id: "change-1", @@ -158,6 +158,6 @@ test("change set memberships must be unique", async () => { .returningAll() .execute(), ).rejects.toThrowErrorMatchingInlineSnapshot( - `[SQLite3Error: SQLITE_CONSTRAINT_UNIQUE: sqlite3 result code 2067: UNIQUE constraint failed: change_set_membership.change_set_id, change_set_membership.change_id]`, + `[SQLite3Error: SQLITE_CONSTRAINT_UNIQUE: sqlite3 result code 2067: UNIQUE constraint failed: change_set_item.change_set_id, change_set_item.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 121e5d194e..9d33ef8974 100644 --- a/packages/lix-sdk/src/database/schema.ts +++ b/packages/lix-sdk/src/database/schema.ts @@ -11,7 +11,7 @@ export type LixDatabaseSchema = { snapshot: SnapshotTable; // change set change_set: ChangeSetTable; - change_set_membership: ChangeSetMembershipTable; + change_set_item: ChangeSetItem; // discussion discussion: DiscussionTable; @@ -128,10 +128,10 @@ type ChangeSetTable = { id: Generated; }; -export type ChangeSetMembership = Selectable; -export type NewChangeSetMembership = Insertable; -export type ChangeSetMembershipUpdate = Updateable; -type ChangeSetMembershipTable = { +export type ChangeSetItem = Selectable; +export type NewChangeSetItem = Insertable; +export type ChangeSetItemUpdate = Updateable; +type ChangeSetItemTable = { change_set_id: string; change_id: string; }; diff --git a/packages/lix-sdk/src/merge/merge.test.ts b/packages/lix-sdk/src/merge/merge.test.ts index 9bef618fe3..6e26f6c019 100644 --- a/packages/lix-sdk/src/merge/merge.test.ts +++ b/packages/lix-sdk/src/merge/merge.test.ts @@ -773,7 +773,7 @@ test("it should copy change sets and merge memberships", async () => { // expand the change set to contain another change // to test if the sets are merged await targetLix.db - .insertInto("change_set_membership") + .insertInto("change_set_item") .values({ change_set_id: changeSet1.id, change_id: mockChanges[1]!.id, @@ -793,13 +793,13 @@ test("it should copy change sets and merge memberships", async () => { .selectAll() .execute(); - const changeSet1Members = await targetLix.db - .selectFrom("change_set_membership") + const changeSet1Items = await targetLix.db + .selectFrom("change_set_item") .selectAll() .where("change_set_id", "=", changeSet1.id) .execute(); - const changeSet2Members = await targetLix.db - .selectFrom("change_set_membership") + const changeSet2Items = await targetLix.db + .selectFrom("change_set_item") .selectAll() .where("change_set_id", "=", changeSet2.id) .execute(); @@ -808,12 +808,12 @@ test("it should copy change sets and merge memberships", async () => { expect(changeSets.length).toBe(2); // expect merger of the change set to contain both changes - expect(changeSet1Members.map((member) => member.change_id)).toEqual( + expect(changeSet1Items.map((item) => item.change_id)).toEqual( expect.arrayContaining([mockChanges[0]?.id, mockChanges[1]?.id]), ); // expect the second change set to contain only the second change - expect(changeSet2Members.map((member) => member.change_id)).toEqual( + expect(changeSet2Items.map((item) => item.change_id)).toEqual( expect.arrayContaining([mockChanges[1]?.id]), ); }); \ No newline at end of file diff --git a/packages/lix-sdk/src/merge/merge.ts b/packages/lix-sdk/src/merge/merge.ts index 1399bc27dc..798c4a6c02 100644 --- a/packages/lix-sdk/src/merge/merge.ts +++ b/packages/lix-sdk/src/merge/merge.ts @@ -129,8 +129,8 @@ export async function merge(args: { .selectAll() .execute(); - const sourceChangeSetMemberships = await args.sourceLix.db - .selectFrom("change_set_membership") + const sourceChangeSetItems = await args.sourceLix.db + .selectFrom("change_set_item") .selectAll() .execute(); @@ -200,10 +200,10 @@ export async function merge(args: { .onConflict((oc) => oc.doNothing()) .execute(); } - if (sourceChangeSetMemberships.length > 0) { + if (sourceChangeSetItems.length > 0) { await trx - .insertInto("change_set_membership") - .values(sourceChangeSetMemberships) + .insertInto("change_set_item") + .values(sourceChangeSetItems) // ignore if already exists .onConflict((oc) => oc.doNothing()) .execute();