diff --git a/packages/lix-sdk/src/change/schema.test.ts b/packages/lix-sdk/src/change/schema.test.ts index 024e05994d..360defbcae 100644 --- a/packages/lix-sdk/src/change/schema.test.ts +++ b/packages/lix-sdk/src/change/schema.test.ts @@ -298,8 +298,8 @@ test("changes in transaction can be accessed via change view", async () => { }) .execute(); - // This should create a change in internal_transaction_state - // The change view should include changes from both internal_change and internal_transaction_state + // This should create a change in internal_transaction_state + // The change view should include changes from both internal_change and internal_transaction_state // Try to find the change within the transaction via the change view const changesInTransaction = await trx @@ -309,7 +309,7 @@ test("changes in transaction can be accessed via change view", async () => { .selectAll() .execute(); - // This should find the change that was created in internal_transaction_state + // This should find the change that was created in internal_transaction_state expect(changesInTransaction).toHaveLength(1); expect(changesInTransaction[0]).toMatchObject({ entity_id: "test_key_in_transaction", diff --git a/packages/lix-sdk/src/deterministic/random.ts b/packages/lix-sdk/src/deterministic/random.ts index a12d1cc4c8..1262cd0961 100644 --- a/packages/lix-sdk/src/deterministic/random.ts +++ b/packages/lix-sdk/src/deterministic/random.ts @@ -248,19 +248,19 @@ export function commitDeterministicRngState(args: { } satisfies LixKeyValue); const now = args.timestamp ?? timestamp({ lix: args.lix }); - updateUntrackedState({ - lix: args.lix, - changes: [ - { - entity_id: "lix_deterministic_rng_state", - schema_key: LixKeyValueSchema["x-lix-key"], - file_id: "lix", - plugin_key: "lix_own_entity", - snapshot_content: newValue, - schema_version: LixKeyValueSchema["x-lix-version"], - created_at: now, - lixcol_version_id: "global", - }, - ], - }); + updateUntrackedState({ + lix: args.lix, + changes: [ + { + entity_id: "lix_deterministic_rng_state", + schema_key: LixKeyValueSchema["x-lix-key"], + file_id: "lix", + plugin_key: "lix_own_entity", + snapshot_content: newValue, + schema_version: LixKeyValueSchema["x-lix-version"], + created_at: now, + lixcol_version_id: "global", + }, + ], + }); } diff --git a/packages/lix-sdk/src/deterministic/sequence.ts b/packages/lix-sdk/src/deterministic/sequence.ts index 77bae4732d..2c6c05e7ef 100644 --- a/packages/lix-sdk/src/deterministic/sequence.ts +++ b/packages/lix-sdk/src/deterministic/sequence.ts @@ -119,19 +119,19 @@ export function commitDeterministicSequenceNumber(args: { } satisfies LixKeyValue); const now = args.timestamp ?? timestamp({ lix: args.lix }); - updateUntrackedState({ - lix: args.lix, - changes: [ - { - entity_id: "lix_deterministic_sequence_number", - schema_key: LixKeyValueSchema["x-lix-key"], - file_id: "lix", - plugin_key: "lix_own_entity", - snapshot_content: newValue, - schema_version: LixKeyValueSchema["x-lix-version"], - created_at: now, - lixcol_version_id: "global", - }, - ], - }); + updateUntrackedState({ + lix: args.lix, + changes: [ + { + entity_id: "lix_deterministic_sequence_number", + schema_key: LixKeyValueSchema["x-lix-key"], + file_id: "lix", + plugin_key: "lix_own_entity", + snapshot_content: newValue, + schema_version: LixKeyValueSchema["x-lix-version"], + created_at: now, + lixcol_version_id: "global", + }, + ], + }); } diff --git a/packages/lix-sdk/src/lix/new-lix.ts b/packages/lix-sdk/src/lix/new-lix.ts index a78a17baef..4f6ac46ddb 100644 --- a/packages/lix-sdk/src/lix/new-lix.ts +++ b/packages/lix-sdk/src/lix/new-lix.ts @@ -218,21 +218,21 @@ export async function newLixFile(args?: { )?.entity_id; // Set active version using updateUntrackedState for proper inheritance handling - updateUntrackedState({ - lix: { sqlite, db }, - changes: [ - { - entity_id: "active", - schema_key: "lix_active_version", - file_id: "lix", - plugin_key: "lix_own_entity", - snapshot_content: JSON.stringify({ version_id: initialVersionId }), - schema_version: "1.0", - created_at: created_at, - lixcol_version_id: "global", - }, - ], - }); + updateUntrackedState({ + lix: { sqlite, db }, + changes: [ + { + entity_id: "active", + schema_key: "lix_active_version", + file_id: "lix", + plugin_key: "lix_own_entity", + snapshot_content: JSON.stringify({ version_id: initialVersionId }), + schema_version: "1.0", + created_at: created_at, + lixcol_version_id: "global", + }, + ], + }); // Create anonymous account as untracked for deterministic behavior const activeAccountId = generateNanoid(); @@ -242,70 +242,70 @@ export async function newLixFile(args?: { const anonymousAccountName = `Anonymous ${humanName}`; // Create the anonymous account as untracked - updateUntrackedState({ - lix: { sqlite, db }, - changes: [ - { - entity_id: activeAccountId, - schema_key: LixAccountSchema["x-lix-key"], - file_id: "lix", - plugin_key: "lix_own_entity", - snapshot_content: JSON.stringify({ - id: activeAccountId, - name: anonymousAccountName, - } satisfies LixAccount), - schema_version: LixAccountSchema["x-lix-version"], - created_at: created_at, - lixcol_version_id: "global", - }, - ], - }); + updateUntrackedState({ + lix: { sqlite, db }, + changes: [ + { + entity_id: activeAccountId, + schema_key: LixAccountSchema["x-lix-key"], + file_id: "lix", + plugin_key: "lix_own_entity", + snapshot_content: JSON.stringify({ + id: activeAccountId, + name: anonymousAccountName, + } satisfies LixAccount), + schema_version: LixAccountSchema["x-lix-version"], + created_at: created_at, + lixcol_version_id: "global", + }, + ], + }); // Set it as the active account - updateUntrackedState({ - lix: { sqlite, db }, - changes: [ - { - entity_id: `active_${activeAccountId}`, - schema_key: LixActiveAccountSchema["x-lix-key"], - file_id: "lix", - plugin_key: "lix_own_entity", - snapshot_content: JSON.stringify({ - account_id: activeAccountId, - } satisfies LixActiveAccount), - schema_version: LixActiveAccountSchema["x-lix-version"], - created_at: created_at, - lixcol_version_id: "global", - }, - ], - }); + updateUntrackedState({ + lix: { sqlite, db }, + changes: [ + { + entity_id: `active_${activeAccountId}`, + schema_key: LixActiveAccountSchema["x-lix-key"], + file_id: "lix", + plugin_key: "lix_own_entity", + snapshot_content: JSON.stringify({ + account_id: activeAccountId, + } satisfies LixActiveAccount), + schema_version: LixActiveAccountSchema["x-lix-version"], + created_at: created_at, + lixcol_version_id: "global", + }, + ], + }); // Handle other untracked key values const untrackedKeyValues = args?.keyValues?.filter( (kv) => kv.lixcol_untracked === true ); if (untrackedKeyValues) { - for (const kv of untrackedKeyValues) { - const versionId = kv.lixcol_version_id ?? "global"; - updateUntrackedState({ - lix: { sqlite, db }, - changes: [ - { - entity_id: kv.key, - schema_key: "lix_key_value", - file_id: "lix", - plugin_key: "lix_own_entity", - snapshot_content: JSON.stringify({ - key: kv.key, - value: kv.value, - }), - schema_version: LixKeyValueSchema["x-lix-version"], - created_at: created_at, - lixcol_version_id: versionId, - }, - ], - }); - } + for (const kv of untrackedKeyValues) { + const versionId = kv.lixcol_version_id ?? "global"; + updateUntrackedState({ + lix: { sqlite, db }, + changes: [ + { + entity_id: kv.key, + schema_key: "lix_key_value", + file_id: "lix", + plugin_key: "lix_own_entity", + snapshot_content: JSON.stringify({ + key: kv.key, + value: kv.value, + }), + schema_version: LixKeyValueSchema["x-lix-version"], + created_at: created_at, + lixcol_version_id: versionId, + }, + ], + }); + } } try { diff --git a/packages/lix-sdk/src/lix/storage/opfs.test.ts b/packages/lix-sdk/src/lix/storage/opfs.test.ts index d36e369047..0a5da4c5b5 100644 --- a/packages/lix-sdk/src/lix/storage/opfs.test.ts +++ b/packages/lix-sdk/src/lix/storage/opfs.test.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/no-this-alias */ import { test, expect, describe, beforeEach, vi } from "vitest"; import { OpfsStorage } from "./opfs.js"; import { openLix } from "../open-lix.js"; @@ -8,24 +9,37 @@ class MockOPFS { private files = new Map(); createMockOpfsRoot() { + const self = this; return { getFileHandle: vi .fn() .mockImplementation( (filename: string, options?: { create?: boolean }) => { - if (!this.files.has(filename) && !options?.create) { + if (!self.files.has(filename) && !options?.create) { throw new Error("File not found"); } - return this.createMockFileHandle(filename); + if (options?.create && !self.files.has(filename)) { + self.files.set(filename, new Uint8Array()); + } + return self.createMockFileHandle(filename); } ), + removeEntry: vi.fn().mockImplementation((name: string) => { + self.files.delete(name); + }), + values: vi.fn().mockImplementation(async function* () { + for (const name of self.files.keys()) { + yield { kind: "file", name } as const; + } + }), }; } private createMockFileHandle(filename: string) { + const self = this; return { getFile: vi.fn().mockImplementation(() => { - const data = this.files.get(filename); + const data = self.files.get(filename); if (typeof data === "string") { return Promise.resolve({ text: vi.fn().mockResolvedValue(data), @@ -42,25 +56,52 @@ class MockOPFS { } }), createWritable: vi.fn().mockImplementation(() => { - return Promise.resolve(this.createMockWritable(filename)); + return Promise.resolve(self.createMockWritable(filename)); }), }; } private createMockWritable(filename: string) { - let buffer: Uint8Array | string; + const self = this; + let chunks: (Uint8Array | string)[] = []; return { - write: vi.fn().mockImplementation((data: Uint8Array | string) => { - if (typeof data === "string") { - buffer = data; + write: vi.fn().mockImplementation((data: Uint8Array | string | any) => { + if (data instanceof Uint8Array) { + chunks.push(new Uint8Array(data)); + } else if (typeof data === "string") { + chunks.push(data); + } else if (data?.buffer instanceof ArrayBuffer) { + chunks.push(new Uint8Array(data.buffer)); } else { - buffer = new Uint8Array(data); + chunks.push(String(data)); } return Promise.resolve(); }), close: vi.fn().mockImplementation(() => { - this.files.set(filename, buffer); + if (chunks.length === 1) { + self.files.set(filename, chunks[0]!); + } else if (chunks.every((c) => c instanceof Uint8Array)) { + const total = (chunks as Uint8Array[]).reduce( + (n, c) => n + c.byteLength, + 0 + ); + const out = new Uint8Array(total); + let off = 0; + for (const c of chunks as Uint8Array[]) { + out.set(c, off); + off += c.byteLength; + } + self.files.set(filename, out); + } else { + const text = chunks + .map((c) => + typeof c === "string" ? c : new TextDecoder().decode(c) + ) + .join(""); + self.files.set(filename, text); + } + chunks = []; return Promise.resolve(); }), }; @@ -70,7 +111,7 @@ class MockOPFS { describe("OpfsStorage", () => { let mockOpfs: MockOPFS; - beforeEach(() => { + beforeEach(async () => { vi.clearAllMocks(); // Create a fresh MockOPFS instance for each test to prevent cross-test pollution mockOpfs = new MockOPFS(); @@ -87,10 +128,9 @@ describe("OpfsStorage", () => { writable: true, configurable: true, }); - }); - test("constructor requires path argument", () => { - expect(() => new OpfsStorage({ path: "test.db" })).not.toThrow(); + // Ensure OPFS is clean for each test after mock is in place + await OpfsStorage.clean(); }); test("throws error if OPFS is not supported", () => { @@ -98,7 +138,7 @@ describe("OpfsStorage", () => { const originalNavigator = globalThis.navigator; delete (globalThis as any).navigator; - expect(() => new OpfsStorage({ path: "test.db" })).toThrow( + expect(() => OpfsStorage.byId("test" as string)).toThrow( "OPFS is not supported in this environment" ); @@ -107,15 +147,14 @@ describe("OpfsStorage", () => { }); test("creates new lix file when OPFS file doesn't exist", async () => { - const storage = new OpfsStorage({ path: "new.db" }); - const lix = await openLix({ storage }); + const lix = await openLix({ storage: OpfsStorage.byName("new-project") }); expect(lix.db).toBeDefined(); expect(lix.sqlite).toBeDefined(); }); test("returns same database instance on multiple open calls", async () => { - const storage = new OpfsStorage({ path: "test.db" }); + const storage = OpfsStorage.byId("same-db"); const db1 = await storage.open({ createBlob: () => newLixFile() }); const db2 = await storage.open({ createBlob: () => newLixFile() }); @@ -123,7 +162,7 @@ describe("OpfsStorage", () => { }); test("saves database on close", async () => { - const storage = new OpfsStorage({ path: "test.db" }); + const storage = OpfsStorage.byId("save-close"); await openLix({ storage }); // This should not throw @@ -131,8 +170,7 @@ describe("OpfsStorage", () => { }); test("integrates with openLix", async () => { - const storage = new OpfsStorage({ path: "integration.db" }); - const lix = await openLix({ storage }); + const lix = await openLix({ storage: OpfsStorage.byId("integration") }); expect(lix.db).toBeDefined(); expect(lix.sqlite).toBeDefined(); @@ -144,10 +182,10 @@ describe("OpfsStorage", () => { }); test("persists data automatically on state mutations", async () => { - const path = "e2e-persist-test.db"; + const id = "e2e-persist-test"; - // Open lix with OPFS storage - const lix1 = await openLix({ storage: new OpfsStorage({ path }) }); + // Open lix with OPFS storage by id + const lix1 = await openLix({ storage: OpfsStorage.byId(id) }); // Insert some data await lix1.db @@ -164,7 +202,7 @@ describe("OpfsStorage", () => { await lix1.close(); // Open the persisted blob to verify the data - const lix2 = await openLix({ storage: new OpfsStorage({ path }) }); + const lix2 = await openLix({ storage: OpfsStorage.byId(id) }); // Verify the data persisted correctly const result = await lix2.db @@ -181,10 +219,10 @@ describe("OpfsStorage", () => { // faulty state materialization might be the cause. // fix after https://github.com/opral/lix-sdk/issues/308 test("can save and load data persistence", async () => { - const path = "persistence-test.db"; + const path = "persistence-test"; // Create and populate database - const storage1 = new OpfsStorage({ path }); + const storage1 = OpfsStorage.byId(path); const lix1 = await openLix({ storage: storage1 }); await lix1.db @@ -202,7 +240,7 @@ describe("OpfsStorage", () => { await lix1.close(); // Open new storage instance with same path - const storage2 = new OpfsStorage({ path }); + const storage2 = OpfsStorage.byId(path); const lix2 = await openLix({ storage: storage2 }); // Data should persist @@ -217,8 +255,8 @@ describe("OpfsStorage", () => { }); test("persists active account", async () => { - const path = "example.lix"; - const storage = new OpfsStorage({ path }); + const path = "example"; + const storage = OpfsStorage.byId(path); const account = { id: "test-account", name: "Test User" }; @@ -257,8 +295,8 @@ describe("OpfsStorage", () => { }); test("active account persistence across multiple storage instances", async () => { - const path = "example.lix"; - const storage1 = new OpfsStorage({ path }); + const path = "example"; + const storage1 = OpfsStorage.byId(path); const account = { id: "test-account", name: "Test User" }; @@ -280,7 +318,7 @@ describe("OpfsStorage", () => { await lix1.close(); - const storage2 = new OpfsStorage({ path }); + const storage2 = OpfsStorage.byId(path); // Reopen the lix instance const lix2 = await openLix({ storage: storage2 }); @@ -297,8 +335,8 @@ describe("OpfsStorage", () => { }); test("only saves active accounts when they change", async () => { - const path = "observer-test.lix"; - const storage = new OpfsStorage({ path }); + const path = "observer-test"; + const storage = OpfsStorage.byId(path); const account = { id: "observer-account", name: "Observer Test" }; const lix = await openLix({ storage, account }); @@ -368,8 +406,8 @@ describe("OpfsStorage", () => { test("clean() removes all files from OPFS", async () => { // Create some files in OPFS - const storage1 = new OpfsStorage({ path: "file1.lix" }); - const storage2 = new OpfsStorage({ path: "file2.lix" }); + const storage1 = OpfsStorage.byId("file1"); + const storage2 = OpfsStorage.byId("file2"); // Open and create files await openLix({ storage: storage1 }); @@ -377,7 +415,7 @@ describe("OpfsStorage", () => { // Also create an active accounts file const lix3 = await openLix({ - storage: new OpfsStorage({ path: "file3.lix" }), + storage: OpfsStorage.byId("file3"), account: { id: "test-clean", name: "Clean Test" }, }); await lix3.close(); @@ -432,4 +470,247 @@ describe("OpfsStorage", () => { // Restore navigator globalThis.navigator = originalNavigator; }); + // helper utilities inside this block to ensure navigator is available + async function fileExistsInOpfs(name: string): Promise { + try { + const root: any = await (navigator as any).storage.getDirectory(); + await root.getFileHandle(name); + return true; + } catch { + return false; + } + } + + async function readJsonFromOpfs(name: string): Promise { + try { + const root: any = await (navigator as any).storage.getDirectory(); + const fh = await root.getFileHandle(name); + const f = await fh.getFile(); + const text = await f.text(); + return JSON.parse(text); + } catch { + return undefined; + } + } + + async function writeOpfsFile( + name: string, + content: Uint8Array | string | Blob + ) { + const root: any = await (navigator as any).storage.getDirectory(); + const fh = await root.getFileHandle(name, { create: true }); + const w = await fh.createWritable(); + if (content instanceof Blob) { + const buf = new Uint8Array(await content.arrayBuffer()); + await w.write(buf); + } else { + await w.write(content); + } + await w.close(); + } + + test("byId opens and persists to .lix", async () => { + const id = "abc123"; + const lix = await openLix({ storage: OpfsStorage.byId(id) }); + + expect(await fileExistsInOpfs(`${id}.lix`)).toBe(true); + + const blob = await lix.toBlob(); + expect(blob).toBeInstanceOf(Blob); + await lix.close(); + }); + + test("byName creates when missing and sets mapping + name", async () => { + const name = "project-alpha"; + const lix = await openLix({ storage: OpfsStorage.byName(name) }); + + const idRow = await lix.db + .selectFrom("key_value") + .select("value") + .where("key", "=", "lix_id") + .executeTakeFirstOrThrow(); + const nameRow = await lix.db + .selectFrom("key_value") + .select("value") + .where("key", "=", "lix_name") + .executeTakeFirstOrThrow(); + + const id = idRow.value as string; + expect(typeof id).toBe("string"); + expect(nameRow.value).toBe(name); + + expect(await fileExistsInOpfs(`${id}.lix`)).toBe(true); + + const index = await readJsonFromOpfs("lix_opfs_storage.json"); + expect(index?.version).toBe(1); + expect(index?.names?.[name]).toBe(id); + + await lix.close(); + }); + + test("byName uses existing mapping and opens existing file", async () => { + const existingId = "id-preexisting"; + const existingName = "preexisting"; + const blob = await newLixFile({ + keyValues: [ + { key: "lix_id", value: existingId, lixcol_version_id: "global" }, + { key: "lix_name", value: existingName, lixcol_version_id: "global" }, + ], + }); + + await writeOpfsFile( + `${existingId}.lix`, + new Uint8Array(await blob.arrayBuffer()) + ); + await writeOpfsFile( + "lix_opfs_storage.json", + JSON.stringify({ version: 1, names: { [existingName]: existingId } }) + ); + + const lix = await openLix({ storage: OpfsStorage.byName(existingName) }); + const idRow = await lix.db + .selectFrom("key_value") + .select("value") + .where("key", "=", "lix_id") + .executeTakeFirstOrThrow(); + + expect(idRow.value).toBe(existingId); + expect(await fileExistsInOpfs(`${existingId}.lix`)).toBe(true); + await lix.close(); + }); + + test("byName enforces uniqueness: opening same name twice returns same id", async () => { + const name = "unique-name"; + const lix1 = await openLix({ storage: OpfsStorage.byName(name) }); + const id1 = ( + await lix1.db + .selectFrom("key_value") + .select("value") + .where("key", "=", "lix_id") + .executeTakeFirstOrThrow() + ).value as string; + await lix1.close(); + + const lix2 = await openLix({ storage: OpfsStorage.byName(name) }); + const id2 = ( + await lix2.db + .selectFrom("key_value") + .select("value") + .where("key", "=", "lix_id") + .executeTakeFirstOrThrow() + ).value as string; + await lix2.close(); + + expect(id2).toBe(id1); + expect(await fileExistsInOpfs(`${id1}.lix`)).toBe(true); + }); + + test("byName with stale mapping recreates and refreshes index", async () => { + const name = "stale-name"; + + await writeOpfsFile( + "lix_opfs_storage.json", + JSON.stringify({ version: 1, names: { [name]: "missing-id" } }) + ); + + const lix = await openLix({ storage: OpfsStorage.byName(name) }); + const id = ( + await lix.db + .selectFrom("key_value") + .select("value") + .where("key", "=", "lix_id") + .executeTakeFirstOrThrow() + ).value as string; + await lix.close(); + + expect(await fileExistsInOpfs(`${id}.lix`)).toBe(true); + const index = await readJsonFromOpfs("lix_opfs_storage.json"); + expect(index?.names?.[name]).toBe(id); + expect(index?.names?.[name]).not.toBe("missing-id"); + }); + + test("list returns minimal entries { id, name, path }", async () => { + const name = "listed-project"; + const lix = await openLix({ storage: OpfsStorage.byName(name) }); + const id = ( + await lix.db + .selectFrom("key_value") + .select("value") + .where("key", "=", "lix_id") + .executeTakeFirstOrThrow() + ).value as string; + await lix.close(); + + const entries = await OpfsStorage.list(); + expect(Array.isArray(entries)).toBe(true); + expect(entries.length).toBeGreaterThanOrEqual(1); + const found = entries.find((e) => e.name === name); + expect(found).toBeDefined(); + expect(found!.id).toBe(id); + expect(found!.path).toBe(`${id}.lix`); + }); + + test("byId throws when DB lix_id mismatches requested id", async () => { + // Prepare a file at expected path but with a different internal lix_id + const requestedId = "expected"; + const dbId = "mismatch"; + const blob = await newLixFile({ + keyValues: [ + { key: "lix_id", value: dbId, lixcol_version_id: "global" }, + { key: "lix_name", value: "keep-db-name", lixcol_version_id: "global" }, + ], + }); + await writeOpfsFile( + `${requestedId}.lix`, + new Uint8Array(await blob.arrayBuffer()) + ); + + // Opening by requested id should fail fast if the DB contains a different lix_id + await expect( + openLix({ storage: OpfsStorage.byId(requestedId) }) + ).rejects.toThrow(); + }); + + test("byName updates index to DB id but does not mutate DB lix_name", async () => { + // Create a DB with a specific id and name + const dbId = "db-id-xyz"; + const dbName = "db-existing-name"; + const desiredName = "requested-name"; + + const blob = await newLixFile({ + keyValues: [ + { key: "lix_id", value: dbId, lixcol_version_id: "global" }, + { key: "lix_name", value: dbName, lixcol_version_id: "global" }, + ], + }); + + // Write a file under an arbitrary different id to simulate wrong path/id pairing + const wrongFileId = "wrong-file-id"; + await writeOpfsFile( + `${wrongFileId}.lix`, + new Uint8Array(await blob.arrayBuffer()) + ); + + // Index maps desiredName -> wrongFileId (stale or incorrect mapping) + await writeOpfsFile( + "lix_opfs_storage.json", + JSON.stringify({ version: 1, names: { [desiredName]: wrongFileId } }) + ); + + const lix = await openLix({ storage: OpfsStorage.byName(desiredName) }); + + // DB name should remain what the file contained (no mutation) + const nameRow = await lix.db + .selectFrom("key_value") + .select("value") + .where("key", "=", "lix_name") + .executeTakeFirstOrThrow(); + expect(nameRow.value).toBe(dbName); + + // Index should be corrected to point name -> dbId (not wrongFileId) + const index = await readJsonFromOpfs("lix_opfs_storage.json"); + expect(index?.names?.[desiredName]).toBe(dbId); + + await lix.close(); + }); }); diff --git a/packages/lix-sdk/src/lix/storage/opfs.ts b/packages/lix-sdk/src/lix/storage/opfs.ts index 34e8903011..22e0f0ab86 100644 --- a/packages/lix-sdk/src/lix/storage/opfs.ts +++ b/packages/lix-sdk/src/lix/storage/opfs.ts @@ -7,6 +7,10 @@ import { import type { LixStorageAdapter } from "./lix-storage-adapter.js"; import type { LixAccount } from "../../account/schema.js"; import type { Lix } from "../open-lix.js"; +import { newLixFile } from "../new-lix.js"; +import type { NewStateAll } from "../../entity-views/types.js"; +import type { LixKeyValue } from "../../key-value/schema.js"; +import { executeSync } from "../../database/execute-sync.js"; /** * OPFS (Origin Private File System) storage adapter for Lix. @@ -16,7 +20,13 @@ import type { Lix } from "../open-lix.js"; * * Features auto-saving functionality when integrated with the hooks system. */ +type OpfsIndex = { + version: 1; + names: Record; // name -> id +}; + export class OpfsStorage implements LixStorageAdapter { + private static readonly INDEX_FILE = "lix_opfs_storage.json"; /** * Cleans the entire OPFS by removing all files. * Useful for debugging and testing. @@ -50,20 +60,26 @@ export class OpfsStorage implements LixStorageAdapter { } private database?: SqliteWasmDatabase; - private readonly path: string; + private path?: string; private opfsRoot?: FileSystemDirectoryHandle; private savePromise?: Promise; private pendingSave = false; private activeAccounts?: Pick[]; private activeAccountSubscription?: { unsubscribe(): void }; private unsubscribeFromStateCommit?: () => void; + private openPromise?: Promise; + + // Creation/resolution mode + private mode?: + | { type: "byId"; id: string } + | { type: "byName"; name: string }; /** * Creates a new OpfsStorage instance. * * @param args.path - Path/name of the file to store in OPFS */ - constructor(args: { path: string }) { + private constructor() { // Check if OPFS is supported if ( !("navigator" in globalThis) || @@ -72,8 +88,37 @@ export class OpfsStorage implements LixStorageAdapter { ) { throw new Error("OPFS is not supported in this environment"); } + } + + /** + * Factory: open by id. Stores at `.lix`. + */ + static byId(id: string): OpfsStorage { + const s = new OpfsStorage(); + s.mode = { type: "byId", id }; + return s; + } - this.path = args.path; + /** + * Factory: open by name. Resolves/creates mapping name -> id and stores at `.lix`. + */ + static byName(name: string): OpfsStorage { + const s = new OpfsStorage(); + s.mode = { type: "byName", name }; + return s; + } + + /** + * Lists known lix entries from the name index. + */ + static async list(): Promise<{ id: string; name: string; path: string }[]> { + const root = await navigator.storage.getDirectory(); + const index = await OpfsStorage.readIndexFile(root); + return Object.entries(index.names).map(([name, id]) => ({ + id, + name, + path: `${id}.lix`, + })); } /** @@ -86,47 +131,80 @@ export class OpfsStorage implements LixStorageAdapter { blob?: Blob; createBlob: () => Promise; }): Promise { - if (!this.database) { - this.database = await createInMemoryDatabase({ readOnly: false }); - this.opfsRoot = await navigator.storage.getDirectory(); - - if (args.blob) { - // Use provided blob - importDatabase({ - db: this.database, - content: new Uint8Array(await args.blob.arrayBuffer()), - }); - // Save the imported state to OPFS - await this.save(); - } else { - try { - // Try to load existing data from OPFS - const fileHandle = await this.opfsRoot.getFileHandle(this.path); - const file = await fileHandle.getFile(); - const content = new Uint8Array(await file.arrayBuffer()); + if (this.openPromise) return this.openPromise; + this.openPromise = (async () => { + if (!this.database) { + this.database = await createInMemoryDatabase({ readOnly: false }); + this.opfsRoot = await navigator.storage.getDirectory(); + // Resolve path if needed + if (!this.path) { + if (this.mode?.type === "byId") { + this.path = `${this.mode.id}.lix`; + } else if (this.mode?.type === "byName") { + // Try to resolve via index + const index = await OpfsStorage.readIndexFile(this.opfsRoot); + const mappedId = index.names[this.mode.name]; + if (mappedId) { + const exists = await OpfsStorage.fileExists( + this.opfsRoot, + `${mappedId}.lix` + ); + if (exists) { + this.path = `${mappedId}.lix`; + } else { + // Stale mapping - remove and create new below + delete index.names[this.mode.name]; + await OpfsStorage.writeIndexFile(this.opfsRoot, index); + } + } + } + } + + if (args.blob) { + // Use provided blob importDatabase({ db: this.database, - content, - }); - } catch { - // File doesn't exist, create new one - const blob = await args.createBlob(); - importDatabase({ - db: this.database, - content: new Uint8Array(await blob.arrayBuffer()), + content: new Uint8Array(await args.blob.arrayBuffer()), }); - - // Save the initial state to OPFS + // Save the imported state to OPFS + await this.ensurePathResolvedForCreation(args); + await this.enforceModeInvariantsAndIndexUpdateAfterImport(); await this.save(); + } else { + if (this.path) { + try { + // Try to load existing data from OPFS + const fileHandle = await this.opfsRoot.getFileHandle(this.path); + const file = await fileHandle.getFile(); + const content = new Uint8Array(await file.arrayBuffer()); + + importDatabase({ + db: this.database, + content, + }); + await this.enforceModeInvariantsAndIndexUpdateAfterImport(); + } catch { + // File doesn't exist, create new one + await this.createNewFromCallbackAndIndex(args); + } + } else { + // No path yet (e.g., byName without mapping) -> create new and resolve id + await this.createNewFromCallbackAndIndex(args); + } } + + // Load active accounts if they exist + await this.loadActiveAccounts(); } - // Load active accounts if they exist - await this.loadActiveAccounts(); + return this.database; + })(); + try { + return await this.openPromise; + } finally { + this.openPromise = undefined; } - - return this.database; } /** @@ -174,7 +252,9 @@ export class OpfsStorage implements LixStorageAdapter { if (!this.database || !this.opfsRoot) { return; } - + if (!this.path) { + throw new Error("Cannot save without a resolved file path"); + } const content = contentFromDatabase(this.database); const fileHandle = await this.opfsRoot.getFileHandle(this.path, { create: true, @@ -190,6 +270,40 @@ export class OpfsStorage implements LixStorageAdapter { * Sets up observers for persisting state changes. */ connect(args: { lix: Lix }): void { + // Enforce invariants and adjust index synchronously after DB is initialized + try { + const idRow = executeSync({ + lix: { sqlite: args.lix.sqlite }, + query: args.lix.db + .selectFrom("key_value_all") + .where("key", "=", "lix_id") + .where("lixcol_version_id", "=", "global") + .select("value"), + }); + const dbId = idRow?.[0]?.value as string | undefined; + + if (this.mode?.type === "byId") { + if (dbId && dbId !== this.mode.id) { + throw new Error( + `OPFS file lix_id mismatch: expected '${this.mode.id}' but found '${dbId}'` + ); + } + } + + if (this.mode?.type === "byName" && dbId && this.opfsRoot) { + // Update index to point name -> dbId (do not mutate DB name) + const desiredName = this.mode.name; + const root = this.opfsRoot; + void (async () => { + const index = await OpfsStorage.readIndexFile(root); + index.names[desiredName] = dbId; + await OpfsStorage.writeIndexFile(root, index); + })(); + } + } catch (e) { + if (e instanceof Error) throw e; + throw new Error(String(e)); + } // Set up hook for database persistence this.unsubscribeFromStateCommit = args.lix.hooks.onStateCommit(() => { this.batchedSave(); @@ -316,4 +430,213 @@ export class OpfsStorage implements LixStorageAdapter { this.pendingSave = false; }); } + + // Helper: ensure path is resolved for creation scenarios when args.blob provided + private async ensurePathResolvedForCreation(args: { + blob?: Blob; + createBlob: () => Promise; + }): Promise { + if (this.path) return; + if (!this.opfsRoot) return; + if (this.mode?.type === "byId") { + this.path = `${this.mode.id}.lix`; + return; + } + if (this.mode?.type === "byName") { + // Try to derive id from provided blob if it has _lix metadata + let idFromBlob: string | undefined; + if (args.blob && (args.blob as any)?._lix?.id) { + idFromBlob = (args.blob as any)._lix.id as string; + } + if (idFromBlob) { + this.path = `${idFromBlob}.lix`; + // update index + const index = await OpfsStorage.readIndexFile(this.opfsRoot); + index.names[this.mode.name] = idFromBlob; + await OpfsStorage.writeIndexFile(this.opfsRoot, index); + } + } + } + + private static async fileExists( + root: FileSystemDirectoryHandle, + name: string + ): Promise { + try { + await root.getFileHandle(name); + return true; + } catch { + return false; + } + } + + private static async readIndexFile( + root: FileSystemDirectoryHandle + ): Promise { + try { + const fileHandle = await root.getFileHandle(OpfsStorage.INDEX_FILE); + const file = await fileHandle.getFile(); + const text = await file.text(); + const parsed = JSON.parse(text) as OpfsIndex; + if (!parsed || parsed.version !== 1 || !parsed.names) { + return { version: 1, names: {} }; + } + return parsed; + } catch { + return { version: 1, names: {} }; + } + } + + private static async writeIndexFile( + root: FileSystemDirectoryHandle, + index: OpfsIndex + ): Promise { + const fh = await root.getFileHandle(OpfsStorage.INDEX_FILE, { + create: true, + }); + const w = await fh.createWritable(); + await w.write(JSON.stringify(index)); + await w.close(); + } + + private async createNewFromCallbackAndIndex(args: { + blob?: Blob; + createBlob: () => Promise; + }): Promise { + if (!this.database || !this.opfsRoot) return; + // Create new blob - customize for byId/byName to satisfy mapping immediately + let blob: Blob; + if (this.mode?.type === "byId" || this.mode?.type === "byName") { + const keyValues: NewStateAll[] = []; + if (this.mode.type === "byId") { + keyValues.push({ + key: "lix_id", + value: this.mode.id, + lixcol_version_id: "global", + }); + } + if (this.mode.type === "byName") { + keyValues.push({ + key: "lix_name", + value: this.mode.name, + lixcol_version_id: "global", + }); + } + blob = await newLixFile({ keyValues }); + } else { + blob = await args.createBlob(); + } + importDatabase({ + db: this.database, + content: new Uint8Array(await blob.arrayBuffer()), + }); + + // Determine id + let id: string | undefined = (blob as any)?._lix?.id as string | undefined; + if (!id) { + // Fallback: try to read from imported database using internal tables + id = await this.readLixIdFromCurrentDb(); + } + if (!id) { + throw new Error("Failed to determine lix_id for new OPFS file"); + } + + // If we are in byId mode, force the id to match the requested id + if (this.mode?.type === "byId") { + try { + (this.database as any).exec({ + sql: `UPDATE key_value SET value = ? WHERE key = 'lix_id'`, + bind: [this.mode.id], + }); + id = this.mode.id; + } catch { + // ignore + } + } + + this.path = `${id}.lix`; + + // If opened by name, ensure the created DB has the requested name + if (this.mode?.type === "byName") { + try { + // Update or insert lix_name to desired + (this.database as any).exec({ + sql: `UPDATE key_value SET value = ? WHERE key = 'lix_name'`, + bind: [this.mode.name], + }); + const rows = (this.database as any).exec({ + sql: `SELECT 1 FROM key_value WHERE key = 'lix_name' LIMIT 1`, + returnValue: "resultRows", + }); + if (!rows || rows.length === 0) { + (this.database as any).exec({ + sql: `INSERT INTO key_value (key, value) VALUES ('lix_name', ?)`, + bind: [this.mode.name], + }); + } + } catch { + // ignore + } + } + await this.save(); + + // Update index if opened by name + if (this.mode?.type === "byName") { + const index = await OpfsStorage.readIndexFile(this.opfsRoot); + index.names[this.mode.name] = id; + await OpfsStorage.writeIndexFile(this.opfsRoot, index); + } + } + + // After importing any DB content, enforce mode invariants and adjust index mapping accordingly. + private async enforceModeInvariantsAndIndexUpdateAfterImport(): Promise { + if (!this.database || !this.opfsRoot) return; + const id = await this.readLixIdFromCurrentDb(); + if (!id) return; + if (this.mode?.type === "byId") { + if (id !== this.mode.id) { + throw new Error( + `OPFS file lix_id mismatch: expected '${this.mode.id}' but found '${id}'` + ); + } + } + if (this.mode?.type === "byName") { + const index = await OpfsStorage.readIndexFile(this.opfsRoot); + index.names[this.mode.name] = id; + await OpfsStorage.writeIndexFile(this.opfsRoot, index); + } + } + + private async readLixIdFromCurrentDb(): Promise { + try { + const columnNames: string[] = []; + const rows = (this.database as any).exec({ + sql: ` + SELECT content + FROM internal_snapshot + WHERE id IN ( + SELECT snapshot_id FROM internal_change WHERE schema_key = 'lix_key_value' + ) + `, + returnValue: "resultRows", + columnNames, + }); + if (Array.isArray(rows) && rows.length > 0) { + for (const row of rows) { + let c = (row && + (Array.isArray(row) ? row[0] : (row as any)["content"])) as any; + if (c instanceof Uint8Array) { + c = new TextDecoder().decode(c); + } + const obj = typeof c === "string" ? JSON.parse(c) : c; + if (obj?.key === "lix_id") { + return String(obj.value); + } + } + } + return undefined; + } catch { + return undefined; + } + } } diff --git a/packages/lix-sdk/src/state/cache/mark-state-cache-as-stale.ts b/packages/lix-sdk/src/state/cache/mark-state-cache-as-stale.ts index 67fb41c459..b44568451a 100644 --- a/packages/lix-sdk/src/state/cache/mark-state-cache-as-stale.ts +++ b/packages/lix-sdk/src/state/cache/mark-state-cache-as-stale.ts @@ -14,21 +14,21 @@ export function markStateCacheAsStale(args: { const ts = args.timestamp ?? timestamp({ lix: args.lix }); - updateUntrackedState({ - lix: args.lix, - changes: [ - { - entity_id: CACHE_STALE_KEY, - schema_key: LixKeyValueSchema["x-lix-key"], - file_id: "lix", - plugin_key: "lix_own_entity", - snapshot_content: snapshotContent, - schema_version: LixKeyValueSchema["x-lix-version"], - created_at: ts, - lixcol_version_id: "global", - }, - ], - }); + updateUntrackedState({ + lix: args.lix, + changes: [ + { + entity_id: CACHE_STALE_KEY, + schema_key: LixKeyValueSchema["x-lix-key"], + file_id: "lix", + plugin_key: "lix_own_entity", + snapshot_content: snapshotContent, + schema_version: LixKeyValueSchema["x-lix-version"], + created_at: ts, + lixcol_version_id: "global", + }, + ], + }); } export function markStateCacheAsFresh(args: { @@ -43,19 +43,19 @@ export function markStateCacheAsFresh(args: { const ts = args.timestamp ?? timestamp({ lix: args.lix }); - updateUntrackedState({ - lix: args.lix, - changes: [ - { - entity_id: CACHE_STALE_KEY, - schema_key: LixKeyValueSchema["x-lix-key"], - file_id: "lix", - plugin_key: "lix_own_entity", - snapshot_content: snapshotContent, - schema_version: LixKeyValueSchema["x-lix-version"], - created_at: ts, - lixcol_version_id: "global", - }, - ], - }); + updateUntrackedState({ + lix: args.lix, + changes: [ + { + entity_id: CACHE_STALE_KEY, + schema_key: LixKeyValueSchema["x-lix-key"], + file_id: "lix", + plugin_key: "lix_own_entity", + snapshot_content: snapshotContent, + schema_version: LixKeyValueSchema["x-lix-version"], + created_at: ts, + lixcol_version_id: "global", + }, + ], + }); } diff --git a/packages/lix-sdk/src/state/transaction/insert-transaction-state.test.ts b/packages/lix-sdk/src/state/transaction/insert-transaction-state.test.ts index 32c3831b39..548e4bdf18 100644 --- a/packages/lix-sdk/src/state/transaction/insert-transaction-state.test.ts +++ b/packages/lix-sdk/src/state/transaction/insert-transaction-state.test.ts @@ -55,16 +55,16 @@ test("creates tracked entity with pending change", async () => { expect(results[0]?.commit_id).toBe("pending"); // should be pending before commit // Check that the change is in the transaction table before commit (not in cache) - const changeInTransaction = await lixInternalDb - .selectFrom("internal_transaction_state") - .where("entity_id", "=", "test-insert") - .selectAll() - .select(sql`json(snapshot_content)`.as("snapshot_content")) - .executeTakeFirstOrThrow(); + const changeInTransaction = await lixInternalDb + .selectFrom("internal_transaction_state") + .where("entity_id", "=", "test-insert") + .selectAll() + .select(sql`json(snapshot_content)`.as("snapshot_content")) + .executeTakeFirstOrThrow(); expect(changeInTransaction).toBeDefined(); expect(changeInTransaction.id).toBe(results[0]?.change_id); -expect(changeInTransaction.lixcol_untracked).toBe(0); // tracked entity + expect(changeInTransaction.lixcol_untracked).toBe(0); // tracked entity expect(changeInTransaction.snapshot_content).toEqual({ value: "inserted-value", }); @@ -107,10 +107,10 @@ expect(changeInTransaction.lixcol_untracked).toBe(0); // tracked entity expect(cacheAfterCommit.change_id).toBe(changeInTransaction.id); // Verify the transaction table is cleared - const transactionAfterCommit = await lixInternalDb - .selectFrom("internal_transaction_state") - .selectAll() - .execute(); + const transactionAfterCommit = await lixInternalDb + .selectFrom("internal_transaction_state") + .selectAll() + .execute(); expect(transactionAfterCommit).toHaveLength(0); @@ -191,16 +191,16 @@ test("creates tombstone for inherited entity deletion", async () => { }); // Verify the deletion is in transaction table (not cache yet) - const transactionDeletion = await lixInternalDb - .selectFrom("internal_transaction_state") - .where("entity_id", "=", "inherited-key") - .where("schema_key", "=", "lix_key_value") - .where("lixcol_version_id", "=", activeVersion.version_id) - .selectAll() - .executeTakeFirstOrThrow(); + const transactionDeletion = await lixInternalDb + .selectFrom("internal_transaction_state") + .where("entity_id", "=", "inherited-key") + .where("schema_key", "=", "lix_key_value") + .where("lixcol_version_id", "=", activeVersion.version_id) + .selectAll() + .executeTakeFirstOrThrow(); expect(transactionDeletion.snapshot_content).toBe(null); // Deletion -expect(transactionDeletion.lixcol_untracked).toBe(0); // tracked entity + expect(transactionDeletion.lixcol_untracked).toBe(0); // tracked entity // Commit to create the tombstone commit({ lix }); @@ -287,16 +287,16 @@ test("creates tombstone for inherited untracked entity deletion", async () => { }); // Verify the deletion is in transaction table first - const transactionDeletion = await lixInternalDb - .selectFrom("internal_transaction_state") - .where("entity_id", "=", "inherited-untracked-key") - .where("schema_key", "=", "lix_key_value") - .where("lixcol_version_id", "=", activeVersion.version_id) - .selectAll() - .executeTakeFirstOrThrow(); + const transactionDeletion = await lixInternalDb + .selectFrom("internal_transaction_state") + .where("entity_id", "=", "inherited-untracked-key") + .where("schema_key", "=", "lix_key_value") + .where("lixcol_version_id", "=", activeVersion.version_id) + .selectAll() + .executeTakeFirstOrThrow(); expect(transactionDeletion.snapshot_content).toBe(null); // Deletion -expect(transactionDeletion.lixcol_untracked).toBe(1); // untracked entity + expect(transactionDeletion.lixcol_untracked).toBe(1); // untracked entity // Commit to create the tombstone in untracked table commit({ lix }); @@ -368,14 +368,14 @@ test("untracked entities use same timestamp for created_at and updated_at", asyn expect(result[0]?.created_at).toBe(result[0]?.updated_at); // Verify the entity is in the transaction table (not untracked table yet) - const transactionEntity = await lixInternalDb - .selectFrom("internal_transaction_state") - .where("entity_id", "=", "test-untracked-timestamp") - .selectAll() - .select(sql`json(snapshot_content)`.as("snapshot_content")) - .executeTakeFirstOrThrow(); - -expect(transactionEntity.lixcol_untracked).toBe(1); // marked as untracked + const transactionEntity = await lixInternalDb + .selectFrom("internal_transaction_state") + .where("entity_id", "=", "test-untracked-timestamp") + .selectAll() + .select(sql`json(snapshot_content)`.as("snapshot_content")) + .executeTakeFirstOrThrow(); + + expect(transactionEntity.lixcol_untracked).toBe(1); // marked as untracked expect(transactionEntity.snapshot_content).toEqual({ key: "test-key", value: "test-value", diff --git a/packages/lix-sdk/src/state/transaction/insert-transaction-state.ts b/packages/lix-sdk/src/state/transaction/insert-transaction-state.ts index 7971644375..a4f5e7cfd6 100644 --- a/packages/lix-sdk/src/state/transaction/insert-transaction-state.ts +++ b/packages/lix-sdk/src/state/transaction/insert-transaction-state.ts @@ -78,40 +78,40 @@ export function insertTransactionState(args: { change_id: uuidV7({ lix: args.lix as any }), })); - // Batch insert into internal_transaction_state - const transactionRows = dataWithChangeIds.map((data) => ({ - id: data.change_id, - entity_id: data.entity_id, - schema_key: data.schema_key, - file_id: data.file_id, - plugin_key: data.plugin_key, - snapshot_content: data.snapshot_content - ? sql`jsonb(${data.snapshot_content})` - : null, - schema_version: data.schema_version, - lixcol_version_id: data.version_id, - created_at: _timestamp, - lixcol_untracked: data.untracked === true ? 1 : 0, - })); + // Batch insert into internal_transaction_state + const transactionRows = dataWithChangeIds.map((data) => ({ + id: data.change_id, + entity_id: data.entity_id, + schema_key: data.schema_key, + file_id: data.file_id, + plugin_key: data.plugin_key, + snapshot_content: data.snapshot_content + ? sql`jsonb(${data.snapshot_content})` + : null, + schema_version: data.schema_version, + lixcol_version_id: data.version_id, + created_at: _timestamp, + lixcol_untracked: data.untracked === true ? 1 : 0, + })); - executeSync({ - lix: args.lix, - query: (args.lix.db as unknown as Kysely) - .insertInto("internal_transaction_state") - .values(transactionRows) - .onConflict((oc) => - oc - .columns(["entity_id", "file_id", "schema_key", "lixcol_version_id"]) - .doUpdateSet((eb) => ({ - id: eb.ref("excluded.id"), - plugin_key: eb.ref("excluded.plugin_key"), - snapshot_content: eb.ref("excluded.snapshot_content"), - schema_version: eb.ref("excluded.schema_version"), - created_at: eb.ref("excluded.created_at"), - lixcol_untracked: eb.ref("excluded.lixcol_untracked"), - })) - ), - }); + executeSync({ + lix: args.lix, + query: (args.lix.db as unknown as Kysely) + .insertInto("internal_transaction_state") + .values(transactionRows) + .onConflict((oc) => + oc + .columns(["entity_id", "file_id", "schema_key", "lixcol_version_id"]) + .doUpdateSet((eb) => ({ + id: eb.ref("excluded.id"), + plugin_key: eb.ref("excluded.plugin_key"), + snapshot_content: eb.ref("excluded.snapshot_content"), + schema_version: eb.ref("excluded.schema_version"), + created_at: eb.ref("excluded.created_at"), + lixcol_untracked: eb.ref("excluded.lixcol_untracked"), + })) + ), + }); // Return results for all data return dataWithChangeIds.map((data) => ({ diff --git a/packages/lix-sdk/src/state/transaction/schema.ts b/packages/lix-sdk/src/state/transaction/schema.ts index a0cab0c060..c593a40706 100644 --- a/packages/lix-sdk/src/state/transaction/schema.ts +++ b/packages/lix-sdk/src/state/transaction/schema.ts @@ -2,7 +2,7 @@ import type { Selectable, Insertable, Generated } from "kysely"; import type { Lix } from "../../lix/open-lix.js"; export function applyTransactionStateSchema(lix: Pick): void { - lix.sqlite.exec(` + lix.sqlite.exec(` CREATE TABLE IF NOT EXISTS internal_transaction_state ( id TEXT PRIMARY KEY DEFAULT (lix_uuid_v7()), entity_id TEXT NOT NULL, @@ -20,20 +20,20 @@ export function applyTransactionStateSchema(lix: Pick): void { } export type InternalTransactionState = - Selectable; + Selectable; export type NewInternalTransactionState = - Insertable; + Insertable; export type InternalTransactionStateTable = { - id: Generated; - entity_id: string; - schema_key: string; - schema_version: string; - file_id: string; - plugin_key: string; - lixcol_version_id: string; - snapshot_content: Record | null; - created_at: Generated; - lixcol_untracked: number; + id: Generated; + entity_id: string; + schema_key: string; + schema_version: string; + file_id: string; + plugin_key: string; + lixcol_version_id: string; + snapshot_content: Record | null; + created_at: Generated; + lixcol_untracked: number; }; // Kysely typing for the new view with lixcol_* naming diff --git a/packages/lix-sdk/src/state/untracked/update-untracked-state.test.ts b/packages/lix-sdk/src/state/untracked/update-untracked-state.test.ts index ba6ab0d640..cbbcebbceb 100644 --- a/packages/lix-sdk/src/state/untracked/update-untracked-state.test.ts +++ b/packages/lix-sdk/src/state/untracked/update-untracked-state.test.ts @@ -25,23 +25,25 @@ test("updateUntrackedState creates direct untracked entity", async () => { const currentTime = timestamp({ lix: lix as any }); // Create direct untracked entity -updateUntrackedState({ - lix, - changes: [{ - id: "test-change-id", - entity_id: "direct-untracked-key", - schema_key: "lix_key_value", - file_id: "lix", - plugin_key: "lix_own_entity", - snapshot_content: JSON.stringify({ - key: "direct-untracked-key", - value: "direct-value", - }), - schema_version: "1.0", - created_at: currentTime, - lixcol_version_id: activeVersion.version_id, - }], -}); + updateUntrackedState({ + lix, + changes: [ + { + id: "test-change-id", + entity_id: "direct-untracked-key", + schema_key: "lix_key_value", + file_id: "lix", + plugin_key: "lix_own_entity", + snapshot_content: JSON.stringify({ + key: "direct-untracked-key", + value: "direct-value", + }), + schema_version: "1.0", + created_at: currentTime, + lixcol_version_id: activeVersion.version_id, + }, + ], + }); // Verify entity exists in untracked table const result = await lixInternalDb @@ -95,42 +97,46 @@ test("updateUntrackedState updates existing direct untracked entity", async () = const currentTime = timestamp({ lix: lix as any }); // Create initial entity -updateUntrackedState({ - lix, - changes: [{ - id: "test-change-id", - entity_id: "update-test-key", - schema_key: "lix_key_value", - file_id: "lix", - plugin_key: "lix_own_entity", - snapshot_content: JSON.stringify({ - key: "update-test-key", - value: "initial-value", - }), - schema_version: "1.0", - created_at: currentTime, - lixcol_version_id: activeVersion.version_id, - }], -}); + updateUntrackedState({ + lix, + changes: [ + { + id: "test-change-id", + entity_id: "update-test-key", + schema_key: "lix_key_value", + file_id: "lix", + plugin_key: "lix_own_entity", + snapshot_content: JSON.stringify({ + key: "update-test-key", + value: "initial-value", + }), + schema_version: "1.0", + created_at: currentTime, + lixcol_version_id: activeVersion.version_id, + }, + ], + }); // Update the entity -updateUntrackedState({ - lix, - changes: [{ - id: "test-change-id-2", - entity_id: "update-test-key", - schema_key: "lix_key_value", - file_id: "lix", - plugin_key: "lix_own_entity", - snapshot_content: JSON.stringify({ - key: "update-test-key", - value: "updated-value", - }), - schema_version: "1.0", - created_at: currentTime, - lixcol_version_id: activeVersion.version_id, - }], -}); + updateUntrackedState({ + lix, + changes: [ + { + id: "test-change-id-2", + entity_id: "update-test-key", + schema_key: "lix_key_value", + file_id: "lix", + plugin_key: "lix_own_entity", + snapshot_content: JSON.stringify({ + key: "update-test-key", + value: "updated-value", + }), + schema_version: "1.0", + created_at: currentTime, + lixcol_version_id: activeVersion.version_id, + }, + ], + }); // Verify entity was updated const result = await lixInternalDb @@ -182,23 +188,25 @@ test("updateUntrackedState deletes direct untracked entity", async () => { const currentTime = timestamp({ lix: lix as any }); // Create direct untracked entity -updateUntrackedState({ - lix, - changes: [{ - id: "test-change-id", - entity_id: "delete-test-key", - schema_key: "lix_key_value", - file_id: "lix", - plugin_key: "lix_own_entity", - snapshot_content: JSON.stringify({ - key: "delete-test-key", - value: "to-be-deleted", - }), - schema_version: "1.0", - created_at: currentTime, - lixcol_version_id: activeVersion.version_id, - }], -}); + updateUntrackedState({ + lix, + changes: [ + { + id: "test-change-id", + entity_id: "delete-test-key", + schema_key: "lix_key_value", + file_id: "lix", + plugin_key: "lix_own_entity", + snapshot_content: JSON.stringify({ + key: "delete-test-key", + value: "to-be-deleted", + }), + schema_version: "1.0", + created_at: currentTime, + lixcol_version_id: activeVersion.version_id, + }, + ], + }); // Verify entity exists const beforeDelete = await lixInternalDb @@ -211,20 +219,22 @@ updateUntrackedState({ expect(beforeDelete).toHaveLength(1); // Delete the entity -updateUntrackedState({ - lix, - changes: [{ - id: "test-delete-change-id", - entity_id: "delete-test-key", - schema_key: "lix_key_value", - file_id: "lix", - plugin_key: "lix_own_entity", - snapshot_content: null, // Deletion - schema_version: "1.0", - created_at: currentTime, - lixcol_version_id: activeVersion.version_id, - }], -}); + updateUntrackedState({ + lix, + changes: [ + { + id: "test-delete-change-id", + entity_id: "delete-test-key", + schema_key: "lix_key_value", + file_id: "lix", + plugin_key: "lix_own_entity", + snapshot_content: null, // Deletion + schema_version: "1.0", + created_at: currentTime, + lixcol_version_id: activeVersion.version_id, + }, + ], + }); // Verify entity is deleted const afterDelete = await lixInternalDb @@ -288,20 +298,22 @@ test("updateUntrackedState creates tombstone for inherited untracked entity dele expect(beforeDelete).toHaveLength(0); // Delete the inherited entity (should create tombstone) -updateUntrackedState({ - lix, - changes: [{ - id: "test-inherited-delete-change-id", - entity_id: "inherited-untracked-key", - schema_key: "lix_key_value", - file_id: "lix", - plugin_key: "lix_own_entity", - snapshot_content: null, // Deletion - schema_version: "1.0", - created_at: currentTime, - lixcol_version_id: activeVersion.version_id, - }], -}); + updateUntrackedState({ + lix, + changes: [ + { + id: "test-inherited-delete-change-id", + entity_id: "inherited-untracked-key", + schema_key: "lix_key_value", + file_id: "lix", + plugin_key: "lix_own_entity", + snapshot_content: null, // Deletion + schema_version: "1.0", + created_at: currentTime, + lixcol_version_id: activeVersion.version_id, + }, + ], + }); // Verify tombstone was created const afterDelete = await lixInternalDb @@ -337,23 +349,25 @@ test("updateUntrackedState handles timestamp consistency for new entities", asyn const currentTime = timestamp({ lix: lix as any }); // Create untracked entity -updateUntrackedState({ - lix, - changes: [{ - id: "test-timestamp-change-id", - entity_id: "timestamp-test-key", - schema_key: "lix_key_value", - file_id: "lix", - plugin_key: "lix_own_entity", - snapshot_content: JSON.stringify({ - key: "timestamp-test-key", - value: "timestamp-value", - }), - schema_version: "1.0", - created_at: currentTime, - lixcol_version_id: activeVersion.version_id, - }], -}); + updateUntrackedState({ + lix, + changes: [ + { + id: "test-timestamp-change-id", + entity_id: "timestamp-test-key", + schema_key: "lix_key_value", + file_id: "lix", + plugin_key: "lix_own_entity", + snapshot_content: JSON.stringify({ + key: "timestamp-test-key", + value: "timestamp-value", + }), + schema_version: "1.0", + created_at: currentTime, + lixcol_version_id: activeVersion.version_id, + }, + ], + }); // Verify timestamps are consistent const result = await lixInternalDb @@ -388,20 +402,22 @@ test("updateUntrackedState resets tombstone flag when updating tombstone", async const currentTime = timestamp({ lix: lix as any }); // Create a tombstone first -updateUntrackedState({ - lix, - changes: [{ - id: "test-tombstone-change-id", - entity_id: "tombstone-test-key", - schema_key: "lix_key_value", - file_id: "lix", - plugin_key: "lix_own_entity", - snapshot_content: null, // Creates tombstone - schema_version: "1.0", - created_at: currentTime, - lixcol_version_id: activeVersion.version_id, - }], -}); + updateUntrackedState({ + lix, + changes: [ + { + id: "test-tombstone-change-id", + entity_id: "tombstone-test-key", + schema_key: "lix_key_value", + file_id: "lix", + plugin_key: "lix_own_entity", + snapshot_content: null, // Creates tombstone + schema_version: "1.0", + created_at: currentTime, + lixcol_version_id: activeVersion.version_id, + }, + ], + }); // Verify tombstone exists const tombstone = await lixInternalDb @@ -416,23 +432,25 @@ updateUntrackedState({ expect(tombstone[0]!.snapshot_content).toBe(null); // Update the tombstone with actual content -updateUntrackedState({ - lix, - changes: [{ - id: "test-tombstone-update-change-id", - entity_id: "tombstone-test-key", - schema_key: "lix_key_value", - file_id: "lix", - plugin_key: "lix_own_entity", - snapshot_content: JSON.stringify({ - key: "tombstone-test-key", - value: "revived-value", - }), - schema_version: "1.0", - created_at: currentTime, - lixcol_version_id: activeVersion.version_id, - }], -}); + updateUntrackedState({ + lix, + changes: [ + { + id: "test-tombstone-update-change-id", + entity_id: "tombstone-test-key", + schema_key: "lix_key_value", + file_id: "lix", + plugin_key: "lix_own_entity", + snapshot_content: JSON.stringify({ + key: "tombstone-test-key", + value: "revived-value", + }), + schema_version: "1.0", + created_at: currentTime, + lixcol_version_id: activeVersion.version_id, + }, + ], + }); // Verify tombstone flag is reset and content is restored const result = await lixInternalDb diff --git a/packages/lix-sdk/src/state/untracked/update-untracked-state.ts b/packages/lix-sdk/src/state/untracked/update-untracked-state.ts index 22a6cd3977..9f57b5bf6d 100644 --- a/packages/lix-sdk/src/state/untracked/update-untracked-state.ts +++ b/packages/lix-sdk/src/state/untracked/update-untracked-state.ts @@ -10,9 +10,9 @@ import type { Lix } from "../../lix/open-lix.js"; * the change ID is not required. */ export type UntrackedChangeData = Omit & { - id?: string; - /** target version for the untracked update (column maps to version_id) */ - lixcol_version_id: string; + id?: string; + /** target version for the untracked update (column maps to version_id) */ + lixcol_version_id: string; }; /** @@ -35,147 +35,149 @@ export type UntrackedChangeData = Omit & { * @param args.version_id - Version ID to update */ export function updateUntrackedState(args: { - lix: Pick; - changes: UntrackedChangeData[]; + lix: Pick; + changes: UntrackedChangeData[]; }): void { - const { lix, changes } = args; - if (!changes || changes.length === 0) return; + const { lix, changes } = args; + if (!changes || changes.length === 0) return; - const intDb = lix.db as unknown as Kysely; + const intDb = lix.db as unknown as Kysely; - // Split into deletions and non-deletions - const deletions = changes.filter((c) => c.snapshot_content == null); - const inserts = changes.filter((c) => c.snapshot_content != null); + // Split into deletions and non-deletions + const deletions = changes.filter((c) => c.snapshot_content == null); + const inserts = changes.filter((c) => c.snapshot_content != null); - // 1) Handle deletions first (delete direct or create tombstones for inherited) - if (deletions.length > 0) { - // Group by version for efficient lookups - const byVersion = new Map(); - for (const c of deletions) { - const v = c.lixcol_version_id; - if (!byVersion.has(v)) byVersion.set(v, []); - byVersion.get(v)!.push(c); - } + // 1) Handle deletions first (delete direct or create tombstones for inherited) + if (deletions.length > 0) { + // Group by version for efficient lookups + const byVersion = new Map(); + for (const c of deletions) { + const v = c.lixcol_version_id; + if (!byVersion.has(v)) byVersion.set(v, []); + byVersion.get(v)!.push(c); + } - for (const [versionId, list] of byVersion) { - // Build compact IN filters and intersect in JS to find direct entries - const desired = new Set(); - const ent = new Set(); - const sch = new Set(); - const fil = new Set(); - for (const c of list) { - desired.add(`${c.entity_id}|${c.schema_key}|${c.file_id}`); - ent.add(c.entity_id); - sch.add(c.schema_key); - fil.add(c.file_id); - } + for (const [versionId, list] of byVersion) { + // Build compact IN filters and intersect in JS to find direct entries + const desired = new Set(); + const ent = new Set(); + const sch = new Set(); + const fil = new Set(); + for (const c of list) { + desired.add(`${c.entity_id}|${c.schema_key}|${c.file_id}`); + ent.add(c.entity_id); + sch.add(c.schema_key); + fil.add(c.file_id); + } - let sel = intDb - .selectFrom("internal_state_all_untracked") - .where("version_id", "=", versionId); - const entArr = Array.from(ent); - const schArr = Array.from(sch); - const filArr = Array.from(fil); - if (entArr.length > 0) sel = sel.where("entity_id", "in", entArr); - if (schArr.length > 0) sel = sel.where("schema_key", "in", schArr); - if (filArr.length > 0) sel = sel.where("file_id", "in", filArr); + let sel = intDb + .selectFrom("internal_state_all_untracked") + .where("version_id", "=", versionId); + const entArr = Array.from(ent); + const schArr = Array.from(sch); + const filArr = Array.from(fil); + if (entArr.length > 0) sel = sel.where("entity_id", "in", entArr); + if (schArr.length > 0) sel = sel.where("schema_key", "in", schArr); + if (filArr.length > 0) sel = sel.where("file_id", "in", filArr); - const existing = executeSync({ - lix, - query: sel.select(["entity_id", "schema_key", "file_id"]), - }); - const existingSet = new Set( - existing.map((r) => `${r.entity_id}|${r.schema_key}|${r.file_id}`) - ); + const existing = executeSync({ + lix, + query: sel.select(["entity_id", "schema_key", "file_id"]), + }); + const existingSet = new Set( + existing.map((r) => `${r.entity_id}|${r.schema_key}|${r.file_id}`) + ); - // Direct deletes (per-row to keep selection precise) - for (const c of list) { - const key = `${c.entity_id}|${c.schema_key}|${c.file_id}`; - if (existingSet.has(key)) { - executeSync({ - lix, - query: intDb - .deleteFrom("internal_state_all_untracked") - .where("entity_id", "=", c.entity_id) - .where("schema_key", "=", c.schema_key) - .where("file_id", "=", c.file_id) - .where("version_id", "=", versionId), - }); - } - } + // Direct deletes (per-row to keep selection precise) + for (const c of list) { + const key = `${c.entity_id}|${c.schema_key}|${c.file_id}`; + if (existingSet.has(key)) { + executeSync({ + lix, + query: intDb + .deleteFrom("internal_state_all_untracked") + .where("entity_id", "=", c.entity_id) + .where("schema_key", "=", c.schema_key) + .where("file_id", "=", c.file_id) + .where("version_id", "=", versionId), + }); + } + } - // Tombstones for inherited (non-existing) entries via batched upsert - const tombstoneValues = list - .filter((c) => !existingSet.has(`${c.entity_id}|${c.schema_key}|${c.file_id}`)) - .map((c) => ({ - entity_id: c.entity_id, - schema_key: c.schema_key, - file_id: c.file_id, - version_id: versionId, - plugin_key: c.plugin_key, - snapshot_content: null as null, - schema_version: c.schema_version, - created_at: c.created_at, - updated_at: c.created_at, - inherited_from_version_id: null as null, - inheritance_delete_marker: 1, - })); + // Tombstones for inherited (non-existing) entries via batched upsert + const tombstoneValues = list + .filter( + (c) => !existingSet.has(`${c.entity_id}|${c.schema_key}|${c.file_id}`) + ) + .map((c) => ({ + entity_id: c.entity_id, + schema_key: c.schema_key, + file_id: c.file_id, + version_id: versionId, + plugin_key: c.plugin_key, + snapshot_content: null as null, + schema_version: c.schema_version, + created_at: c.created_at, + updated_at: c.created_at, + inherited_from_version_id: null as null, + inheritance_delete_marker: 1, + })); - if (tombstoneValues.length > 0) { - executeSync({ - lix, - query: intDb - .insertInto("internal_state_all_untracked") - .values(tombstoneValues) - .onConflict((oc) => - oc - .columns(["entity_id", "schema_key", "file_id", "version_id"]) - .doUpdateSet((eb) => ({ - snapshot_content: eb.val(null), - updated_at: eb.ref("excluded.updated_at"), - inheritance_delete_marker: eb.val(1), - plugin_key: eb.ref("excluded.plugin_key"), - schema_version: eb.ref("excluded.schema_version"), - })) - ), - }); - } - } - } + if (tombstoneValues.length > 0) { + executeSync({ + lix, + query: intDb + .insertInto("internal_state_all_untracked") + .values(tombstoneValues) + .onConflict((oc) => + oc + .columns(["entity_id", "schema_key", "file_id", "version_id"]) + .doUpdateSet((eb) => ({ + snapshot_content: eb.val(null), + updated_at: eb.ref("excluded.updated_at"), + inheritance_delete_marker: eb.val(1), + plugin_key: eb.ref("excluded.plugin_key"), + schema_version: eb.ref("excluded.schema_version"), + })) + ), + }); + } + } + } - // 2) Handle non-deletions: upsert actual content rows in batch using a prepared stmt - if (inserts.length > 0) { - for (const c of inserts) { - const content: any = c.snapshot_content as any; - executeSync({ - lix, - query: intDb - .insertInto("internal_state_all_untracked") - .values({ - entity_id: c.entity_id, - schema_key: c.schema_key, - file_id: c.file_id, - version_id: c.lixcol_version_id, - plugin_key: c.plugin_key, - snapshot_content: sql`jsonb(${content})`, - schema_version: c.schema_version, - created_at: c.created_at, - updated_at: c.created_at, - inherited_from_version_id: null, - inheritance_delete_marker: 0, - }) - .onConflict((oc) => - oc - .columns(["entity_id", "schema_key", "file_id", "version_id"]) - .doUpdateSet({ - plugin_key: c.plugin_key, - snapshot_content: sql`jsonb(${content})`, - schema_version: c.schema_version, - updated_at: c.created_at, - inheritance_delete_marker: 0, - }) - ), - }); - } - } + // 2) Handle non-deletions: upsert actual content rows in batch using a prepared stmt + if (inserts.length > 0) { + for (const c of inserts) { + const content: any = c.snapshot_content as any; + executeSync({ + lix, + query: intDb + .insertInto("internal_state_all_untracked") + .values({ + entity_id: c.entity_id, + schema_key: c.schema_key, + file_id: c.file_id, + version_id: c.lixcol_version_id, + plugin_key: c.plugin_key, + snapshot_content: sql`jsonb(${content})`, + schema_version: c.schema_version, + created_at: c.created_at, + updated_at: c.created_at, + inherited_from_version_id: null, + inheritance_delete_marker: 0, + }) + .onConflict((oc) => + oc + .columns(["entity_id", "schema_key", "file_id", "version_id"]) + .doUpdateSet({ + plugin_key: c.plugin_key, + snapshot_content: sql`jsonb(${content})`, + schema_version: c.schema_version, + updated_at: c.created_at, + inheritance_delete_marker: 0, + }) + ), + }); + } + } } diff --git a/packages/lix-sdk/src/state/vtable/commit.bench.ts b/packages/lix-sdk/src/state/vtable/commit.bench.ts index ed22889996..d6b6075a59 100644 --- a/packages/lix-sdk/src/state/vtable/commit.bench.ts +++ b/packages/lix-sdk/src/state/vtable/commit.bench.ts @@ -122,79 +122,90 @@ bench("commit 10 transactions x 10 changes (sequential)", async () => { }); bench("commit with mixed operations (insert/update/delete)", async () => { - const lix = await openLix({}); - - // Preload a baseline of entities to update/delete - const BASE_COUNT = 30; // baseline rows to enable realistic updates/deletes - const baseRows = [] as any[]; - for (let i = 0; i < BASE_COUNT; i++) { - baseRows.push({ - entity_id: `mixed_entity_${i}`, - version_id: "global", - schema_key: "commit_benchmark_entity", - file_id: "commit_file", - plugin_key: "benchmark_plugin", - snapshot_content: JSON.stringify({ id: `mixed_entity_${i}`, value: `base_${i}` }), - schema_version: "1.0", - untracked: false, - }); - } - insertTransactionState({ lix: lix as any, data: baseRows, timestamp: timestamp({ lix }) }); - commit({ lix: { sqlite: lix.sqlite, db: lix.db as any, hooks: lix.hooks } }); - - // Prepare a mixed batch: 10 inserts, 10 updates, 10 deletes - const INSERTS = 10; - const UPDATES = 10; - const DELETES = 10; - const ops: any[] = []; - - // Inserts: new entities - for (let i = 0; i < INSERTS; i++) { - const id = `mixed_new_${i}`; - ops.push({ - entity_id: id, - version_id: "global", - schema_key: "commit_benchmark_entity", - file_id: "commit_file", - plugin_key: "benchmark_plugin", - snapshot_content: JSON.stringify({ id, value: `insert_${i}` }), - schema_version: "1.0", - untracked: false, - }); - } - - // Updates: modify existing baseline entities - for (let i = 0; i < UPDATES; i++) { - const id = `mixed_entity_${i}`; - ops.push({ - entity_id: id, - version_id: "global", - schema_key: "commit_benchmark_entity", - file_id: "commit_file", - plugin_key: "benchmark_plugin", - snapshot_content: JSON.stringify({ id, value: `updated_${i}` }), - schema_version: "1.0", - untracked: false, - }); - } - - // Deletes: remove other baseline entities - for (let i = 0; i < DELETES; i++) { - const id = `mixed_entity_${BASE_COUNT - 1 - i}`; - ops.push({ - entity_id: id, - version_id: "global", - schema_key: "commit_benchmark_entity", - file_id: "commit_file", - plugin_key: "benchmark_plugin", - snapshot_content: null, - schema_version: "1.0", - untracked: false, - }); - } - - insertTransactionState({ lix: lix as any, data: ops, timestamp: timestamp({ lix }) }); - - // Benchmark: single commit with mixed operations - commit({ lix: { sqlite: lix.sqlite, db: lix.db as any, hooks: lix.hooks } }); + const lix = await openLix({}); + + // Preload a baseline of entities to update/delete + const BASE_COUNT = 30; // baseline rows to enable realistic updates/deletes + const baseRows = [] as any[]; + for (let i = 0; i < BASE_COUNT; i++) { + baseRows.push({ + entity_id: `mixed_entity_${i}`, + version_id: "global", + schema_key: "commit_benchmark_entity", + file_id: "commit_file", + plugin_key: "benchmark_plugin", + snapshot_content: JSON.stringify({ + id: `mixed_entity_${i}`, + value: `base_${i}`, + }), + schema_version: "1.0", + untracked: false, + }); + } + insertTransactionState({ + lix: lix as any, + data: baseRows, + timestamp: timestamp({ lix }), + }); + commit({ lix: { sqlite: lix.sqlite, db: lix.db as any, hooks: lix.hooks } }); + + // Prepare a mixed batch: 10 inserts, 10 updates, 10 deletes + const INSERTS = 10; + const UPDATES = 10; + const DELETES = 10; + const ops: any[] = []; + + // Inserts: new entities + for (let i = 0; i < INSERTS; i++) { + const id = `mixed_new_${i}`; + ops.push({ + entity_id: id, + version_id: "global", + schema_key: "commit_benchmark_entity", + file_id: "commit_file", + plugin_key: "benchmark_plugin", + snapshot_content: JSON.stringify({ id, value: `insert_${i}` }), + schema_version: "1.0", + untracked: false, + }); + } + + // Updates: modify existing baseline entities + for (let i = 0; i < UPDATES; i++) { + const id = `mixed_entity_${i}`; + ops.push({ + entity_id: id, + version_id: "global", + schema_key: "commit_benchmark_entity", + file_id: "commit_file", + plugin_key: "benchmark_plugin", + snapshot_content: JSON.stringify({ id, value: `updated_${i}` }), + schema_version: "1.0", + untracked: false, + }); + } + + // Deletes: remove other baseline entities + for (let i = 0; i < DELETES; i++) { + const id = `mixed_entity_${BASE_COUNT - 1 - i}`; + ops.push({ + entity_id: id, + version_id: "global", + schema_key: "commit_benchmark_entity", + file_id: "commit_file", + plugin_key: "benchmark_plugin", + snapshot_content: null, + schema_version: "1.0", + untracked: false, + }); + } + + insertTransactionState({ + lix: lix as any, + data: ops, + timestamp: timestamp({ lix }), + }); + + // Benchmark: single commit with mixed operations + commit({ lix: { sqlite: lix.sqlite, db: lix.db as any, hooks: lix.hooks } }); }); diff --git a/packages/lix-sdk/src/state/vtable/commit.test.ts b/packages/lix-sdk/src/state/vtable/commit.test.ts index 6327370970..6e86cd023b 100644 --- a/packages/lix-sdk/src/state/vtable/commit.test.ts +++ b/packages/lix-sdk/src/state/vtable/commit.test.ts @@ -926,43 +926,47 @@ test("global version should move forward when mutations occur", async () => { }); // https://github.com/opral/lix-sdk/issues/364#issuecomment-3218464923 -// +// // Verifies that working change set elements are NOT updated for the global version. // We intentionally assert no working elements are written for global's working commit. // This documents current behavior and makes it explicit until a future lazy design. test("does not update working change set elements for global version", async () => { - const lix = await openLix({}); - - // Stage a tracked change in the global version - await lix.db - .insertInto("key_value_all") - .values({ key: "global_key", value: "global_value", lixcol_version_id: "global" }) - .execute(); - - // Resolve global version and its working commit - const globalVersion = await lix.db - .selectFrom("version") - .where("id", "=", "global") - .selectAll() - .executeTakeFirstOrThrow(); - - const workingCommit = await lix.db - .selectFrom("commit") - .where("id", "=", globalVersion.working_commit_id) - .selectAll() - .executeTakeFirstOrThrow(); - - // There should be no working change set element for the global working change set - const workingElements = await lix.db - .selectFrom("change_set_element_all") - .where("lixcol_version_id", "=", "global") - .where("change_set_id", "=", workingCommit.change_set_id) - .where("entity_id", "=", "global_key") - .where("schema_key", "=", "lix_key_value") - .selectAll() - .execute(); - - expect(workingElements).toHaveLength(0); + const lix = await openLix({}); + + // Stage a tracked change in the global version + await lix.db + .insertInto("key_value_all") + .values({ + key: "global_key", + value: "global_value", + lixcol_version_id: "global", + }) + .execute(); + + // Resolve global version and its working commit + const globalVersion = await lix.db + .selectFrom("version") + .where("id", "=", "global") + .selectAll() + .executeTakeFirstOrThrow(); + + const workingCommit = await lix.db + .selectFrom("commit") + .where("id", "=", globalVersion.working_commit_id) + .selectAll() + .executeTakeFirstOrThrow(); + + // There should be no working change set element for the global working change set + const workingElements = await lix.db + .selectFrom("change_set_element_all") + .where("lixcol_version_id", "=", "global") + .where("change_set_id", "=", workingCommit.change_set_id) + .where("entity_id", "=", "global_key") + .where("schema_key", "=", "lix_key_value") + .selectAll() + .execute(); + + expect(workingElements).toHaveLength(0); }); /** diff --git a/packages/lix-sdk/src/state/vtable/commit.ts b/packages/lix-sdk/src/state/vtable/commit.ts index 844f6ecacc..8200498211 100644 --- a/packages/lix-sdk/src/state/vtable/commit.ts +++ b/packages/lix-sdk/src/state/vtable/commit.ts @@ -41,24 +41,24 @@ export function commit(args: { // Collect per-version snapshots once to avoid duplicate queries in this commit const versionSnapshots = new Map(); - // Query all transaction changes - const allTransactionChanges = executeSync({ - lix: args.lix, - query: db - .selectFrom("internal_transaction_state") - .select([ - "id", - "entity_id", - "schema_key", - "schema_version", - "file_id", - "plugin_key", - sql`lixcol_version_id`.as("version_id"), - sql`json(snapshot_content)`.as("snapshot_content"), - "created_at", - sql`lixcol_untracked`.as("untracked"), - ]), - }); + // Query all transaction changes + const allTransactionChanges = executeSync({ + lix: args.lix, + query: db + .selectFrom("internal_transaction_state") + .select([ + "id", + "entity_id", + "schema_key", + "schema_version", + "file_id", + "plugin_key", + sql`lixcol_version_id`.as("version_id"), + sql`json(snapshot_content)`.as("snapshot_content"), + "created_at", + sql`lixcol_untracked`.as("untracked"), + ]), + }); // Separate tracked and untracked changes const trackedChangesByVersion = new Map(); @@ -505,7 +505,10 @@ export function commit(args: { } if (workingUntrackedBatch.length > 0) { - updateUntrackedState({ lix: args.lix, changes: workingUntrackedBatch }); + updateUntrackedState({ + lix: args.lix, + changes: workingUntrackedBatch, + }); } } } @@ -694,11 +697,11 @@ export function commit(args: { }); } - // Clear the transaction table after committing - executeSync({ - lix: args.lix, - query: db.deleteFrom("internal_transaction_state"), - }); + // Clear the transaction table after committing + executeSync({ + lix: args.lix, + query: db.deleteFrom("internal_transaction_state"), + }); // Update cache entries for each version for (const [version_id, meta] of versionMetadata) { diff --git a/packages/lix-sdk/src/state/vtable/validate-state-mutation.test.ts b/packages/lix-sdk/src/state/vtable/validate-state-mutation.test.ts index 7df4abaab4..b20f259c7f 100644 --- a/packages/lix-sdk/src/state/vtable/validate-state-mutation.test.ts +++ b/packages/lix-sdk/src/state/vtable/validate-state-mutation.test.ts @@ -2776,7 +2776,7 @@ test("should validate foreign keys that reference changes in internal_transactio .executeTakeFirstOrThrow(); await lix.db.transaction().execute(async (trx) => { - // Insert a key-value entity which creates a change in internal_transaction_state + // Insert a key-value entity which creates a change in internal_transaction_state await trx .insertInto("key_value") .values({ @@ -2785,20 +2785,20 @@ test("should validate foreign keys that reference changes in internal_transactio }) .execute(); - // Get the change ID that was just created in internal_transaction_state - const changes = await (trx as unknown as Kysely) - .selectFrom("internal_transaction_state") - .select("id") - .where("entity_id", "=", "test_key_for_change_reference") - .where("schema_key", "=", "lix_key_value") - .execute(); + // Get the change ID that was just created in internal_transaction_state + const changes = await (trx as unknown as Kysely) + .selectFrom("internal_transaction_state") + .select("id") + .where("entity_id", "=", "test_key_for_change_reference") + .where("schema_key", "=", "lix_key_value") + .execute(); expect(changes).toHaveLength(1); const changeId = changes[0]!.id; - // This should NOT throw an error because the change exists in internal_transaction_state - // But currently it will throw because validation only checks the "change" table (internal_change) - // which doesn't include internal_transaction_state + // This should NOT throw an error because the change exists in internal_transaction_state + // But currently it will throw because validation only checks the "change" table (internal_change) + // which doesn't include internal_transaction_state expect(() => validateStateMutation({ lix: { sqlite: lix.sqlite, db: trx as any }, diff --git a/packages/lix-sdk/src/state/vtable/vtable.test.ts b/packages/lix-sdk/src/state/vtable/vtable.test.ts index 387b7c94fd..7f3d23ad0f 100644 --- a/packages/lix-sdk/src/state/vtable/vtable.test.ts +++ b/packages/lix-sdk/src/state/vtable/vtable.test.ts @@ -2404,7 +2404,7 @@ simulationTest( // Helper to assert transaction table is empty const expectTxnEmpty = async () => { const rows = await db - .selectFrom("internal_transaction_state") + .selectFrom("internal_transaction_state") .selectAll() .execute(); expectDeterministic(rows.length).toBe(0); diff --git a/packages/lix-sdk/src/version/merge-version.ts b/packages/lix-sdk/src/version/merge-version.ts index 8e6001afae..72b131a0df 100644 --- a/packages/lix-sdk/src/version/merge-version.ts +++ b/packages/lix-sdk/src/version/merge-version.ts @@ -106,21 +106,21 @@ export async function mergeVersion(args: { const refIds = toReference.map((r) => r.id); // Read pending rows from the transaction table - const pending = await intDbLocal - .selectFrom("internal_transaction_state") - .select([ - "id", - "entity_id", - "schema_key", - "schema_version", - "file_id", - "plugin_key", - sql`json(snapshot_content)`.as("snapshot_content"), - "created_at", - ]) - .where("lixcol_version_id", "=", sourceVersion.id) - .where("id", "in", refIds) - .execute(); + const pending = await intDbLocal + .selectFrom("internal_transaction_state") + .select([ + "id", + "entity_id", + "schema_key", + "schema_version", + "file_id", + "plugin_key", + sql`json(snapshot_content)`.as("snapshot_content"), + "created_at", + ]) + .where("lixcol_version_id", "=", sourceVersion.id) + .where("id", "in", refIds) + .execute(); if (pending.length > 0) { // Insert into persistent change table using the view's insert trigger @@ -143,10 +143,10 @@ export async function mergeVersion(args: { .execute(); // Remove from transaction queue to prevent automatic commit logic for source - await intDbLocal - .deleteFrom("internal_transaction_state") - .where("id", "in", refIds) - .execute(); + await intDbLocal + .deleteFrom("internal_transaction_state") + .where("id", "in", refIds) + .execute(); } } diff --git a/packages/lix/plugins/prosemirror/example/src/App.tsx b/packages/lix/plugins/prosemirror/example/src/App.tsx index 5e1d37eec2..0abf1af8ad 100644 --- a/packages/lix/plugins/prosemirror/example/src/App.tsx +++ b/packages/lix/plugins/prosemirror/example/src/App.tsx @@ -15,7 +15,7 @@ let lix; try { lix = await openLix({ providePlugins: [prosemirrorPlugin], - storage: new OpfsStorage({ path: "example.lix" }), + storage: OpfsStorage.byName("example"), }); } catch (error) { console.error("Failed to open Lix, cleaning OPFS and reloading:", error); diff --git a/packages/md-app/src/helper/initializeLix.ts b/packages/md-app/src/helper/initializeLix.ts index aa2d74f154..58c77bc9ad 100644 --- a/packages/md-app/src/helper/initializeLix.ts +++ b/packages/md-app/src/helper/initializeLix.ts @@ -44,7 +44,7 @@ export async function initializeLix( if (lixFile) { // Import existing file data if found - const storage = new OpfsStorage({ path: `${lixId}.lix` }); + const storage = OpfsStorage.byId(lixId!); lix = await openLix({ blob: lixFile, providePlugins: [mdPlugin], @@ -57,7 +57,7 @@ export async function initializeLix( lix = await openLix({ blob: _newLixFile, providePlugins: [mdPlugin], - storage: new OpfsStorage({ path: `${lixId}.lix` }), + storage: OpfsStorage.byId(lixId), }); } diff --git a/packages/md-app/src/helper/newLix.ts b/packages/md-app/src/helper/newLix.ts index 06a3a017c9..9792152704 100644 --- a/packages/md-app/src/helper/newLix.ts +++ b/packages/md-app/src/helper/newLix.ts @@ -6,7 +6,7 @@ export async function createNewLixFileInOpfs(): Promise<{ id: string }> { await openLix({ blob: lixFile, providePlugins: [mdPlugin], - storage: new OpfsStorage({ path: `${lixFile._lix.id}.lix` }), + storage: OpfsStorage.byId(lixFile._lix.id), }); return { id: lixFile._lix.id }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 249c2cce68..1bf5380d74 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1863,8 +1863,6 @@ importers: specifier: 2.1.8 version: 2.1.8(@types/node@20.5.9)(@vitest/browser@2.1.8)(jsdom@26.1.0)(lightningcss@1.30.1)(msw@2.10.2(@types/node@20.5.9)(typescript@5.8.3))(sass-embedded@1.89.2)(terser@5.36.0) - inlang/packages/website/dist/server: {} - inlang/packages/website/tailwind-color-plugin: dependencies: '@ctrl/tinycolor':