From 6c284082bb277422eae97a72ecd5138c1324ab9d Mon Sep 17 00:00:00 2001 From: Samuel Stroschein <35429197+samuelstroschein@users.noreply.github.com> Date: Tue, 27 Aug 2024 13:26:20 -0400 Subject: [PATCH] refactor: use kysely recommended types closes https://github.com/opral/lix-sdk/issues/53 and https://github.com/opral/lix-sdk/issues/60 --- .../sdk2/src/database/serializeJsonPlugin.ts | 11 +- .../sdk2/src/lix-plugin/applyChanges.test.ts | 14 +- .../src/lix-plugin/detectConflicts.test.ts | 106 ++++++++------ .../sdk2/src/lix-plugin/detectConflicts.ts | 6 +- .../sdk2/src/project/newProject.ts | 12 +- lix/packages/sdk/src/commit.ts | 4 +- lix/packages/sdk/src/database/createSchema.ts | 79 +++++++++++ lix/packages/sdk/src/database/initDb.test.ts | 59 ++++++++ lix/packages/sdk/src/database/initDb.ts | 24 ++++ lix/packages/sdk/src/database/schema.ts | 133 ++++++++++++++++++ .../sdk/src/database/serializeJsonPlugin.ts | 97 +++++++++++++ lix/packages/sdk/src/file-handlers.ts | 8 +- lix/packages/sdk/src/index.ts | 2 +- lix/packages/sdk/src/load-plugin.ts | 4 +- lix/packages/sdk/src/merge/merge.test.ts | 85 ++++++----- lix/packages/sdk/src/newLix.ts | 80 +---------- lix/packages/sdk/src/open/openLix.ts | 15 +- lix/packages/sdk/src/plugin.ts | 5 +- .../get-leaf-changes-only-in-source.test.ts | 10 +- .../resolve-conflict/resolve-conflict.test.ts | 8 +- .../src/resolve-conflict/resolve-conflict.ts | 38 +++-- lix/packages/sdk/src/schema.ts | 118 ---------------- 22 files changed, 565 insertions(+), 353 deletions(-) create mode 100644 lix/packages/sdk/src/database/createSchema.ts create mode 100644 lix/packages/sdk/src/database/initDb.test.ts create mode 100644 lix/packages/sdk/src/database/initDb.ts create mode 100644 lix/packages/sdk/src/database/schema.ts create mode 100644 lix/packages/sdk/src/database/serializeJsonPlugin.ts delete mode 100644 lix/packages/sdk/src/schema.ts diff --git a/inlang/source-code/sdk2/src/database/serializeJsonPlugin.ts b/inlang/source-code/sdk2/src/database/serializeJsonPlugin.ts index adc70c8b0b..d4e0fa6cb7 100644 --- a/inlang/source-code/sdk2/src/database/serializeJsonPlugin.ts +++ b/inlang/source-code/sdk2/src/database/serializeJsonPlugin.ts @@ -82,8 +82,15 @@ class ParseJsonTransformer extends OperationNodeTransformer { } } -function serializeJson(value: any): string { - if (typeof value === "object" || Array.isArray(value)) { +function serializeJson(value: any): any { + if ( + // binary data + value instanceof ArrayBuffer || + // uint8array, etc + ArrayBuffer.isView(value) + ) { + return value; + } else if (typeof value === "object" || Array.isArray(value)) { return JSON.stringify(value); } return value; diff --git a/inlang/source-code/sdk2/src/lix-plugin/applyChanges.test.ts b/inlang/source-code/sdk2/src/lix-plugin/applyChanges.test.ts index 5035f4042f..7f10a3fb37 100644 --- a/inlang/source-code/sdk2/src/lix-plugin/applyChanges.test.ts +++ b/inlang/source-code/sdk2/src/lix-plugin/applyChanges.test.ts @@ -1,7 +1,7 @@ import { test, expect } from "vitest"; import { loadProjectInMemory } from "../project/loadProjectInMemory.js"; import { newProject } from "../project/newProject.js"; -import type { Change } from "@lix-js/sdk"; +import type { Change, NewChange } from "@lix-js/sdk"; import type { Bundle } from "../schema/schemaV2.js"; import { applyChanges } from "./applyChanges.js"; import { loadDatabaseInMemory } from "sqlite-wasm-kysely"; @@ -12,7 +12,7 @@ test("it should be able to delete", async () => { blob: await newProject(), }); - const changes: Change[] = [ + const changes: NewChange[] = [ { id: "1", parent_id: undefined, @@ -21,7 +21,6 @@ test("it should be able to delete", async () => { plugin_key: "mock", type: "bundle", meta: { id: "mock" }, - // @ts-expect-error - type error somewhere value: { id: "mock", alias: { @@ -39,7 +38,6 @@ test("it should be able to delete", async () => { meta: { id: "mock", }, - // @ts-expect-error - type error somewhere value: { id: "mock", alias: { @@ -80,7 +78,7 @@ test("it should be able to delete", async () => { const dbFileAfter = await applyChanges({ lix: project.lix, file: dbFile, - changes, + changes: changes as Change[], }); const db = initDb({ @@ -97,7 +95,7 @@ test("it should be able to upsert (insert & update)", async () => { blob: await newProject(), }); - const changes: Change[] = [ + const changes: NewChange[] = [ { id: "1", parent_id: undefined, @@ -106,7 +104,6 @@ test("it should be able to upsert (insert & update)", async () => { plugin_key: "mock", type: "bundle", meta: { id: "mock" }, - // @ts-expect-error - type error somewhere value: { id: "mock", alias: { @@ -124,7 +121,6 @@ test("it should be able to upsert (insert & update)", async () => { meta: { id: "mock", }, - // @ts-expect-error - type error somewhere value: { id: "mock", alias: { @@ -154,7 +150,7 @@ test("it should be able to upsert (insert & update)", async () => { const dbFileAfter = await applyChanges({ lix: project.lix, file: dbFile, - changes, + changes: changes as Change[], }); const db = initDb({ 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 6888702ba6..8452aae6da 100644 --- a/inlang/source-code/sdk2/src/lix-plugin/detectConflicts.test.ts +++ b/inlang/source-code/sdk2/src/lix-plugin/detectConflicts.test.ts @@ -1,26 +1,35 @@ /* eslint-disable @typescript-eslint/no-non-null-assertion */ import { test, expect } from "vitest"; import { inlangLixPluginV1 } from "./inlangLixPluginV1.js"; -import { newLixFile, openLixInMemory, type Change } from "@lix-js/sdk"; +import { + newLixFile, + openLixInMemory, + type Change, + type NewChange, +} from "@lix-js/sdk"; 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 change: Change = { - id: "1", - parent_id: undefined, - operation: "create", - file_id: "mock", - plugin_key: "mock", - type: "mock", - // @ts-expect-error - type error in lix - value: JSON.stringify(["change 1"]), - }; - await sourceLix.db.insertInto("change").values([change]).execute(); + const changes = await sourceLix.db + .insertInto("change") + .values([ + { + id: "1", + parent_id: undefined, + operation: "create", + file_id: "mock", + plugin_key: "mock", + type: "mock", + value: { id: "change 1" }, + }, + ]) + .returningAll() + .execute(); const conflicts = await inlangLixPluginV1.detectConflicts!({ sourceLix, targetLix, - leafChangesOnlyInSource: [change], + leafChangesOnlyInSource: changes, }); expect(conflicts).toHaveLength(0); }); @@ -39,15 +48,16 @@ test.todo( file_id: "mock", plugin_key: "mock", type: "mock", - // @ts-expect-error - type error in lix - value: JSON.stringify(["change 1"]), + value: { + id: "change 1", + }, }, ]) .execute(); const sourceLix = await openLixInMemory({ blob: await targetLix.toBlob() }); - const changesNotInTarget: Change[] = [ + const changesNotInTarget: NewChange[] = [ { id: "2", parent_id: "1", @@ -67,7 +77,7 @@ test.todo( const conflicts = await inlangLixPluginV1.detectConflicts!({ sourceLix, targetLix, - leafChangesOnlyInSource: changesNotInTarget, + leafChangesOnlyInSource: changesNotInTarget as Change[], }); expect(conflicts).toHaveLength(1); expect(conflicts[0]?.change_id).toBe("1"); @@ -80,7 +90,7 @@ 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 commonChanges: Change[] = [ + const commonChanges: NewChange[] = [ { id: "12s", parent_id: undefined, @@ -88,12 +98,13 @@ test("it should report an UPDATE as a conflict if leaf changes are conflicting", file_id: "mock", plugin_key: "mock", type: "mock", - // @ts-expect-error - type error in lix - value: JSON.stringify(["change 12s"]), + value: { + id: "change 12s", + }, }, ]; - const changesOnlyInTarget: Change[] = [ + const changesOnlyInTarget: NewChange[] = [ { id: "3sd", parent_id: "12s", @@ -101,12 +112,13 @@ test("it should report an UPDATE as a conflict if leaf changes are conflicting", file_id: "mock", plugin_key: "mock", type: "mock", - // @ts-expect-error - type error in lix - value: JSON.stringify(["change 3sd"]), + value: { + id: "change 3sd", + }, }, ]; - const changesOnlyInSource: Change[] = [ + const changesOnlyInSource: NewChange[] = [ { id: "2qa", parent_id: "12s", @@ -114,8 +126,9 @@ test("it should report an UPDATE as a conflict if leaf changes are conflicting", file_id: "mock", plugin_key: "mock", type: "mock", - // @ts-expect-error - type error in lix - value: JSON.stringify(["change 2qa"]), + value: { + id: "change 2qa", + }, }, ]; @@ -130,7 +143,7 @@ test("it should report an UPDATE as a conflict if leaf changes are conflicting", .execute(); const conflicts = await inlangLixPluginV1.detectConflicts!({ - leafChangesOnlyInSource: changesOnlyInSource, + leafChangesOnlyInSource: changesOnlyInSource as Change[], sourceLix: sourceLix, targetLix: targetLix, }); @@ -148,7 +161,7 @@ test("it should NOT report an UPDATE as a conflict if the common ancestor is the const targetLix = await openLixInMemory({ blob: await newLixFile() }); const sourceLix = await openLixInMemory({ blob: await targetLix.toBlob() }); - const commonChanges: Change[] = [ + const commonChanges: NewChange[] = [ { id: "12s", parent_id: undefined, @@ -156,12 +169,13 @@ test("it should NOT report an UPDATE as a conflict if the common ancestor is the file_id: "mock", plugin_key: "mock", type: "mock", - // @ts-expect-error - type error in lix - value: JSON.stringify(["change 12s"]), + value: { + id: "change 12s", + }, }, ]; - const changesOnlyInTarget: Change[] = [ + const changesOnlyInTarget: NewChange[] = [ { id: "3sd", parent_id: "12s", @@ -169,8 +183,9 @@ test("it should NOT report an UPDATE as a conflict if the common ancestor is the file_id: "mock", plugin_key: "mock", type: "mock", - // @ts-expect-error - type error in lix - value: JSON.stringify(["change 3sd"]), + value: { + id: "change 3sd", + }, }, { id: "23a", @@ -179,12 +194,13 @@ test("it should NOT report an UPDATE as a conflict if the common ancestor is the file_id: "mock", plugin_key: "mock", type: "mock", - // @ts-expect-error - type error in lix - value: JSON.stringify(["change 23a"]), + value: { + id: "change 23a", + }, }, ]; - const changesOnlyInSource: Change[] = []; + const changesOnlyInSource: NewChange[] = []; await sourceLix.db .insertInto("change") @@ -197,7 +213,7 @@ test("it should NOT report an UPDATE as a conflict if the common ancestor is the .execute(); const conflicts = await inlangLixPluginV1.detectConflicts!({ - leafChangesOnlyInSource: changesOnlyInSource, + leafChangesOnlyInSource: changesOnlyInSource as Change[], sourceLix: sourceLix, targetLix: targetLix, }); @@ -217,15 +233,16 @@ test("it should NOT report a DELETE as a conflict if the parent of the target an file_id: "mock", plugin_key: "mock", type: "mock", - // @ts-expect-error - type error in lix - value: JSON.stringify(["change 12s"]), + value: { + id: "change 12s", + }, }, ]) .execute(); const sourceLix = await openLixInMemory({ blob: await targetLix.toBlob() }); - const changesNotInTarget: Change[] = [ + const changesNotInTarget: NewChange[] = [ { id: "3sd", parent_id: "12s", @@ -237,7 +254,7 @@ test("it should NOT report a DELETE as a conflict if the parent of the target an }, ]; - const changesNotInSource: Change[] = [ + const changesNotInSource: NewChange[] = [ { id: "2qa", parent_id: "12s", @@ -245,8 +262,9 @@ test("it should NOT report a DELETE as a conflict if the parent of the target an file_id: "mock", plugin_key: "mock", type: "mock", - // @ts-expect-error - type error in lix - value: JSON.stringify(["change 2qa"]), + value: { + id: "2qa", + }, }, ]; @@ -257,7 +275,7 @@ test("it should NOT report a DELETE as a conflict if the parent of the target an const conflicts = await inlangLixPluginV1.detectConflicts!({ sourceLix, targetLix, - leafChangesOnlyInSource: changesNotInTarget, + leafChangesOnlyInSource: changesNotInTarget as Change[], }); expect(conflicts).toHaveLength(1); diff --git a/inlang/source-code/sdk2/src/lix-plugin/detectConflicts.ts b/inlang/source-code/sdk2/src/lix-plugin/detectConflicts.ts index 1e80b42194..59b6fe566e 100644 --- a/inlang/source-code/sdk2/src/lix-plugin/detectConflicts.ts +++ b/inlang/source-code/sdk2/src/lix-plugin/detectConflicts.ts @@ -1,8 +1,8 @@ import { getLowestCommonAncestor, getLeafChange, - type Conflict, type LixPlugin, + type NewConflict, } from "@lix-js/sdk"; export const detectConflicts: LixPlugin["detectConflicts"] = async ({ @@ -10,7 +10,7 @@ export const detectConflicts: LixPlugin["detectConflicts"] = async ({ targetLix, leafChangesOnlyInSource, }) => { - const result: Conflict[] = []; + const result: NewConflict[] = []; for (const change of leafChangesOnlyInSource) { const lowestCommonAncestor = await getLowestCommonAncestor({ sourceChange: change, @@ -29,7 +29,7 @@ export const detectConflicts: LixPlugin["detectConflicts"] = async ({ }); if (lowestCommonAncestor.id === leafChangeInTarget.id) { - // no conflict. the lowest common ancestor is + // no conflict. the lowest common ancestor is // the leaf change in the target. aka, no changes // in target have been made that could conflict with the source continue; diff --git a/inlang/source-code/sdk2/src/project/newProject.ts b/inlang/source-code/sdk2/src/project/newProject.ts index 1724b51531..91edc78944 100644 --- a/inlang/source-code/sdk2/src/project/newProject.ts +++ b/inlang/source-code/sdk2/src/project/newProject.ts @@ -1,4 +1,4 @@ -import { newLixFile, openLixInMemory, uuidv4 } from "@lix-js/sdk"; +import { newLixFile, openLixInMemory } from "@lix-js/sdk"; import type { ProjectSettings } from "../schema/settings.js"; import { contentFromDatabase, @@ -33,21 +33,13 @@ export async function newProject(args?: { .insertInto("file") .values([ { - // TODO ensure posix paths validation with lix path: "/db.sqlite", - // TODO let lix generate the id - id: uuidv4(), data: inlangDbContent, }, { path: "/settings.json", - id: uuidv4(), data: await new Blob([ - JSON.stringify( - args?.settings ?? defaultProjectSettings, - undefined, - 2 - ), + JSON.stringify(args?.settings ?? defaultProjectSettings), ]).arrayBuffer(), }, ]) diff --git a/lix/packages/sdk/src/commit.ts b/lix/packages/sdk/src/commit.ts index 73e3d82b3a..9fe8425772 100644 --- a/lix/packages/sdk/src/commit.ts +++ b/lix/packages/sdk/src/commit.ts @@ -1,9 +1,9 @@ import { Kysely } from "kysely"; -import type { LixDatabase } from "./schema.js"; +import type { LixDatabaseSchema } from "./database/schema.js"; import { v4 } from "uuid"; export async function commit(args: { - db: Kysely; + db: Kysely; currentAuthor?: string; // TODO remove description description: string; diff --git a/lix/packages/sdk/src/database/createSchema.ts b/lix/packages/sdk/src/database/createSchema.ts new file mode 100644 index 0000000000..2e67868365 --- /dev/null +++ b/lix/packages/sdk/src/database/createSchema.ts @@ -0,0 +1,79 @@ +import { sql, type Kysely } from "kysely"; + +export async function createSchema(args: { db: Kysely }) { + return await sql` + CREATE TABLE ref ( + name TEXT PRIMARY KEY, + commit_id TEXT + ); + + CREATE TABLE file_internal ( + id TEXT PRIMARY KEY DEFAULT (uuid_v4()), + path TEXT NOT NULL, + data BLOB NOT NULL + ) strict; + + CREATE TABLE change_queue ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + file_id TEXT, + path TEXT NOT NULL, + data BLOB + ) strict; + + create view file as + select z.id as id, z.path as path, z.data as data, MAX(z.mx) as queue_id from + (select file_id as id, path, data, id as mx from change_queue UNION select id, path, data, 0 as mx from file_internal) as z + group by z.id; + + CREATE TABLE change ( + id TEXT PRIMARY KEY DEFAULT (uuid_v4()), + author TEXT, + parent_id TEXT, + type TEXT NOT NULL, + file_id TEXT NOT NULL, + plugin_key TEXT NOT NULL, + operation TEXT NOT NULL, + value TEXT, + meta TEXT, + commit_id TEXT, + created_at TEXT DEFAULT CURRENT_TIMESTAMP NOT NULL + ) strict; + + CREATE TABLE conflict ( + change_id TEXT NOT NULL, + conflicting_change_id TEXT NOT NULL, + reason TEXT, + 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 + insert into change_queue(file_id, path, data) values(NEW.id, NEW.path, NEW.data); + select triggerWorker(); + END; + + CREATE TRIGGER file_insert INSTEAD OF INSERT ON file + BEGIN + insert into change_queue(file_id, path, data) values(NEW.id, NEW.path, NEW.data); + select triggerWorker(); + END; + + CREATE TRIGGER change_queue_remove BEFORE DELETE ON change_queue + BEGIN + insert or replace into file_internal(id, path, data) values(OLD.file_id, OLD.path, OLD.data); + END; +`.execute(args.db); +} diff --git a/lix/packages/sdk/src/database/initDb.test.ts b/lix/packages/sdk/src/database/initDb.test.ts new file mode 100644 index 0000000000..e5cd71f5f9 --- /dev/null +++ b/lix/packages/sdk/src/database/initDb.test.ts @@ -0,0 +1,59 @@ +import { createInMemoryDatabase } from "sqlite-wasm-kysely"; +import { test, expect } from "vitest"; +import { initDb } from "./initDb.js"; +import { createSchema } from "./createSchema.js"; +import { validate } from "uuid"; + +test("file ids should default to uuid", async () => { + const sqlite = await createInMemoryDatabase({ + readOnly: false, + }); + const db = initDb({ sqlite }); + await createSchema({ db }); + + const file = await db + .insertInto("file_internal") + .values({ path: "/mock", data: new Uint8Array() }) + .returningAll() + .executeTakeFirstOrThrow(); + + 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, + }); + const db = initDb({ sqlite }); + await createSchema({ db }); + + const change = await db + .insertInto("change") + .values({ + commit_id: "mock", + type: "file", + file_id: "mock", + plugin_key: "mock-plugin", + operation: "create", + }) + .returningAll() + .executeTakeFirstOrThrow(); + + expect(validate(change.id)).toBe(true); +}); diff --git a/lix/packages/sdk/src/database/initDb.ts b/lix/packages/sdk/src/database/initDb.ts new file mode 100644 index 0000000000..18876f8fca --- /dev/null +++ b/lix/packages/sdk/src/database/initDb.ts @@ -0,0 +1,24 @@ +import { Kysely, ParseJSONResultsPlugin } from "kysely"; +import { createDialect, type SqliteDatabase } from "sqlite-wasm-kysely"; +import { v4 } from "uuid"; +import { SerializeJsonPlugin } from "./serializeJsonPlugin.js"; +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; +} + +function initDefaultValueFunctions(args: { sqlite: SqliteDatabase }) { + args.sqlite.createFunction({ + name: "uuid_v4", + arity: 0, + xFunc: () => v4(), + }); +} diff --git a/lix/packages/sdk/src/database/schema.ts b/lix/packages/sdk/src/database/schema.ts new file mode 100644 index 0000000000..5db7fe9d63 --- /dev/null +++ b/lix/packages/sdk/src/database/schema.ts @@ -0,0 +1,133 @@ +import type { Generated, Insertable, Selectable, Updateable } from "kysely"; +import type { LixPlugin } from "../plugin.js"; + +export type LixDatabaseSchema = { + file: LixFileTable; + change: ChangeTable; + commit: CommitTable; + ref: RefTable; + file_internal: LixFileTable; + change_queue: ChangeQueueTable; + conflict: ConflictTable; +}; + +export type Ref = Selectable; +export type NewRef = Insertable; +export type RefUpdate = Updateable; +type RefTable = { + name: string; + commit_id: string; +}; + +export type ChangeQueueEntry = Selectable; +export type NewChangeQueueEntry = Insertable; +export type ChangeQueueEntryUpdate = Updateable; +type ChangeQueueTable = { + id: Generated; + path: string; + file_id: LixFileTable["id"]; + data: ArrayBuffer; +}; + +// named lix file to avoid conflict with built-in file type +export type LixFile = Selectable; +export type NewLixFile = Insertable; +export type LixFileUpdate = Updateable; +type LixFileTable = { + id: Generated; + path: string; + data: ArrayBuffer; +}; + +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; +type ChangeTable = { + id: Generated; + parent_id?: ChangeTable["id"]; + author?: string; + file_id: LixFileTable["id"]; + /** + * If no commit id exists on a change, + * the change is considered uncommitted. + */ + commit_id?: CommitTable["id"]; + /** + * The plugin key that contributed the change. + * + * Exists to ease querying for changes by plugin, + * in case the user changes the plugin configuration. + */ + plugin_key: LixPlugin["key"]; + /** + * The operation that was performed. + * + * The operation is taken from the diff reports. + */ + operation: "create" | "update" | "delete"; + + /** + * The type of change that was made. + * + * @example + * - "cell" for csv cell change + * - "message" for inlang message change + * - "user" for a user change + */ + type: string; + /** + * The value of the change. + * + * The value is `undefined` for a delete operation. + * + * @example + * - For a csv cell change, the value would be the new cell value. + * - For an inlang message change, the value would be the new message. + */ + value?: Record & { id: string }; + /** + * Additional metadata for the change used by the plugin + * to process changes. + */ + meta?: Record; // JSONB + /** + * The time the change was created. + */ + created_at: Generated; +}; + +export type Conflict = Selectable; +export type NewConflict = Insertable; +export type ConflictUpdate = Updateable; +type ConflictTable = { + meta?: Record; + reason?: string; + change_id: ChangeTable["id"]; + conflicting_change_id: ChangeTable["id"]; + /** + * The change id that the conflict was resolved with. + * + * Can be the change_id, conflicting_change_id, or another change_id + * that resulted from a merge. + */ + resolved_with_change_id?: ChangeTable["id"]; +}; diff --git a/lix/packages/sdk/src/database/serializeJsonPlugin.ts b/lix/packages/sdk/src/database/serializeJsonPlugin.ts new file mode 100644 index 0000000000..1b01f6527b --- /dev/null +++ b/lix/packages/sdk/src/database/serializeJsonPlugin.ts @@ -0,0 +1,97 @@ +import { + OperationNodeTransformer, + sql, + ValueListNode, + ValueNode, + ValuesNode, + type KyselyPlugin, + type PluginTransformQueryArgs, + type PluginTransformResultArgs, + type QueryResult, + type RootOperationNode, + type UnknownRow, +} from "kysely"; + +export class SerializeJsonPlugin implements KyselyPlugin { + #parseJsonTransformer = new ParseJsonTransformer(); + + transformQuery(args: PluginTransformQueryArgs): RootOperationNode { + if ( + args.node.kind === "InsertQueryNode" || + args.node.kind === "UpdateQueryNode" + ) { + const result = this.#parseJsonTransformer.transformNode(args.node); + + return result; + } + return args.node; + } + + async transformResult( + args: PluginTransformResultArgs, + ): Promise> { + return args.result; + } +} + +class ParseJsonTransformer extends OperationNodeTransformer { + protected override transformValueList(node: ValueListNode): ValueListNode { + return super.transformValueList({ + ...node, + values: node.values.map((listNodeItem) => { + if (listNodeItem.kind !== "ValueNode") { + return listNodeItem; + } + + // @ts-ignore + const { value } = listNodeItem; + + const serializedValue = serializeJson(value); + + if (value === serializedValue) { + return listNodeItem; + } + + // TODO use jsonb. depends on parsing again + // https://github.com/opral/inlang-sdk/issues/132 + return sql`json(${serializedValue})`.toOperationNode(); + }), + }); + } + + override transformValues(node: ValuesNode): ValuesNode { + return super.transformValues({ + ...node, + values: node.values.map((valueItemNode) => { + if (valueItemNode.kind !== "PrimitiveValueListNode") { + return valueItemNode; + } + + return { + kind: "ValueListNode", + values: valueItemNode.values.map( + (value) => + ({ + kind: "ValueNode", + value, + }) as ValueNode, + ), + } as ValueListNode; + }), + }); + } +} + +function serializeJson(value: any): any { + if ( + // binary data + value instanceof ArrayBuffer || + // uint8array, etc + ArrayBuffer.isView(value) + ) { + return value; + } else if (typeof value === "object" || Array.isArray(value)) { + return JSON.stringify(value); + } + return value; +} diff --git a/lix/packages/sdk/src/file-handlers.ts b/lix/packages/sdk/src/file-handlers.ts index be4929df93..7218fe250c 100644 --- a/lix/packages/sdk/src/file-handlers.ts +++ b/lix/packages/sdk/src/file-handlers.ts @@ -1,5 +1,5 @@ import { v4 } from "uuid"; -import type { LixDatabase, LixFile } from "./schema.js"; +import type { LixDatabaseSchema, LixFile } from "./database/schema.js"; import type { LixPlugin } from "./plugin.js"; import { minimatch } from "minimatch"; import { Kysely } from "kysely"; @@ -25,7 +25,7 @@ async function getChangeHistory({ fileId: string; pluginKey: string; diffType: string; - db: Kysely; + db: Kysely; }): Promise { if (depth > 1) { // TODO: walk change parents until depth @@ -72,7 +72,7 @@ async function getChangeHistory({ export async function handleFileInsert(args: { neu: LixFile; plugins: LixPlugin[]; - db: Kysely; + db: Kysely; currentAuthor?: string; queueEntry: any; }) { @@ -132,7 +132,7 @@ export async function handleFileChange(args: { neu: LixFile; plugins: LixPlugin[]; currentAuthor?: string; - db: Kysely; + db: Kysely; }) { const fileId = args.neu?.id ?? args.old?.id; diff --git a/lix/packages/sdk/src/index.ts b/lix/packages/sdk/src/index.ts index 48f7faac45..e23eea9e34 100644 --- a/lix/packages/sdk/src/index.ts +++ b/lix/packages/sdk/src/index.ts @@ -1,7 +1,7 @@ export { openLixInMemory } from "./open/openLixInMemory.js"; export { newLixFile } from "./newLix.js"; export * from "./plugin.js"; -export * from "./schema.js"; +export * from "./database/schema.js"; export { jsonObjectFrom, jsonArrayFrom } from "kysely/helpers/sqlite"; export { v4 as uuidv4 } from "uuid"; export * from "./types.js"; diff --git a/lix/packages/sdk/src/load-plugin.ts b/lix/packages/sdk/src/load-plugin.ts index c28aa98b49..02aca8a7f7 100644 --- a/lix/packages/sdk/src/load-plugin.ts +++ b/lix/packages/sdk/src/load-plugin.ts @@ -1,8 +1,8 @@ -import type { LixDatabase, LixFile } from "./schema.js"; +import type { LixDatabaseSchema, LixFile } from "./database/schema.js"; import type { LixPlugin } from "./plugin.js"; import { Kysely, sql } from "kysely"; -export async function loadPlugins(db: Kysely) { +export async function loadPlugins(db: Kysely) { const pluginFiles = ( await sql` SELECT * FROM file diff --git a/lix/packages/sdk/src/merge/merge.test.ts b/lix/packages/sdk/src/merge/merge.test.ts index 34e896f044..7351ee8fbb 100644 --- a/lix/packages/sdk/src/merge/merge.test.ts +++ b/lix/packages/sdk/src/merge/merge.test.ts @@ -3,17 +3,16 @@ import { test, expect, vi } from "vitest"; import { openLixInMemory } from "../open/openLixInMemory.js"; import { newLixFile } from "../newLix.js"; import { merge } from "./merge.js"; -import type { Change, Commit, Conflict } from "../schema.js"; +import type { NewChange, NewCommit, 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 () => { - const mockChanges: Change[] = [ + const mockChanges: NewChange[] = [ { id: "1", operation: "create", type: "mock", - // @ts-expect-error - expects serialized json - value: JSON.stringify({ id: "mock-id", color: "red" }), + value: { id: "mock-id", color: "red" }, file_id: "mock-file", plugin_key: "mock-plugin", }, @@ -21,8 +20,7 @@ test("it should copy changes from the sourceLix into the targetLix that do not e id: "2", operation: "update", type: "mock", - // @ts-expect-error - expects serialized json - value: JSON.stringify({ id: "mock-id", color: "blue" }), + value: { id: "mock-id", color: "blue" }, file_id: "mock-file", plugin_key: "mock-plugin", }, @@ -30,8 +28,7 @@ test("it should copy changes from the sourceLix into the targetLix that do not e id: "3", operation: "update", type: "mock", - // @ts-expect-error - expects serialized json - value: JSON.stringify({ id: "mock-id", color: "green" }), + value: { id: "mock-id", color: "green" }, file_id: "mock-file", plugin_key: "mock-plugin", }, @@ -75,7 +72,10 @@ test("it should copy changes from the sourceLix into the targetLix that do not e await merge({ sourceLix, targetLix }); - const changes = await targetLix.db.selectFrom("change").select("id").execute(); + const changes = await targetLix.db + .selectFrom("change") + .select("id") + .execute(); expect(changes.map((c) => c.id)).toStrictEqual([ mockChanges[0]?.id, @@ -88,13 +88,12 @@ test("it should copy changes from the sourceLix into the targetLix that do not e }); test("it should save change conflicts", async () => { - const mockChanges: Change[] = [ + const mockChanges: NewChange[] = [ { id: "1", operation: "create", type: "mock", - // @ts-expect-error - expects serialized json - value: JSON.stringify({ id: "mock-id", color: "red" }), + value: { id: "mock-id", color: "red" }, file_id: "mock-file", plugin_key: "mock-plugin", }, @@ -102,8 +101,7 @@ test("it should save change conflicts", async () => { id: "2", operation: "update", type: "mock", - // @ts-expect-error - expects serialized json - value: JSON.stringify({ id: "mock-id", color: "blue" }), + value: { id: "mock-id", color: "blue" }, file_id: "mock-file", plugin_key: "mock-plugin", }, @@ -111,8 +109,7 @@ test("it should save change conflicts", async () => { id: "3", operation: "update", type: "mock", - // @ts-expect-error - expects serialized json - value: JSON.stringify({ id: "mock-id", color: "green" }), + value: { id: "mock-id", color: "green" }, file_id: "mock-file", plugin_key: "mock-plugin", }, @@ -128,7 +125,7 @@ test("it should save change conflicts", async () => { { change_id: mockChanges[1]!.id, conflicting_change_id: mockChanges[2]!.id, - } satisfies Conflict, + } satisfies NewConflict, ]), applyChanges: vi.fn().mockResolvedValue({ fileData: new Uint8Array() }), }; @@ -177,26 +174,24 @@ test("it should save change conflicts", async () => { }); test("diffing should not be invoked to prevent the generation of duplicate changes", async () => { - const commonChanges: Change[] = [ + const commonChanges: NewChange[] = [ { id: "1", operation: "create", type: "mock", - // @ts-expect-error - expects serialized json - value: JSON.stringify({ id: "mock-id", color: "red" }), + value: { id: "mock-id", color: "red" }, file_id: "mock-file", plugin_key: "mock-plugin", }, ]; - const changesOnlyInTargetLix: Change[] = []; - const changesOnlyInSourceLix: Change[] = [ + const changesOnlyInTargetLix: NewChange[] = []; + const changesOnlyInSourceLix: NewChange[] = [ { id: "2", operation: "update", type: "mock", - // @ts-expect-error - expects serialized json - value: JSON.stringify({ id: "mock-id", color: "blue" }), + value: { id: "mock-id", color: "blue" }, file_id: "mock-file", plugin_key: "mock-plugin", }, @@ -259,13 +254,12 @@ test("diffing should not be invoked to prevent the generation of duplicate chang }); test("it should apply changes that are not conflicting", async () => { - const mockChanges: Change[] = [ + const mockChanges: NewChange[] = [ { id: "1", operation: "create", type: "mock", - // @ts-expect-error - expects serialized json - value: JSON.stringify({ id: "mock-id", color: "red" }), + value: { id: "mock-id", color: "red" }, file_id: "mock-file", plugin_key: "mock-plugin", }, @@ -273,8 +267,7 @@ test("it should apply changes that are not conflicting", async () => { id: "2", operation: "update", type: "mock", - // @ts-expect-error - expects serialized json - value: JSON.stringify({ id: "mock-id", color: "blue" }), + value: { id: "mock-id", color: "blue" }, file_id: "mock-file", plugin_key: "mock-plugin", }, @@ -282,8 +275,7 @@ test("it should apply changes that are not conflicting", async () => { id: "3", operation: "update", type: "mock", - // @ts-expect-error - expects serialized json - value: JSON.stringify({ id: "mock-id", color: "green" }), + value: { id: "mock-id", color: "green" }, file_id: "mock-file", plugin_key: "mock-plugin", }, @@ -354,25 +346,23 @@ test("it should apply changes that are not conflicting", async () => { }); test("subsequent merges should not lead to duplicate changes and/or conflicts", async () => { - const commonChanges: Change[] = [ + const commonChanges: NewChange[] = [ { id: "1", operation: "create", type: "mock", - // @ts-expect-error - expects serialized json - value: JSON.stringify({ id: "mock-id", color: "red" }), + value: { id: "mock-id", color: "red" }, file_id: "mock-file", plugin_key: "mock-plugin", }, ]; - const changesOnlyInTargetLix: Change[] = []; - const changesOnlyInSourceLix: Change[] = [ + const changesOnlyInTargetLix: NewChange[] = []; + const changesOnlyInSourceLix: NewChange[] = [ { id: "2", operation: "update", type: "mock", - // @ts-expect-error - expects serialized json - value: JSON.stringify({ id: "mock-id", color: "blue" }), + value: { id: "mock-id", color: "blue" }, file_id: "mock-file", plugin_key: "mock-plugin", }, @@ -388,7 +378,7 @@ test("subsequent merges should not lead to duplicate changes and/or conflicts", { change_id: commonChanges[0]!.id, conflicting_change_id: changesOnlyInSourceLix[0]!.id, - } satisfies Conflict, + } satisfies NewConflict, ]), applyChanges: vi.fn().mockResolvedValue({ fileData: new Uint8Array() }), }; @@ -451,20 +441,19 @@ test("subsequent merges should not lead to duplicate changes and/or conflicts", }); test("it should naively copy changes from the sourceLix into the targetLix that do not exist in targetLix yet", async () => { - const changesOnlyInSourceLix: Change[] = [ + const changesOnlyInSourceLix: NewChange[] = [ { id: "2", operation: "update", commit_id: "commit-1", type: "mock", - // @ts-expect-error - expects serialized json - value: JSON.stringify({ id: "mock-id", color: "blue" }), + value: { id: "mock-id", color: "blue" }, file_id: "mock-file", plugin_key: "mock-plugin", }, ]; - const commitsOnlyInSourceLix: Commit[] = [ + const commitsOnlyInSourceLix: NewCommit[] = [ { id: "commit-1", description: "", @@ -497,9 +486,15 @@ test("it should naively copy changes from the sourceLix into the targetLix that .values({ id: "mock-file", path: "", data: new Uint8Array() }) .execute(); - await sourceLix.db.insertInto("change").values(changesOnlyInSourceLix).execute(); + await sourceLix.db + .insertInto("change") + .values(changesOnlyInSourceLix) + .execute(); - await sourceLix.db.insertInto("commit").values(commitsOnlyInSourceLix).execute(); + await sourceLix.db + .insertInto("commit") + .values(commitsOnlyInSourceLix) + .execute(); await merge({ sourceLix, targetLix }); diff --git a/lix/packages/sdk/src/newLix.ts b/lix/packages/sdk/src/newLix.ts index 7044284779..2634428e8a 100644 --- a/lix/packages/sdk/src/newLix.ts +++ b/lix/packages/sdk/src/newLix.ts @@ -1,9 +1,10 @@ -import { Kysely, sql } from "kysely"; +import { Kysely } from "kysely"; import { createDialect, createInMemoryDatabase, contentFromDatabase, } from "sqlite-wasm-kysely"; +import { createSchema } from "./database/createSchema.js"; /** * Creates a new lix file. @@ -23,82 +24,7 @@ export async function newLixFile(): Promise { }); try { - await sql` - CREATE TABLE ref ( - name TEXT PRIMARY KEY, - commit_id TEXT - ); - - CREATE TABLE file_internal ( - id TEXT PRIMARY KEY, - path TEXT NOT NULL, - data BLOB NOT NULL - ) strict; - - CREATE TABLE change_queue ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - file_id TEXT, - path TEXT NOT NULL, - data BLOB - ) strict; - - create view file as - select z.id as id, z.path as path, z.data as data, MAX(z.mx) as queue_id from - (select file_id as id, path, data, id as mx from change_queue UNION select id, path, data, 0 as mx from file_internal) as z - group by z.id; - - CREATE TABLE change ( - id TEXT PRIMARY KEY, - author TEXT, - parent_id TEXT, - type TEXT NOT NULL, - file_id TEXT NOT NULL, - plugin_key TEXT NOT NULL, - operation TEXT NOT NULL, - value TEXT, - meta TEXT, - commit_id TEXT, - created_at TEXT DEFAULT CURRENT_TIMESTAMP NOT NULL - ) strict; - - CREATE TABLE conflict ( - change_id TEXT NOT NULL, - conflicting_change_id TEXT NOT NULL, - reason TEXT, - meta TEXT, - resolved_with_change_id TEXT, - PRIMARY KEY (change_id, conflicting_change_id) - ) strict; - - CREATE TABLE 'commit' ( - id TEXT PRIMARY KEY, - 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 - insert into change_queue(file_id, path, data) values(NEW.id, NEW.path, NEW.data); - select triggerWorker(); - END; - - CREATE TRIGGER file_insert INSTEAD OF INSERT ON file - BEGIN - insert into change_queue(file_id, path, data) values(NEW.id, NEW.path, NEW.data); - select triggerWorker(); - END; - - CREATE TRIGGER change_queue_remove BEFORE DELETE ON change_queue - BEGIN - insert or replace into file_internal(id, path, data) values(OLD.file_id, OLD.path, OLD.data); - END; - `.execute(db); - + await createSchema({ db }); return new Blob([contentFromDatabase(sqlite)]); } catch (e) { throw new Error(`Failed to create new Lix file: ${e}`, { cause: e }); diff --git a/lix/packages/sdk/src/open/openLix.ts b/lix/packages/sdk/src/open/openLix.ts index befb1f11c0..8606fa8115 100644 --- a/lix/packages/sdk/src/open/openLix.ts +++ b/lix/packages/sdk/src/open/openLix.ts @@ -1,14 +1,9 @@ import type { LixPlugin } from "../plugin.js"; -import { Kysely, ParseJSONResultsPlugin } from "kysely"; -import type { LixDatabase } from "../schema.js"; import { commit } from "../commit.js"; import { handleFileChange, handleFileInsert } from "../file-handlers.js"; import { loadPlugins } from "../load-plugin.js"; -import { - contentFromDatabase, - createDialect, - type SqliteDatabase, -} from "sqlite-wasm-kysely"; +import { contentFromDatabase, type SqliteDatabase } from "sqlite-wasm-kysely"; +import { initDb } from "../database/initDb.js"; // TODO: fix in fink to not use time ordering! // .orderBy("commit.created desc") @@ -32,10 +27,7 @@ export async function openLix(args: { */ providePlugins?: LixPlugin[]; }) { - const db = new Kysely({ - dialect: createDialect({ database: args.database }), - plugins: [new ParseJSONResultsPlugin()], - }); + const db = initDb({ sqlite: args.database }); const plugins = await loadPlugins(db); if (args.providePlugins && args.providePlugins.length > 0) { @@ -190,4 +182,3 @@ export async function openLix(args: { // } // } // } - diff --git a/lix/packages/sdk/src/plugin.ts b/lix/packages/sdk/src/plugin.ts index 6fb3a04893..79d982e753 100644 --- a/lix/packages/sdk/src/plugin.ts +++ b/lix/packages/sdk/src/plugin.ts @@ -1,4 +1,4 @@ -import type { Change, Conflict, LixFile } from "./schema.js"; +import type { Change, LixFile, NewConflict } from "./database/schema.js"; import type { LixReadonly } from "./types.js"; // named lixplugin to avoid conflict with built-in plugin type @@ -29,7 +29,7 @@ export type LixPlugin< * conflicting changes in the target lix. */ leafChangesOnlyInSource: Change[]; - }) => Promise; + }) => Promise; applyChanges?: (args: { lix: LixReadonly; file: LixFile; @@ -106,4 +106,3 @@ type DiffReportDeletion = { }; neu: undefined; }; - 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 52f010b698..a02d5860a9 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 @@ -1,6 +1,8 @@ -import { newLixFile, openLixInMemory, type Change } from "@lix-js/sdk"; import { test, expect } from "vitest"; import { getLeafChangesOnlyInSource } from "./get-leaf-changes-only-in-source.js"; +import { openLixInMemory } from "../open/openLixInMemory.js"; +import { newLixFile } from "../newLix.js"; +import type { NewChange } from "../database/schema.js"; test("it should get the leaf changes that only exist in source", async () => { const sourceLix = await openLixInMemory({ @@ -9,7 +11,7 @@ test("it should get the leaf changes that only exist in source", async () => { const targetLix = await openLixInMemory({ blob: await newLixFile(), }); - const commonChanges: Change[] = [ + const commonChanges: NewChange[] = [ { id: "c1", file_id: "mock", @@ -26,7 +28,7 @@ test("it should get the leaf changes that only exist in source", async () => { type: "mock", }, ]; - const changesOnlyInSource: Change[] = [ + const changesOnlyInSource: NewChange[] = [ { id: "s1", file_id: "mock", @@ -51,7 +53,7 @@ test("it should get the leaf changes that only exist in source", async () => { type: "mock", }, ]; - const changesOnlyInTarget: Change[] = [ + const changesOnlyInTarget: NewChange[] = [ { id: "t1", parent_id: "c2", diff --git a/lix/packages/sdk/src/resolve-conflict/resolve-conflict.test.ts b/lix/packages/sdk/src/resolve-conflict/resolve-conflict.test.ts index 67be9351b1..7a5d28c744 100644 --- a/lix/packages/sdk/src/resolve-conflict/resolve-conflict.test.ts +++ b/lix/packages/sdk/src/resolve-conflict/resolve-conflict.test.ts @@ -4,7 +4,7 @@ import { test, expect, vi } from "vitest"; import { openLixInMemory } from "../open/openLixInMemory.js"; import { newLixFile } from "../newLix.js"; import { resolveConflict } from "./resolve-conflict.js"; -import type { Change } from "../schema.js"; +import type { Change } from "../database/schema.js"; import type { LixPlugin } from "../plugin.js"; import { ChangeDoesNotBelongToFileError, @@ -422,8 +422,11 @@ test("resolving a conflict with a new change (likely the result of a merge resol parent_id: changes[0]!.id, plugin_key: "plugin1", type: "mock", + meta: {}, + author: undefined, + commit_id: "mock", value: { - // @ts-expect-error - manual stringification + id: "mock", key: "value3", }, }, @@ -452,6 +455,7 @@ test("resolving a conflict with a new change (likely the result of a merge resol changesAfterResolve[2]?.id, ); expect(changesAfterResolve[2]!.value).toStrictEqual({ + id: "mock", key: "value3", }); expect(new TextDecoder().decode(fileAfterResolve.data)).toBe( diff --git a/lix/packages/sdk/src/resolve-conflict/resolve-conflict.ts b/lix/packages/sdk/src/resolve-conflict/resolve-conflict.ts index 169db9cd68..6d6282fa5a 100644 --- a/lix/packages/sdk/src/resolve-conflict/resolve-conflict.ts +++ b/lix/packages/sdk/src/resolve-conflict/resolve-conflict.ts @@ -1,4 +1,5 @@ -import type { Change, Conflict } from "../schema.js"; +import type { Change, Conflict, NewChange } from "../database/schema.js"; +import { uuidv4 } from "../index.js"; import type { Lix } from "../types.js"; import { ChangeDoesNotBelongToFileError, @@ -13,7 +14,7 @@ import { isEqual } from "lodash-es"; export async function resolveConflict(args: { lix: Lix; conflict: Conflict; - resolveWithChange: Change; + resolveWithChange: Change | NewChange; }): Promise { if (args.lix.plugins.length !== 1) { throw new Error("Unimplemented. Only one plugin is supported for now"); @@ -30,13 +31,15 @@ export async function resolveConflict(args: { .selectFrom("change") .selectAll() .where("id", "=", args.conflict.change_id) - .executeTakeFirst(); + .executeTakeFirstOrThrow(); - const existingResolvedChange = await args.lix.db - .selectFrom("change") - .selectAll() - .where("id", "=", args.resolveWithChange.id) - .executeTakeFirst(); + const existingResolvedChange = args.resolveWithChange.id + ? await args.lix.db + .selectFrom("change") + .selectAll() + .where("id", "=", args.resolveWithChange.id) + .executeTakeFirst() + : undefined; // verify that the existing change does not differ from the resolveWithChange // (changes are immutable). A change that is the result of a merge resolution @@ -65,20 +68,27 @@ export async function resolveConflict(args: { const file = await args.lix.db .selectFrom("file") .selectAll() - .where("id", "=", args.resolveWithChange.file_id) + .where("id", "=", change.file_id) .executeTakeFirstOrThrow(); + if (args.resolveWithChange.id === undefined) { + args.resolveWithChange.id = uuidv4(); + } + const { fileData } = await plugin.applyChanges({ lix: args.lix, file: file, - changes: [args.resolveWithChange], + changes: [ + // @ts-ignore + args.resolveWithChange, + ], }); await args.lix.db.transaction().execute(async (trx) => { await trx .updateTable("file") .set("data", fileData) - .where("id", "=", args.resolveWithChange.file_id) + .where("id", "=", change.file_id) .execute(); // The change does not exist yet. (likely a merge resolution which led to a new change) @@ -87,10 +97,8 @@ export async function resolveConflict(args: { .insertInto("change") .values({ ...args.resolveWithChange, - // @ts-expect-error - manual stringification - value: JSON.stringify(args.resolveWithChange.value), - // @ts-expect-error - manual stringification - meta: JSON.stringify(args.resolveWithChange.meta), + value: args.resolveWithChange.value, + meta: args.resolveWithChange.meta, }) .execute(); } diff --git a/lix/packages/sdk/src/schema.ts b/lix/packages/sdk/src/schema.ts deleted file mode 100644 index 64f004625f..0000000000 --- a/lix/packages/sdk/src/schema.ts +++ /dev/null @@ -1,118 +0,0 @@ -import type { LixPlugin } from "./plugin.js"; - -export type LixDatabase = { - file: LixFile; - change: Change; - commit: Commit; - ref: Ref; - file_internal: LixFile; - change_queue: ChangeQueueEntry; - conflict: Conflict; -}; - -export type Ref = { - name: string; - commit_id: string; -}; - -export type ChangeQueueEntry = { - id?: number; - path: string; - file_id: LixFile["id"]; - data: ArrayBuffer; -}; - -// named lix file to avoid conflict with built-in file type -export type LixFile = { - id: string; - path: string; - data: ArrayBuffer; -}; - -export type Commit = { - id: string; // uuid - // 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?: string; - created_at?: string; - parent_id: string; - // @relation changes: Change[] -}; - -export type Change< - T extends Record = Record, -> = { - id: string; - parent_id?: Change["id"]; - author?: string; - file_id: LixFile["id"]; - /** - * If no commit id exists on a change, - * the change is considered uncommitted. - */ - commit_id?: Commit["id"]; - /** - * The plugin key that contributed the change. - * - * Exists to ease querying for changes by plugin, - * in case the user changes the plugin configuration. - */ - plugin_key: LixPlugin["key"]; - /** - * The operation that was performed. - * - * The operation is taken from the diff reports. - */ - operation: "create" | "update" | "delete"; - - /** - * The type of change that was made. - * - * @example - * - "cell" for csv cell change - * - "message" for inlang message change - * - "user" for a user change - */ - type: string; - /** - * The value of the change. - * - * The value is `undefined` for a delete operation. - * - * @example - * - For a csv cell change, the value would be the new cell value. - * - For an inlang message change, the value would be the new message. - */ - value?: T; // JSONB - /** - * Additional metadata for the change used by the plugin - * to process changes. - */ - meta?: Record; // JSONB - /** - * The time the change was created. - */ - // TODO make selectable, updatable - created_at?: string; -}; - -export type Conflict = { - meta?: Record; - reason?: string; - change_id: Change["id"]; - conflicting_change_id: Change["id"]; - /** - * The change id that the conflict was resolved with. - * - * Can be the change_id, conflicting_change_id, or another change_id - * that resulted from a merge. - */ - resolved_with_change_id?: Change["id"]; -};