├── src ├── services │ ├── env-variables │ │ ├── .gitignore │ │ ├── index.d.ts │ │ └── create-index-file.js │ └── telemetry │ │ └── capture.test.ts ├── engine │ ├── entity-views │ │ ├── index.ts │ │ ├── build-json-object-entries.test.ts │ │ ├── build-json-object-entries.ts │ │ └── entity-view-builder.ts │ ├── index.ts │ ├── functions │ │ ├── index.ts │ │ ├── nano-id.bench.ts │ │ └── function-registry.ts │ ├── explain-query.test.ts │ ├── cel-environment │ │ ├── cel-environment.test.ts │ │ └── cel-environment.ts │ ├── execute-sync.ts │ ├── deterministic-mode │ │ └── options.ts │ ├── internal-query-builder.ts │ └── preprocessor │ │ └── steps │ │ └── rewrite-vtable-selects.bench.ts ├── test-utilities │ └── simulation-test │ │ ├── chaotic-timestamp-simulation.ts │ │ ├── engine-boundary-simulation.test.ts │ │ ├── engine-boundary-simulation.ts │ │ └── out-of-order-sequence-simulation.ts ├── hooks │ └── index.ts ├── observe │ ├── index.ts │ └── lix-observable.ts ├── database │ ├── kysely │ │ ├── index.ts │ │ ├── plugins.ts │ │ ├── plugins │ │ │ ├── view-insert-returning-error-plugin.ts │ │ │ └── view-insert-returning-error-plugin.test.ts │ │ └── json-column-config.ts │ ├── sqlite │ │ ├── sqlite-wasm-binary.d.ts │ │ ├── kysely-driver │ │ │ ├── index.ts │ │ │ ├── connection-mutex.ts │ │ │ ├── sqlite-wasm-dialect-config.ts │ │ │ ├── sqlite-wasm-driver.ts │ │ │ └── sqlite-wasm-connection.ts │ │ ├── content-from-database.ts │ │ ├── index.ts │ │ ├── load-database-in-memory.ts │ │ ├── lix-dialect.ts │ │ ├── import-database.ts │ │ └── create-in-memory-database.ts │ ├── index.ts │ ├── nano-id.test.ts │ ├── graph-traversal-mode.ts │ └── schema-view-map.ts ├── key-value │ └── index.ts ├── lix │ ├── index.ts │ └── open-lix.bench.ts ├── change │ └── index.ts ├── log │ ├── index.ts │ ├── schema-definition.ts │ └── create-log.ts ├── change-author │ ├── index.ts │ └── schema-definition.ts ├── diff │ ├── index.ts │ └── select-commit-diff.test.ts ├── label │ ├── index.ts │ ├── schema-definition.ts │ ├── create-label.ts │ └── schema.test.ts ├── stored-schema │ ├── index.ts │ └── schema-definition.ts ├── commit │ └── index.ts ├── dependency │ ├── kysely │ │ ├── helpers │ │ │ └── sqlite.ts │ │ └── index.ts │ └── zettel-ast │ │ └── index.ts ├── query-filter │ └── index.ts ├── state │ ├── vtable │ │ ├── index.ts │ │ ├── primary-key.test.ts │ │ └── insert-vtable-log.ts │ ├── cache-v2 │ │ ├── cache-columns.ts │ │ ├── schema-metadata.ts │ │ ├── is-stale-state-cache.test.ts │ │ ├── clear-state-cache.ts │ │ ├── sqlite-type-mapper.ts │ │ └── sqlite-type-mapper.test.ts │ ├── views │ │ ├── __bench__ │ │ │ ├── state-insert-tracked-row.explain.txt │ │ │ ├── state-insert-untracked-row.explain.txt │ │ │ ├── state-delete-tracked-row.explain.txt │ │ │ └── state-update-tracked-row.explain.txt │ │ └── state-with-tombstones.ts │ ├── index.ts │ ├── cache │ │ ├── builtin-schemas.ts │ │ ├── is-stale-state-cache.test.ts │ │ ├── clear-state-cache.test.ts │ │ ├── clear-state-cache.ts │ │ ├── schema-resolver.ts │ │ ├── mark-state-cache-as-stale.ts │ │ ├── select-from-state-cache.ts │ │ └── create-schema-cache-table.test.ts │ ├── transaction │ │ └── schema.ts │ └── schema.ts ├── environment │ ├── index.ts │ ├── test-actors │ │ └── echo.actor.ts │ ├── load-from-string.test.ts │ ├── kysely │ │ └── kysely-driver.test.ts │ ├── api.ts │ └── in-memory.ts ├── plugin │ ├── index.ts │ ├── lix-plugin.test-d.ts │ └── lix-plugin.test.ts ├── change-conflict │ ├── index.ts │ ├── resolve-conflict-by-selecting.ts │ └── detect-change-conflicts.ts ├── server-protocol-handler │ ├── index.ts │ ├── environment │ │ └── environment.ts │ └── routes │ │ ├── new-v1.ts │ │ ├── get-v1.ts │ │ └── pull-v1.ts ├── types │ └── fs.d.ts ├── change-set │ ├── index.ts │ └── schema-definition.ts ├── account │ ├── index.ts │ ├── switch-account.ts │ ├── create-account.test.ts │ ├── create-account.ts │ ├── schema-definition.ts │ └── switch-account.test.ts ├── filesystem │ ├── __bench__ │ │ ├── file-insert-operations.explain.txt │ │ ├── file-delete-operations-insert.explain.txt │ │ ├── file-update-by-path.explain.txt │ │ └── file-delete-operations-delete.explain.txt │ ├── index.ts │ ├── schema.ts │ ├── file │ │ ├── cache │ │ │ ├── update-file-data-cache.ts │ │ │ ├── schema.ts │ │ │ ├── update-file-path-cache.ts │ │ │ ├── path-cache-schema.ts │ │ │ ├── lixcol-schema.ts │ │ │ └── clear-file-data-cache.ts │ │ ├── schema-definition.ts │ │ ├── select-file-data.ts │ │ └── store-detected-change-schema.ts │ ├── util │ │ └── glob.ts │ └── directory │ │ └── schema-definition.ts ├── conversation │ ├── index.ts │ └── schema-definition.ts ├── change-proposal │ ├── index.ts │ ├── reject-change-proposal.ts │ ├── reject-change-proposal.test.ts │ ├── schema-definition.ts │ ├── accept-change-proposal.ts │ ├── create-change-proposal.ts │ ├── accept-change-proposal.test.ts │ └── create-change-proposal.test.ts ├── schema-definition │ ├── index.ts │ ├── json-type.test.ts │ └── json-type.ts ├── entity │ ├── types.ts │ ├── index.ts │ ├── label │ │ └── schema-definition.ts │ └── conversation │ │ └── schema-definition.ts ├── version │ ├── index.ts │ ├── select-active-version.ts │ ├── switch-version.test.ts │ ├── switch-version.ts │ └── create-version.ts ├── index.ts ├── snapshot │ └── schema.ts └── sync │ └── push-to-server.ts ├── .gitignore ├── .prettierrc.json ├── assets ├── banner.png ├── interop.png ├── open_file.png ├── use_cases.png └── backend_features_to_files.png ├── examples └── server-protocol-handler-hono │ ├── tsconfig.json │ ├── package.json │ ├── src │ └── main.ts │ └── CHANGELOG.md ├── tsconfig.json ├── AGENTS.md ├── LICENSE ├── .oxlintrc.json ├── .github ├── workflows │ └── readme-sync.yml └── ISSUE_TEMPLATE │ └── bug_report.yml ├── vitest.config.ts └── scripts └── embed-sqlite-wasm.js /src/services/env-variables/.gitignore: -------------------------------------------------------------------------------- 1 | index.ts 2 | index.js -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | tsconfig.vitest-temp.json 2 | sqlite-wasm-binary.js 3 | -------------------------------------------------------------------------------- /src/engine/entity-views/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./types.js"; 2 | -------------------------------------------------------------------------------- /src/engine/index.ts: -------------------------------------------------------------------------------- 1 | export type { LixEngine } from "./boot.js"; 2 | -------------------------------------------------------------------------------- /src/test-utilities/simulation-test/chaotic-timestamp-simulation.ts: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/hooks/index.ts: -------------------------------------------------------------------------------- 1 | export type { LixHooks } from "./create-hooks.js"; 2 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "useTabs": true, 3 | "trailingComma": "es5" 4 | } 5 | -------------------------------------------------------------------------------- /src/observe/index.ts: -------------------------------------------------------------------------------- 1 | export { LixObservable } from "./lix-observable.js"; 2 | -------------------------------------------------------------------------------- /assets/banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/opral/lix-sdk/HEAD/assets/banner.png -------------------------------------------------------------------------------- /assets/interop.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/opral/lix-sdk/HEAD/assets/interop.png -------------------------------------------------------------------------------- /src/database/kysely/index.ts: -------------------------------------------------------------------------------- 1 | export { createDefaultPlugins } from "./plugins.js"; 2 | -------------------------------------------------------------------------------- /assets/open_file.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/opral/lix-sdk/HEAD/assets/open_file.png -------------------------------------------------------------------------------- /assets/use_cases.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/opral/lix-sdk/HEAD/assets/use_cases.png -------------------------------------------------------------------------------- /src/key-value/index.ts: -------------------------------------------------------------------------------- 1 | export { LixKeyValueSchema, type LixKeyValue } from "./schema-definition.js"; 2 | -------------------------------------------------------------------------------- /src/lix/index.ts: -------------------------------------------------------------------------------- 1 | export { type Lix, openLix } from "./open-lix.js"; 2 | export { newLixFile } from "./new-lix.js"; 3 | -------------------------------------------------------------------------------- /assets/backend_features_to_files.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/opral/lix-sdk/HEAD/assets/backend_features_to_files.png -------------------------------------------------------------------------------- /src/database/sqlite/sqlite-wasm-binary.d.ts: -------------------------------------------------------------------------------- 1 | export declare const wasmBinary: ArrayBuffer; 2 | export default wasmBinary; 3 | -------------------------------------------------------------------------------- /src/change/index.ts: -------------------------------------------------------------------------------- 1 | export { 2 | type LixChange as Change, 3 | type NewLixChange as NewChange, 4 | } from "./schema-definition.js"; 5 | -------------------------------------------------------------------------------- /src/log/index.ts: -------------------------------------------------------------------------------- 1 | export { LixLogSchema, type LixLog } from "./schema-definition.js"; 2 | export { createLog } from "./create-log.js"; 3 | -------------------------------------------------------------------------------- /src/change-author/index.ts: -------------------------------------------------------------------------------- 1 | export { 2 | LixChangeAuthorSchema, 3 | type LixChangeAuthor as ChangeAuthor, 4 | } from "./schema-definition.js"; 5 | -------------------------------------------------------------------------------- /src/diff/index.ts: -------------------------------------------------------------------------------- 1 | export { selectWorkingDiff } from "./select-working-diff.js"; 2 | export { selectCommitDiff } from "./select-commit-diff.js"; 3 | -------------------------------------------------------------------------------- /src/label/index.ts: -------------------------------------------------------------------------------- 1 | export { createLabel } from "./create-label.js"; 2 | export { LixLabelSchema, type LixLabel } from "./schema-definition.js"; 3 | -------------------------------------------------------------------------------- /src/stored-schema/index.ts: -------------------------------------------------------------------------------- 1 | export { 2 | LixStoredSchemaSchema, 3 | type LixStoredSchema as StoredSchema, 4 | } from "./schema-definition.js"; 5 | -------------------------------------------------------------------------------- /src/commit/index.ts: -------------------------------------------------------------------------------- 1 | export { 2 | LixCommitSchema, 3 | LixCommitEdgeSchema, 4 | type LixCommit, 5 | type LixCommitEdge, 6 | } from "./schema-definition.js"; 7 | -------------------------------------------------------------------------------- /src/dependency/kysely/helpers/sqlite.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Internal re-export of Kysely's SQLite helper utilities. 3 | */ 4 | export * from "kysely/helpers/sqlite"; 5 | -------------------------------------------------------------------------------- /src/query-filter/index.ts: -------------------------------------------------------------------------------- 1 | export { commitIsAncestorOf } from "./commit-is-ancestor-of.js"; 2 | export { commitIsDescendantOf } from "./commit-is-descendant-of.js"; 3 | -------------------------------------------------------------------------------- /src/database/index.ts: -------------------------------------------------------------------------------- 1 | export { type LixDatabaseSchema } from "./schema.js"; 2 | export { jsonObjectFrom, jsonArrayFrom } from "kysely/helpers/sqlite"; 3 | export { sql } from "kysely"; 4 | -------------------------------------------------------------------------------- /src/state/vtable/index.ts: -------------------------------------------------------------------------------- 1 | export { 2 | validateStateMutation, 3 | type ValidateStateMutationArgs, 4 | } from "./validate-state-mutation.js"; 5 | export { serializeStatePk, parseStatePk } from "./primary-key.js"; 6 | -------------------------------------------------------------------------------- /src/environment/index.ts: -------------------------------------------------------------------------------- 1 | export { OpfsSahEnvironment } from "./opfs-sah.js"; 2 | export { InMemoryEnvironment } from "./in-memory.js"; 3 | export type { 4 | LixEnvironment, 5 | EnvironmentActorHandle, 6 | SpawnActorOptions, 7 | } from "./api.js"; 8 | -------------------------------------------------------------------------------- /src/plugin/index.ts: -------------------------------------------------------------------------------- 1 | export type { 2 | LixPlugin, 3 | DetectedChange, 4 | RenderDiffArgs, 5 | } from "./lix-plugin.js"; 6 | 7 | export { mockJsonPlugin } from "./mock-json-plugin.js"; 8 | export { createQuerySync, type QuerySync } from "./query-sync.js"; 9 | -------------------------------------------------------------------------------- /src/change-conflict/index.ts: -------------------------------------------------------------------------------- 1 | export { createChangeConflict } from "./create-change-conflict.js"; 2 | export { detectChangeConflicts } from "./detect-change-conflicts.js"; 3 | export { resolveChangeConflictBySelecting } from "./resolve-conflict-by-selecting.js"; 4 | -------------------------------------------------------------------------------- /src/environment/test-actors/echo.actor.ts: -------------------------------------------------------------------------------- 1 | const globalScope = self as any; 2 | 3 | globalScope.onmessage = (event: MessageEvent) => { 4 | // Echo back the data so tests can observe initialization + pings. 5 | globalScope.postMessage({ received: event.data }); 6 | }; 7 | -------------------------------------------------------------------------------- /src/server-protocol-handler/index.ts: -------------------------------------------------------------------------------- 1 | export { 2 | createServerProtocolHandler, 3 | type LixServerProtocolHandlerContext, 4 | } from "./create-server-protocol-handler.js"; 5 | export { createLspInMemoryEnvironment } from "./environment/create-in-memory-environment.js"; 6 | -------------------------------------------------------------------------------- /src/types/fs.d.ts: -------------------------------------------------------------------------------- 1 | declare module "fs" { 2 | export const promises: { 3 | writeFile(path: string, data: string, encoding?: string): Promise; 4 | mkdir( 5 | path: string, 6 | options?: { 7 | recursive?: boolean; 8 | } 9 | ): Promise; 10 | }; 11 | } 12 | -------------------------------------------------------------------------------- /src/change-set/index.ts: -------------------------------------------------------------------------------- 1 | export { 2 | LixChangeSetSchema, 3 | LixChangeSetElementSchema, 4 | type LixChangeSet, 5 | type LixChangeSetElement, 6 | } from "./schema-definition.js"; 7 | export { createChangeSet } from "./create-change-set.js"; 8 | export { applyChangeSet } from "./apply-change-set.js"; 9 | -------------------------------------------------------------------------------- /src/account/index.ts: -------------------------------------------------------------------------------- 1 | export { createAccount } from "./create-account.js"; 2 | export { switchAccount } from "./switch-account.js"; 3 | export { 4 | LixAccountSchema, 5 | type LixAccount as Account, 6 | LixActiveAccountSchema, 7 | type LixActiveAccount as ActiveAccount, 8 | } from "./schema-definition.js"; 9 | -------------------------------------------------------------------------------- /src/database/sqlite/kysely-driver/index.ts: -------------------------------------------------------------------------------- 1 | export { SqliteWasmConnection } from "./sqlite-wasm-connection.js"; 2 | export type { SqliteWasmDialectConfig } from "./sqlite-wasm-dialect-config.js"; 3 | export { SqliteWasmDriver } from "./sqlite-wasm-driver.js"; 4 | export { ConnectionMutex } from "./connection-mutex.js"; 5 | -------------------------------------------------------------------------------- /examples/server-protocol-handler-hono/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["src/**/*"], 3 | "compilerOptions": { 4 | "allowImportingTsExtensions": false, 5 | "emitDeclarationOnly": false, 6 | "module": "Node16", 7 | "lib": ["ESNext", "DOM"], 8 | "outDir": "./dist", 9 | "rootDir": "./src" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/filesystem/__bench__/file-insert-operations.explain.txt: -------------------------------------------------------------------------------- 1 | -- label -- 2 | file insert operations 3 | 4 | -- original SQL -- 5 | insert into "file" ("id", "path", "data") values (?, ?, ?) 6 | 7 | -- rewritten SQL -- 8 | INSERT INTO "file" ("id", "path", "data") VALUES (?, ?, ?) 9 | 10 | -- plan -- 11 | [] 12 | -------------------------------------------------------------------------------- /src/engine/functions/index.ts: -------------------------------------------------------------------------------- 1 | export { nextSequenceNumber } from "./sequence.js"; 2 | export { getTimestamp } from "./timestamp.js"; 3 | export { uuidV7, uuidV7Sync } from "./uuid-v7.js"; 4 | export { nanoId } from "./nano-id.js"; 5 | export { random } from "./random.js"; 6 | export { humanId } from "./generate-human-id.js"; 7 | -------------------------------------------------------------------------------- /src/filesystem/__bench__/file-delete-operations-insert.explain.txt: -------------------------------------------------------------------------------- 1 | -- label -- 2 | file delete operations - insert 3 | 4 | -- original SQL -- 5 | insert into "file" ("id", "path", "data") values (?, ?, ?) 6 | 7 | -- rewritten SQL -- 8 | INSERT INTO "file" ("id", "path", "data") VALUES (?, ?, ?) 9 | 10 | -- plan -- 11 | [] 12 | -------------------------------------------------------------------------------- /src/conversation/index.ts: -------------------------------------------------------------------------------- 1 | export { 2 | LixConversationSchema, 3 | LixConversationMessageSchema, 4 | type LixConversation, 5 | type LixConversationMessage, 6 | } from "./schema-definition.js"; 7 | export { createConversation } from "./create-conversation.js"; 8 | export { createConversationMessage } from "./create-conversation-message.js"; 9 | -------------------------------------------------------------------------------- /src/change-proposal/index.ts: -------------------------------------------------------------------------------- 1 | export { createChangeProposal } from "./create-change-proposal.js"; 2 | export { acceptChangeProposal } from "./accept-change-proposal.js"; 3 | export { rejectChangeProposal } from "./reject-change-proposal.js"; 4 | export { 5 | LixChangeProposalSchema, 6 | type LixChangeProposal, 7 | } from "./schema-definition.js"; 8 | -------------------------------------------------------------------------------- /src/dependency/kysely/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Internal re-export of Kysely used by Lix tooling. 3 | * 4 | * This surface is considered implementation detail: it exists so that 5 | * packages such as `@lix-js/react-utils` can share the same Kysely 6 | * dependency as the SDK without each declaring its own version. 7 | */ 8 | export * from "kysely"; 9 | -------------------------------------------------------------------------------- /src/schema-definition/index.ts: -------------------------------------------------------------------------------- 1 | export { 2 | LixSchemaDefinition, 3 | type FromLixSchemaDefinition, 4 | type LixGenerated, 5 | type LixInsertable, 6 | type LixUpdateable, 7 | type LixSelectable, 8 | } from "./definition.js"; 9 | export { JSONTypeSchema, type JSONType } from "./json-type.js"; 10 | export { 11 | validateLixSchema, 12 | validateLixSchemaDefinition, 13 | } from "./validate-lix-schema.js"; 14 | -------------------------------------------------------------------------------- /src/services/env-variables/index.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Avoiding TypeScript errors before the `createIndexFile` script 3 | * is invoked by defining the type ahead of time. 4 | */ 5 | 6 | /** 7 | * Env variables that are available at engine. 8 | */ 9 | export declare const ENV_VARIABLES: { 10 | LIX_SDK_POSTHOG_TOKEN?: string; 11 | /** 12 | * As defined in the package.json 13 | */ 14 | LIX_SDK_VERSION?: string; 15 | }; 16 | -------------------------------------------------------------------------------- /src/dependency/zettel-ast/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Re-exporting the Zettel AST to make the getting started easier. 3 | * 4 | * Developers need access to the Zettel AST types when dealing 5 | * with comments. Forcing lix dev's to install @opral/zettel-ast 6 | * as a dependency in addition to the lix SDK is not ideal. 7 | * 8 | * Re-exporting from a subpath to avoid name collisions. 9 | */ 10 | export * from "@opral/zettel-ast"; 11 | -------------------------------------------------------------------------------- /src/entity/types.ts: -------------------------------------------------------------------------------- 1 | // Entity type with canonical column names (used in regular tables like state, entity_label) 2 | export type LixEntityCanonical = { 3 | schema_key: string; 4 | file_id: string; 5 | entity_id: string; 6 | }; 7 | 8 | // Entity type with lixcol_ prefixed columns (used in entity views) 9 | export type LixEntity = { 10 | lixcol_schema_key: string; 11 | lixcol_file_id: string; 12 | lixcol_entity_id: string; 13 | }; 14 | -------------------------------------------------------------------------------- /src/filesystem/index.ts: -------------------------------------------------------------------------------- 1 | export { 2 | LixFileDescriptorSchema, 3 | type LixFileDescriptor, 4 | } from "./file/schema-definition.js"; 5 | export type { LixFile } from "./file/schema.js"; 6 | 7 | export { 8 | LixDirectoryDescriptorSchema, 9 | type LixDirectoryDescriptor, 10 | } from "./directory/schema-definition.js"; 11 | export { 12 | normalizeFilePath, 13 | normalizeDirectoryPath, 14 | normalizePathSegment, 15 | } from "./path.js"; 16 | -------------------------------------------------------------------------------- /src/state/cache-v2/cache-columns.ts: -------------------------------------------------------------------------------- 1 | export const PRIMARY_KEY_COLUMNS = [ 2 | "entity_id", 3 | "file_id", 4 | "version_id", 5 | ] as const; 6 | 7 | export const CACHE_COLUMNS = [ 8 | "entity_id", 9 | "schema_key", 10 | "file_id", 11 | "version_id", 12 | "plugin_key", 13 | "schema_version", 14 | "created_at", 15 | "updated_at", 16 | "inherited_from_version_id", 17 | "is_tombstone", 18 | "change_id", 19 | "commit_id", 20 | ] as const; 21 | -------------------------------------------------------------------------------- /src/entity/index.ts: -------------------------------------------------------------------------------- 1 | export type { LixEntity, LixEntityCanonical } from "./types.js"; 2 | export type { LixEntityLabel } from "./label/schema-definition.js"; 3 | export type { LixEntityConversation } from "./conversation/schema-definition.js"; 4 | export { attachLabel, detachLabel } from "./label/attach-label.js"; 5 | export { 6 | attachConversation, 7 | detachConversation, 8 | } from "./conversation/attach-conversation.js"; 9 | export { ebEntity } from "./eb-entity.js"; 10 | -------------------------------------------------------------------------------- /src/database/sqlite/content-from-database.ts: -------------------------------------------------------------------------------- 1 | import type { SqliteWasmDatabase } from "./create-in-memory-database.js"; 2 | 3 | /** 4 | * Exports the content of a database as a Uint8Array. 5 | * 6 | * @example 7 | * const db = createInMemoryDatabase({ readOnly: false }); 8 | * const content = contentFromDatabase(db); 9 | */ 10 | export const contentFromDatabase = (db: SqliteWasmDatabase): Uint8Array => { 11 | return db.sqlite3.capi.sqlite3_js_db_export(db); 12 | }; 13 | -------------------------------------------------------------------------------- /src/database/sqlite/index.ts: -------------------------------------------------------------------------------- 1 | export { contentFromDatabase } from "./content-from-database.js"; 2 | export { createInMemoryDatabase } from "./create-in-memory-database.js"; 3 | export { importDatabase } from "./import-database.js"; 4 | export { loadDatabaseInMemory } from "./load-database-in-memory.js"; 5 | export type { SqliteWasmDatabase } from "./create-in-memory-database.js"; 6 | export { createLixDialect as createEngineDialect } from "./lix-dialect.js"; 7 | export { createEnvironmentDialect } from "./environment-dialect.js"; 8 | -------------------------------------------------------------------------------- /src/database/sqlite/kysely-driver/connection-mutex.ts: -------------------------------------------------------------------------------- 1 | export class ConnectionMutex { 2 | #promise?: Promise; 3 | #resolve?: () => void; 4 | 5 | async lock(): Promise { 6 | while (this.#promise) { 7 | await this.#promise; 8 | } 9 | 10 | this.#promise = new Promise((resolve) => { 11 | this.#resolve = resolve; 12 | }); 13 | } 14 | 15 | unlock(): void { 16 | const resolve = this.#resolve; 17 | 18 | this.#promise = undefined; 19 | this.#resolve = undefined; 20 | 21 | resolve?.(); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/test-utilities/simulation-test/engine-boundary-simulation.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect } from "vitest"; 2 | import { simulationTest } from "./simulation-test.js"; 3 | import { engineBoundarySimulation } from "./engine-boundary-simulation.js"; 4 | 5 | describe("engine boundary", () => { 6 | simulationTest( 7 | "engine is undefined across the boundary", 8 | async ({ openSimulatedLix }) => { 9 | const lix = await openSimulatedLix({}); 10 | expect(lix.engine).toBeUndefined(); 11 | }, 12 | { simulations: [engineBoundarySimulation] } 13 | ); 14 | }); 15 | -------------------------------------------------------------------------------- /src/filesystem/schema.ts: -------------------------------------------------------------------------------- 1 | import type { LixEngine } from "../engine/boot.js"; 2 | import { applyFileDatabaseSchema } from "./file/schema.js"; 3 | import { applyDirectoryDatabaseSchema } from "./directory/schema.js"; 4 | 5 | /** 6 | * Applies all filesystem-related database schemas (files + directories). 7 | * 8 | * @example 9 | * applyFilesystemSchema({ engine }); 10 | */ 11 | export function applyFilesystemSchema(args: { engine: LixEngine }): void { 12 | applyFileDatabaseSchema({ engine: args.engine }); 13 | applyDirectoryDatabaseSchema({ engine: args.engine }); 14 | } 15 | -------------------------------------------------------------------------------- /src/version/index.ts: -------------------------------------------------------------------------------- 1 | export { 2 | type LixVersion, 3 | type LixActiveVersion, 4 | type LixVersionDescriptor, 5 | type LixVersionTip, 6 | LixVersionTipSchema, 7 | LixVersionDescriptorSchema, 8 | LixActiveVersionSchema, 9 | } from "./schema-definition.js"; 10 | 11 | export { createVersion } from "./create-version.js"; 12 | export { switchVersion } from "./switch-version.js"; 13 | export { createVersionFromCommit } from "./create-version-from-commit.js"; 14 | export { selectVersionDiff } from "./select-version-diff.js"; 15 | export { mergeVersion } from "./merge-version.js"; 16 | -------------------------------------------------------------------------------- /src/state/views/__bench__/state-insert-tracked-row.explain.txt: -------------------------------------------------------------------------------- 1 | -- label -- 2 | state insert • tracked row 3 | 4 | -- original SQL -- 5 | INSERT INTO state ( 6 | entity_id, 7 | schema_key, 8 | file_id, 9 | plugin_key, 10 | snapshot_content, 11 | schema_version, 12 | metadata, 13 | untracked 14 | ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) 15 | 16 | -- rewritten SQL -- 17 | INSERT INTO state (entity_id, schema_key, file_id, plugin_key, snapshot_content, schema_version, metadata, untracked) VALUES (?, ?, ?, ?, ?, ?, ?, ?) 18 | 19 | -- plan -- 20 | [] 21 | -------------------------------------------------------------------------------- /src/state/views/__bench__/state-insert-untracked-row.explain.txt: -------------------------------------------------------------------------------- 1 | -- label -- 2 | state insert • untracked row 3 | 4 | -- original SQL -- 5 | INSERT INTO state ( 6 | entity_id, 7 | schema_key, 8 | file_id, 9 | plugin_key, 10 | snapshot_content, 11 | schema_version, 12 | metadata, 13 | untracked 14 | ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) 15 | 16 | -- rewritten SQL -- 17 | INSERT INTO state (entity_id, schema_key, file_id, plugin_key, snapshot_content, schema_version, metadata, untracked) VALUES (?, ?, ?, ?, ?, ?, ?, ?) 18 | 19 | -- plan -- 20 | [] 21 | -------------------------------------------------------------------------------- /src/lix/open-lix.bench.ts: -------------------------------------------------------------------------------- 1 | import { bench, describe } from "vitest"; 2 | import { openLix } from "./open-lix.js"; 3 | 4 | describe("openLix baselines", () => { 5 | bench("openLix (empty, in-memory)", async () => { 6 | const lix = await openLix({}); 7 | await lix.close(); 8 | }); 9 | 10 | describe("with key values", () => { 11 | bench("openLix with 1 keyValue", async () => { 12 | const lix = await openLix({ 13 | keyValues: [ 14 | { 15 | key: "lix_deterministic_mode", 16 | value: { enabled: true }, 17 | }, 18 | ], 19 | }); 20 | await lix.close(); 21 | }); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /src/state/index.ts: -------------------------------------------------------------------------------- 1 | // State types 2 | export type { 3 | StateView, 4 | StateRow, 5 | NewStateRow, 6 | StateRowUpdate, 7 | } from "./views/state.js"; 8 | 9 | export type { 10 | StateByVersionView, 11 | StateByVersionRow, 12 | NewStateByVersionRow, 13 | StateByVersionRowUpdate, 14 | } from "./views/state-by-version.js"; 15 | 16 | export type { 17 | StateWithTombstonesView, 18 | StateWithTombstonesRow, 19 | } from "./views/state-with-tombstones.js"; 20 | 21 | // State operations 22 | export { createCheckpoint } from "./create-checkpoint.js"; 23 | export { transition } from "./transition.js"; 24 | export { withWriterKey } from "./writer.js"; 25 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@opral/tsconfig/default", 3 | "include": ["src/**/*"], 4 | "compilerOptions": { 5 | "allowJs": false, 6 | "checkJs": false, 7 | // isolated declarations lead to substantially faster build times 8 | // which is overproportionally useful in a monorepo 9 | // https://devblogs.microsoft.com/typescript/announcing-typescript-5-5/#isolated-declarations 10 | "isolatedDeclarations": true, 11 | "allowImportingTsExtensions": false, 12 | "emitDeclarationOnly": false, 13 | "module": "Node16", 14 | "lib": ["ESNext", "DOM"], 15 | "types": ["vitest/globals"], 16 | "outDir": "./dist", 17 | "rootDir": "./src" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/version/select-active-version.ts: -------------------------------------------------------------------------------- 1 | import type { SelectQueryBuilder } from "kysely"; 2 | import type { LixDatabaseSchema } from "../database/schema.js"; 3 | import type { Lix } from "../lix/open-lix.js"; 4 | import type { State } from "../engine/entity-views/types.js"; 5 | import type { LixVersion } from "./schema-definition.js"; 6 | 7 | export function selectActiveVersion( 8 | lix: Lix 9 | ): SelectQueryBuilder< 10 | LixDatabaseSchema, 11 | "version" | "active_version", 12 | State 13 | > { 14 | return lix.db 15 | .selectFrom("active_version") 16 | .innerJoin("version", "active_version.version_id", "version.id") 17 | .selectAll("version"); 18 | } 19 | -------------------------------------------------------------------------------- /examples/server-protocol-handler-hono/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@lix-js/sdk-example-server-protocol-handler-hono", 3 | "private": true, 4 | "type": "module", 5 | "version": "0.0.4", 6 | "license": "Apache-2.0", 7 | "scripts": { 8 | "dev": "node --experimental-strip-types ./src/main.ts", 9 | "build": "tsc --build", 10 | "format": "prettier ./src --write" 11 | }, 12 | "engines": { 13 | "node": ">=22.6" 14 | }, 15 | "dependencies": { 16 | "@hono/node-server": "^1.13.7", 17 | "@lix-js/sdk": "workspace:*", 18 | "hono": "^4.6.11" 19 | }, 20 | "devDependencies": { 21 | "@types/node": "^22.10.1", 22 | "prettier": "^3.3.3", 23 | "typescript": "^5.7.2" 24 | } 25 | } -------------------------------------------------------------------------------- /examples/server-protocol-handler-hono/src/main.ts: -------------------------------------------------------------------------------- 1 | import { Hono } from "hono"; 2 | import { serve } from "@hono/node-server"; 3 | import { 4 | createServerProtocolHandler, 5 | createLspInMemoryEnvironment, 6 | } from "@lix-js/sdk"; 7 | 8 | const app = new Hono(); 9 | 10 | const lixServerProtocolHandler = await createServerProtocolHandler({ 11 | environment: createLspInMemoryEnvironment(), 12 | }); 13 | 14 | app.get("/", (c) => c.text("Hono!")); 15 | 16 | // @ts-expect-error - Hono provides a subset of the Request object 17 | app.use("/lsp/*", (c) => lixServerProtocolHandler(c.req)); 18 | 19 | serve({ 20 | fetch: app.fetch, 21 | port: 3000, 22 | }); 23 | 24 | console.log("Listening on http://localhost:3000"); 25 | -------------------------------------------------------------------------------- /src/engine/explain-query.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from "vitest"; 2 | import { openLix } from "../lix/index.js"; 3 | import { createExplainQuery } from "./explain-query.js"; 4 | 5 | test("createExplainQuery returns rewritten when query is mutated", async () => { 6 | const lix = await openLix({}); 7 | 8 | const engine = lix.engine!; 9 | const explain = createExplainQuery({ engine }); 10 | 11 | const query = { 12 | sql: "SELECT * FROM lix_internal_state_vtable WHERE schema_key = 'mock_schema'", 13 | parameters: [], 14 | }; 15 | 16 | const report = explain(query); 17 | 18 | expect(report.rewrittenSql).toBeDefined(); 19 | expect(report.rewrittenSql).not.toBe(report.originalSql); 20 | await lix.close(); 21 | }); 22 | -------------------------------------------------------------------------------- /src/change-proposal/reject-change-proposal.ts: -------------------------------------------------------------------------------- 1 | import type { Lix } from "../lix/open-lix.js"; 2 | import type { LixChangeProposal } from "./schema-definition.js"; 3 | 4 | export async function rejectChangeProposal(args: { 5 | lix: Lix; 6 | proposal: Pick | LixChangeProposal; 7 | }): Promise { 8 | const executeInTransaction = async (trx: Lix["db"]) => { 9 | await trx 10 | .updateTable("change_proposal") 11 | .set({ status: "rejected" }) 12 | .where("id", "=", args.proposal.id) 13 | .execute(); 14 | }; 15 | 16 | if (args.lix.db.isTransaction) { 17 | await executeInTransaction(args.lix.db); 18 | return; 19 | } 20 | 21 | return await args.lix.db.transaction().execute(executeInTransaction); 22 | } 23 | -------------------------------------------------------------------------------- /src/database/kysely/plugins.ts: -------------------------------------------------------------------------------- 1 | import { ParseJSONResultsPlugin, type KyselyPlugin } from "kysely"; 2 | import { JSONColumnPlugin } from "./plugins/json-column-plugin.js"; 3 | import { ViewInsertReturningErrorPlugin } from "./plugins/view-insert-returning-error-plugin.js"; 4 | import { buildJsonColumnConfig } from "./json-column-config.js"; 5 | import { LixSchemaViewMap } from "../schema-view-map.js"; 6 | 7 | export function createDefaultPlugins(): KyselyPlugin[] { 8 | const jsonColumnsConfig = buildJsonColumnConfig({ includeChangeView: true }); 9 | const viewNames = Object.keys(LixSchemaViewMap); 10 | 11 | return [ 12 | new ParseJSONResultsPlugin(), 13 | JSONColumnPlugin(jsonColumnsConfig), 14 | new ViewInsertReturningErrorPlugin(viewNames), 15 | ]; 16 | } 17 | -------------------------------------------------------------------------------- /src/label/schema-definition.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | FromLixSchemaDefinition, 3 | LixSchemaDefinition, 4 | } from "../schema-definition/definition.js"; 5 | 6 | export type LixLabel = FromLixSchemaDefinition; 7 | 8 | export const LixLabelSchema = { 9 | "x-lix-key": "lix_label", 10 | "x-lix-version": "1.0", 11 | "x-lix-primary-key": ["/id"], 12 | "x-lix-override-lixcols": { 13 | lixcol_file_id: '"lix"', 14 | lixcol_plugin_key: '"lix_sdk"', 15 | }, 16 | type: "object", 17 | properties: { 18 | id: { 19 | type: "string", 20 | "x-lix-default": "lix_uuid_v7()", 21 | }, 22 | name: { type: "string" }, 23 | }, 24 | required: ["id", "name"], 25 | additionalProperties: false, 26 | } as const; 27 | LixLabelSchema satisfies LixSchemaDefinition; 28 | -------------------------------------------------------------------------------- /src/environment/load-from-string.test.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from "vitest"; 2 | import { loadPluginFromString } from "./load-from-string.js"; 3 | 4 | test("loads plugin from code with named export", async () => { 5 | const code = `export const plugin = { key: 'test-named', detectChanges() { return [] } }`; 6 | const plugin = await loadPluginFromString(code); 7 | expect(plugin).toBeDefined(); 8 | expect(plugin.key).toBe("test-named"); 9 | expect(typeof plugin.detectChanges).toBe("function"); 10 | }); 11 | 12 | test("loads plugin from code with default export", async () => { 13 | const code = `export default { key: 'test-default', detectChanges() { return [] } }`; 14 | const plugin = await loadPluginFromString(code); 15 | expect(plugin).toBeDefined(); 16 | expect(plugin.key).toBe("test-default"); 17 | }); 18 | -------------------------------------------------------------------------------- /src/engine/cel-environment/cel-environment.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from "vitest"; 2 | import { createCelEnvironment } from "./cel-environment.js"; 3 | import { openLix } from "../../lix/open-lix.js"; 4 | 5 | describe("createCelEnvironment (v3)", () => { 6 | test("evaluates literal expressions", async () => { 7 | const lix = await openLix({}); 8 | const env = createCelEnvironment({ engine: lix.engine! }); 9 | expect(env.evaluate("'draft'", {})).toBe("draft"); 10 | await lix.close(); 11 | }); 12 | 13 | test("returns values from registered functions", async () => { 14 | const lix = await openLix({}); 15 | const env = createCelEnvironment({ engine: lix.engine! }); 16 | const value = env.evaluate("lix_uuid_v7()", {}); 17 | expect(typeof value).toBe("string"); 18 | await lix.close(); 19 | }); 20 | }); 21 | -------------------------------------------------------------------------------- /src/database/sqlite/kysely-driver/sqlite-wasm-dialect-config.ts: -------------------------------------------------------------------------------- 1 | import type { DatabaseConnection } from "kysely"; 2 | import type { SqliteWasmDatabase } from "../create-in-memory-database.js"; 3 | 4 | export interface SqliteWasmDialectConfig { 5 | /** 6 | * An sqlite Database instance or a function that returns one. 7 | * 8 | * If a function is provided, it's called once when the first query is executed. 9 | * 10 | * https://github.com/JoshuaWise/better-sqlite3/blob/master/docs/api.md#new-databasepath-options 11 | */ 12 | database: SqliteWasmDatabase | (() => Promise); 13 | /** 14 | * Called once when the first query is executed. 15 | * 16 | * This is a Kysely specific feature and does not come from the `better-sqlite3` module. 17 | */ 18 | onCreateConnection?: (connection: DatabaseConnection) => Promise; 19 | } 20 | -------------------------------------------------------------------------------- /src/state/cache/builtin-schemas.ts: -------------------------------------------------------------------------------- 1 | import type { LixSchemaDefinition } from "../../schema-definition/definition.js"; 2 | import { LixSchemaViewMap } from "../../database/schema-view-map.js"; 3 | import { 4 | LixVersionDescriptorSchema, 5 | LixVersionTipSchema, 6 | } from "../../version/schema-definition.js"; 7 | 8 | const registry = new Map(); 9 | 10 | function register(definition: LixSchemaDefinition): void { 11 | const key = definition["x-lix-key"]; 12 | if (typeof key === "string" && key.length > 0) { 13 | registry.set(key, definition); 14 | } 15 | } 16 | 17 | for (const definition of Object.values(LixSchemaViewMap)) { 18 | register(definition); 19 | } 20 | 21 | register(LixVersionDescriptorSchema); 22 | register(LixVersionTipSchema); 23 | 24 | export const BUILTIN_CACHE_SCHEMAS: Record = 25 | Object.fromEntries(registry); 26 | -------------------------------------------------------------------------------- /AGENTS.md: -------------------------------------------------------------------------------- 1 | # Rules for LLMs 2 | 3 | - Do not mock lix. Lix is a local SQLite database that does not need mocking. Test cases should always use the real lix. 4 | 5 | - Lix uses Kysely to expose the the SQL API in a typesafe way https://kysely-org.github.io/kysely-apidoc/. 6 | 7 | - tests for the lix sdk can be run with `pnpm exec vitest run ...` 8 | 9 | - validate the types AFTER the tests pass with `pnpm exec tsc --noEmit` 10 | 11 | - always start with implementing test cases that reproduce bugs before implementing a fix to validate if the test captures the bug 12 | 13 | - do not create getter functions. isntead query sql directly via kysely. otherwise, we end up with a huge pile of wrapper functions 14 | 15 | - use type instead of interface 16 | 17 | - do not rely on try/catch for crash avoidance. we want to fail fast to catch bugs early. this does not apply if we explicitly want to catch errors -------------------------------------------------------------------------------- /src/database/sqlite/load-database-in-memory.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createInMemoryDatabase, 3 | type SqliteWasmDatabase, 4 | } from "./create-in-memory-database.js"; 5 | import { importDatabase } from "./import-database.js"; 6 | 7 | /** 8 | * Convenience helper that instantiates a database and hydrates it from bytes. 9 | * 10 | * This combines {@link createInMemoryDatabase} and {@link importDatabase} so 11 | * callers can go from raw ArrayBuffer data to a ready-to-use database in one 12 | * call. 13 | * 14 | * @example 15 | * const db = await loadDatabaseInMemory(await fetch("/db.sqlite").then(r => r.arrayBuffer())); 16 | */ 17 | export async function loadDatabaseInMemory( 18 | data: ArrayBuffer 19 | ): Promise { 20 | const database = await createInMemoryDatabase({ 21 | readOnly: false, 22 | }); 23 | importDatabase({ 24 | db: database, 25 | content: new Uint8Array(data), 26 | }); 27 | return database; 28 | } 29 | -------------------------------------------------------------------------------- /src/stored-schema/schema-definition.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | FromLixSchemaDefinition, 3 | LixSchemaDefinition, 4 | } from "../schema-definition/definition.js"; 5 | import { JSONTypeSchema } from "../schema-definition/json-type.js"; 6 | 7 | export type LixStoredSchema = FromLixSchemaDefinition< 8 | typeof LixStoredSchemaSchema 9 | > & { 10 | value: LixSchemaDefinition; 11 | }; 12 | 13 | export const LixStoredSchemaSchema = { 14 | "x-lix-key": "lix_stored_schema", 15 | "x-lix-version": "1.0", 16 | "x-lix-primary-key": ["/value/x-lix-key", "/value/x-lix-version"], 17 | "x-lix-immutable": true, 18 | "x-lix-override-lixcols": { 19 | lixcol_file_id: '"lix"', 20 | lixcol_plugin_key: '"lix_sdk"', 21 | lixcol_version_id: '"global"', 22 | }, 23 | type: "object", 24 | properties: { 25 | value: JSONTypeSchema as any, 26 | }, 27 | required: ["value"], 28 | additionalProperties: false, 29 | } as const; 30 | LixStoredSchemaSchema satisfies LixSchemaDefinition; 31 | -------------------------------------------------------------------------------- /src/filesystem/file/cache/update-file-data-cache.ts: -------------------------------------------------------------------------------- 1 | import type { LixEngine } from "../../../engine/boot.js"; 2 | 3 | /** 4 | * Updates the file data cache with materialized file content. 5 | * Used for write-through caching after file insert/update operations. 6 | * 7 | * @example 8 | * updateFileDataCache({ 9 | * engine: lix.engine!, 10 | * fileId: "file_123", 11 | * versionId: "version_456", 12 | * data: new Uint8Array([...]) 13 | * }); 14 | */ 15 | export function updateFileDataCache(args: { 16 | engine: Pick; 17 | fileId: string; 18 | versionId: string; 19 | data: Uint8Array; 20 | }): void { 21 | // Use INSERT OR REPLACE for write-through caching 22 | args.engine.sqlite.exec({ 23 | sql: ` 24 | INSERT OR REPLACE INTO lix_internal_file_data_cache 25 | (file_id, version_id, data) 26 | VALUES (?, ?, ?) 27 | `, 28 | bind: [args.fileId, args.versionId, args.data], 29 | returnValue: "resultRows", 30 | }); 31 | } 32 | -------------------------------------------------------------------------------- /src/filesystem/util/glob.ts: -------------------------------------------------------------------------------- 1 | import type { LixEngine } from "../../engine/boot.js"; 2 | 3 | /** 4 | * Tests whether a given path matches a SQLite `GLOB` pattern. 5 | * 6 | * Uses SQLite's matcher so plugin patterns behave identically in tests and in 7 | * the database. 8 | * 9 | * @example 10 | * ```ts 11 | * matchesGlob({ engine, path: "/docs/readme.md", pattern: "*.md" }); 12 | * ``` 13 | */ 14 | export function matchesGlob(args: { 15 | engine: Pick; 16 | pattern: string; 17 | path: string; 18 | }): boolean { 19 | const rows = args.engine.executeSync({ 20 | sql: `SELECT CASE WHEN ? GLOB ? THEN 1 ELSE 0 END AS matches`, 21 | parameters: [args.path, args.pattern], 22 | }).rows; 23 | 24 | const first = rows?.[0]; 25 | if (first == null) return false; 26 | if (typeof first === "object" && first !== null) { 27 | const value = (first as Record).matches; 28 | return value === 1 || value === "1"; 29 | } 30 | return first === 1 || first === "1"; 31 | } 32 | -------------------------------------------------------------------------------- /src/environment/kysely/kysely-driver.test.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from "vitest"; 2 | import { Kysely } from "kysely"; 3 | import { createEnvironmentDialect } from "../../database/sqlite/environment-dialect.js"; 4 | import { InMemoryEnvironment } from "../in-memory.js"; 5 | 6 | test("EngineDriver runs basic Kysely queries", async () => { 7 | const backend = new InMemoryEnvironment(); 8 | await backend.open({ 9 | boot: { args: { providePlugins: [] } }, 10 | emit: () => {}, 11 | }); 12 | 13 | const db = new Kysely({ 14 | dialect: createEnvironmentDialect({ environment: backend }), 15 | }); 16 | 17 | await db.executeQuery({ sql: "CREATE TABLE t(a)", parameters: [] } as any); 18 | await db.executeQuery({ 19 | sql: "INSERT INTO t(a) VALUES (?), (?)", 20 | parameters: [1, 2], 21 | } as any); 22 | 23 | const res = await db.executeQuery({ 24 | sql: "SELECT a FROM t ORDER BY a", 25 | parameters: [], 26 | } as any); 27 | expect((res.rows as any[]).length).toBe(2); 28 | expect((res.rows as any[])[0]?.a ?? (res.rows as any[])[0]?.[0]).toBe(1); 29 | }); 30 | -------------------------------------------------------------------------------- /src/services/env-variables/create-index-file.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-undef */ 2 | /** 3 | * This script writes public environment variables 4 | * to an importable env file. 5 | * 6 | * - The SDK must bundle this file with the rest of the SDK 7 | * - This scripts avoids the need for a bundler 8 | * - Must be ran before building the SDK 9 | */ 10 | 11 | import fs from "node:fs/promises"; 12 | import url from "node:url"; 13 | import path from "node:path"; 14 | 15 | const dirname = path.dirname(url.fileURLToPath(import.meta.url)); 16 | 17 | const packageJson = JSON.parse( 18 | await fs.readFile(path.resolve(dirname, "../../../package.json"), "utf-8") 19 | ); 20 | 21 | await fs.writeFile( 22 | dirname + "/index.ts", 23 | ` 24 | export const ENV_VARIABLES = { 25 | LIX_SDK_POSTHOG_TOKEN: ${ifDefined(process.env.LIX_SDK_POSTHOG_TOKEN)}, 26 | LIX_SDK_VERSION: ${ifDefined(packageJson.version)}, 27 | } 28 | ` 29 | ); 30 | 31 | // console.log("✅ Created env variable index file."); 32 | 33 | function ifDefined(value) { 34 | return value ? `"${value}"` : undefined; 35 | } 36 | -------------------------------------------------------------------------------- /examples/server-protocol-handler-hono/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # @lix-js/sdk-example-server-protocol-handler-hono 2 | 3 | ## 0.0.4 4 | 5 | ### Patch Changes 6 | 7 | - Updated dependencies [657bdc4] 8 | - Updated dependencies [0de2866] 9 | - Updated dependencies [48fac78] 10 | - Updated dependencies [03e746d] 11 | - @lix-js/sdk@0.2.0 12 | 13 | ## 0.0.3 14 | 15 | ### Patch Changes 16 | 17 | - Updated dependencies [85eb03e] 18 | - Updated dependencies [2d3ab95] 19 | - Updated dependencies [d78a1bf] 20 | - Updated dependencies [6b14433] 21 | - Updated dependencies [9f1765a] 22 | - Updated dependencies [c494dca] 23 | - Updated dependencies [4d9d980] 24 | - Updated dependencies [cc93bd9] 25 | - Updated dependencies [fc5a5dd] 26 | - Updated dependencies [8c4ac57] 27 | - Updated dependencies [8629faa] 28 | - Updated dependencies [de6d717] 29 | - Updated dependencies [be9effa] 30 | - Updated dependencies [b74e982] 31 | - Updated dependencies [5eecc61] 32 | - @lix-js/sdk@0.1.0 33 | 34 | ## 0.0.2 35 | 36 | ### Patch Changes 37 | 38 | - Updated dependencies [400db21] 39 | - @lix-js/sdk@0.0.1 40 | -------------------------------------------------------------------------------- /src/schema-definition/json-type.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, it } from "vitest"; 2 | import { isJsonType } from "./json-type.js"; 3 | 4 | it("detects type: 'object'", () => { 5 | expect(isJsonType({ type: "object" })).toBe(true); 6 | expect(isJsonType({ type: ["object", "null"] })).toBe(true); 7 | }); 8 | 9 | it("detects type: 'array'", () => { 10 | expect(isJsonType({ type: "array" })).toBe(true); 11 | expect(isJsonType({ type: ["array", "null"] })).toBe(true); 12 | }); 13 | 14 | it("detects anyOf with object or array", () => { 15 | expect(isJsonType({ anyOf: [{ type: "object" }, { type: "string" }] })).toBe( 16 | true 17 | ); 18 | expect(isJsonType({ anyOf: [{ type: "array" }, { type: "number" }] })).toBe( 19 | true 20 | ); 21 | expect(isJsonType({ anyOf: [{ type: "boolean" }, { type: "null" }] })).toBe( 22 | false 23 | ); 24 | }); 25 | 26 | it("returns false for non-json types", () => { 27 | expect(isJsonType({ type: "string" })).toBe(false); 28 | expect(isJsonType({ type: ["number", "null"] })).toBe(false); 29 | expect(isJsonType({})).toBe(false); 30 | }); 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Opral US Inc. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /src/change-proposal/reject-change-proposal.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from "vitest"; 2 | import { openLix } from "../lix/open-lix.js"; 3 | import { createVersion } from "../version/create-version.js"; 4 | import { createChangeProposal } from "./create-change-proposal.js"; 5 | import { rejectChangeProposal } from "./reject-change-proposal.js"; 6 | 7 | test("rejectChangeProposal updates base change_proposal status", async () => { 8 | const lix = await openLix({}); 9 | 10 | const main = await lix.db 11 | .selectFrom("version") 12 | .where("name", "=", "main") 13 | .selectAll() 14 | .executeTakeFirstOrThrow(); 15 | 16 | const stage = await createVersion({ 17 | lix, 18 | from: main, 19 | name: "cp_stage_reject_status", 20 | }); 21 | 22 | const cp = await createChangeProposal({ lix, source: stage, target: main }); 23 | 24 | await rejectChangeProposal({ lix, proposal: cp }); 25 | 26 | const updated = await lix.db 27 | .selectFrom("change_proposal") 28 | .where("id", "=", cp.id) 29 | .select(["status"]) 30 | .executeTakeFirst(); 31 | 32 | expect(updated).toBeDefined(); 33 | expect(updated?.status).toBe("rejected"); 34 | }); 35 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./account/index.js"; 2 | export * from "./change/index.js"; 3 | export * from "./change-author/index.js"; 4 | export * from "./environment/index.js"; 5 | export * from "./change-set/index.js"; 6 | export * from "./commit/index.js"; 7 | export * from "./database/index.js"; 8 | export * from "./engine/functions/index.js"; 9 | export * from "./entity/index.js"; 10 | export * from "./engine/entity-views/index.js"; 11 | export * from "./filesystem/index.js"; 12 | export * from "./hooks/create-hooks.js"; 13 | export * from "./key-value/index.js"; 14 | export * from "./label/index.js"; 15 | export * from "./lix/index.js"; 16 | export * from "./log/index.js"; 17 | export * from "./observe/index.js"; 18 | export * from "./plugin/index.js"; 19 | export * from "./query-filter/index.js"; 20 | export * from "./schema-definition/index.js"; 21 | export * from "./state/index.js"; 22 | export * from "./stored-schema/index.js"; 23 | export * from "./server-protocol-handler/index.js"; 24 | export * from "./conversation/index.js"; 25 | export * from "./change-proposal/index.js"; 26 | export * from "./version/index.js"; 27 | export * from "./diff/index.js"; 28 | -------------------------------------------------------------------------------- /src/server-protocol-handler/environment/environment.ts: -------------------------------------------------------------------------------- 1 | import type { Lix } from "../../lix/open-lix.js"; 2 | 3 | /** 4 | * Key value storage interface. 5 | */ 6 | export type LspEnvironment = { 7 | getLix(args: { id: string }): Promise; 8 | 9 | /** 10 | * If a lix exists on the server. 11 | */ 12 | hasLix(args: { id: string }): Promise; 13 | 14 | /** 15 | * Set's a lix. 16 | */ 17 | setLix(args: { id: string; blob: Blob }): Promise; 18 | 19 | /** 20 | * Opens a lix. 21 | * 22 | * The server will return a connection id that can be used to close the lix. 23 | */ 24 | openLix(args: { 25 | id: string; 26 | }): Promise<{ lix: Lix; id: string; connectionId: string }>; 27 | /** 28 | * Closes a lix. 29 | * 30 | * The connection id is returned when opening a lix. 31 | * 32 | * @example 33 | * const { lix, connectionId } = await openLix({ id: 'my-lix' }); 34 | * // do stuff with the lix 35 | * await closeLix({ connectionId }); 36 | */ 37 | closeLix(args: { id: string; connectionId: string }): Promise; 38 | }; 39 | 40 | // For backward compatibility 41 | export type LsaEnvironment = LspEnvironment; 42 | -------------------------------------------------------------------------------- /src/version/switch-version.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from "vitest"; 2 | import { openLix } from "../lix/open-lix.js"; 3 | import { switchVersion } from "./switch-version.js"; 4 | import { createVersion } from "./create-version.js"; 5 | 6 | test("switching versiones should update the active_version", async () => { 7 | const lix = await openLix({}); 8 | 9 | const activeVersion = await lix.db 10 | .selectFrom("active_version") 11 | .innerJoin("version", "active_version.version_id", "version.id") 12 | .selectAll("version") 13 | .executeTakeFirstOrThrow(); 14 | 15 | const newVersion = await lix.db.transaction().execute(async (trx) => { 16 | const newVersion = await createVersion({ 17 | lix: { ...lix, db: trx }, 18 | from: activeVersion, 19 | }); 20 | await switchVersion({ lix: { ...lix, db: trx }, to: newVersion }); 21 | return newVersion; 22 | }); 23 | 24 | const activeVersionAfterSwitch = await lix.db 25 | .selectFrom("active_version") 26 | .innerJoin("version", "active_version.version_id", "version.id") 27 | .selectAll("version") 28 | .executeTakeFirstOrThrow(); 29 | 30 | expect(activeVersionAfterSwitch.id).toBe(newVersion?.id); 31 | }); 32 | -------------------------------------------------------------------------------- /src/change-author/schema-definition.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | FromLixSchemaDefinition, 3 | LixSchemaDefinition, 4 | } from "../schema-definition/definition.js"; 5 | 6 | export type LixChangeAuthor = FromLixSchemaDefinition< 7 | typeof LixChangeAuthorSchema 8 | >; 9 | 10 | export const LixChangeAuthorSchema = { 11 | "x-lix-key": "lix_change_author", 12 | "x-lix-version": "1.0", 13 | "x-lix-primary-key": ["/change_id", "/account_id"], 14 | "x-lix-override-lixcols": { 15 | lixcol_file_id: '"lix"', 16 | lixcol_plugin_key: '"lix_sdk"', 17 | lixcol_version_id: '"global"', 18 | }, 19 | "x-lix-foreign-keys": [ 20 | { 21 | properties: ["/change_id"], 22 | references: { 23 | schemaKey: "lix_change", 24 | properties: ["/id"], 25 | }, 26 | }, 27 | { 28 | properties: ["/account_id"], 29 | references: { 30 | schemaKey: "lix_account", 31 | properties: ["/id"], 32 | }, 33 | }, 34 | ], 35 | type: "object", 36 | properties: { 37 | change_id: { type: "string" }, 38 | account_id: { type: "string" }, 39 | }, 40 | required: ["change_id", "account_id"], 41 | additionalProperties: false, 42 | } as const; 43 | LixChangeAuthorSchema satisfies LixSchemaDefinition; 44 | -------------------------------------------------------------------------------- /src/filesystem/file/cache/schema.ts: -------------------------------------------------------------------------------- 1 | import type { Selectable } from "kysely"; 2 | import type { LixEngine } from "../../../engine/boot.js"; 3 | 4 | /** 5 | * Applies the file data cache schema to the database. 6 | * 7 | * The cache stores materialized file data to avoid repeated 8 | * plugin processing and change aggregation. 9 | * 10 | * @example 11 | * applyFileDataCacheSchema(lix); 12 | */ 13 | export function applyFileDataCacheSchema(args: { 14 | engine: Pick; 15 | }): void { 16 | args.engine.sqlite.exec(` 17 | CREATE TABLE IF NOT EXISTS lix_internal_file_data_cache ( 18 | file_id TEXT NOT NULL, 19 | version_id TEXT NOT NULL, 20 | data BLOB NOT NULL, 21 | PRIMARY KEY (file_id, version_id) 22 | ) strict; 23 | 24 | -- Index for fast version_id filtering 25 | CREATE INDEX IF NOT EXISTS idx_lix_internal_file_data_cache_version_id 26 | ON lix_internal_file_data_cache (version_id); 27 | `); 28 | } 29 | 30 | export type InternalFileDataCacheRow = Selectable; 31 | 32 | export type InternalFileDataCacheTable = { 33 | file_id: string; 34 | version_id: string; 35 | data: Uint8Array; 36 | }; 37 | -------------------------------------------------------------------------------- /src/filesystem/file/cache/update-file-path-cache.ts: -------------------------------------------------------------------------------- 1 | import type { LixEngine } from "../../../engine/boot.js"; 2 | 3 | /** 4 | * Inserts or updates a file path cache entry. 5 | * 6 | * @example 7 | * updateFilePathCache({ 8 | * engine: lix.engine!, 9 | * fileId: "file_1", 10 | * versionId: "global", 11 | * directoryId: "dir_1", 12 | * name: "index", 13 | * extension: "ts", 14 | * path: "/src/index.ts" 15 | * }); 16 | */ 17 | export function updateFilePathCache(args: { 18 | engine: Pick; 19 | fileId: string; 20 | versionId: string; 21 | directoryId: string | null; 22 | name: string; 23 | extension: string | null; 24 | path: string; 25 | }): void { 26 | args.engine.sqlite.exec({ 27 | sql: ` 28 | INSERT OR REPLACE INTO lix_internal_file_path_cache ( 29 | file_id, 30 | version_id, 31 | directory_id, 32 | name, 33 | extension, 34 | path 35 | ) VALUES (?, ?, ?, ?, ?, ?) 36 | `, 37 | bind: [ 38 | args.fileId, 39 | args.versionId, 40 | args.directoryId, 41 | args.name, 42 | args.extension, 43 | args.path, 44 | ], 45 | returnValue: "resultRows", 46 | }); 47 | } 48 | -------------------------------------------------------------------------------- /src/snapshot/schema.ts: -------------------------------------------------------------------------------- 1 | import type { SqliteWasmDatabase } from "../database/sqlite/index.js"; 2 | import type { Generated } from "kysely"; 3 | 4 | export function applySnapshotDatabaseSchema( 5 | sqlite: SqliteWasmDatabase 6 | ): SqliteWasmDatabase { 7 | return sqlite.exec(` 8 | CREATE TABLE IF NOT EXISTS lix_internal_snapshot ( 9 | id TEXT PRIMARY KEY DEFAULT (lix_uuid_v7()), 10 | content BLOB -- jsonb, 11 | 12 | -- 8 = strictly JSONB 13 | -- https://www.sqlite.org/json1.html#jvalid 14 | CHECK (json_valid(content, 8)), 15 | 16 | -- Ensure content is either NULL or a JSON object (not string, array, etc) 17 | -- This prevents double-stringified JSON from being stored 18 | CHECK (content IS NULL OR json_type(content) = 'object') 19 | ) STRICT; 20 | 21 | INSERT OR IGNORE INTO lix_internal_snapshot (id, content) 22 | VALUES ('no-content', NULL); 23 | 24 | -- Index on id (explicit, though PRIMARY KEY already provides one) 25 | CREATE INDEX IF NOT EXISTS idx_lix_internal_snapshot_id 26 | ON lix_internal_snapshot(id); 27 | `); 28 | } 29 | 30 | export type InternalSnapshotTable = { 31 | id: Generated; 32 | content: Record | null; 33 | }; 34 | -------------------------------------------------------------------------------- /src/server-protocol-handler/routes/new-v1.ts: -------------------------------------------------------------------------------- 1 | import { openLix } from "../../lix/open-lix.js"; 2 | import type { Lix } from "../../lix/open-lix.js"; 3 | import type { LixServerProtocolHandlerRoute } from "../create-server-protocol-handler.js"; 4 | 5 | export const route: LixServerProtocolHandlerRoute = async (context) => { 6 | const blob = await context.request.blob(); 7 | 8 | let lix: Lix; 9 | 10 | try { 11 | lix = await openLix({ 12 | blob, 13 | // turn off sync for server 14 | keyValues: [{ key: "lix_sync", value: "false" }], 15 | }); 16 | } catch { 17 | return new Response(null, { 18 | status: 400, 19 | }); 20 | } 21 | 22 | const lixId = await lix.db 23 | .selectFrom("key_value") 24 | .where("key", "=", "lix_id") 25 | .selectAll() 26 | .executeTakeFirstOrThrow(); 27 | 28 | const exists = await context.environment.hasLix({ id: lixId.value }); 29 | 30 | if (exists) { 31 | return new Response(null, { 32 | status: 409, 33 | }); 34 | } 35 | 36 | await context.environment.setLix({ id: lixId.value, blob }); 37 | 38 | return new Response(JSON.stringify({ id: lixId.value }), { 39 | status: 201, 40 | headers: { 41 | "Content-Type": "application/json", 42 | }, 43 | }); 44 | }; 45 | -------------------------------------------------------------------------------- /src/database/sqlite/lix-dialect.ts: -------------------------------------------------------------------------------- 1 | import { SqliteAdapter, SqliteIntrospector, SqliteQueryCompiler } from "kysely"; 2 | import { SqliteWasmDriver } from "./kysely-driver/sqlite-wasm-driver.js"; 3 | import type { SqliteWasmDatabase } from "./create-in-memory-database.js"; 4 | 5 | /** 6 | * Creates a Kysely dialect that talks to a WebAssembly powered SQLite database. 7 | * 8 | * Dialects configure how Kysely executes queries under the hood. The returned 9 | * dialect wires up the WebAssembly driver, adapter, and introspector so we can 10 | * use a browser-first SQLite database transparently throughout the SDK. 11 | * 12 | * @example 13 | * const db = await createInMemoryDatabase({ readOnly: false }); 14 | * const dialect = createLixDialect({ sqlite: db }); 15 | */ 16 | export const createLixDialect = (args: { sqlite: SqliteWasmDatabase }) => { 17 | return { 18 | createAdapter: (): SqliteAdapter => new SqliteAdapter(), 19 | createDriver: (): SqliteWasmDriver => 20 | new SqliteWasmDriver({ 21 | database: args.sqlite, 22 | }), 23 | createIntrospector: (db: any): SqliteIntrospector => 24 | new SqliteIntrospector(db), 25 | createQueryCompiler: (): SqliteQueryCompiler => new SqliteQueryCompiler(), 26 | }; 27 | }; 28 | -------------------------------------------------------------------------------- /.oxlintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "ignore": [ 3 | "dist/**", 4 | "build/**", 5 | "coverage/**", 6 | "**/*.d.ts", 7 | "eslint.config.js" 8 | ], 9 | "rules": { 10 | /** 11 | * CRITICAL: Prevents unhandled promises which can cause: 12 | * 1. Database operations executing out of order → race conditions & data corruption 13 | * 2. Unhandled rejections → crashes or inconsistent state 14 | * 3. Transaction integrity violations 15 | * 16 | * How to fix: 17 | * - await someAsyncOperation() - Preferred for DB operations 18 | * - void someAsyncOperation() - Intentional fire-and-forget (see below) 19 | * - someAsyncOperation().catch() - When you need error handling 20 | * 21 | * Using void: 22 | * void is appropriate for async operations where: 23 | * - No database operations are performed 24 | * - Order doesn't matter (e.g., analytics, telemetry) 25 | * - Blocking would hurt UX (e.g., server fetch for non-critical data) 26 | * 27 | * Example: void captureAnalytics(...) // Won't block user actions 28 | * 29 | * ⚠️ ALWAYS await database operations to guarantee execution order! 30 | */ 31 | "@typescript-eslint/no-floating-promises": "error", 32 | "typescript/no-floating-promises": "error" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/version/switch-version.ts: -------------------------------------------------------------------------------- 1 | import type { Lix } from "../lix/open-lix.js"; 2 | import type { LixVersion } from "./schema-definition.js"; 3 | 4 | /** 5 | * Switches the current Version to the given Version. 6 | * 7 | * The Version must already exist before calling this function. 8 | * 9 | * @example 10 | * ```ts 11 | * await switchVersion({ lix, to: otherVersion }); 12 | * ``` 13 | * 14 | * @example 15 | * Switching to a newly created version. 16 | * 17 | * ```ts 18 | * await lix.db.transaction().execute(async (trx) => { 19 | * const newVersion = await createVersion({ lix: { db: trx }, commit: currentVersion }); 20 | * await switchVersion({ lix: { db: trx }, to: newVersion }); 21 | * }); 22 | * ``` 23 | */ 24 | export async function switchVersion(args: { 25 | lix: Lix; 26 | to: Pick; 27 | }): Promise { 28 | const executeInTransaction = async (trx: Lix["db"]) => { 29 | await trx 30 | .updateTable("active_version") 31 | .set({ version_id: args.to.id }) 32 | .execute(); 33 | }; 34 | 35 | if (args.lix.db.isTransaction) { 36 | return executeInTransaction(args.lix.db); 37 | } else { 38 | return args.lix.db.transaction().execute(executeInTransaction); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/services/telemetry/capture.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test, vi } from "vitest"; 2 | import { capture } from "./capture.js"; 3 | 4 | test("it should not capture if telemetry is off", async () => { 5 | vi.stubGlobal("fetch", vi.fn(() => Promise.resolve(new Response())) as any); 6 | 7 | vi.mock("../env-variables/index.js", async () => { 8 | return { 9 | ENV_VARIABLES: { 10 | LIX_SDK_POSTHOG_TOKEN: "mock-defined", 11 | }, 12 | }; 13 | }); 14 | 15 | await capture("LIX-SDK lix opened", { 16 | lixId: "test", 17 | accountId: "test", 18 | telemetryKeyValue: "off", 19 | properties: {}, 20 | }); 21 | 22 | expect((globalThis as any).fetch).not.toHaveBeenCalled(); 23 | }); 24 | 25 | test("it should not capture if telemetry is NOT off", async () => { 26 | vi.stubGlobal("fetch", vi.fn(() => Promise.resolve(new Response())) as any); 27 | 28 | vi.mock("../env-variables/index.js", async () => { 29 | return { 30 | ENV_VARIABLES: { 31 | LIX_SDK_POSTHOG_TOKEN: "mock-defined", 32 | }, 33 | }; 34 | }); 35 | 36 | await capture("LIX-SDK lix opened", { 37 | lixId: "test", 38 | accountId: "test", 39 | telemetryKeyValue: "on", 40 | properties: {}, 41 | }); 42 | 43 | expect((globalThis as any).fetch).toHaveBeenCalled(); 44 | }); 45 | -------------------------------------------------------------------------------- /src/filesystem/directory/schema-definition.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | FromLixSchemaDefinition, 3 | LixSchemaDefinition, 4 | } from "../../schema-definition/definition.js"; 5 | 6 | export type LixDirectoryDescriptor = FromLixSchemaDefinition< 7 | typeof LixDirectoryDescriptorSchema 8 | >; 9 | 10 | export const LixDirectoryDescriptorSchema = { 11 | "x-lix-key": "lix_directory_descriptor", 12 | "x-lix-version": "1.0", 13 | "x-lix-primary-key": ["/id"], 14 | "x-lix-unique": [["/parent_id", "/name"]], 15 | "x-lix-override-lixcols": { 16 | lixcol_file_id: '"lix"', 17 | lixcol_plugin_key: '"lix_sdk"', 18 | }, 19 | type: "object", 20 | properties: { 21 | id: { 22 | type: "string", 23 | "x-lix-default": "lix_uuid_v7()", 24 | }, 25 | parent_id: { 26 | type: "string", 27 | nullable: true, 28 | description: 29 | "Identifier of the parent directory. Null indicates the virtual root directory.", 30 | }, 31 | name: { 32 | type: "string", 33 | pattern: "^[^/\\\\]+$", 34 | description: "Directory segment without slashes.", 35 | }, 36 | hidden: { 37 | type: "boolean", 38 | "x-lix-default": "false", 39 | }, 40 | }, 41 | required: ["id", "parent_id", "name"], 42 | additionalProperties: false, 43 | } as const; 44 | LixDirectoryDescriptorSchema satisfies LixSchemaDefinition; 45 | -------------------------------------------------------------------------------- /src/log/schema-definition.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | FromLixSchemaDefinition, 3 | LixSchemaDefinition, 4 | } from "../schema-definition/definition.js"; 5 | import { JSONTypeSchema } from "../schema-definition/json-type.js"; 6 | 7 | export type LixLog = FromLixSchemaDefinition; 8 | 9 | export const LixLogSchema = { 10 | "x-lix-key": "lix_log", 11 | "x-lix-version": "1.0", 12 | "x-lix-primary-key": ["/id"], 13 | "x-lix-override-lixcols": { 14 | lixcol_file_id: '"lix"', 15 | lixcol_plugin_key: '"lix_sdk"', 16 | }, 17 | type: "object", 18 | properties: { 19 | id: { 20 | type: "string", 21 | description: "The unique identifier of the log entry", 22 | "x-lix-default": "lix_uuid_v7()", 23 | }, 24 | key: { 25 | type: "string", 26 | description: "The key of the log entry", 27 | }, 28 | message: { 29 | type: "string", 30 | description: "The message of the log entry", 31 | nullable: true, 32 | }, 33 | payload: { 34 | ...JSONTypeSchema, 35 | description: "Structured payload for the log entry", 36 | } as any, 37 | level: { 38 | type: "string", 39 | description: "The level of the log entry", 40 | }, 41 | }, 42 | required: ["id", "key", "level"], 43 | additionalProperties: false, 44 | } as const; 45 | LixLogSchema satisfies LixSchemaDefinition; 46 | -------------------------------------------------------------------------------- /src/environment/api.ts: -------------------------------------------------------------------------------- 1 | export type EnvironmentActorHandle = { 2 | post(message: unknown, transfer?: Transferable[]): void; 3 | subscribe(listener: (message: unknown) => void): () => void; 4 | terminate(): Promise; 5 | }; 6 | 7 | export type SpawnActorOptions = { 8 | entryModule: string; 9 | name?: string; 10 | initialMessage?: unknown; 11 | transfer?: Transferable[]; 12 | }; 13 | 14 | export interface LixEnvironment { 15 | open(opts: { 16 | boot: { args: import("../engine/boot.js").BootArgs }; 17 | emit: (ev: import("../engine/boot.js").EngineEvent) => void; 18 | }): Promise<{ engine?: import("../engine/boot.js").LixEngine }>; 19 | 20 | create(opts: { blob: ArrayBuffer }): Promise; 21 | 22 | exists(): Promise; 23 | 24 | export(): Promise; 25 | 26 | close(): Promise; 27 | 28 | /** 29 | * Invoke an engine function inside the environment. 30 | * 31 | * Environments MUST implement this and route the call to the engine that 32 | * booted next to SQLite, regardless of whether the engine runs on the main 33 | * thread or inside a Worker. The SQL driver uses the "lix_exec_sync" route to 34 | * execute compiled statements. 35 | */ 36 | call: import("../engine/functions/function-registry.js").Call; 37 | 38 | spawnActor?: (opts: SpawnActorOptions) => Promise; 39 | } 40 | -------------------------------------------------------------------------------- /src/change-proposal/schema-definition.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | FromLixSchemaDefinition, 3 | LixSchemaDefinition, 4 | } from "../schema-definition/definition.js"; 5 | 6 | export type LixChangeProposal = FromLixSchemaDefinition< 7 | typeof LixChangeProposalSchema 8 | >; 9 | 10 | export const LixChangeProposalSchema = { 11 | "x-lix-key": "lix_change_proposal", 12 | "x-lix-version": "1.0", 13 | "x-lix-primary-key": ["/id"], 14 | "x-lix-override-lixcols": { 15 | lixcol_file_id: '"lix"', 16 | lixcol_plugin_key: '"lix_sdk"', 17 | lixcol_version_id: '"global"', 18 | }, 19 | "x-lix-foreign-keys": [ 20 | { 21 | properties: ["/source_version_id"], 22 | references: { schemaKey: "lix_version_descriptor", properties: ["/id"] }, 23 | }, 24 | { 25 | properties: ["/target_version_id"], 26 | references: { schemaKey: "lix_version_descriptor", properties: ["/id"] }, 27 | }, 28 | ], 29 | type: "object", 30 | properties: { 31 | id: { 32 | type: "string", 33 | "x-lix-default": "lix_uuid_v7()", 34 | }, 35 | source_version_id: { type: "string" }, 36 | target_version_id: { type: "string" }, 37 | status: { type: "string", "x-lix-default": "'open'" }, 38 | }, 39 | required: ["id", "source_version_id", "target_version_id", "status"], 40 | additionalProperties: false, 41 | } as const; 42 | LixChangeProposalSchema satisfies LixSchemaDefinition; 43 | -------------------------------------------------------------------------------- /src/account/switch-account.ts: -------------------------------------------------------------------------------- 1 | import type { Lix } from "../lix/open-lix.js"; 2 | import type { LixAccount } from "./schema-definition.js"; 3 | 4 | /** 5 | * Switch the current account to the provided account. 6 | * 7 | * @example 8 | * 9 | * One active account 10 | * 11 | * ```ts 12 | * await switchAccount({ lix, to: [otherAccount] }); 13 | * ``` 14 | * 15 | * @example 16 | * 17 | * Multiple active accounts 18 | * 19 | * ```ts 20 | * await switchAccount({ lix, to: [account1, account2] }); 21 | * ``` 22 | */ 23 | export async function switchAccount(args: { 24 | lix: Pick; 25 | to: Pick[]; 26 | }): Promise { 27 | const executeInTransaction = async (trx: Lix["db"]) => { 28 | // Delete all active account entries (both tracked and untracked) 29 | await trx.deleteFrom("active_account").execute(); 30 | 31 | // insert the new account id into the current_account table 32 | // active_account view only has account_id column 33 | await trx 34 | .insertInto("active_account") 35 | .values( 36 | args.to.map((account) => ({ 37 | account_id: account.id, 38 | })) 39 | ) 40 | .execute(); 41 | }; 42 | 43 | if (args.lix.db.isTransaction) { 44 | return executeInTransaction(args.lix.db); 45 | } else { 46 | return args.lix.db.transaction().execute(executeInTransaction); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/database/sqlite/import-database.ts: -------------------------------------------------------------------------------- 1 | import type { SqliteWasmDatabase } from "./create-in-memory-database.js"; 2 | 3 | /** 4 | * Loads serialized SQLite bytes into an in-memory database. 5 | * 6 | * The content is copied into the provided database and made available under 7 | * the configured schema. By default the database becomes writable, but you can 8 | * force read-only mode through the `readOnly` option. 9 | * 10 | * @example 11 | * importDatabase({ db, content: new Uint8Array(bytes) }); 12 | */ 13 | export const importDatabase = ({ 14 | db, 15 | content, 16 | schema = "main", 17 | readOnly = false, 18 | }: { 19 | db: SqliteWasmDatabase; 20 | content: Uint8Array; 21 | schema?: string; 22 | readOnly?: boolean; 23 | }): SqliteWasmDatabase => { 24 | const deserializeFlag = readOnly 25 | ? db.sqlite3.capi.SQLITE_DESERIALIZE_READONLY 26 | : db.sqlite3.capi.SQLITE_DESERIALIZE_FREEONCLOSE | 27 | db.sqlite3.capi.SQLITE_DESERIALIZE_RESIZEABLE; 28 | 29 | const contentPointer = db.sqlite3.wasm.allocFromTypedArray(content); 30 | const deserializeReturnCode = db.sqlite3.capi.sqlite3_deserialize( 31 | db.pointer!, 32 | schema, 33 | contentPointer, 34 | content.byteLength, // db size 35 | content.byteLength, // content size 36 | deserializeFlag 37 | ); 38 | 39 | // check if the deserialization was successfull 40 | db.checkRc(deserializeReturnCode); 41 | 42 | return db; 43 | }; 44 | -------------------------------------------------------------------------------- /src/engine/execute-sync.ts: -------------------------------------------------------------------------------- 1 | import type { LixEngine } from "./boot.js"; 2 | 3 | export function createExecuteSync(args: { 4 | engine: Pick< 5 | LixEngine, 6 | "sqlite" | "hooks" | "runtimeCacheRef" | "preprocessQuery" 7 | >; 8 | }): LixEngine["executeSync"] { 9 | const executeSyncFn: LixEngine["executeSync"] = (args2) => { 10 | const mode = args2.preprocessMode ?? "full"; 11 | const preprocessed = 12 | mode === "none" 13 | ? { 14 | sql: args2.sql, 15 | parameters: (args2.parameters as ReadonlyArray) ?? [], 16 | } 17 | : args.engine.preprocessQuery({ 18 | sql: args2.sql, 19 | parameters: (args2.parameters as ReadonlyArray) ?? [], 20 | mode, 21 | }); 22 | 23 | const columnNames: string[] = []; 24 | try { 25 | const rows = args.engine.sqlite.exec({ 26 | sql: preprocessed.sql, 27 | bind: preprocessed.parameters as any[], 28 | returnValue: "resultRows", 29 | rowMode: "object", 30 | columnNames, 31 | }); 32 | return { rows }; 33 | } catch (error) { 34 | const enriched = 35 | error instanceof Error ? error : new Error(String(error)); 36 | const debugPayload = { 37 | rewrittenSql: preprocessed.sql, 38 | originalSql: args2.sql, 39 | parameters: preprocessed.parameters, 40 | }; 41 | Object.assign(enriched, debugPayload); 42 | throw enriched; 43 | } 44 | }; 45 | 46 | return executeSyncFn; 47 | } 48 | -------------------------------------------------------------------------------- /src/engine/deterministic-mode/options.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | FromLixSchemaDefinition, 3 | LixInsertable, 4 | LixSchemaDefinition, 5 | } from "../../schema-definition/index.js"; 6 | 7 | /** 8 | * Schema definition for deterministic mode options. 9 | * 10 | * @example 11 | * const options = { 12 | * enabled: true, 13 | * randomLixId: false, 14 | * timestamp: true, 15 | * random_seed: "default-seed", 16 | * nano_id: true, 17 | * uuid_v7: true 18 | * }; 19 | */ 20 | export const LixDeterministicModeOptionsSchema = { 21 | "x-lix-key": "lix_deterministic_mode", 22 | "x-lix-version": "1.0", 23 | type: "object", 24 | properties: { 25 | enabled: { 26 | type: "boolean", 27 | }, 28 | randomLixId: { 29 | type: "boolean", 30 | "x-lix-default": "false", 31 | }, 32 | timestamp: { 33 | type: "boolean", 34 | "x-lix-default": "true", 35 | }, 36 | random_seed: { 37 | type: "string", 38 | "x-lix-default": "'lix-deterministic-seed'", 39 | }, 40 | nano_id: { 41 | type: "boolean", 42 | "x-lix-default": "true", 43 | }, 44 | uuid_v7: { 45 | type: "boolean", 46 | "x-lix-default": "true", 47 | }, 48 | }, 49 | required: ["enabled"], 50 | additionalProperties: false, 51 | } as const; 52 | 53 | export type DeterministicModeOptions = LixInsertable< 54 | FromLixSchemaDefinition 55 | >; 56 | 57 | LixDeterministicModeOptionsSchema satisfies LixSchemaDefinition; 58 | -------------------------------------------------------------------------------- /src/database/nano-id.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test, vi } from "vitest"; 2 | import { _nanoIdAlphabet, randomNanoId } from "./nano-id.js"; 3 | 4 | test("length is obeyed", () => { 5 | const id = randomNanoId(10); 6 | expect(id.length).toBe(10); 7 | }); 8 | 9 | test("the alphabet does not contain underscores `_` because they are not URL safe", () => { 10 | expect(_nanoIdAlphabet).not.toContain("_"); 11 | }); 12 | 13 | test("the alphabet does not contain dashes `-` because they break selecting the ID from the URL in the browser", () => { 14 | expect(_nanoIdAlphabet).not.toContain("-"); 15 | }); 16 | 17 | /** 18 | * Test the crypto polyfill that was added to fix environments without crypto support 19 | * like older versions of Node in Stackblitz. 20 | * 21 | * See: https://github.com/opral/lix-sdk/issues/258 22 | */ 23 | test("polyfill works when crypto is not available", () => { 24 | // Store original crypto 25 | const originalCrypto = globalThis.crypto as any; 26 | 27 | // Mock crypto as undefined to test the polyfill 28 | vi.stubGlobal("crypto", undefined); 29 | 30 | try { 31 | const id = randomNanoId(10); 32 | expect(id.length).toBe(10); 33 | expect(typeof id).toBe("string"); 34 | // Check that it only contains characters from our alphabet 35 | for (let i = 0; i < id.length; i++) { 36 | expect(_nanoIdAlphabet).toContain(id[i]); 37 | } 38 | } finally { 39 | // Restore original crypto 40 | vi.stubGlobal("crypto", originalCrypto); 41 | } 42 | }); 43 | -------------------------------------------------------------------------------- /src/engine/internal-query-builder.ts: -------------------------------------------------------------------------------- 1 | import { 2 | DummyDriver, 3 | Kysely, 4 | SqliteAdapter, 5 | SqliteIntrospector, 6 | SqliteQueryCompiler, 7 | } from "kysely"; 8 | import type { LixInternalDatabaseSchema } from "../database/schema.js"; 9 | import { createDefaultPlugins } from "../database/kysely/plugins.js"; 10 | 11 | /** 12 | * Cold Kysely instance wired to the SQLite dialect for compiling queries against 13 | * the internal schema without holding a live database connection. 14 | * 15 | * Pair this with `engine.executeSync` when you need synchronous access to 16 | * internal views: 17 | * 18 | * @example 19 | * ```ts 20 | * const [config] = engine.executeSync( 21 | * internalQueryBuilder 22 | * .selectFrom("lix_internal_state_vtable") 23 | * .where("entity_id", "=", "lix_deterministic_mode") 24 | * .where("schema_key", "=", "lix_key_value") 25 | * .where("snapshot_content", "is not", null) 26 | * .select( 27 | * sql`json_extract(snapshot_content, '$.value.nano_id')`.as("nano_id") 28 | * ) 29 | * .compile() 30 | * ).rows; 31 | * ``` 32 | */ 33 | export const internalQueryBuilder: Kysely = 34 | new Kysely({ 35 | dialect: { 36 | createAdapter: () => new SqliteAdapter(), 37 | createDriver: () => new DummyDriver(), 38 | createIntrospector: (db) => new SqliteIntrospector(db), 39 | createQueryCompiler: () => new SqliteQueryCompiler(), 40 | }, 41 | plugins: [...createDefaultPlugins()], 42 | }); 43 | -------------------------------------------------------------------------------- /src/entity/label/schema-definition.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | FromLixSchemaDefinition, 3 | LixSchemaDefinition, 4 | } from "../../schema-definition/definition.js"; 5 | 6 | export type LixEntityLabel = FromLixSchemaDefinition< 7 | typeof LixEntityLabelSchema 8 | >; 9 | 10 | export const LixEntityLabelSchema = { 11 | "x-lix-key": "lix_entity_label", 12 | "x-lix-version": "1.0", 13 | "x-lix-primary-key": ["/entity_id", "/schema_key", "/file_id", "/label_id"], 14 | "x-lix-override-lixcols": { 15 | lixcol_file_id: '"lix"', 16 | lixcol_plugin_key: '"lix_sdk"', 17 | }, 18 | "x-lix-foreign-keys": [ 19 | { 20 | properties: ["/entity_id", "/schema_key", "/file_id"], 21 | references: { 22 | schemaKey: "state", 23 | properties: ["/entity_id", "/schema_key", "/file_id"], 24 | }, 25 | // Labels live in the shared "lix" file but reference entities in 26 | // whatever file they originally came from, so we scope only by version. 27 | scope: ["version_id"], 28 | }, 29 | { 30 | properties: ["/label_id"], 31 | references: { 32 | schemaKey: "lix_label", 33 | properties: ["/id"], 34 | }, 35 | }, 36 | ], 37 | type: "object", 38 | properties: { 39 | entity_id: { type: "string" }, 40 | schema_key: { type: "string" }, 41 | file_id: { type: "string" }, 42 | label_id: { type: "string" }, 43 | }, 44 | required: ["entity_id", "schema_key", "file_id", "label_id"], 45 | additionalProperties: false, 46 | } as const; 47 | LixEntityLabelSchema satisfies LixSchemaDefinition; 48 | -------------------------------------------------------------------------------- /src/state/cache-v2/schema-metadata.ts: -------------------------------------------------------------------------------- 1 | export type CacheSchemaPropertyValueKind = 2 | | "string" 3 | | "number" 4 | | "integer" 5 | | "boolean" 6 | | "json"; 7 | 8 | export interface CacheSchemaPropertyMetadata { 9 | propertyName: string; 10 | columnName: string; 11 | valueKind: CacheSchemaPropertyValueKind; 12 | } 13 | 14 | const schemaPropertyMetadata = new Map(); 15 | 16 | function metadataKey(schemaKey: string, schemaVersion: string): string { 17 | return `${schemaKey}::${schemaVersion}`; 18 | } 19 | 20 | export function registerStateCacheSchemaProperties(args: { 21 | schemaKey: string; 22 | schemaVersion: string; 23 | properties: CacheSchemaPropertyMetadata[]; 24 | }): void { 25 | const key = metadataKey(args.schemaKey, args.schemaVersion); 26 | // Store a copy to avoid accidental external mutation 27 | schemaPropertyMetadata.set(key, [...args.properties]); 28 | } 29 | 30 | export function getStateCacheSchemaProperties(args: { 31 | schemaKey: string; 32 | schemaVersion: string; 33 | }): CacheSchemaPropertyMetadata[] | undefined { 34 | const key = metadataKey(args.schemaKey, args.schemaVersion); 35 | const properties = schemaPropertyMetadata.get(key); 36 | return properties ? [...properties] : undefined; 37 | } 38 | 39 | export function clearStateCacheSchemaProperties(args: { 40 | schemaKey: string; 41 | schemaVersion: string; 42 | }): void { 43 | schemaPropertyMetadata.delete( 44 | metadataKey(args.schemaKey, args.schemaVersion) 45 | ); 46 | } 47 | -------------------------------------------------------------------------------- /src/engine/entity-views/build-json-object-entries.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from "vitest"; 2 | import { buildJsonObjectEntries } from "./build-json-object-entries.js"; 3 | import type { LixSchemaDefinition } from "../../schema-definition/definition.js"; 4 | 5 | const TestSchema = { 6 | "x-lix-key": "test_schema", 7 | "x-lix-version": "1.0", 8 | "x-lix-primary-key": ["/id"], 9 | type: "object", 10 | properties: { 11 | id: { type: "string" }, 12 | name: { type: "string" }, 13 | version: { type: "string" }, 14 | count: { type: "number" }, 15 | flag: { type: "boolean" }, 16 | data: { type: "object" }, 17 | arr: { type: "array" }, 18 | }, 19 | additionalProperties: false, 20 | } as const satisfies LixSchemaDefinition; 21 | 22 | test("uses json_quote for string fields to prevent coercion", () => { 23 | const sql = buildJsonObjectEntries({ 24 | schema: TestSchema, 25 | ref: (p) => `NEW.${p}`, 26 | }); 27 | expect(sql).toContain("'name', json_quote(NEW.name)"); 28 | expect(sql).toContain("'version', json_quote(NEW.version)"); 29 | }); 30 | 31 | test("uses json/json_quote combo for object/array fields", () => { 32 | const sql = buildJsonObjectEntries({ 33 | schema: TestSchema, 34 | ref: (p) => `NEW.${p}`, 35 | }); 36 | expect(sql).toContain( 37 | "'data', CASE WHEN json_valid(NEW.data) THEN json(NEW.data) ELSE json_quote(NEW.data) END" 38 | ); 39 | expect(sql).toContain( 40 | "'arr', CASE WHEN json_valid(NEW.arr) THEN json(NEW.arr) ELSE json_quote(NEW.arr) END" 41 | ); 42 | }); 43 | -------------------------------------------------------------------------------- /.github/workflows/readme-sync.yml: -------------------------------------------------------------------------------- 1 | name: Readme Sync 2 | 3 | on: 4 | workflow_dispatch: # Allows manual trigger 5 | 6 | jobs: 7 | sync-readme: 8 | runs-on: ubuntu-latest 9 | 10 | steps: 11 | - name: Checkout Repository 12 | uses: actions/checkout@v4 13 | 14 | - name: Fetch README from Monorepo 15 | run: | 16 | curl -o README.tmp https://raw.githubusercontent.com/opral/monorepo/main/packages/lix-sdk/README.md 17 | echo -e "> [!NOTE]\n> This repository serves as an issue tracker. The readme is mirrored from, and the source code is at [monorepo](https://github.com/opral/monorepo/tree/main/packages/lix-sdk). Make pull requests to the monorepo.\n\n$(cat README.tmp)" > README.md 18 | rm README.tmp 19 | 20 | - name: Check for Changes 21 | id: check_changes 22 | run: | 23 | if git diff --quiet README.md; then 24 | echo "No changes detected." 25 | echo "changed=false" >> $GITHUB_ENV 26 | else 27 | echo "Changes detected." 28 | echo "changed=true" >> $GITHUB_ENV 29 | fi 30 | 31 | - name: Commit and Push Changes 32 | if: env.changed == 'true' # Only run if changes exist 33 | run: | 34 | git config --global user.name "github-actions[bot]" 35 | git config --global user.email "github-actions[bot]@users.noreply.github.com" 36 | git add README.md 37 | git commit -m "chore: sync README from monorepo" 38 | git push 39 | -------------------------------------------------------------------------------- /src/filesystem/file/schema-definition.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | FromLixSchemaDefinition, 3 | LixSchemaDefinition, 4 | } from "../../schema-definition/definition.js"; 5 | 6 | export type LixFileDescriptor = FromLixSchemaDefinition< 7 | typeof LixFileDescriptorSchema 8 | >; 9 | 10 | export const LixFileDescriptorSchema = { 11 | "x-lix-key": "lix_file_descriptor", 12 | "x-lix-version": "1.0", 13 | "x-lix-primary-key": ["/id"], 14 | "x-lix-unique": [["/directory_id", "/name", "/extension"]], 15 | "x-lix-override-lixcols": { 16 | lixcol_file_id: '"lix"', 17 | lixcol_plugin_key: '"lix_sdk"', 18 | }, 19 | type: "object", 20 | properties: { 21 | id: { type: "string", "x-lix-default": "lix_uuid_v7()" }, 22 | directory_id: { 23 | type: "string", 24 | nullable: true, 25 | description: 26 | "Identifier of the directory containing the file. Null indicates the virtual root directory.", 27 | }, 28 | name: { 29 | type: "string", 30 | pattern: "^[^/\\\\]+$", 31 | description: "File name without directory segments.", 32 | }, 33 | extension: { 34 | type: "string", 35 | nullable: true, 36 | pattern: "^[^./\\\\]+$", 37 | description: 38 | "File extension without the leading dot. Null when no extension is present.", 39 | }, 40 | metadata: { 41 | type: "object", 42 | nullable: true, 43 | }, 44 | hidden: { type: "boolean", "x-lix-default": "false" }, 45 | }, 46 | required: ["id", "directory_id", "name", "extension"], 47 | additionalProperties: false, 48 | } as const; 49 | LixFileDescriptorSchema satisfies LixSchemaDefinition; 50 | -------------------------------------------------------------------------------- /src/filesystem/file/cache/path-cache-schema.ts: -------------------------------------------------------------------------------- 1 | import type { Selectable } from "kysely"; 2 | import type { LixEngine } from "../../../engine/boot.js"; 3 | 4 | /** 5 | * Applies the schema for the internal file path cache table. 6 | * 7 | * This cache will store precomputed full file paths so that queries like 8 | * `WHERE path LIKE '/foo/%'` can be answered without recomputing paths for 9 | * every row. 10 | * 11 | * @example 12 | * applyFilePathCacheSchema({ engine: lix }); 13 | */ 14 | export function applyFilePathCacheSchema(args: { 15 | engine: Pick; 16 | }): void { 17 | args.engine.sqlite.exec(` 18 | CREATE TABLE IF NOT EXISTS lix_internal_file_path_cache ( 19 | file_id TEXT NOT NULL, 20 | version_id TEXT NOT NULL, 21 | directory_id TEXT, 22 | name TEXT NOT NULL, 23 | extension TEXT, 24 | path TEXT NOT NULL, 25 | PRIMARY KEY (file_id, version_id) 26 | ) strict; 27 | 28 | CREATE INDEX IF NOT EXISTS idx_lix_internal_file_path_cache_version_path 29 | ON lix_internal_file_path_cache (version_id, path, file_id); 30 | 31 | CREATE INDEX IF NOT EXISTS idx_lix_internal_file_path_cache_version_directory 32 | ON lix_internal_file_path_cache (version_id, directory_id); 33 | `); 34 | } 35 | 36 | export type InternalFilePathCacheRow = Selectable; 37 | 38 | export type InternalFilePathCacheTable = { 39 | file_id: string; 40 | version_id: string; 41 | directory_id: string | null; 42 | name: string; 43 | extension: string | null; 44 | path: string; 45 | }; 46 | -------------------------------------------------------------------------------- /src/state/vtable/primary-key.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from "vitest"; 2 | import { 3 | encodeStatePkPart, 4 | parseStatePk, 5 | serializeStatePk, 6 | } from "./primary-key.js"; 7 | 8 | describe("primary-key serialize/parse", () => { 9 | const tags = ["T", "TI", "U", "UI", "C", "CI"] as const; 10 | 11 | test.each(tags as unknown as string[])("roundtrips for tag %s", (tag) => { 12 | const fileId = "file~id/with%chars"; 13 | const entityId = "entity~id:123%$"; 14 | const versionId = "version~id%alpha"; 15 | 16 | const pk = serializeStatePk(tag as any, fileId, entityId, versionId); 17 | const parsed = parseStatePk(pk); 18 | 19 | expect(parsed.tag).toBe(tag); 20 | expect(parsed.fileId).toBe(fileId); 21 | expect(parsed.entityId).toBe(entityId); 22 | expect(parsed.versionId).toBe(versionId); 23 | }); 24 | 25 | test("encodeStatePkPart escapes '~' and '%' safely", () => { 26 | const original = "val~with%percent and /slashes?&"; 27 | const encoded = encodeStatePkPart(original); 28 | 29 | // No raw '~' should remain in encoded part 30 | expect(encoded).not.toContain("~"); 31 | // Encoded should be reversible 32 | const pk = ["U", encoded, encoded, encoded].join("~"); 33 | const parsed = parseStatePk(pk); 34 | expect(parsed.fileId).toBe(original); 35 | expect(parsed.entityId).toBe(original); 36 | expect(parsed.versionId).toBe(original); 37 | }); 38 | 39 | test("parseStatePk throws on invalid format", () => { 40 | expect(() => parseStatePk("only-one-part" as any)).toThrow( 41 | /Invalid composite key/ 42 | ); 43 | }); 44 | }); 45 | -------------------------------------------------------------------------------- /src/schema-definition/json-type.ts: -------------------------------------------------------------------------------- 1 | export type JSONType = 2 | | string 3 | | number 4 | | boolean 5 | | null 6 | | JSONType[] 7 | | { [key: string]: JSONType }; 8 | 9 | /** 10 | * JSON schema definition for JSON values (object, array, string, number, boolean, null). 11 | * 12 | * @example 13 | * const MySchema = { 14 | * type: "object", 15 | * properties: { 16 | * myJsonField: JSONTypeSchema, 17 | * }, 18 | * } 19 | */ 20 | export const JSONTypeSchema: Record = { 21 | anyOf: [ 22 | { type: "object" }, 23 | { type: "array" }, 24 | { type: "string" }, 25 | { type: "number" }, 26 | { type: "boolean" }, 27 | { type: "null" }, 28 | ], 29 | }; 30 | 31 | /** 32 | * Determines if a JSON schema property definition allows JSON values (object or array). 33 | * 34 | * Returns true if the definition's `type` is "object" or "array" (including arrays of types), 35 | * or if any `anyOf` subschemas specify `type: "object"` or `type: "array"`. 36 | * Used to identify columns that should be treated as JSON in the database layer. 37 | */ 38 | export function isJsonType(def: any): boolean { 39 | // Handles type: "object" or "array", or anyOf: [{type:...}] 40 | if (def.type) { 41 | const types = Array.isArray(def.type) ? def.type : [def.type]; 42 | if (types.includes("object") || types.includes("array")) return true; 43 | } 44 | if (Array.isArray(def.anyOf)) { 45 | if ( 46 | def.anyOf.some( 47 | (sub: any) => sub.type === "object" || sub.type === "array" 48 | ) 49 | ) 50 | return true; 51 | } 52 | return false; 53 | } 54 | -------------------------------------------------------------------------------- /src/state/cache/is-stale-state-cache.test.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from "vitest"; 2 | import { isStaleStateCache } from "./is-stale-state-cache.js"; 3 | import { clearStateCache } from "./clear-state-cache.js"; 4 | import { 5 | markStateCacheAsFresh, 6 | markStateCacheAsStale, 7 | } from "./mark-state-cache-as-stale.js"; 8 | import { openLix } from "../../lix/open-lix.js"; 9 | 10 | test("cache is stale after cache clear", async () => { 11 | const lix = await openLix({}); 12 | 13 | // Start with a known fresh flag and warm the cached value 14 | markStateCacheAsStale({ engine: lix.engine! }); 15 | markStateCacheAsFresh({ engine: lix.engine! }); 16 | await Promise.resolve(); 17 | expect(isStaleStateCache({ engine: lix.engine! })).toBe(false); 18 | 19 | // Clearing should flip the flag back to stale and invalidate the memoized result 20 | clearStateCache({ engine: lix.engine! }); 21 | await Promise.resolve(); 22 | expect(isStaleStateCache({ engine: lix.engine! })).toBe(true); 23 | }); 24 | 25 | test("cached stale flag invalidates when the key toggles", async () => { 26 | const lix = await openLix({}); 27 | 28 | markStateCacheAsStale({ engine: lix.engine! }); 29 | await Promise.resolve(); 30 | expect(isStaleStateCache({ engine: lix.engine! })).toBe(true); 31 | 32 | markStateCacheAsFresh({ engine: lix.engine! }); 33 | await Promise.resolve(); 34 | expect(isStaleStateCache({ engine: lix.engine! })).toBe(false); 35 | 36 | markStateCacheAsStale({ engine: lix.engine! }); 37 | await Promise.resolve(); 38 | expect(isStaleStateCache({ engine: lix.engine! })).toBe(true); 39 | }); 40 | -------------------------------------------------------------------------------- /src/state/cache/clear-state-cache.test.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from "vitest"; 2 | import { clearStateCache } from "./clear-state-cache.js"; 3 | import { isStaleStateCache } from "./is-stale-state-cache.js"; 4 | import { markStateCacheAsFresh } from "./mark-state-cache-as-stale.js"; 5 | import { openLix } from "../../lix/open-lix.js"; 6 | 7 | test("clearStateCache deletes all cache entries", async () => { 8 | const lix = await openLix({}); 9 | // Insert some data to populate cache 10 | await lix.db 11 | .insertInto("key_value") 12 | .values({ 13 | key: "test_key", 14 | value: "test_value", 15 | }) 16 | .execute(); 17 | 18 | const readPhysicalKeyValueCache = () => { 19 | return lix.engine!.executeSync({ 20 | sql: `SELECT entity_id FROM lix_internal_state_cache_v1_lix_key_value`, 21 | }); 22 | }; 23 | 24 | // Verify cache has entries in the physical schema table 25 | const cacheBeforeClear = readPhysicalKeyValueCache(); 26 | expect(cacheBeforeClear.rows.length).toBeGreaterThan(0); 27 | 28 | // Ensure the stale flag is false and cached before we clear it 29 | markStateCacheAsFresh({ engine: lix.engine! }); 30 | expect(isStaleStateCache({ engine: lix.engine! })).toBe(false); 31 | 32 | // Clear the cache 33 | clearStateCache({ engine: lix.engine! }); 34 | 35 | // Verify cache is empty 36 | const cacheAfterClear = readPhysicalKeyValueCache(); 37 | expect(cacheAfterClear.rows.length).toBe(0); 38 | 39 | // Verify the cache is marked as stale 40 | const isStale = isStaleStateCache({ 41 | engine: lix.engine!, 42 | }); 43 | expect(isStale).toBe(true); 44 | }); 45 | -------------------------------------------------------------------------------- /src/plugin/lix-plugin.test-d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unused-vars */ 2 | import { assertType, test } from "vitest"; 3 | import type { DetectedChange, LixPlugin } from "./lix-plugin.js"; 4 | import type { 5 | FromLixSchemaDefinition, 6 | LixSchemaDefinition, 7 | } from "../schema-definition/definition.js"; 8 | import { openLix } from "../lix/index.js"; 9 | 10 | test("json schema type of a detected change", () => { 11 | const MockChangeSchema = { 12 | "x-lix-key": "mock", 13 | "x-lix-version": "1.0", 14 | type: "object", 15 | properties: { 16 | name: { type: "string" }, 17 | age: { type: "number" }, 18 | location: { 19 | type: "object", 20 | properties: { 21 | city: { type: "string" }, 22 | country: { type: "string" }, 23 | }, 24 | required: ["city", "country"], 25 | }, 26 | }, 27 | required: ["name", "age", "location"], 28 | additionalProperties: false, 29 | } as const satisfies LixSchemaDefinition; 30 | 31 | const change: DetectedChange< 32 | FromLixSchemaDefinition 33 | > = { 34 | entity_id: "123", 35 | schema: MockChangeSchema, 36 | snapshot_content: { 37 | name: "John", 38 | age: 5, 39 | location: { 40 | city: "New York", 41 | country: "USA", 42 | }, 43 | }, 44 | }; 45 | 46 | assertType(change); 47 | }); 48 | 49 | test("file.data is potentially undefined", () => { 50 | const plugin: LixPlugin = { 51 | key: "plugin1", 52 | applyChanges: ({ file }) => { 53 | assertType(file.data); 54 | return { fileData: new Uint8Array() }; 55 | }, 56 | }; 57 | }); 58 | -------------------------------------------------------------------------------- /src/entity/conversation/schema-definition.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | FromLixSchemaDefinition, 3 | LixSchemaDefinition, 4 | } from "../../schema-definition/definition.js"; 5 | 6 | export type LixEntityConversation = FromLixSchemaDefinition< 7 | typeof LixEntityConversationSchema 8 | >; 9 | 10 | export const LixEntityConversationSchema = { 11 | "x-lix-key": "lix_entity_conversation", 12 | "x-lix-version": "1.0", 13 | "x-lix-primary-key": [ 14 | "/entity_id", 15 | "/schema_key", 16 | "/file_id", 17 | "/conversation_id", 18 | ], 19 | "x-lix-override-lixcols": { 20 | lixcol_file_id: '"lix"', 21 | lixcol_plugin_key: '"lix_sdk"', 22 | }, 23 | "x-lix-foreign-keys": [ 24 | { 25 | properties: ["/entity_id", "/schema_key", "/file_id"], 26 | references: { 27 | schemaKey: "state", 28 | properties: ["/entity_id", "/schema_key", "/file_id"], 29 | }, 30 | // Conversation links are stored in the shared lix file but target 31 | // entities in their original files, so only version_id scoping applies. 32 | scope: ["version_id"], 33 | }, 34 | { 35 | properties: ["/conversation_id"], 36 | references: { 37 | schemaKey: "lix_conversation", 38 | properties: ["/id"], 39 | }, 40 | }, 41 | ], 42 | type: "object", 43 | properties: { 44 | entity_id: { type: "string" }, 45 | schema_key: { type: "string" }, 46 | file_id: { type: "string" }, 47 | conversation_id: { type: "string" }, 48 | }, 49 | required: ["entity_id", "schema_key", "file_id", "conversation_id"], 50 | additionalProperties: false, 51 | } as const; 52 | LixEntityConversationSchema satisfies LixSchemaDefinition; 53 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: "\U0001F41B Bug Report" 2 | description: "Report an issue or possible bug" 3 | labels: [] 4 | assignees: [] 5 | body: 6 | - type: checkboxes 7 | id: checklist 8 | attributes: 9 | label: Checklist 10 | options: 11 | - label: I have searched for existing issues to avoid duplicates. 12 | required: true 13 | - label: The bug is reproducible in the latest version. 14 | required: true 15 | - label: I am willing to submit a pull request for this issue. 16 | required: false 17 | 18 | - type: input 19 | id: bug-reproduction 20 | attributes: 21 | label: Link to Minimal Reproducible Example 22 | description: "Use [this StackBlitz template](https://stackblitz.com/edit/lix-minimal-reproduction) to create a minimal reproduction..." 23 | validations: 24 | required: true 25 | 26 | - type: textarea 27 | id: bug-description 28 | attributes: 29 | label: Describe the Bug 30 | description: A clear and concise description of what the bug is. 31 | validations: 32 | required: true 33 | 34 | - type: textarea 35 | id: bug-expectation 36 | attributes: 37 | label: "What's the expected result?" 38 | description: Describe what you expect to happen. 39 | validations: 40 | required: true 41 | 42 | - type: textarea 43 | id: additional-info 44 | attributes: 45 | label: Additional Information 46 | description: "Provide any other relevant details, logs, or context about the issue." 47 | validations: 48 | required: false 49 | -------------------------------------------------------------------------------- /src/state/cache-v2/is-stale-state-cache.test.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from "vitest"; 2 | import { isStaleStateCacheV2 } from "./is-stale-state-cache.js"; 3 | import { clearStateCacheV2 } from "./clear-state-cache.js"; 4 | import { 5 | markStateCacheAsFreshV2, 6 | markStateCacheAsStaleV2, 7 | } from "./mark-state-cache-as-stale.js"; 8 | import { openLix } from "../../lix/open-lix.js"; 9 | 10 | test("cache v2 is stale after cache clear", async () => { 11 | const lix = await openLix({}); 12 | 13 | // Start with a known fresh flag and warm the cached value 14 | markStateCacheAsStaleV2({ engine: lix.engine! }); 15 | markStateCacheAsFreshV2({ engine: lix.engine! }); 16 | await Promise.resolve(); 17 | expect(isStaleStateCacheV2({ engine: lix.engine! })).toBe(false); 18 | 19 | // Clearing should flip the flag back to stale and invalidate the memoized result 20 | clearStateCacheV2({ engine: lix.engine! }); 21 | await Promise.resolve(); 22 | expect(isStaleStateCacheV2({ engine: lix.engine! })).toBe(true); 23 | }); 24 | 25 | test("cached stale flag invalidates when the key toggles", async () => { 26 | const lix = await openLix({}); 27 | 28 | markStateCacheAsStaleV2({ engine: lix.engine! }); 29 | await Promise.resolve(); 30 | expect(isStaleStateCacheV2({ engine: lix.engine! })).toBe(true); 31 | 32 | markStateCacheAsFreshV2({ engine: lix.engine! }); 33 | await Promise.resolve(); 34 | expect(isStaleStateCacheV2({ engine: lix.engine! })).toBe(false); 35 | 36 | markStateCacheAsStaleV2({ engine: lix.engine! }); 37 | await Promise.resolve(); 38 | expect(isStaleStateCacheV2({ engine: lix.engine! })).toBe(true); 39 | }); 40 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig, defineProject } from "vitest/config"; 2 | import { playwright } from "@vitest/browser-playwright"; 3 | import codspeedPlugin from "@codspeed/vitest-plugin"; 4 | 5 | const COMMON_EXCLUDES = [ 6 | "node_modules/**", 7 | "dist/**", 8 | "build/**", 9 | "coverage/**", 10 | "**/*.d.ts", 11 | ".{git,cache,output,idea}/**", 12 | ]; 13 | 14 | const isBenchRun = process.argv.includes("bench"); 15 | 16 | const nodeProject = defineProject({ 17 | test: { 18 | name: "node", 19 | // increased default timeout to avoid ci/cd issues 20 | testTimeout: 120000, 21 | // Only pick up tests from src; ignore compiled output in dist 22 | include: ["src/**/*.{test,spec}.{js,ts,jsx,tsx}"], 23 | exclude: [...COMMON_EXCLUDES, "**/*.browser.test.ts"], 24 | environment: "node", 25 | }, 26 | }); 27 | 28 | const browserProject = defineProject({ 29 | test: { 30 | name: "browser", 31 | // increased default timeout to avoid ci/cd issues 32 | testTimeout: 120000, 33 | include: ["src/**/*.browser.test.ts"], 34 | exclude: [...COMMON_EXCLUDES, "**/*.node.test.ts"], 35 | browser: { 36 | enabled: true, 37 | headless: true, 38 | provider: playwright({ 39 | launchOptions: { headless: true }, 40 | }), 41 | instances: [{ browser: "chromium" }], 42 | screenshotFailures: false, 43 | }, 44 | }, 45 | }); 46 | 47 | const projects = [nodeProject]; 48 | if (!isBenchRun) { 49 | projects.push(browserProject); 50 | } 51 | 52 | export default defineConfig({ 53 | plugins: [process.env.CODSPEED_BENCH ? codspeedPlugin() : undefined], 54 | test: { 55 | projects, 56 | }, 57 | }); 58 | -------------------------------------------------------------------------------- /src/change-proposal/accept-change-proposal.ts: -------------------------------------------------------------------------------- 1 | import type { Lix } from "../lix/open-lix.js"; 2 | import type { LixChangeProposal } from "./schema-definition.js"; 3 | import { mergeVersion } from "../version/merge-version.js"; 4 | 5 | export async function acceptChangeProposal(args: { 6 | lix: Lix; 7 | proposal: Pick | LixChangeProposal; 8 | }): Promise { 9 | const { lix, proposal: proposalRef } = args; 10 | const id = proposalRef.id as string; 11 | 12 | // 1) Load the proposal 13 | const proposal = await lix.db 14 | .selectFrom("change_proposal") 15 | .where("id", "=", id) 16 | .select(["id", "source_version_id", "target_version_id", "status"]) 17 | .executeTakeFirstOrThrow(); 18 | 19 | // 2) Merge in its own transaction (mergeVersion manages transaction scope internally) 20 | await mergeVersion({ 21 | lix, 22 | source: { id: proposal.source_version_id }, 23 | target: { id: proposal.target_version_id }, 24 | }); 25 | 26 | // 3) Mark accepted, delete the proposal row (releasing FKs), then delete source version. 27 | await lix.db.transaction().execute(async (trx) => { 28 | await trx 29 | .updateTable("change_proposal_by_version") 30 | .set({ status: "accepted" }) 31 | .where("id", "=", id) 32 | .where("lixcol_version_id", "=", "global") 33 | .execute(); 34 | 35 | await trx 36 | .deleteFrom("change_proposal_by_version") 37 | .where("id", "=", id) 38 | .where("lixcol_version_id", "=", "global") 39 | .execute(); 40 | 41 | await trx 42 | .deleteFrom("version") 43 | .where("id", "=", proposal.source_version_id) 44 | .execute(); 45 | }); 46 | } 47 | -------------------------------------------------------------------------------- /src/change-proposal/create-change-proposal.ts: -------------------------------------------------------------------------------- 1 | import type { Lix } from "../lix/open-lix.js"; 2 | import type { LixChangeProposal } from "./schema-definition.js"; 3 | import type { LixVersion } from "../version/schema-definition.js"; 4 | 5 | /** 6 | * Creates a change proposal that tracks an isolated source version 7 | * against a target version. Rows live in the global scope. 8 | * 9 | * @param source - the source version ref 10 | * @param target - the target version ref (usually Main) 11 | */ 12 | export async function createChangeProposal(args: { 13 | lix: Lix; 14 | id?: string; 15 | source: Pick; 16 | target: Pick; 17 | status?: "open" | "accepted" | "rejected"; 18 | }): Promise { 19 | const { lix } = args; 20 | const exec = async (trx: Lix["db"]) => { 21 | const values: Partial = { 22 | source_version_id: args.source.id, 23 | target_version_id: args.target.id, 24 | }; 25 | if (args.id) values.id = args.id; 26 | if (args.status) values.status = args.status; 27 | 28 | await trx 29 | .insertInto("change_proposal") 30 | .values(values as any) 31 | .execute(); 32 | 33 | const created = await trx 34 | .selectFrom("change_proposal") 35 | .where("source_version_id", "=", args.source.id) 36 | .where("target_version_id", "=", args.target.id) 37 | .selectAll() 38 | .orderBy("lixcol_created_at", "desc") 39 | .limit(1) 40 | .executeTakeFirstOrThrow(); 41 | return created as unknown as LixChangeProposal; 42 | }; 43 | 44 | if (lix.db.isTransaction) return exec(lix.db); 45 | return lix.db.transaction().execute(exec); 46 | } 47 | -------------------------------------------------------------------------------- /src/filesystem/file/cache/lixcol-schema.ts: -------------------------------------------------------------------------------- 1 | import type { Selectable } from "kysely"; 2 | import type { LixEngine } from "../../../engine/boot.js"; 3 | 4 | export type InternalFileLixcolCacheRow = 5 | Selectable; 6 | 7 | export type InternalFileLixcolCacheTable = { 8 | file_id: string; 9 | version_id: string; 10 | latest_change_id: string; 11 | latest_commit_id: string; 12 | created_at: string; 13 | updated_at: string; 14 | writer_key: string | null; 15 | }; 16 | 17 | /** 18 | * Schema for the file lixcol metadata cache. 19 | * 20 | * This cache stores the expensive-to-compute lixcol columns for files, 21 | * avoiding the need for complex subqueries in the file view. 22 | * 23 | * The view can query this table directly using SQL: 24 | * SELECT latest_change_id, latest_commit_id, created_at, updated_at, writer_key 25 | * FROM lix_internal_file_lixcol_cache 26 | * WHERE file_id = ? AND version_id = ? 27 | */ 28 | export function applyFileLixcolCacheSchema(args: { 29 | engine: Pick; 30 | }): void { 31 | // Create the cache table for file lixcol metadata 32 | args.engine.sqlite.exec(` 33 | CREATE TABLE IF NOT EXISTS lix_internal_file_lixcol_cache ( 34 | file_id TEXT NOT NULL, 35 | version_id TEXT NOT NULL, 36 | latest_change_id TEXT, 37 | latest_commit_id TEXT, 38 | created_at TEXT, 39 | updated_at TEXT, 40 | writer_key TEXT, 41 | PRIMARY KEY (file_id, version_id) 42 | ) STRICT; 43 | 44 | -- Index for fast lookups 45 | CREATE INDEX IF NOT EXISTS idx_file_lixcol_cache_lookup 46 | ON lix_internal_file_lixcol_cache(file_id, version_id); 47 | `); 48 | } 49 | -------------------------------------------------------------------------------- /src/filesystem/file/cache/clear-file-data-cache.ts: -------------------------------------------------------------------------------- 1 | import type { LixEngine } from "../../../engine/boot.js"; 2 | 3 | /** 4 | * Clears the file data cache for a specific file and version. 5 | * 6 | * @example 7 | * clearFileDataCache({ 8 | * engine: lix.engine!, 9 | * fileId: "file_123", 10 | * versionId: "version_456" 11 | * }); 12 | */ 13 | export function clearFileDataCache(args: { 14 | engine: Pick; 15 | fileId?: string; 16 | versionId?: string; 17 | }): void { 18 | if (args.fileId && args.versionId) { 19 | // Clear specific file in specific version 20 | args.engine.sqlite.exec({ 21 | sql: ` 22 | DELETE FROM lix_internal_file_data_cache 23 | WHERE file_id = ? 24 | AND version_id = ? 25 | `, 26 | bind: [args.fileId, args.versionId], 27 | returnValue: "resultRows", 28 | }); 29 | } else if (args.versionId) { 30 | // Clear all files in a specific version 31 | args.engine.sqlite.exec({ 32 | sql: ` 33 | DELETE FROM lix_internal_file_data_cache 34 | WHERE version_id = ? 35 | `, 36 | bind: [args.versionId], 37 | returnValue: "resultRows", 38 | }); 39 | } else if (args.fileId) { 40 | // Clear specific file across all versions 41 | args.engine.sqlite.exec({ 42 | sql: ` 43 | DELETE FROM lix_internal_file_data_cache 44 | WHERE file_id = ? 45 | `, 46 | bind: [args.fileId], 47 | returnValue: "resultRows", 48 | }); 49 | } else { 50 | // Clear entire cache 51 | args.engine.sqlite.exec({ 52 | sql: `DELETE FROM lix_internal_file_data_cache`, 53 | returnValue: "resultRows", 54 | }); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/account/create-account.test.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from "vitest"; 2 | import { openLix } from "../lix/open-lix.js"; 3 | import { createAccount } from "./create-account.js"; 4 | 5 | test("should create an account", async () => { 6 | const lix = await openLix({}); 7 | 8 | const accountName = "test_account"; 9 | const account = await createAccount({ 10 | lix, 11 | name: accountName, 12 | }); 13 | 14 | // Verify the account was created 15 | expect(account).toMatchObject({ 16 | name: accountName, 17 | }); 18 | 19 | // Verify the account exists in the database 20 | const dbAccount = await lix.db 21 | .selectFrom("account") 22 | .selectAll() 23 | .where("id", "=", account.id) 24 | .executeTakeFirstOrThrow(); 25 | 26 | expect(dbAccount).toMatchObject({ 27 | name: accountName, 28 | }); 29 | }); 30 | 31 | test("should create an account using schema default version when lixcol_version_id is not provided", async () => { 32 | const lix = await openLix({}); 33 | 34 | const accountName = "test_account_active"; 35 | const account = await createAccount({ 36 | lix, 37 | name: accountName, 38 | }); 39 | 40 | // Verify the account was created 41 | expect(account).toMatchObject({ 42 | name: accountName, 43 | }); 44 | 45 | // Verify the account exists in the database using the active version 46 | const dbAccount = await lix.db 47 | .selectFrom("account_by_version") 48 | .selectAll() 49 | .where("id", "=", account.id) 50 | .where("lixcol_version_id", "=", "global") 51 | .executeTakeFirstOrThrow(); 52 | 53 | expect(dbAccount).toMatchObject({ 54 | name: accountName, 55 | lixcol_version_id: "global", 56 | }); 57 | }); 58 | -------------------------------------------------------------------------------- /src/database/kysely/plugins/view-insert-returning-error-plugin.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | KyselyPlugin, 3 | PluginTransformQueryArgs, 4 | PluginTransformResultArgs, 5 | QueryResult, 6 | RootOperationNode, 7 | UnknownRow, 8 | } from "kysely"; 9 | 10 | /** 11 | * A Kysely plugin that prevents using `returning()` or `returningAll()` 12 | * with INSERT operations on database views. 13 | * 14 | * This provides better developer experience by failing fast with clear 15 | * error messages instead of letting SQLite return cryptic errors. 16 | */ 17 | export class ViewInsertReturningErrorPlugin implements KyselyPlugin { 18 | private readonly viewNames: Set; 19 | 20 | constructor(viewNames: string[]) { 21 | this.viewNames = new Set(viewNames); 22 | } 23 | 24 | transformQuery(args: PluginTransformQueryArgs): RootOperationNode { 25 | const { node } = args; 26 | 27 | // Check if this is an INSERT operation 28 | if (node.kind === "InsertQueryNode") { 29 | const tableName = node.into?.table.identifier.name; 30 | 31 | // Check if inserting into a view and has returning clause 32 | if (tableName && this.viewNames.has(tableName) && node.returning) { 33 | throw new Error( 34 | `Cannot use returning() or returningAll() with INSERT operations on view '${tableName}'. ` + 35 | `Views do not support returning clauses in INSERT statements. ` + 36 | `Use a separate SELECT query after the INSERT to retrieve the data.` 37 | ); 38 | } 39 | } 40 | 41 | return node; 42 | } 43 | 44 | async transformResult( 45 | args: PluginTransformResultArgs 46 | ): Promise> { 47 | return args.result; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/account/create-account.ts: -------------------------------------------------------------------------------- 1 | import { nanoId } from "../engine/functions/nano-id.js"; 2 | import type { Lix } from "../lix/open-lix.js"; 3 | import type { LixAccount } from "./schema-definition.js"; 4 | 5 | /** 6 | * Inserts a new account into the Lix database. 7 | * 8 | * Accounts represent different identities working with the same Lix 9 | * file. Switching the active account is handled separately via 10 | * {@link switchAccount}. 11 | * 12 | * @example 13 | * ```ts 14 | * const account = await createAccount({ lix, name: "Jane" }) 15 | * ``` 16 | */ 17 | 18 | export async function createAccount(args: { 19 | lix: Lix; 20 | id?: LixAccount["id"]; 21 | name: LixAccount["name"]; 22 | }): Promise { 23 | const executeInTransaction = async (trx: Lix["db"]) => { 24 | // Generate ID if not provided (views handle this, but we need it for querying back) 25 | const accountId = 26 | args.id || (await nanoId({ lix: { ...args.lix, db: trx } })); 27 | // Insert the account (views don't support returningAll) 28 | await trx 29 | .insertInto("account_by_version") 30 | .values({ 31 | id: accountId, 32 | name: args.name, 33 | }) 34 | .execute(); 35 | 36 | // Query back the inserted account 37 | let selectQuery = trx 38 | .selectFrom("account") 39 | .selectAll() 40 | .where("id", "=", accountId); 41 | 42 | const account = await selectQuery.executeTakeFirstOrThrow(); 43 | 44 | return account; 45 | }; 46 | 47 | // user provided an open transaction 48 | if (args.lix.db.isTransaction) { 49 | return executeInTransaction(args.lix.db); 50 | } else { 51 | return args.lix.db.transaction().execute(executeInTransaction); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/engine/entity-views/build-json-object-entries.ts: -------------------------------------------------------------------------------- 1 | import type { LixSchemaDefinition } from "../../schema-definition/definition.js"; 2 | import { isJsonType } from "../../schema-definition/json-type.js"; 3 | 4 | /** 5 | * Builds a json_object entries list for snapshot_content serialization that respects schema types. 6 | * - For JSON-like props (object/array): accept raw JSON when valid, otherwise quote 7 | * - For string props: always json_quote to avoid coercion (e.g. "1.0" -> 1) 8 | * - For others (number/boolean/null): generic JSON handling 9 | */ 10 | export function buildJsonObjectEntries(args: { 11 | schema: LixSchemaDefinition; 12 | ref: (prop: string) => string; 13 | }): string { 14 | const properties = Object.keys((args.schema as any).properties); 15 | 16 | return properties 17 | .map((prop) => { 18 | const def: any = (args.schema as any).properties[prop]; 19 | const ref = () => args.ref(prop); 20 | const jsonLike = isJsonType(def); 21 | const types = def?.type 22 | ? Array.isArray(def.type) 23 | ? def.type 24 | : [def.type] 25 | : []; 26 | const isString = !jsonLike && types.includes("string"); 27 | 28 | if (jsonLike) { 29 | const first = ref(); 30 | const second = ref(); 31 | const third = ref(); 32 | return `'${prop}', CASE WHEN json_valid(${first}) THEN json(${second}) ELSE json_quote(${third}) END`; 33 | } 34 | if (isString) { 35 | return `'${prop}', json_quote(${ref()})`; 36 | } 37 | const first = ref(); 38 | const second = ref(); 39 | const third = ref(); 40 | return `'${prop}', CASE WHEN json_valid(${first}) THEN json(${second}) ELSE json_quote(${third}) END`; 41 | }) 42 | .join(", "); 43 | } 44 | -------------------------------------------------------------------------------- /src/engine/functions/nano-id.bench.ts: -------------------------------------------------------------------------------- 1 | import { afterAll, beforeAll, bench, describe } from "vitest"; 2 | import { openLix } from "../../lix/open-lix.js"; 3 | import { nanoId, nanoIdSync } from "./nano-id.js"; 4 | 5 | const deterministicModeSeed = { 6 | key: "lix_deterministic_mode", 7 | value: { enabled: true }, 8 | }; 9 | 10 | describe("nanoId deterministic mode", () => { 11 | describe("async nanoId", () => { 12 | let lix: Awaited>; 13 | 14 | beforeAll(async () => { 15 | lix = await openLix({ keyValues: [deterministicModeSeed] }); 16 | }); 17 | 18 | afterAll(async () => { 19 | await lix.close(); 20 | }); 21 | 22 | bench("nanoId - async deterministic generation", async () => { 23 | for (let i = 0; i < 10; i++) { 24 | const id = await nanoId({ lix }); 25 | if (!id.startsWith("test_")) { 26 | throw new Error("deterministic nanoId lost test_ prefix"); 27 | } 28 | } 29 | }); 30 | }); 31 | 32 | describe("sync nanoId", () => { 33 | let lix: Awaited>; 34 | 35 | beforeAll(async () => { 36 | lix = await openLix({ keyValues: [deterministicModeSeed] }); 37 | }); 38 | 39 | afterAll(async () => { 40 | await lix.close(); 41 | }); 42 | 43 | bench("nanoIdSync - engine deterministic generation", async () => { 44 | const engine = lix.engine; 45 | if (!engine) { 46 | throw new Error("nanoIdSync benchmark requires in-process engine"); 47 | } 48 | 49 | for (let i = 0; i < 10; i++) { 50 | const id = nanoIdSync({ engine }); 51 | if (!id.startsWith("test_")) { 52 | throw new Error("deterministic nanoId lost test_ prefix (sync)"); 53 | } 54 | } 55 | }); 56 | }); 57 | }); 58 | -------------------------------------------------------------------------------- /src/account/schema-definition.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | FromLixSchemaDefinition, 3 | LixSchemaDefinition, 4 | } from "../schema-definition/definition.js"; 5 | 6 | export type LixAccount = FromLixSchemaDefinition; 7 | export const LixAccountSchema = { 8 | "x-lix-key": "lix_account", 9 | "x-lix-version": "1.0", 10 | "x-lix-primary-key": ["/id"], 11 | "x-lix-override-lixcols": { 12 | lixcol_file_id: '"lix"', 13 | lixcol_plugin_key: '"lix_sdk"', 14 | lixcol_version_id: '"global"', 15 | }, 16 | type: "object", 17 | properties: { 18 | id: { 19 | type: "string", 20 | "x-lix-default": "lix_uuid_v7()", 21 | }, 22 | name: { type: "string" }, 23 | }, 24 | required: ["id", "name"], 25 | additionalProperties: false, 26 | } as const; 27 | LixAccountSchema satisfies LixSchemaDefinition; 28 | 29 | export type LixActiveAccount = FromLixSchemaDefinition< 30 | typeof LixActiveAccountSchema 31 | >; 32 | 33 | export const LixActiveAccountSchema = { 34 | "x-lix-key": "lix_active_account", 35 | "x-lix-version": "1.0", 36 | "x-lix-primary-key": ["/account_id"], 37 | "x-lix-override-lixcols": { 38 | lixcol_file_id: '"lix"', 39 | lixcol_plugin_key: '"lix_sdk"', 40 | lixcol_version_id: '"global"', 41 | lixcol_untracked: "1", 42 | }, 43 | "x-lix-entity-views": ["state"], 44 | "x-lix-foreign-keys": [ 45 | { 46 | properties: ["/account_id"], 47 | references: { 48 | schemaKey: "lix_account", 49 | properties: ["/id"], 50 | }, 51 | }, 52 | ], 53 | type: "object", 54 | properties: { 55 | account_id: { type: "string" }, 56 | }, 57 | required: ["account_id"], 58 | additionalProperties: false, 59 | } as const; 60 | LixActiveAccountSchema satisfies LixSchemaDefinition; 61 | -------------------------------------------------------------------------------- /src/state/transaction/schema.ts: -------------------------------------------------------------------------------- 1 | import type { Selectable, Insertable, Generated } from "kysely"; 2 | import type { LixEngine } from "../../engine/boot.js"; 3 | 4 | export function applyTransactionStateSchema(args: { 5 | engine: Pick; 6 | }): void { 7 | args.engine.sqlite.exec(` 8 | CREATE TABLE IF NOT EXISTS lix_internal_transaction_state ( 9 | id TEXT PRIMARY KEY DEFAULT (lix_uuid_v7()), 10 | entity_id TEXT NOT NULL, 11 | schema_key TEXT NOT NULL, 12 | schema_version TEXT NOT NULL, 13 | file_id TEXT NOT NULL, 14 | plugin_key TEXT NOT NULL, 15 | version_id TEXT NOT NULL, 16 | writer_key TEXT NULL, 17 | snapshot_content BLOB, 18 | metadata BLOB, 19 | created_at TEXT NOT NULL, 20 | untracked INTEGER NOT NULL DEFAULT 0, 21 | UNIQUE(entity_id, file_id, schema_key, version_id) 22 | ) STRICT; 23 | 24 | CREATE INDEX IF NOT EXISTS ix_txn_v_f_s_e 25 | ON lix_internal_transaction_state(version_id, file_id, schema_key, entity_id); 26 | `); 27 | } 28 | 29 | export type InternalTransactionState = 30 | Selectable; 31 | export type NewInternalTransactionState = 32 | Insertable; 33 | export type InternalTransactionStateTable = { 34 | id: Generated; 35 | entity_id: string; 36 | schema_key: string; 37 | schema_version: string; 38 | file_id: string; 39 | plugin_key: string; 40 | version_id: string; 41 | writer_key: string | null; 42 | snapshot_content: Record | null; 43 | metadata: Record | null; 44 | created_at: Generated; 45 | untracked: number; 46 | }; 47 | 48 | // Kysely typing for the new view with lixcol_* naming 49 | // No separate view – the table above is the source of truth. 50 | -------------------------------------------------------------------------------- /src/version/create-version.ts: -------------------------------------------------------------------------------- 1 | import type { Lix } from "../lix/open-lix.js"; 2 | import type { LixVersion } from "./schema-definition.js"; 3 | import { createVersionFromCommit } from "./create-version-from-commit.js"; 4 | 5 | /** 6 | * Creates a new version branching from a version's commit id (defaults to active when `from` is omitted). 7 | * 8 | * For branching from a specific commit id, use `createVersionFromCommit`. 9 | */ 10 | export async function createVersion(args: { 11 | lix: Lix; 12 | id?: LixVersion["id"]; 13 | name?: LixVersion["name"]; 14 | from?: LixVersion | Pick; 15 | inheritsFrom?: LixVersion | Pick | null; 16 | }): Promise { 17 | const executeInTransaction = async (trx: Lix["db"]) => { 18 | // Resolve base commit from from/active 19 | let baseCommitId: string; 20 | if (args.from) { 21 | const base = await trx 22 | .selectFrom("version") 23 | .select(["commit_id"]) 24 | .where("id", "=", args.from.id) 25 | .executeTakeFirstOrThrow(); 26 | baseCommitId = base.commit_id; 27 | } else { 28 | const active = await trx 29 | .selectFrom("active_version") 30 | .innerJoin("version", "version.id", "active_version.version_id") 31 | .select("version.commit_id") 32 | .executeTakeFirstOrThrow(); 33 | baseCommitId = active.commit_id; 34 | } 35 | 36 | return createVersionFromCommit({ 37 | lix: { ...args.lix, db: trx }, 38 | commit: { id: baseCommitId }, 39 | id: args.id, 40 | name: args.name, 41 | inheritsFrom: args.inheritsFrom, 42 | }); 43 | }; 44 | 45 | if (args.lix.db.isTransaction) { 46 | return executeInTransaction(args.lix.db); 47 | } else { 48 | return args.lix.db.transaction().execute(executeInTransaction); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/state/cache-v2/clear-state-cache.ts: -------------------------------------------------------------------------------- 1 | import type { LixEngine } from "../../engine/boot.js"; 2 | import { markStateCacheAsStaleV2 } from "./mark-state-cache-as-stale.js"; 3 | 4 | /** 5 | * Clears all state cache tables. 6 | * 7 | * This function: 8 | * 1. Marks the cache as stale to prevent repopulation during delete 9 | * 2. Finds ALL per-schema physical tables (not just cached ones) 10 | * 3. Deletes all entries from each table 11 | * 12 | * @example 13 | * clearStateCacheV2({ engine: lix.engine! }); 14 | */ 15 | export function clearStateCacheV2(args: { 16 | engine: Pick< 17 | LixEngine, 18 | "sqlite" | "hooks" | "executeSync" | "runtimeCacheRef" 19 | >; 20 | timestamp?: string; 21 | }): void { 22 | // Mark the cache as stale first to prevent repopulation during delete 23 | markStateCacheAsStaleV2({ 24 | engine: args.engine, 25 | timestamp: args.timestamp, 26 | }); 27 | 28 | // Find ALL physical cache tables in the database (not just cached ones) 29 | // This ensures we clear tables even if they weren't in our cache 30 | // Exclude the v2 virtual table itself 31 | const existingTables = args.engine.sqlite.exec({ 32 | sql: `SELECT name FROM sqlite_schema 33 | WHERE type='table' 34 | AND name LIKE 'lix_internal_state_cache_v2_%'`, 35 | returnValue: "resultRows", 36 | }) as any[]; 37 | 38 | // Delete all entries from each physical table 39 | if (existingTables) { 40 | for (const row of existingTables) { 41 | const tableName = row[0] as string; 42 | // Skip virtual tables (shouldn't happen with our query, but be safe) 43 | if (tableName === "lix_internal_state_cache") continue; 44 | 45 | args.engine.sqlite.exec({ 46 | sql: `DELETE FROM ${tableName}`, 47 | returnValue: "resultRows", 48 | }); 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/state/cache/clear-state-cache.ts: -------------------------------------------------------------------------------- 1 | import type { LixEngine } from "../../engine/boot.js"; 2 | import { markStateCacheAsStale } from "./mark-state-cache-as-stale.js"; 3 | 4 | /** 5 | * Clears all state cache tables. 6 | * 7 | * This function: 8 | * 1. Marks the cache as stale to prevent repopulation during delete 9 | * 2. Finds ALL per-schema physical tables (not just cached ones) 10 | * 3. Deletes all entries from each table 11 | * 12 | * @example 13 | * clearStateCache({ engine: lix.engine! }); 14 | */ 15 | export function clearStateCache(args: { 16 | engine: Pick< 17 | LixEngine, 18 | "sqlite" | "hooks" | "executeSync" | "runtimeCacheRef" 19 | >; 20 | timestamp?: string; 21 | }): void { 22 | // Mark the cache as stale first to prevent repopulation during delete 23 | markStateCacheAsStale({ 24 | engine: args.engine, 25 | timestamp: args.timestamp, 26 | }); 27 | 28 | // Find ALL physical cache tables in the database (not just cached ones) 29 | // This ensures we clear tables even if they weren't in our cache 30 | // Exclude the v2 virtual table itself 31 | const existingTables = args.engine.sqlite.exec({ 32 | sql: `SELECT name FROM sqlite_schema 33 | WHERE type='table' 34 | AND name LIKE 'lix_internal_state_cache_v1_%' 35 | AND name != 'lix_internal_state_cache'`, 36 | returnValue: "resultRows", 37 | }) as any[]; 38 | 39 | // Delete all entries from each physical table 40 | if (existingTables) { 41 | for (const row of existingTables) { 42 | const tableName = row[0] as string; 43 | // Skip virtual tables (shouldn't happen with our query, but be safe) 44 | if (tableName === "lix_internal_state_cache") continue; 45 | 46 | args.engine.sqlite.exec({ 47 | sql: `DELETE FROM ${tableName}`, 48 | returnValue: "resultRows", 49 | }); 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/filesystem/file/select-file-data.ts: -------------------------------------------------------------------------------- 1 | import type { LixEngine } from "../../engine/boot.js"; 2 | import type { LixFile } from "./schema.js"; 3 | import { materializeFileData } from "./materialize-file-data.js"; 4 | import { updateFileDataCache } from "./cache/update-file-data-cache.js"; 5 | 6 | /** 7 | * Selects file data with caching support. 8 | * 9 | * First checks the cache, and if not found, materializes the file data 10 | * and updates the cache for future reads. 11 | * 12 | * @example 13 | * const data = selectFileData({ 14 | * engine: lix.engine!, 15 | * file: { id: "file_123", path: "/test.json", metadata: null }, 16 | * versionId: "version_456" 17 | * }); 18 | */ 19 | export function selectFileData(args: { 20 | engine: Pick; 21 | file: Pick & 22 | Partial>; 23 | versionId: string; 24 | }): Uint8Array { 25 | // Check cache first 26 | const result = args.engine.sqlite.exec({ 27 | sql: ` 28 | SELECT data 29 | FROM lix_internal_file_data_cache 30 | WHERE file_id = ? 31 | AND version_id = ? 32 | `, 33 | bind: [args.file.id, args.versionId], 34 | returnValue: "resultRows", 35 | }); 36 | 37 | const cachedData = result[0]?.[0] as Uint8Array | undefined; 38 | if (cachedData) { 39 | // Cache hit! 40 | return cachedData; 41 | } 42 | 43 | // Cache miss - materialize the file data 44 | const data = materializeFileData({ 45 | engine: args.engine, 46 | file: args.file, 47 | versionId: args.versionId, 48 | }); 49 | 50 | // Update cache for next time (write-through) 51 | updateFileDataCache({ 52 | engine: { sqlite: args.engine.sqlite } as any, 53 | fileId: args.file.id, 54 | versionId: args.versionId, 55 | data, 56 | }); 57 | 58 | return data; 59 | } 60 | -------------------------------------------------------------------------------- /src/engine/functions/function-registry.ts: -------------------------------------------------------------------------------- 1 | import type { LixEngine } from "../boot.js"; 2 | 3 | export type Call = (name: string, args?: unknown) => any; 4 | 5 | export type FunctionHandlerContext = { 6 | engine: Pick< 7 | LixEngine, 8 | | "sqlite" 9 | | "hooks" 10 | | "executeSync" 11 | | "runtimeCacheRef" 12 | | "call" 13 | | "preprocessQuery" 14 | >; 15 | }; 16 | 17 | export type RegisteredFunctionDefinition = { 18 | name: string; 19 | handler: (ctx: FunctionHandlerContext, args: any) => any; 20 | }; 21 | 22 | export type FunctionRegistry = { 23 | register: (def: RegisteredFunctionDefinition) => void; 24 | call: Call; 25 | list: () => readonly { name: string }[]; 26 | }; 27 | 28 | type RegisteredFunction = { 29 | handler: RegisteredFunctionDefinition["handler"]; 30 | }; 31 | 32 | export function createFunctionRegistry(args: { 33 | engine: Pick< 34 | LixEngine, 35 | | "sqlite" 36 | | "hooks" 37 | | "executeSync" 38 | | "runtimeCacheRef" 39 | | "call" 40 | | "preprocessQuery" 41 | >; 42 | }): FunctionRegistry { 43 | const functions = new Map(); 44 | 45 | const register = (def: RegisteredFunctionDefinition): void => { 46 | if (functions.has(def.name)) { 47 | throw new Error(`Function "${def.name}" is already registered.`); 48 | } 49 | functions.set(def.name, { handler: def.handler }); 50 | }; 51 | 52 | const call: Call = (name, argsValue) => { 53 | const entry = functions.get(name); 54 | if (!entry) { 55 | const err: any = new Error(`Unknown function: ${name}`); 56 | err.code = "LIX_CALL_UNKNOWN"; 57 | throw err; 58 | } 59 | return entry.handler({ engine: args.engine }, argsValue); 60 | }; 61 | 62 | const list = () => Array.from(functions.keys(), (name) => ({ name })); 63 | 64 | return { 65 | register, 66 | call, 67 | list, 68 | }; 69 | } 70 | -------------------------------------------------------------------------------- /src/state/schema.ts: -------------------------------------------------------------------------------- 1 | import type { LixEngine } from "../engine/boot.js"; 2 | import { applyMaterializeStateSchema } from "./materialize-state.js"; 3 | import { applyUntrackedStateSchema } from "./untracked/schema.js"; 4 | import { applyStateCacheSchema } from "./cache/schema.js"; 5 | import { applyStateByVersionView } from "./views/state-by-version.js"; 6 | import { applyStateWithTombstonesView } from "./views/state-with-tombstones.js"; 7 | import { applyStateView } from "./views/state.js"; 8 | import { applyStateVTable } from "./vtable/vtable.js"; 9 | 10 | export function applyStateDatabaseSchema(args: { 11 | engine: Pick< 12 | LixEngine, 13 | "sqlite" | "hooks" | "executeSync" | "runtimeCacheRef" | "preprocessQuery" 14 | >; 15 | }): void { 16 | const { engine } = args; 17 | applyMaterializeStateSchema({ engine }); 18 | applyStateCacheSchema({ engine }); 19 | applyUntrackedStateSchema({ engine }); 20 | 21 | // Writer metadata table: stores last writer per (file, version, entity, schema). 22 | // No NULL storage policy: absence of row = unknown writer. 23 | engine.sqlite.exec(` 24 | CREATE TABLE IF NOT EXISTS lix_internal_state_writer ( 25 | file_id TEXT NOT NULL, 26 | version_id TEXT NOT NULL, 27 | entity_id TEXT NOT NULL, 28 | schema_key TEXT NOT NULL, 29 | writer_key TEXT NULL, 30 | PRIMARY KEY (file_id, version_id, entity_id, schema_key) 31 | ) WITHOUT ROWID; 32 | 33 | CREATE INDEX IF NOT EXISTS idx_lix_internal_state_writer_fvw 34 | ON lix_internal_state_writer(file_id, version_id, writer_key); 35 | `); 36 | 37 | // Apply the virtual table (binds to the in-process engine) 38 | applyStateVTable(engine); 39 | 40 | // Public views over the internal vtable 41 | applyStateView({ engine }); 42 | applyStateByVersionView({ engine }); 43 | applyStateWithTombstonesView({ engine }); 44 | } 45 | -------------------------------------------------------------------------------- /src/server-protocol-handler/routes/get-v1.ts: -------------------------------------------------------------------------------- 1 | import { 2 | contentFromDatabase, 3 | createInMemoryDatabase, 4 | importDatabase, 5 | } from "../../database/sqlite/index.js"; 6 | import type { LixServerProtocolHandlerRoute } from "../create-server-protocol-handler.js"; 7 | 8 | export const route: LixServerProtocolHandlerRoute = async (context) => { 9 | const { lix_id } = await context.request.json(); 10 | 11 | if (!lix_id) { 12 | return new Response( 13 | JSON.stringify({ error: "Missing required field 'lix_id'" }), 14 | { 15 | status: 400, 16 | headers: { 17 | "Content-Type": "application/json", 18 | }, 19 | } 20 | ); 21 | } 22 | 23 | const exists = await context.environment.hasLix({ id: lix_id }); 24 | 25 | if (!exists) { 26 | return new Response(JSON.stringify({ error: "Lix not found" }), { 27 | status: 404, 28 | headers: { 29 | "Content-Type": "application/json", 30 | }, 31 | }); 32 | } 33 | 34 | const blob = await context.environment.getLix({ id: lix_id }); 35 | 36 | // setting the sync to true if a client requests the lix 37 | // else, the client opens the lix and it's not syncing 38 | // 39 | // - not opening via openLix because that would trigger 40 | // the sync process 41 | const sqlite = await createInMemoryDatabase({ 42 | readOnly: false, 43 | }); 44 | 45 | importDatabase({ 46 | db: sqlite, 47 | content: new Uint8Array(await blob!.arrayBuffer()), 48 | }); 49 | 50 | sqlite.exec( 51 | "UPDATE key_value SET value = json('true') WHERE key = 'lix_sync'" 52 | ); 53 | 54 | const blob2 = new Blob([ 55 | contentFromDatabase(sqlite) as Uint8Array, 56 | ]); 57 | 58 | return new Response(blob2, { 59 | status: 200, 60 | headers: { 61 | "Content-Type": "application/octet-stream", 62 | "Content-Disposition": `attachment; filename="${lix_id}.bin"`, 63 | }, 64 | }); 65 | }; 66 | -------------------------------------------------------------------------------- /src/state/cache-v2/sqlite-type-mapper.ts: -------------------------------------------------------------------------------- 1 | import type { LixSchemaDefinition } from "../../schema-definition/definition.js"; 2 | 3 | export type JsonPropertyDefinition = Record; 4 | 5 | export function mapSchemaPropertyToSqliteType( 6 | definition: JsonPropertyDefinition | undefined 7 | ): string { 8 | if (!definition || typeof definition !== "object") { 9 | return "ANY"; 10 | } 11 | 12 | const primaryType = extractPrimaryType(definition); 13 | switch (primaryType) { 14 | case "integer": 15 | return "INTEGER"; 16 | case "number": 17 | return "REAL"; 18 | case "boolean": 19 | return "INTEGER"; 20 | case "string": 21 | return "TEXT"; 22 | case "array": 23 | case "object": 24 | return "TEXT"; 25 | default: 26 | return "ANY"; 27 | } 28 | } 29 | 30 | export function extractPrimaryType( 31 | definition: JsonPropertyDefinition | undefined 32 | ): string | null { 33 | if (!definition || typeof definition !== "object") { 34 | return null; 35 | } 36 | 37 | const rawType = definition.type; 38 | if (typeof rawType === "string") { 39 | return rawType; 40 | } 41 | 42 | if (Array.isArray(rawType)) { 43 | for (const type of rawType) { 44 | if (typeof type === "string" && type.toLowerCase() !== "null") { 45 | return type; 46 | } 47 | } 48 | } 49 | 50 | return null; 51 | } 52 | 53 | export function extractPropertySchema( 54 | schema: LixSchemaDefinition | null | undefined, 55 | property: string 56 | ): JsonPropertyDefinition | undefined { 57 | if (!schema || typeof schema !== "object") return undefined; 58 | const properties = schema.properties; 59 | if (!properties || typeof properties !== "object") return undefined; 60 | const definition = (properties as Record)[property]; 61 | return typeof definition === "object" 62 | ? (definition as JsonPropertyDefinition) 63 | : undefined; 64 | } 65 | -------------------------------------------------------------------------------- /src/state/cache-v2/sqlite-type-mapper.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from "vitest"; 2 | import { 3 | extractPrimaryType, 4 | mapSchemaPropertyToSqliteType, 5 | } from "./sqlite-type-mapper.js"; 6 | 7 | describe("mapSchemaPropertyToSqliteType", () => { 8 | test("maps string to TEXT", () => { 9 | expect(mapSchemaPropertyToSqliteType({ type: "string" })).toBe("TEXT"); 10 | }); 11 | 12 | test("maps integer to INTEGER", () => { 13 | expect(mapSchemaPropertyToSqliteType({ type: "integer" })).toBe("INTEGER"); 14 | }); 15 | 16 | test("maps number to REAL", () => { 17 | expect(mapSchemaPropertyToSqliteType({ type: "number" })).toBe("REAL"); 18 | }); 19 | 20 | test("maps boolean to INTEGER", () => { 21 | expect(mapSchemaPropertyToSqliteType({ type: "boolean" })).toBe("INTEGER"); 22 | }); 23 | 24 | test("maps object to TEXT", () => { 25 | expect(mapSchemaPropertyToSqliteType({ type: "object" })).toBe("TEXT"); 26 | }); 27 | 28 | test("maps array to TEXT", () => { 29 | expect(mapSchemaPropertyToSqliteType({ type: "array" })).toBe("TEXT"); 30 | }); 31 | 32 | test("maps multi-type excluding null", () => { 33 | expect(mapSchemaPropertyToSqliteType({ type: ["null", "string"] })).toBe( 34 | "TEXT" 35 | ); 36 | }); 37 | 38 | test("defaults unknown to ANY", () => { 39 | expect(mapSchemaPropertyToSqliteType({})).toBe("ANY"); 40 | }); 41 | }); 42 | 43 | describe("extractPrimaryType", () => { 44 | test("returns null for invalid definitions", () => { 45 | expect(extractPrimaryType(undefined)).toBeNull(); 46 | expect(extractPrimaryType(42 as any)).toBeNull(); 47 | }); 48 | 49 | test("returns string type", () => { 50 | expect(extractPrimaryType({ type: "string" })).toBe("string"); 51 | }); 52 | 53 | test("returns first non-null type in array", () => { 54 | expect(extractPrimaryType({ type: ["null", "number"] })).toBe("number"); 55 | }); 56 | }); 57 | -------------------------------------------------------------------------------- /src/sync/push-to-server.ts: -------------------------------------------------------------------------------- 1 | import type * as LixServerProtocol from "../../../server-protocol-schema/dist/schema.js"; 2 | import type { Lix } from "../lix/open-lix.js"; 3 | import type { VectorClock } from "./merge-state.js"; 4 | import { getDiffingRows } from "./get-diffing-rows.js"; 5 | 6 | /** 7 | * Pushes local changes to a Lix server. 8 | * 9 | * Only rows unknown to the server are transmitted based on the provided 10 | * vector clock. An error is thrown when the server responds with a non 11 | * successful status. 12 | * 13 | * @example 14 | * ```ts 15 | * await pushToServer({ id, serverUrl, lix, targetVectorClock }) 16 | * ``` 17 | */ 18 | export async function pushToServer(args: { 19 | id: string; 20 | serverUrl: string; 21 | lix: Pick; 22 | targetVectorClock: VectorClock; 23 | }): Promise { 24 | // console.log( 25 | // "collecting rows to push using known server state:", 26 | // args.targetVectorClock 27 | // ); 28 | const { upsertedRows: tableRowsToPush, state } = await getDiffingRows({ 29 | lix: args.lix, 30 | targetVectorClock: args.targetVectorClock, 31 | }); 32 | // console.log("rows to push", tableRowsToPush); 33 | 34 | if (Object.keys(tableRowsToPush).length === 0) { 35 | // console.log("nothing to push"); 36 | return; 37 | } 38 | 39 | const response = await fetch( 40 | new Request(`${args.serverUrl}/lsp/push-v1`, { 41 | method: "POST", 42 | body: JSON.stringify({ 43 | lix_id: args.id, 44 | vector_clock: state, 45 | data: tableRowsToPush, 46 | } satisfies LixServerProtocol.paths["/lsp/push-v1"]["post"]["requestBody"]["content"]["application/json"]), 47 | headers: { 48 | "Content-Type": "application/json", 49 | }, 50 | }) 51 | ); 52 | if (!response.ok) { 53 | const body = await response.json(); 54 | throw new Error(`Failed to push to server: ${body.code} ${body.message}`); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/engine/preprocessor/steps/rewrite-vtable-selects.bench.ts: -------------------------------------------------------------------------------- 1 | import { bench, describe } from "vitest"; 2 | import { parse as parseStatements } from "../sql-parser/parse.js"; 3 | import { rewriteVtableSelects } from "./rewrite-vtable-selects.js"; 4 | import { compile } from "../sql-parser/compile.js"; 5 | 6 | const baseContext = { 7 | getStoredSchemas: () => new Map(), 8 | getCacheTables: () => new Map(), 9 | getSqlViews: () => new Map(), 10 | hasOpenTransaction: () => true, 11 | trace: undefined, 12 | } as const; 13 | 14 | const scenarios = [ 15 | { 16 | name: "simple select", 17 | sql: ` 18 | SELECT * 19 | FROM lix_internal_state_vtable 20 | `, 21 | }, 22 | { 23 | name: "schema filter", 24 | sql: ` 25 | SELECT v.* 26 | FROM lix_internal_state_vtable AS v 27 | WHERE v.schema_key = 'test_schema_key' 28 | `, 29 | }, 30 | { 31 | name: "joined aliases", 32 | sql: ` 33 | SELECT a.schema_key, b.writer_key 34 | FROM lix_internal_state_vtable AS a 35 | INNER JOIN lix_internal_state_vtable AS b 36 | ON a.schema_key = b.schema_key 37 | `, 38 | }, 39 | { 40 | name: "compound union", 41 | sql: ` 42 | SELECT v.schema_key 43 | FROM lix_internal_state_vtable AS v 44 | UNION ALL 45 | SELECT w.schema_key 46 | FROM lix_internal_state_vtable AS w 47 | `, 48 | }, 49 | ] as const; 50 | 51 | const runRewrite = (sql: string) => 52 | rewriteVtableSelects({ 53 | statements: parseStatements(sql), 54 | parameters: [], 55 | ...baseContext, 56 | }); 57 | 58 | for (const scenario of scenarios) { 59 | describe(`[rewrite_vtable_select.js] ${scenario.name}`, () => { 60 | bench("rewrite", () => { 61 | const statements = runRewrite(scenario.sql); 62 | const rendered = compile(statements).sql.toLowerCase(); 63 | if (rendered.includes('from "lix_internal_state_vtable"')) { 64 | throw new Error("vtable reference should be rewritten"); 65 | } 66 | }); 67 | }); 68 | } 69 | -------------------------------------------------------------------------------- /src/state/views/__bench__/state-delete-tracked-row.explain.txt: -------------------------------------------------------------------------------- 1 | -- label -- 2 | state delete • tracked row 3 | 4 | -- original SQL -- 5 | DELETE FROM state 6 | WHERE entity_id = ? AND schema_key = ? AND file_id = ? 7 | 8 | -- rewritten SQL -- 9 | DELETE FROM state WHERE entity_id = ? AND schema_key = ? AND file_id = ? 10 | 11 | -- plan -- 12 | [ 13 | { 14 | "id": 3, 15 | "parent": 0, 16 | "notused": 176, 17 | "detail": "SCAN lix_internal_state_vtable VIRTUAL TABLE INDEX 0:version_id,entity_id,schema_key,file_id" 18 | }, 19 | { 20 | "id": 7, 21 | "parent": 0, 22 | "notused": 0, 23 | "detail": "LIST SUBQUERY 7" 24 | }, 25 | { 26 | "id": 11, 27 | "parent": 7, 28 | "notused": 61, 29 | "detail": "SEARCH lix_internal_state_all_untracked USING INDEX idx_lix_internal_state_all_untracked_version_id (version_id=?)" 30 | }, 31 | { 32 | "id": 24, 33 | "parent": 7, 34 | "notused": 0, 35 | "detail": "CREATE BLOOM FILTER" 36 | }, 37 | { 38 | "id": 62, 39 | "parent": 0, 40 | "notused": 0, 41 | "detail": "CORRELATED SCALAR SUBQUERY 4" 42 | }, 43 | { 44 | "id": 64, 45 | "parent": 62, 46 | "notused": 0, 47 | "detail": "COMPOUND QUERY" 48 | }, 49 | { 50 | "id": 65, 51 | "parent": 64, 52 | "notused": 0, 53 | "detail": "LEFT-MOST SUBQUERY" 54 | }, 55 | { 56 | "id": 69, 57 | "parent": 65, 58 | "notused": 46, 59 | "detail": "SEARCH c USING INDEX sqlite_autoindex_lix_internal_change_1 (id=?)" 60 | }, 61 | { 62 | "id": 81, 63 | "parent": 64, 64 | "notused": 0, 65 | "detail": "UNION ALL" 66 | }, 67 | { 68 | "id": 84, 69 | "parent": 81, 70 | "notused": 39, 71 | "detail": "SEARCH t USING INDEX sqlite_autoindex_lix_internal_transaction_state_1 (id=?)" 72 | }, 73 | { 74 | "id": 106, 75 | "parent": 0, 76 | "notused": 216, 77 | "detail": "SCAN state" 78 | } 79 | ] 80 | -------------------------------------------------------------------------------- /src/engine/entity-views/entity-view-builder.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | FromLixSchemaDefinition, 3 | LixSchemaDefinition, 4 | } from "../../schema-definition/definition.js"; 5 | import { type StateEntityView } from "./entity-state.js"; 6 | import { type StateEntityByVersionView } from "./entity-state-by-version.js"; 7 | import { type StateEntityHistoryView } from "./entity-state-history.js"; 8 | import type { 9 | EntityStateView, 10 | EntityStateByVersionView, 11 | EntityStateHistoryView, 12 | ToKysely, 13 | } from "./types.js"; 14 | 15 | // Re-export types for backward compatibility 16 | export type { 17 | StateEntityView, 18 | StateEntityByVersionView, 19 | StateEntityHistoryView, 20 | }; 21 | 22 | /** 23 | * Utility type that generates database schema view entries for an entity schema. 24 | * Creates three views: active version, per-version (`_by_version`), and history. 25 | * 26 | * TSchema should be a LixSchemaDefinition (typeof SomeSchema). 27 | * TOverride allows you to provide partial type overrides for specific properties. 28 | * 29 | * @example 30 | * ```typescript 31 | * // Basic usage with schema definition 32 | * type LogViews = EntityViews; 33 | * 34 | * // With partial property override 35 | * type ThreadCommentViews = EntityViews< 36 | * typeof LixThreadCommentSchema, 37 | * "thread_comment", 38 | * { body: ZettelDoc } 39 | * >; 40 | * ``` 41 | */ 42 | export type EntityViews< 43 | TSchema extends LixSchemaDefinition, 44 | TViewName extends string, 45 | TOverride = object, 46 | > = { 47 | [K in TViewName]: ToKysely< 48 | EntityStateView & TOverride> 49 | >; 50 | } & { 51 | [K in `${TViewName}_by_version`]: ToKysely< 52 | EntityStateByVersionView & TOverride> 53 | >; 54 | } & { 55 | [K in `${TViewName}_history`]: ToKysely< 56 | EntityStateHistoryView & TOverride> 57 | >; 58 | }; 59 | -------------------------------------------------------------------------------- /src/label/create-label.ts: -------------------------------------------------------------------------------- 1 | import { nanoId } from "../engine/functions/index.js"; 2 | import type { Lix } from "../lix/open-lix.js"; 3 | import type { LixLabel } from "./schema-definition.js"; 4 | 5 | /** 6 | * Creates a label that can be attached to change sets. 7 | * 8 | * Labels help categorise change sets, for example "checkpoint" or 9 | * "reviewed". They are simple name identifiers stored per version. 10 | * 11 | * @example 12 | * ```ts 13 | * const label = await createLabel({ lix, name: "checkpoint" }) 14 | * ``` 15 | */ 16 | 17 | export async function createLabel(args: { 18 | lix: Pick; 19 | id?: LixLabel["id"]; 20 | name: LixLabel["name"]; 21 | lixcol_version_id?: string; 22 | }): Promise { 23 | const executeInTransaction = async (trx: Lix["db"]) => { 24 | // Generate ID if not provided (views handle this, but we need it for querying back) 25 | const labelId = 26 | args.id || 27 | (await nanoId({ 28 | lix: args.lix, 29 | })); 30 | 31 | // Insert the label (views don't support returningAll) 32 | await trx 33 | .insertInto("label_by_version") 34 | .values({ 35 | id: labelId, 36 | name: args.name, 37 | lixcol_version_id: 38 | args.lixcol_version_id ?? 39 | trx.selectFrom("active_version").select("version_id"), 40 | }) 41 | .execute(); 42 | 43 | // Query back the inserted label 44 | const label = await trx 45 | .selectFrom("label_by_version") 46 | .selectAll() 47 | .where("id", "=", labelId) 48 | .where( 49 | "lixcol_version_id", 50 | "=", 51 | args.lixcol_version_id ?? 52 | trx.selectFrom("active_version").select("version_id") 53 | ) 54 | .executeTakeFirstOrThrow(); 55 | 56 | return label; 57 | }; 58 | 59 | // user provided an open transaction 60 | if (args.lix.db.isTransaction) { 61 | return executeInTransaction(args.lix.db); 62 | } else { 63 | return args.lix.db.transaction().execute(executeInTransaction); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/plugin/lix-plugin.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, test, expect } from "vitest"; 2 | import { openLix } from "../lix/open-lix.js"; 3 | import type { LixPlugin } from "./lix-plugin.js"; 4 | import { handleFileInsert } from "../filesystem/file/file-handlers.js"; 5 | // no direct executeSync import needed with querySync 6 | 7 | describe("detectChanges()", () => { 8 | test("exposes querySync for sync Kysely", async () => { 9 | const plugin: LixPlugin = { 10 | key: "plugin_query_test", 11 | detectChangesGlob: "*", 12 | detectChanges: ({ after, querySync }) => { 13 | // Build a typed Kysely query via querySync and execute synchronously 14 | const rows = querySync("state") 15 | .where("file_id", "=", after.id) 16 | .select([ 17 | "entity_id", 18 | "schema_key", 19 | "file_id", 20 | "plugin_key", 21 | "snapshot_content", 22 | ]) 23 | .execute(); 24 | 25 | expect(Array.isArray(rows)).toBe(true); 26 | expect(rows.length).toBeGreaterThan(0); 27 | // The file insert handler stores a file descriptor snapshot first 28 | const hasFileDescriptor = rows.some( 29 | (r: any) => r.schema_key === "lix_file_descriptor" 30 | ); 31 | expect(hasFileDescriptor).toBe(true); 32 | 33 | // no plugin changes emitted in this probe 34 | return []; 35 | }, 36 | }; 37 | 38 | const lix = await openLix({ providePlugins: [plugin] }); 39 | 40 | // Acquire active version id for handlers 41 | const active = await lix.db 42 | .selectFrom("active_version") 43 | .select("version_id") 44 | .executeTakeFirst(); 45 | const versionId = active!.version_id as unknown as string; 46 | 47 | // Insert a file to trigger plugin.detectChanges 48 | const file = { 49 | id: "file_query_test", 50 | path: "/test.txt", 51 | data: new Uint8Array(), 52 | metadata: {}, 53 | }; 54 | const rc = handleFileInsert({ engine: lix.engine!, file, versionId }); 55 | expect(rc).toBeTypeOf("number"); 56 | }); 57 | }); 58 | -------------------------------------------------------------------------------- /src/log/create-log.ts: -------------------------------------------------------------------------------- 1 | import { nanoId } from "../engine/functions/nano-id.js"; 2 | import type { State } from "../engine/entity-views/types.js"; 3 | import type { JSONType } from "../schema-definition/json-type.js"; 4 | import type { Lix } from "../lix/open-lix.js"; 5 | import type { LixLog } from "./schema-definition.js"; 6 | 7 | /** 8 | * Directly creates a log entry in the Lix database without applying any filters. 9 | * 10 | * This function inserts the log entry regardless of the `lix_log_levels` setting 11 | * in the key-value store. It is the responsibility of the calling application 12 | * to implement any desired log level filtering before invoking this function. 13 | * 14 | * Use `snake_case` for log keys (e.g., `app_checkout_submit`) to keep filters predictable. 15 | * Provide either a `message`, a structured `payload`, or both depending on your needs. 16 | * 17 | * @example 18 | * 19 | * await createLog({ 20 | * lix, 21 | * key: 'app_init', 22 | * level: 'info', 23 | * message: 'Application initialized' 24 | * }); 25 | * 26 | * @example 27 | * 28 | * await createLog({ 29 | * lix, 30 | * key: 'app_init_state', 31 | * level: 'debug', 32 | * payload: { ready: true } 33 | * }); 34 | * 35 | * @returns A promise that resolves with the created log entry. 36 | */ 37 | export async function createLog(args: { 38 | lix: Lix; 39 | message?: string | null; 40 | payload?: JSONType; 41 | level: string; 42 | key: string; 43 | }): Promise> { 44 | // Insert the log entry 45 | const id = await nanoId({ lix: args.lix }); 46 | await args.lix.db 47 | .insertInto("log") 48 | .values({ 49 | id, 50 | key: args.key, 51 | message: args.message ?? null, 52 | payload: args.payload ?? null, 53 | level: args.level, 54 | }) 55 | .execute(); 56 | 57 | // Query to get the created log entry 58 | return await args.lix.db 59 | .selectFrom("log") 60 | .where("id", "=", id) 61 | .selectAll() 62 | .executeTakeFirstOrThrow(); 63 | } 64 | -------------------------------------------------------------------------------- /src/state/views/__bench__/state-update-tracked-row.explain.txt: -------------------------------------------------------------------------------- 1 | -- label -- 2 | state update • tracked row 3 | 4 | -- original SQL -- 5 | UPDATE state 6 | SET snapshot_content = ?, metadata = ? 7 | WHERE entity_id = ? AND schema_key = ? AND file_id = ? 8 | 9 | -- rewritten SQL -- 10 | UPDATE state SET snapshot_content = ?, metadata = ? WHERE entity_id = ? AND schema_key = ? AND file_id = ? 11 | 12 | -- plan -- 13 | [ 14 | { 15 | "id": 3, 16 | "parent": 0, 17 | "notused": 176, 18 | "detail": "SCAN lix_internal_state_vtable VIRTUAL TABLE INDEX 0:version_id,entity_id,schema_key,file_id" 19 | }, 20 | { 21 | "id": 7, 22 | "parent": 0, 23 | "notused": 0, 24 | "detail": "LIST SUBQUERY 7" 25 | }, 26 | { 27 | "id": 11, 28 | "parent": 7, 29 | "notused": 61, 30 | "detail": "SEARCH lix_internal_state_all_untracked USING INDEX idx_lix_internal_state_all_untracked_version_id (version_id=?)" 31 | }, 32 | { 33 | "id": 24, 34 | "parent": 7, 35 | "notused": 0, 36 | "detail": "CREATE BLOOM FILTER" 37 | }, 38 | { 39 | "id": 62, 40 | "parent": 0, 41 | "notused": 0, 42 | "detail": "CORRELATED SCALAR SUBQUERY 4" 43 | }, 44 | { 45 | "id": 64, 46 | "parent": 62, 47 | "notused": 0, 48 | "detail": "COMPOUND QUERY" 49 | }, 50 | { 51 | "id": 65, 52 | "parent": 64, 53 | "notused": 0, 54 | "detail": "LEFT-MOST SUBQUERY" 55 | }, 56 | { 57 | "id": 69, 58 | "parent": 65, 59 | "notused": 46, 60 | "detail": "SEARCH c USING INDEX sqlite_autoindex_lix_internal_change_1 (id=?)" 61 | }, 62 | { 63 | "id": 81, 64 | "parent": 64, 65 | "notused": 0, 66 | "detail": "UNION ALL" 67 | }, 68 | { 69 | "id": 84, 70 | "parent": 81, 71 | "notused": 39, 72 | "detail": "SEARCH t USING INDEX sqlite_autoindex_lix_internal_transaction_state_1 (id=?)" 73 | }, 74 | { 75 | "id": 107, 76 | "parent": 0, 77 | "notused": 216, 78 | "detail": "SCAN state" 79 | } 80 | ] 81 | -------------------------------------------------------------------------------- /src/server-protocol-handler/routes/pull-v1.ts: -------------------------------------------------------------------------------- 1 | import type * as LixServerProtocol from "../../../../server-protocol-schema/dist/schema.js"; 2 | import type { LixServerProtocolHandlerRoute } from "../create-server-protocol-handler.js"; 3 | import { getDiffingRows } from "../../sync/get-diffing-rows.js"; 4 | 5 | type RequestBody = 6 | LixServerProtocol.paths["/lsp/pull-v1"]["post"]["requestBody"]["content"]["application/json"]; 7 | 8 | type ResponseBody = 9 | LixServerProtocol.paths["/lsp/pull-v1"]["post"]["responses"]; 10 | 11 | export const route: LixServerProtocolHandlerRoute = async (context) => { 12 | const body = (await context.request.json()) as RequestBody; 13 | const exists = await context.environment.hasLix({ id: body.lix_id }); 14 | 15 | if (!exists) { 16 | return new Response(null, { status: 404 }); 17 | } 18 | 19 | const open = await context.environment.openLix({ id: body.lix_id }); 20 | 21 | try { 22 | // console.log("----------- PROCESSING PULL FROM CLIENT -------------"); 23 | const { upsertedRows: tableRowsToReturn, state: sessionStatesServer } = 24 | await getDiffingRows({ 25 | lix: open.lix, 26 | targetVectorClock: body.vector_clock, 27 | }); 28 | 29 | // console.log("----------- DONE PROCESSING PULL FROM CLIENT -------------"); 30 | return new Response( 31 | JSON.stringify({ 32 | vector_clock: sessionStatesServer, 33 | data: tableRowsToReturn, 34 | } satisfies ResponseBody["200"]["content"]["application/json"]), 35 | { 36 | status: 200, 37 | headers: { 38 | "Content-Type": "application/json", 39 | }, 40 | } 41 | ); 42 | } catch (error) { 43 | return new Response( 44 | JSON.stringify({ 45 | code: "FAILED_TO_FETCH_DATA", 46 | message: (error as any)?.message, 47 | } satisfies ResponseBody["500"]["content"]["application/json"]), 48 | { 49 | status: 500, 50 | headers: { 51 | "Content-Type": "application/json", 52 | }, 53 | } 54 | ); 55 | } finally { 56 | await context.environment.closeLix(open); 57 | } 58 | }; 59 | -------------------------------------------------------------------------------- /src/state/cache/schema-resolver.ts: -------------------------------------------------------------------------------- 1 | import type { LixEngine } from "../../engine/boot.js"; 2 | import type { LixSchemaDefinition } from "../../schema-definition/definition.js"; 3 | import { 4 | getStoredSchema, 5 | getAllStoredSchemas, 6 | } from "../../stored-schema/get-stored-schema.js"; 7 | import { BUILTIN_CACHE_SCHEMAS } from "./builtin-schemas.js"; 8 | 9 | /** 10 | * Resolves a cache schema definition from stored state or built-in fallbacks. 11 | * 12 | * @example 13 | * 14 | * ```ts 15 | * const definition = resolveCacheSchemaDefinition({ engine, schemaKey: "lix_commit" }); 16 | * if (definition) { 17 | * console.log(definition["x-lix-version"]); 18 | * } 19 | * ``` 20 | */ 21 | export function resolveCacheSchemaDefinition(args: { 22 | engine: Pick; 23 | schemaKey: string; 24 | }): LixSchemaDefinition | null { 25 | const stored = getStoredSchema({ engine: args.engine, key: args.schemaKey }); 26 | if (stored) { 27 | return stored; 28 | } 29 | return BUILTIN_CACHE_SCHEMAS[args.schemaKey] ?? null; 30 | } 31 | 32 | /** 33 | * Lists all cache schema definitions currently available to the engine. 34 | * 35 | * @example 36 | * 37 | * ```ts 38 | * const schemas = listAvailableCacheSchemas({ engine }); 39 | * for (const [key] of schemas.entries()) { 40 | * console.log(key); 41 | * } 42 | * ``` 43 | */ 44 | export function listAvailableCacheSchemas(args: { 45 | engine: Pick; 46 | }): Map { 47 | const schemas = new Map(); 48 | const { definitions } = getAllStoredSchemas({ engine: args.engine }); 49 | for (const [schemaKey, definition] of definitions) { 50 | if (typeof schemaKey !== "string" || schemaKey.length === 0) continue; 51 | schemas.set(schemaKey, definition); 52 | } 53 | for (const [key, definition] of Object.entries(BUILTIN_CACHE_SCHEMAS)) { 54 | if (!schemas.has(key)) { 55 | schemas.set(key, definition); 56 | } 57 | } 58 | return schemas; 59 | } 60 | -------------------------------------------------------------------------------- /src/test-utilities/simulation-test/engine-boundary-simulation.ts: -------------------------------------------------------------------------------- 1 | import type { SimulationTestDef } from "./simulation-test.js"; 2 | import { openLix } from "../../lix/open-lix.js"; 3 | import type { LixEnvironment } from "../../environment/api.js"; 4 | import { InMemoryEnvironment } from "../../environment/in-memory.js"; 5 | 6 | export const engineBoundarySimulation: SimulationTestDef = { 7 | name: "engine boundary", 8 | setup: async (lix) => { 9 | // Re-seed into an isomorphic boundary environment that hides engine 10 | const blob = await lix.toBlob(); 11 | // Preserve plugin instances from the original session if available 12 | const providePlugins = await (async () => { 13 | try { 14 | return await (lix as any).plugin?.getAll?.(); 15 | } catch { 16 | return undefined; 17 | } 18 | })(); 19 | await lix.close(); 20 | const boundaryLix = await openLix({ 21 | blob, 22 | environment: new EngineBoundaryEnvironment(), 23 | providePlugins, 24 | }); 25 | return boundaryLix; 26 | }, 27 | }; 28 | 29 | // Inline, isomorphic environment wrapper that hides the engine from openLix() 30 | class EngineBoundaryEnvironment implements LixEnvironment { 31 | private inner: InMemoryEnvironment; 32 | 33 | constructor() { 34 | this.inner = new InMemoryEnvironment(); 35 | } 36 | 37 | async open( 38 | initOpts: Parameters[0] 39 | ): Promise<{ engine?: import("../../engine/boot.js").LixEngine }> { 40 | await this.inner.open(initOpts); 41 | return {}; 42 | } 43 | 44 | async create( 45 | createOpts: Parameters[0] 46 | ): Promise { 47 | await this.inner.create(createOpts); 48 | } 49 | 50 | async export(): Promise { 51 | return this.inner.export(); 52 | } 53 | 54 | async exists(): Promise { 55 | return this.inner.exists(); 56 | } 57 | 58 | async call(name: string, args?: unknown): Promise { 59 | return this.inner.call(name, args); 60 | } 61 | 62 | async close(): Promise { 63 | await this.inner.close(); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/database/sqlite/create-in-memory-database.ts: -------------------------------------------------------------------------------- 1 | import sqlite3InitModule from "@sqlite.org/sqlite-wasm"; 2 | import type { Database, Sqlite3Static } from "@sqlite.org/sqlite-wasm"; 3 | import { wasmBinary } from "./sqlite-wasm-binary.js"; 4 | 5 | // https://github.com/opral/lix-sdk/issues/231 6 | // @ts-expect-error - globalThis 7 | globalThis.sqlite3ApiConfig = { 8 | warn: (message: string, details: any) => { 9 | if (message === "Ignoring inability to install OPFS sqlite3_vfs:") { 10 | // filter out 11 | return; 12 | } 13 | console.log(message + " " + details); 14 | }, 15 | }; 16 | 17 | export type SqliteWasmDatabase = Database & { 18 | /** 19 | * The sqlite3 module used to create the database. 20 | * 21 | * Use this API to access the sqlite3 module directly. 22 | */ 23 | sqlite3: Sqlite3Static; 24 | }; 25 | 26 | let sqlite3: Sqlite3Static; 27 | 28 | /** 29 | * Boots a WebAssembly SQLite database that operates entirely in memory. 30 | * 31 | * The first call lazily initialises the SQLite WASM module and caches it so 32 | * subsequent invocations reuse the same runtime. Pass `readOnly: true` to 33 | * obtain a database instance that prevents writes. 34 | * 35 | * @example 36 | * const db = await createInMemoryDatabase({ readOnly: false }); 37 | */ 38 | export const createInMemoryDatabase = async ({ 39 | readOnly = false, 40 | }: { 41 | readOnly?: boolean; 42 | }): Promise => { 43 | if (sqlite3 === undefined) { 44 | // avoiding a top level await by initializing the module here 45 | sqlite3 = await sqlite3InitModule({ 46 | // @ts-expect-error 47 | wasmBinary: wasmBinary, 48 | // https://github.com/opral/inlang-sdk/issues/170#issuecomment-2334768193 49 | locateFile: () => "sqlite3.wasm", 50 | }); 51 | } 52 | const flags = [ 53 | readOnly ? "r" : "cw", // read and write 54 | "", // non verbose 55 | ].join(""); 56 | 57 | const db = new sqlite3.oo1.DB(":memory:", flags); 58 | // @ts-expect-error - assigning new type 59 | db.sqlite3 = sqlite3; 60 | return db as SqliteWasmDatabase; 61 | }; 62 | -------------------------------------------------------------------------------- /src/change-proposal/accept-change-proposal.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from "vitest"; 2 | import { openLix } from "../lix/open-lix.js"; 3 | import { createVersion } from "../version/create-version.js"; 4 | import { createChangeProposal } from "./create-change-proposal.js"; 5 | import { acceptChangeProposal } from "./accept-change-proposal.js"; 6 | 7 | function enc(s: string) { 8 | return new TextEncoder().encode(s); 9 | } 10 | 11 | test("acceptChangeProposal merges source into target, deletes proposal, then deletes source version", async () => { 12 | const lix = await openLix({}); 13 | 14 | const main = await lix.db 15 | .selectFrom("version") 16 | .where("name", "=", "main") 17 | .selectAll() 18 | .executeTakeFirstOrThrow(); 19 | 20 | // Create source version and make a change only there 21 | const stage = await createVersion({ 22 | lix, 23 | from: main, 24 | name: "cp_stage_accept_spec", 25 | }); 26 | 27 | await lix.db 28 | .insertInto("file_by_version") 29 | .values({ 30 | path: "/accept-spec.md", 31 | data: enc("hello"), 32 | lixcol_version_id: stage.id, 33 | }) 34 | .execute(); 35 | 36 | const cp = await createChangeProposal({ lix, source: stage, target: main }); 37 | 38 | await acceptChangeProposal({ lix, proposal: cp }); 39 | 40 | // Source version should be deleted 41 | const stageExists = await lix.db 42 | .selectFrom("version") 43 | .where("id", "=", stage.id) 44 | .select("id") 45 | .executeTakeFirst(); 46 | expect(stageExists).toBeUndefined(); 47 | 48 | // File should be present in main 49 | const fileInMain = await lix.db 50 | .selectFrom("file_by_version") 51 | .where("path", "=", "/accept-spec.md") 52 | .where("lixcol_version_id", "=", main.id) 53 | .selectAll() 54 | .executeTakeFirst(); 55 | expect(fileInMain).toBeDefined(); 56 | 57 | // Proposal should be removed after acceptance (FK-safe deletion order) 58 | const cpAfter = await lix.db 59 | .selectFrom("change_proposal") 60 | .where("id", "=", cp.id) 61 | .selectAll() 62 | .executeTakeFirst(); 63 | expect(cpAfter).toBeUndefined(); 64 | }); 65 | -------------------------------------------------------------------------------- /src/database/graph-traversal-mode.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Describes how to traverse a graph structure (such as a change set graph). 3 | * 4 | * - `direct`: {@link GraphTraversalModeDirect} 5 | * - `recursive`: {@link GraphTraversalModeRecursive} 6 | * 7 | * This is used throughout Lix to determine how much of the graph should be included 8 | * during operations like applying, merging, or analyzing change sets. 9 | */ 10 | export type GraphTraversalMode = 11 | | GraphTraversalModeDirect 12 | | GraphTraversalModeRecursive; 13 | 14 | /** 15 | * Direct mode: Only the specified node is included. 16 | * 17 | * No parent or child traversal is performed. 18 | * 19 | * ```mermaid 20 | * graph TD 21 | * A[ChangeSet A] 22 | * B[ChangeSet B] 23 | * C[ChangeSet C] 24 | * B --> A 25 | * C --> B 26 | * click A "Selected (direct)" 27 | * ``` 28 | * 29 | * Selected node: A 30 | * Included: only A 31 | * 32 | * @example 33 | * ```ts 34 | * const mode: GraphTraversalMode = { type: "direct" }; 35 | * ``` 36 | */ 37 | export type GraphTraversalModeDirect = { 38 | type: "direct"; 39 | }; 40 | 41 | /** 42 | * Recursive mode: Includes the specified node and all transitive parents (or children). 43 | * 44 | * Optionally limits depth of traversal. 45 | * 46 | * ```mermaid 47 | * graph TD 48 | * A[ChangeSet A] 49 | * B[ChangeSet B] 50 | * C[ChangeSet C] 51 | * B --> A 52 | * C --> B 53 | * click C "Selected (recursive)" 54 | * ``` 55 | * 56 | * @example 57 | * ```ts 58 | * const mode: GraphTraversalMode = { type: "recursive" }; 59 | * const mode: GraphTraversalMode = { type: "recursive", depth: 1 }; 60 | * ``` 61 | * 62 | * Selected node: C 63 | * Included: 64 | * - If `depth` is undefined: C → B → A 65 | * - If `depth` is 1: C → B only 66 | */ 67 | export type GraphTraversalModeRecursive = { 68 | type: "recursive"; 69 | /** 70 | * Optional maximum depth to traverse. 71 | * - `depth = 0` includes direct parents/children only. 72 | * - `undefined` includes full ancestry/descendants. 73 | */ 74 | depth?: number; 75 | }; 76 | -------------------------------------------------------------------------------- /src/filesystem/file/store-detected-change-schema.ts: -------------------------------------------------------------------------------- 1 | import type { LixEngine } from "../../engine/boot.js"; 2 | import { internalQueryBuilder } from "../../engine/internal-query-builder.js"; 3 | import type { LixSchemaDefinition } from "../../schema-definition/definition.js"; 4 | import { sql } from "kysely"; 5 | 6 | export function storeDetectedChangeSchema(args: { 7 | engine: Pick; 8 | schema: LixSchemaDefinition; 9 | untracked?: boolean; 10 | }): void { 11 | const schemaKey = args.schema["x-lix-key"]; 12 | const schemaVersion = args.schema["x-lix-version"]; 13 | 14 | // Check if schema already exists 15 | const [existingSchema] = args.engine.executeSync( 16 | internalQueryBuilder 17 | .selectFrom("stored_schema") 18 | .select("value") 19 | .where( 20 | sql`json_extract("stored_schema"."value", '$."x-lix-key"')`, 21 | "=", 22 | schemaKey 23 | ) 24 | .where( 25 | sql`json_extract("stored_schema"."value", '$."x-lix-version"')`, 26 | "=", 27 | schemaVersion 28 | ) 29 | .limit(1) 30 | .compile() 31 | ).rows; 32 | 33 | if (existingSchema) { 34 | // Compare schemas using JSON.stringify for strict determinism 35 | // Handle case where stored value might already be stringified 36 | const existingSchemaJson = 37 | typeof existingSchema.value === "string" 38 | ? existingSchema.value 39 | : JSON.stringify(existingSchema.value); 40 | const newSchemaJson = JSON.stringify(args.schema); 41 | 42 | if (existingSchemaJson !== newSchemaJson) { 43 | throw new Error( 44 | `Schema differs from stored version for key '${schemaKey}' version '${schemaVersion}'. ` + 45 | `Please bump the schema version (x-lix-version) to use a different schema.` 46 | ); 47 | } 48 | // Schemas match, continue 49 | } else { 50 | // Store new schema 51 | 52 | args.engine.executeSync( 53 | internalQueryBuilder 54 | .insertInto("stored_schema") 55 | .values({ 56 | value: args.schema, 57 | lixcol_untracked: args.untracked || false, 58 | }) 59 | .compile() 60 | ); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/conversation/schema-definition.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | FromLixSchemaDefinition, 3 | LixSchemaDefinition, 4 | } from "../schema-definition/definition.js"; 5 | import { ZettelDocJsonSchema, type ZettelDoc } from "@opral/zettel-ast"; 6 | 7 | export type LixConversation = FromLixSchemaDefinition< 8 | typeof LixConversationSchema 9 | >; 10 | 11 | export const LixConversationSchema = { 12 | "x-lix-key": "lix_conversation", 13 | "x-lix-version": "1.0", 14 | "x-lix-primary-key": ["/id"], 15 | "x-lix-override-lixcols": { 16 | lixcol_file_id: '"lix"', 17 | lixcol_plugin_key: '"lix_sdk"', 18 | }, 19 | type: "object", 20 | properties: { 21 | id: { 22 | type: "string", 23 | "x-lix-default": "lix_uuid_v7()", 24 | }, 25 | }, 26 | required: ["id"], 27 | additionalProperties: false, 28 | } as const; 29 | LixConversationSchema satisfies LixSchemaDefinition; 30 | 31 | export type LixConversationMessage = FromLixSchemaDefinition< 32 | typeof LixConversationMessageSchema 33 | > & { 34 | body: ZettelDoc; 35 | }; 36 | 37 | export const LixConversationMessageSchema = { 38 | "x-lix-key": "lix_conversation_message", 39 | "x-lix-version": "1.0", 40 | "x-lix-primary-key": ["/id"], 41 | "x-lix-override-lixcols": { 42 | lixcol_file_id: '"lix"', 43 | lixcol_plugin_key: '"lix_sdk"', 44 | }, 45 | "x-lix-foreign-keys": [ 46 | { 47 | properties: ["/conversation_id"], 48 | references: { 49 | schemaKey: "lix_conversation", 50 | properties: ["/id"], 51 | }, 52 | }, 53 | { 54 | properties: ["/parent_id"], 55 | references: { 56 | schemaKey: "lix_conversation_message", 57 | properties: ["/id"], 58 | }, 59 | }, 60 | ], 61 | type: "object", 62 | properties: { 63 | id: { 64 | type: "string", 65 | "x-lix-default": "lix_uuid_v7()", 66 | }, 67 | conversation_id: { type: "string" }, 68 | parent_id: { type: "string", nullable: true }, 69 | body: ZettelDocJsonSchema as any, 70 | }, 71 | required: ["id", "conversation_id", "body"], 72 | additionalProperties: false, 73 | } as const; 74 | LixConversationMessageSchema satisfies LixSchemaDefinition; 75 | -------------------------------------------------------------------------------- /src/change-conflict/resolve-conflict-by-selecting.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 2 | // @ts-nocheck 3 | 4 | import type { Change, ChangeConflict } from "../database/schema.js"; 5 | import type { Lix } from "../lix/open-lix.js"; 6 | 7 | /** 8 | * Resolves a conflict by selecting one of the two 9 | * changes in the conflict. 10 | */ 11 | export async function resolveChangeConflictBySelecting(args: { 12 | lix: Lix; 13 | conflict: ChangeConflict; 14 | select: Change; 15 | }): Promise { 16 | const executeInTransaction = async (trx: Lix["db"]) => { 17 | await trx 18 | .insertInto("change_conflict_resolution") 19 | .values({ 20 | change_conflict_id: args.conflict.id, 21 | resolved_change_id: args.select.id, 22 | }) 23 | // conflict resolution already exists 24 | .onConflict((oc) => oc.doNothing()) 25 | .execute(); 26 | 27 | // TODO on-demand apply changes https://linear.app/opral/issue/LIXDK-219/on-demand-applychanges-on-filedata-read 28 | await applyChanges({ 29 | lix: { ...args.lix, db: trx }, 30 | changes: [args.select], 31 | }); 32 | 33 | const versionsWithConflicts = await trx 34 | .selectFrom("version") 35 | .innerJoin( 36 | "version_change_conflict", 37 | "version_change_conflict.version_id", 38 | "version.id" 39 | ) 40 | .where( 41 | "version_change_conflict.change_conflict_id", 42 | "=", 43 | args.conflict.id 44 | ) 45 | .selectAll() 46 | .execute(); 47 | 48 | for (const version of versionsWithConflicts) { 49 | await updateChangesInVersion({ 50 | lix: { ...args.lix, db: trx }, 51 | changes: [args.select], 52 | version, 53 | }); 54 | // remove the conflict pointer 55 | await trx 56 | .deleteFrom("version_change_conflict") 57 | .where("version_id", "=", version.id) 58 | .where("change_conflict_id", "=", args.conflict.id) 59 | .execute(); 60 | } 61 | }; 62 | 63 | if (args.lix.db.isTransaction) { 64 | return executeInTransaction(args.lix.db); 65 | } 66 | return args.lix.db.transaction().execute(executeInTransaction); 67 | } 68 | -------------------------------------------------------------------------------- /src/state/views/state-with-tombstones.ts: -------------------------------------------------------------------------------- 1 | import type { Generated, Selectable } from "kysely"; 2 | import type { LixEngine } from "../../engine/boot.js"; 3 | 4 | export type StateWithTombstonesView = { 5 | entity_id: string; 6 | schema_key: string; 7 | file_id: string; 8 | plugin_key: string; 9 | snapshot_content: Record | null; // null for tombstones 10 | schema_version: string; 11 | version_id: string; 12 | created_at: Generated; 13 | updated_at: Generated; 14 | inherited_from_version_id: string | null; 15 | change_id: Generated; 16 | untracked: Generated; 17 | commit_id: Generated; 18 | writer_key: string | null; 19 | metadata: Generated | null>; 20 | }; 21 | 22 | export type StateWithTombstonesRow = Selectable; 23 | 24 | /** 25 | * Creates a read-only view that exposes tracked deletions as tombstones. 26 | * 27 | * This view reads from the materialized state which includes both live rows 28 | * and deletion tombstones (NULL snapshot_content). It intentionally does NOT 29 | * filter out tombstones, unlike the resolved-state or public state_by_version views. 30 | * 31 | * We restrict to non-inherited rows (inherited_from_version_id IS NULL) so that 32 | * each version only reports its own direct state or tombstones. 33 | */ 34 | export function applyStateWithTombstonesView(args: { 35 | engine: Pick; 36 | }): void { 37 | args.engine.sqlite.exec(` 38 | CREATE VIEW IF NOT EXISTS state_with_tombstones AS 39 | SELECT 40 | entity_id, 41 | schema_key, 42 | file_id, 43 | version_id, 44 | plugin_key, 45 | snapshot_content, 46 | schema_version, 47 | created_at, 48 | updated_at, 49 | inherited_from_version_id, 50 | change_id, 51 | untracked, 52 | commit_id, 53 | writer_key, 54 | ( 55 | SELECT json(metadata) 56 | FROM change 57 | WHERE change.id = lix_internal_state_vtable.change_id 58 | ) AS metadata 59 | FROM lix_internal_state_vtable; 60 | `); 61 | } 62 | -------------------------------------------------------------------------------- /src/database/sqlite/kysely-driver/sqlite-wasm-driver.ts: -------------------------------------------------------------------------------- 1 | import { CompiledQuery } from "kysely"; 2 | import type { DatabaseConnection, Driver } from "kysely"; 3 | import type { SqliteWasmDialectConfig } from "./sqlite-wasm-dialect-config.js"; 4 | import { ConnectionMutex } from "./connection-mutex.js"; 5 | import { SqliteWasmConnection } from "./sqlite-wasm-connection.js"; 6 | import type { SqliteWasmDatabase } from "../create-in-memory-database.js"; 7 | 8 | export class SqliteWasmDriver implements Driver { 9 | readonly #config: SqliteWasmDialectConfig; 10 | readonly #connectionMutex = new ConnectionMutex(); 11 | 12 | #db?: SqliteWasmDatabase; 13 | #connection?: DatabaseConnection; 14 | 15 | constructor(config: SqliteWasmDialectConfig) { 16 | // this.#config = freeze({ ...config }) 17 | this.#config = { ...config }; 18 | } 19 | 20 | async init(): Promise { 21 | this.#db = 22 | typeof this.#config.database === "function" 23 | ? await this.#config.database() 24 | : this.#config.database; 25 | 26 | this.#connection = new SqliteWasmConnection(this.#db); 27 | 28 | if (this.#config.onCreateConnection) { 29 | await this.#config.onCreateConnection(this.#connection); 30 | } 31 | } 32 | 33 | async acquireConnection(): Promise { 34 | // SQLite only has one single connection. We use a mutex here to wait 35 | // until the single connection has been released. 36 | await this.#connectionMutex.lock(); 37 | return this.#connection!; 38 | } 39 | 40 | async beginTransaction(connection: DatabaseConnection): Promise { 41 | await connection.executeQuery(CompiledQuery.raw("begin")); 42 | } 43 | 44 | async commitTransaction(connection: DatabaseConnection): Promise { 45 | await connection.executeQuery(CompiledQuery.raw("commit")); 46 | } 47 | 48 | async rollbackTransaction(connection: DatabaseConnection): Promise { 49 | await connection.executeQuery(CompiledQuery.raw("rollback")); 50 | } 51 | 52 | async releaseConnection(): Promise { 53 | this.#connectionMutex.unlock(); 54 | } 55 | 56 | async destroy(): Promise { 57 | this.#db?.close(); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/environment/in-memory.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createInMemoryDatabase, 3 | importDatabase, 4 | contentFromDatabase, 5 | type SqliteWasmDatabase, 6 | } from "../database/sqlite/index.js"; 7 | import type { LixEnvironment } from "./api.js"; 8 | import { boot, type LixEngine } from "../engine/boot.js"; 9 | 10 | /** 11 | * In-process, in-memory environment. 12 | * 13 | * Runs on the calling thread; great for tests, CLI, or light usage. In 14 | * browsers, heavy operations can block the UI – prefer the Worker environment for 15 | * production use. 16 | */ 17 | export class InMemoryEnvironment implements LixEnvironment { 18 | private sqlite: SqliteWasmDatabase | undefined; 19 | 20 | private engine: LixEngine | undefined; 21 | 22 | async open( 23 | opts: Parameters[0] 24 | ): Promise<{ engine?: LixEngine }> { 25 | if (!this.sqlite) { 26 | this.sqlite = await createInMemoryDatabase({ readOnly: false }); 27 | } 28 | if (!this.engine) { 29 | const engine = await boot({ 30 | sqlite: this.sqlite, 31 | emit: (ev) => opts.emit(ev), 32 | args: opts.boot.args, 33 | }); 34 | this.engine = engine; 35 | } 36 | return { engine: this.engine }; 37 | } 38 | 39 | async create(opts: Parameters[0]): Promise { 40 | this.sqlite = await createInMemoryDatabase({ readOnly: false }); 41 | importDatabase({ db: this.sqlite, content: new Uint8Array(opts.blob) }); 42 | this.engine = undefined; 43 | } 44 | 45 | async export(): Promise { 46 | if (!this.sqlite) throw new Error("Engine not initialized"); 47 | const bytes = contentFromDatabase(this.sqlite); 48 | const copy = bytes.slice(); 49 | return copy.buffer; 50 | } 51 | 52 | async close(): Promise { 53 | if (this.sqlite) { 54 | this.sqlite.close(); 55 | this.sqlite = undefined; 56 | } 57 | this.engine = undefined; 58 | } 59 | 60 | async exists(): Promise { 61 | return false; 62 | } 63 | 64 | async call(name: string, args?: unknown): Promise { 65 | if (!this.engine) throw new Error("Environment not initialized"); 66 | return this.engine.call(name, args); 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/state/cache/mark-state-cache-as-stale.ts: -------------------------------------------------------------------------------- 1 | import type { LixEngine } from "../../engine/boot.js"; 2 | import { getTimestampSync } from "../../engine/functions/timestamp.js"; 3 | import { LixKeyValueSchema } from "../../key-value/schema-definition.js"; 4 | import { updateUntrackedState } from "../untracked/update-untracked-state.js"; 5 | import { setStaleStateCacheMemo } from "./is-stale-state-cache.js"; 6 | 7 | const CACHE_STALE_KEY = "lix_state_cache_stale"; 8 | 9 | export function markStateCacheAsStale(args: { 10 | engine: Pick< 11 | LixEngine, 12 | "sqlite" | "hooks" | "executeSync" | "runtimeCacheRef" 13 | >; 14 | timestamp?: string; 15 | }): void { 16 | const createdAt = args.timestamp ?? getTimestampSync({ engine: args.engine }); 17 | 18 | updateUntrackedState({ 19 | engine: args.engine, 20 | changes: [ 21 | { 22 | entity_id: CACHE_STALE_KEY, 23 | schema_key: LixKeyValueSchema["x-lix-key"], 24 | file_id: "lix", 25 | plugin_key: "lix_sdk", 26 | snapshot_content: JSON.stringify({ 27 | key: CACHE_STALE_KEY, 28 | value: true, 29 | }), 30 | schema_version: LixKeyValueSchema["x-lix-version"], 31 | created_at: createdAt, 32 | lixcol_version_id: "global", 33 | }, 34 | ], 35 | }); 36 | 37 | setStaleStateCacheMemo({ engine: args.engine, value: true }); 38 | } 39 | 40 | export function markStateCacheAsFresh(args: { 41 | engine: Pick; 42 | timestamp?: string; 43 | }): void { 44 | const createdAt = args.timestamp ?? getTimestampSync({ engine: args.engine }); 45 | 46 | updateUntrackedState({ 47 | engine: args.engine, 48 | changes: [ 49 | { 50 | entity_id: CACHE_STALE_KEY, 51 | schema_key: LixKeyValueSchema["x-lix-key"], 52 | file_id: "lix", 53 | plugin_key: "lix_sdk", 54 | snapshot_content: JSON.stringify({ 55 | key: CACHE_STALE_KEY, 56 | value: false, 57 | }), 58 | schema_version: LixKeyValueSchema["x-lix-version"], 59 | created_at: createdAt, 60 | lixcol_version_id: "global", 61 | }, 62 | ], 63 | }); 64 | 65 | setStaleStateCacheMemo({ engine: args.engine, value: false }); 66 | } 67 | -------------------------------------------------------------------------------- /src/database/kysely/plugins/view-insert-returning-error-plugin.test.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from "vitest"; 2 | import { openLix } from "../../../lix/open-lix.js"; 3 | 4 | test("should throw error when using returningAll() on view insert", async () => { 5 | const lix = await openLix({}); 6 | 7 | // Test with a view that's in the plugin's view list 8 | expect(() => { 9 | lix.db.insertInto("change_set").defaultValues().returningAll().compile(); 10 | }).toThrow( 11 | "Cannot use returning() or returningAll() with INSERT operations on view 'change_set'. " + 12 | "Views do not support returning clauses in INSERT statements. " + 13 | "Use a separate SELECT query after the INSERT to retrieve the data." 14 | ); 15 | }); 16 | 17 | test("should throw error when using returning() on view insert", async () => { 18 | const lix = await openLix({}); 19 | 20 | // Test with returning() instead of returningAll() 21 | expect(() => { 22 | lix.db 23 | .insertInto("conversation") 24 | .values({ id: "test-id" }) 25 | .returning("id") 26 | .compile(); 27 | }).toThrow( 28 | "Cannot use returning() or returningAll() with INSERT operations on view 'conversation'. " + 29 | "Views do not support returning clauses in INSERT statements. " + 30 | "Use a separate SELECT query after the INSERT to retrieve the data." 31 | ); 32 | }); 33 | 34 | test("should allow insert without returning on views", async () => { 35 | const lix = await openLix({}); 36 | 37 | // This should not throw 38 | expect(() => { 39 | lix.db.insertInto("change_set").defaultValues().compile(); 40 | }).not.toThrow(); 41 | }); 42 | 43 | test("should allow returning on non-view tables", async () => { 44 | const lix = await openLix({}); 45 | 46 | // This should not throw since 'state' is a table, not a view 47 | expect(() => { 48 | lix.db 49 | .insertInto("state_by_version") 50 | .values({ 51 | entity_id: "test", 52 | schema_key: "test", 53 | file_id: "test", 54 | schema_version: "1.0", 55 | plugin_key: "test", 56 | snapshot_content: {}, 57 | version_id: "test", 58 | }) 59 | .returningAll() 60 | .compile(); 61 | }).not.toThrow(); 62 | }); 63 | -------------------------------------------------------------------------------- /src/database/kysely/json-column-config.ts: -------------------------------------------------------------------------------- 1 | import { LixSchemaViewMap } from "../schema-view-map.js"; 2 | import { isJsonType } from "../../schema-definition/json-type.js"; 3 | 4 | export function buildJsonColumnConfig(args?: { 5 | includeChangeView?: boolean; 6 | }): Record> { 7 | const includeChangeView = args?.includeChangeView ?? true; 8 | const result: Record> = {}; 9 | 10 | const fileJsonColumns = { 11 | metadata: { type: "object" }, 12 | } as const; 13 | 14 | const hardcoded: Record> = { 15 | state: { 16 | snapshot_content: { type: "object" }, 17 | metadata: { type: "object" }, 18 | }, 19 | state_by_version: { 20 | snapshot_content: { type: "object" }, 21 | metadata: { type: "object" }, 22 | }, 23 | state_history: { 24 | snapshot_content: { type: "object" }, 25 | metadata: { type: "object" }, 26 | }, 27 | // hardcoded special handling for file views 28 | file: fileJsonColumns, 29 | file_by_version: fileJsonColumns, 30 | file_history: fileJsonColumns, 31 | }; 32 | 33 | if (includeChangeView) { 34 | hardcoded.change = { 35 | snapshot_content: { type: "object" }, 36 | metadata: { type: "object" }, 37 | }; 38 | } 39 | 40 | Object.assign(result, hardcoded); 41 | 42 | for (const [viewName, schema] of Object.entries(LixSchemaViewMap)) { 43 | if (typeof schema === "boolean" || !schema.properties) continue; 44 | const jsonColumns: Record = {}; 45 | for (const [key, def] of Object.entries(schema.properties)) { 46 | if (isJsonType(def)) { 47 | jsonColumns[key] = { 48 | type: ["string", "number", "boolean", "object", "array", "null"], 49 | }; 50 | } 51 | } 52 | 53 | // All entity views expose lixcol_metadata as a JSON column sourced from change metadata. 54 | jsonColumns.lixcol_metadata = { type: "object" }; 55 | if (Object.keys(jsonColumns).length > 0) { 56 | result[viewName] = jsonColumns; 57 | result[viewName + "_by_version"] = jsonColumns; 58 | result[viewName + "_history"] = jsonColumns; 59 | } 60 | } 61 | 62 | return result; 63 | } 64 | -------------------------------------------------------------------------------- /src/account/switch-account.test.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from "vitest"; 2 | import { openLix } from "../lix/open-lix.js"; 3 | import { createAccount } from "./create-account.js"; 4 | import { switchAccount } from "./switch-account.js"; 5 | 6 | test("should switch the active account", async () => { 7 | const lix = await openLix({}); 8 | 9 | // Create two accounts 10 | const account1 = await createAccount({ 11 | lix, 12 | id: "account1", 13 | name: "Account One", 14 | }); 15 | const account2 = await createAccount({ 16 | lix, 17 | id: "account2", 18 | name: "Account Two", 19 | }); 20 | 21 | // Switch to account1 22 | await switchAccount({ lix, to: [account1] }); 23 | 24 | // Verify the current account is account1 25 | let activeAccount = await lix.db 26 | .selectFrom("active_account") 27 | .selectAll() 28 | .executeTakeFirstOrThrow(); 29 | 30 | expect(activeAccount.account_id).toBe(account1.id); 31 | 32 | // Switch to account2 33 | await switchAccount({ lix, to: [account2] }); 34 | 35 | // Verify the current account is account2 36 | activeAccount = await lix.db 37 | .selectFrom("active_account") 38 | .selectAll() 39 | .executeTakeFirstOrThrow(); 40 | 41 | expect(activeAccount.account_id).toBe(account2.id); 42 | }); 43 | 44 | test("should handle switching to the same account", async () => { 45 | const lix = await openLix({}); 46 | 47 | // Create an account 48 | const account = await createAccount({ 49 | lix, 50 | name: "account", 51 | }); 52 | 53 | // Switch to the account 54 | await switchAccount({ lix, to: [account] }); 55 | 56 | // Verify the current account is the created account 57 | let activeAccount = await lix.db 58 | .selectFrom("active_account") 59 | .selectAll() 60 | .executeTakeFirstOrThrow(); 61 | 62 | expect(activeAccount.account_id).toBe(account.id); 63 | 64 | // Switch to the same account again 65 | await switchAccount({ lix, to: [account] }); 66 | 67 | // Verify the current account is still the same account 68 | activeAccount = await lix.db 69 | .selectFrom("active_account") 70 | .selectAll() 71 | .executeTakeFirstOrThrow(); 72 | 73 | expect(activeAccount.account_id).toBe(account.id); 74 | }); 75 | -------------------------------------------------------------------------------- /src/database/schema-view-map.ts: -------------------------------------------------------------------------------- 1 | import { 2 | LixAccountSchema, 3 | LixActiveAccountSchema, 4 | } from "../account/schema-definition.js"; 5 | import { LixChangeAuthorSchema } from "../change-author/schema-definition.js"; 6 | import { LixChangeProposalSchema } from "../change-proposal/schema-definition.js"; 7 | import { 8 | LixChangeSetElementSchema, 9 | LixChangeSetSchema, 10 | } from "../change-set/schema-definition.js"; 11 | import { 12 | LixCommitEdgeSchema, 13 | LixCommitSchema, 14 | } from "../commit/schema-definition.js"; 15 | import { 16 | LixConversationMessageSchema, 17 | LixConversationSchema, 18 | } from "../conversation/schema-definition.js"; 19 | import { LixEntityConversationSchema } from "../entity/conversation/schema-definition.js"; 20 | import { LixEntityLabelSchema } from "../entity/label/schema-definition.js"; 21 | import { LixDirectoryDescriptorSchema } from "../filesystem/directory/schema-definition.js"; 22 | import { LixFileDescriptorSchema } from "../filesystem/file/schema-definition.js"; 23 | import { LixKeyValueSchema } from "../key-value/schema-definition.js"; 24 | import { LixLabelSchema } from "../label/schema-definition.js"; 25 | import { LixLogSchema } from "../log/schema-definition.js"; 26 | import type { LixSchemaDefinition } from "../schema-definition/definition.js"; 27 | import { LixStoredSchemaSchema } from "../stored-schema/schema-definition.js"; 28 | 29 | export const LixSchemaViewMap: Record = { 30 | change_set: LixChangeSetSchema, 31 | change_set_element: LixChangeSetElementSchema, 32 | commit: LixCommitSchema, 33 | commit_edge: LixCommitEdgeSchema, 34 | file_descriptor: LixFileDescriptorSchema, 35 | directory_descriptor: LixDirectoryDescriptorSchema, 36 | log: LixLogSchema, 37 | stored_schema: LixStoredSchemaSchema, 38 | key_value: LixKeyValueSchema, 39 | account: LixAccountSchema, 40 | active_account: LixActiveAccountSchema, 41 | change_author: LixChangeAuthorSchema, 42 | label: LixLabelSchema, 43 | entity_label: LixEntityLabelSchema, 44 | entity_conversation: LixEntityConversationSchema, 45 | conversation: LixConversationSchema, 46 | conversation_message: LixConversationMessageSchema, 47 | change_proposal: LixChangeProposalSchema, 48 | }; 49 | -------------------------------------------------------------------------------- /src/database/sqlite/kysely-driver/sqlite-wasm-connection.ts: -------------------------------------------------------------------------------- 1 | import type { CompiledQuery, DatabaseConnection, QueryResult } from "kysely"; 2 | import type { SqliteWasmDatabase } from "../create-in-memory-database.js"; 3 | 4 | export class SqliteWasmConnection implements DatabaseConnection { 5 | readonly #db: SqliteWasmDatabase; 6 | 7 | constructor(db: SqliteWasmDatabase) { 8 | this.#db = db; 9 | } 10 | 11 | executeQuery(compiledQuery: CompiledQuery): Promise> { 12 | const { sql, parameters } = compiledQuery; 13 | 14 | const statementData = { 15 | rows: [], 16 | columns: [], 17 | }; 18 | 19 | // we cant know what kind of query we are dealing with at that state - unless we switch to perpared statments 20 | // for now we collect all information required 21 | // save the changes before (total changes seems to be fast and worth the twoe extra round trips inspiration from https://github.com/WiseLibs/better-sqlite3/blob/254b8e93d78b1b03c9a2c777f4d304a0ea1530c6/src/objects/statement.lzz#L159) 22 | const totalChangesBefore = this.#db.changes(true); 23 | 24 | // execute the statement 25 | const rows = this.#db.exec({ 26 | sql: sql, 27 | bind: parameters as any, 28 | returnValue: "resultRows", 29 | rowMode: "object", 30 | columnNames: statementData.columns, 31 | }); 32 | 33 | const lastInsertId = this.#db.sqlite3.capi.sqlite3_last_insert_rowid( 34 | this.#db 35 | ); 36 | 37 | // check if we had changes in the db at all - if so - collect the number of changes 38 | const changes = 39 | totalChangesBefore === this.#db.changes(true) ? 0 : this.#db.changes(); 40 | 41 | // console.log('sql: ' + sql); 42 | // console.log('result: ', rows); 43 | // We don't have knowledge about rather its update/delete/or select - so we return the results 44 | // @ts-expect-error - TODO for @martin-lysk - typescript complains 45 | return Promise.resolve({ 46 | numAffectedRows: changes, 47 | insertId: lastInsertId, 48 | 49 | // queries with result 50 | rows: rows as O[], 51 | }); 52 | } 53 | 54 | // eslint-disable-next-line require-yield 55 | async *streamQuery(): AsyncGenerator { 56 | throw new Error("not supported for wasm driver yet"); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/change-conflict/detect-change-conflicts.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 2 | // @ts-nocheck 3 | 4 | import type { Change } from "../database/schema.js"; 5 | import type { DetectedConflict, LixReadonly } from "../plugin/lix-plugin.js"; 6 | import { detectDivergingEntityConflict } from "./detect-diverging-entity-conflict.js"; 7 | 8 | /** 9 | * Detects conflicts in the given set of changes. 10 | * 11 | * The caller is responsible for filtering out changes 12 | * that should not lead to conflicts before calling this function. 13 | * 14 | * For example, detecting conflicts between two versiones should 15 | * only include changes that are different between the two versiones 16 | * when calling this function. 17 | * 18 | * @example 19 | * const detectedConflicts = await detectChangeConflicts({ 20 | * lix: lix, 21 | * changes: diffingChages, 22 | * }); 23 | */ 24 | export async function detectChangeConflicts(args: { 25 | lix: Pick; 26 | changes: Change[]; 27 | }): Promise { 28 | const detectedConflicts: DetectedConflict[] = []; 29 | const plugins = await args.lix.plugin.getAll(); 30 | 31 | // let plugin detect conflicts 32 | await Promise.all( 33 | plugins.map(async (plugin) => { 34 | if (plugin.detectConflicts) { 35 | const conflicts = await plugin.detectConflicts({ 36 | lix: args.lix, 37 | changes: args.changes, 38 | }); 39 | detectedConflicts.push(...conflicts); 40 | } 41 | }) 42 | ); 43 | 44 | // auto detect diverging entity conflicts 45 | 46 | // group by the primary key (entity_id, file_id, type) 47 | const groupedByEntity = Object.groupBy( 48 | args.changes, 49 | (change) => `${change.entity_id}_${change.file_id}_${change.schema_key}` 50 | ); 51 | for (const changes of Object.values(groupedByEntity)) { 52 | if (changes === undefined) { 53 | continue; 54 | } 55 | 56 | if (changes.length > 1) { 57 | const divergingEntityConflict = await detectDivergingEntityConflict({ 58 | lix: args.lix, 59 | changes, 60 | }); 61 | if (divergingEntityConflict) { 62 | detectedConflicts.push(divergingEntityConflict); 63 | } 64 | } 65 | } 66 | 67 | return detectedConflicts; 68 | } 69 | -------------------------------------------------------------------------------- /src/label/schema.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from "vitest"; 2 | import { openLix } from "../lix/open-lix.js"; 3 | 4 | test("creates checkpoint label on boot up", async () => { 5 | const lix = await openLix({}); 6 | 7 | const checkpointLabel = await lix.db 8 | .selectFrom("label") 9 | .where("name", "=", "checkpoint") 10 | .selectAll() 11 | .executeTakeFirst(); 12 | 13 | expect(checkpointLabel).toBeDefined(); 14 | expect(checkpointLabel?.name).toBe("checkpoint"); 15 | expect(checkpointLabel?.id).toBeDefined(); 16 | }); 17 | 18 | test("insert, update, delete on the label view", async () => { 19 | const lix = await openLix({}); 20 | 21 | await lix.db 22 | .insertInto("label") 23 | .values({ id: "label1", name: "bug" }) 24 | .execute(); 25 | 26 | const viewAfterInsert = await lix.db 27 | .selectFrom("label") 28 | .where("id", "=", "label1") 29 | .selectAll() 30 | .execute(); 31 | 32 | expect(viewAfterInsert).toMatchObject([ 33 | { 34 | id: "label1", 35 | name: "bug", 36 | }, 37 | ]); 38 | 39 | await lix.db 40 | .updateTable("label") 41 | .where("id", "=", "label1") 42 | .set({ name: "feature" }) 43 | .execute(); 44 | 45 | const viewAfterUpdate = await lix.db 46 | .selectFrom("label") 47 | .where("id", "=", "label1") 48 | .selectAll() 49 | .execute(); 50 | 51 | expect(viewAfterUpdate).toMatchObject([ 52 | { 53 | id: "label1", 54 | name: "feature", 55 | }, 56 | ]); 57 | 58 | await lix.db.deleteFrom("label").where("id", "=", "label1").execute(); 59 | 60 | const viewAfterDelete = await lix.db 61 | .selectFrom("label") 62 | .where("id", "=", "label1") 63 | .selectAll() 64 | .execute(); 65 | 66 | expect(viewAfterDelete).toEqual([]); 67 | 68 | const changes = await lix.db 69 | .selectFrom("change") 70 | 71 | .where("schema_key", "=", "lix_label") 72 | .where("entity_id", "=", "label1") 73 | .orderBy("change.created_at", "asc") 74 | .selectAll("change") 75 | 76 | .execute(); 77 | 78 | expect(changes.map((change) => change.snapshot_content)).toMatchObject([ 79 | // insert 80 | { 81 | id: "label1", 82 | name: "bug", 83 | }, 84 | // update 85 | { 86 | id: "label1", 87 | name: "feature", 88 | }, 89 | // delete 90 | null, 91 | ]); 92 | }); 93 | -------------------------------------------------------------------------------- /src/filesystem/__bench__/file-update-by-path.explain.txt: -------------------------------------------------------------------------------- 1 | -- label -- 2 | file update by path 3 | 4 | -- original SQL -- 5 | update "file" set "data" = ? where "path" = ? 6 | 7 | -- rewritten SQL -- 8 | UPDATE "file" SET "data" = ? WHERE "path" = ? 9 | 10 | -- plan -- 11 | [ 12 | { 13 | "id": 7, 14 | "parent": 0, 15 | "notused": 183, 16 | "detail": "SCAN lix_internal_state_vtable VIRTUAL TABLE INDEX 0:schema_key,version_id" 17 | }, 18 | { 19 | "id": 12, 20 | "parent": 0, 21 | "notused": 0, 22 | "detail": "LIST SUBQUERY 11" 23 | }, 24 | { 25 | "id": 16, 26 | "parent": 12, 27 | "notused": 61, 28 | "detail": "SEARCH lix_internal_state_all_untracked USING INDEX idx_lix_internal_state_all_untracked_version_id (version_id=?)" 29 | }, 30 | { 31 | "id": 29, 32 | "parent": 12, 33 | "notused": 0, 34 | "detail": "CREATE BLOOM FILTER" 35 | }, 36 | { 37 | "id": 46, 38 | "parent": 0, 39 | "notused": 47, 40 | "detail": "SEARCH file_path_cache USING INDEX sqlite_autoindex_lix_internal_file_path_cache_1 (file_id=? AND version_id=?) LEFT-JOIN" 41 | }, 42 | { 43 | "id": 68, 44 | "parent": 0, 45 | "notused": 47, 46 | "detail": "SEARCH cache USING INDEX sqlite_autoindex_lix_internal_file_lixcol_cache_1 (file_id=? AND version_id=?) LEFT-JOIN" 47 | }, 48 | { 49 | "id": 244, 50 | "parent": 0, 51 | "notused": 0, 52 | "detail": "CORRELATED SCALAR SUBQUERY 8" 53 | }, 54 | { 55 | "id": 246, 56 | "parent": 244, 57 | "notused": 0, 58 | "detail": "COMPOUND QUERY" 59 | }, 60 | { 61 | "id": 247, 62 | "parent": 246, 63 | "notused": 0, 64 | "detail": "LEFT-MOST SUBQUERY" 65 | }, 66 | { 67 | "id": 251, 68 | "parent": 247, 69 | "notused": 46, 70 | "detail": "SEARCH c USING INDEX sqlite_autoindex_lix_internal_change_1 (id=?)" 71 | }, 72 | { 73 | "id": 263, 74 | "parent": 246, 75 | "notused": 0, 76 | "detail": "UNION ALL" 77 | }, 78 | { 79 | "id": 266, 80 | "parent": 263, 81 | "notused": 39, 82 | "detail": "SEARCH t USING INDEX sqlite_autoindex_lix_internal_transaction_state_1 (id=?)" 83 | }, 84 | { 85 | "id": 297, 86 | "parent": 0, 87 | "notused": 216, 88 | "detail": "SCAN file" 89 | } 90 | ] 91 | -------------------------------------------------------------------------------- /src/filesystem/__bench__/file-delete-operations-delete.explain.txt: -------------------------------------------------------------------------------- 1 | -- label -- 2 | file delete operations - delete 3 | 4 | -- original SQL -- 5 | delete from "file" where "path" = ? 6 | 7 | -- rewritten SQL -- 8 | DELETE FROM "file" WHERE "path" = ? 9 | 10 | -- plan -- 11 | [ 12 | { 13 | "id": 7, 14 | "parent": 0, 15 | "notused": 183, 16 | "detail": "SCAN lix_internal_state_vtable VIRTUAL TABLE INDEX 0:schema_key,version_id" 17 | }, 18 | { 19 | "id": 12, 20 | "parent": 0, 21 | "notused": 0, 22 | "detail": "LIST SUBQUERY 11" 23 | }, 24 | { 25 | "id": 16, 26 | "parent": 12, 27 | "notused": 61, 28 | "detail": "SEARCH lix_internal_state_all_untracked USING INDEX idx_lix_internal_state_all_untracked_version_id (version_id=?)" 29 | }, 30 | { 31 | "id": 29, 32 | "parent": 12, 33 | "notused": 0, 34 | "detail": "CREATE BLOOM FILTER" 35 | }, 36 | { 37 | "id": 46, 38 | "parent": 0, 39 | "notused": 47, 40 | "detail": "SEARCH file_path_cache USING INDEX sqlite_autoindex_lix_internal_file_path_cache_1 (file_id=? AND version_id=?) LEFT-JOIN" 41 | }, 42 | { 43 | "id": 68, 44 | "parent": 0, 45 | "notused": 47, 46 | "detail": "SEARCH cache USING INDEX sqlite_autoindex_lix_internal_file_lixcol_cache_1 (file_id=? AND version_id=?) LEFT-JOIN" 47 | }, 48 | { 49 | "id": 244, 50 | "parent": 0, 51 | "notused": 0, 52 | "detail": "CORRELATED SCALAR SUBQUERY 8" 53 | }, 54 | { 55 | "id": 246, 56 | "parent": 244, 57 | "notused": 0, 58 | "detail": "COMPOUND QUERY" 59 | }, 60 | { 61 | "id": 247, 62 | "parent": 246, 63 | "notused": 0, 64 | "detail": "LEFT-MOST SUBQUERY" 65 | }, 66 | { 67 | "id": 251, 68 | "parent": 247, 69 | "notused": 46, 70 | "detail": "SEARCH c USING INDEX sqlite_autoindex_lix_internal_change_1 (id=?)" 71 | }, 72 | { 73 | "id": 263, 74 | "parent": 246, 75 | "notused": 0, 76 | "detail": "UNION ALL" 77 | }, 78 | { 79 | "id": 266, 80 | "parent": 263, 81 | "notused": 39, 82 | "detail": "SEARCH t USING INDEX sqlite_autoindex_lix_internal_transaction_state_1 (id=?)" 83 | }, 84 | { 85 | "id": 296, 86 | "parent": 0, 87 | "notused": 216, 88 | "detail": "SCAN file" 89 | } 90 | ] 91 | -------------------------------------------------------------------------------- /src/state/cache/select-from-state-cache.ts: -------------------------------------------------------------------------------- 1 | import { sql, type SelectQueryBuilder } from "kysely"; 2 | import { internalQueryBuilder } from "../../engine/internal-query-builder.js"; 3 | import { schemaKeyToCacheTableName } from "./create-schema-cache-table.js"; 4 | 5 | export const CACHE_COLUMNS = [ 6 | "entity_id", 7 | "schema_key", 8 | "file_id", 9 | "version_id", 10 | "plugin_key", 11 | "snapshot_content", 12 | "schema_version", 13 | "created_at", 14 | "updated_at", 15 | "inherited_from_version_id", 16 | "is_tombstone", 17 | "change_id", 18 | "commit_id", 19 | ] as const; 20 | 21 | const ROUTED_ALIAS = "lix_internal_state_cache_routed"; 22 | 23 | export type StateCacheColumn = (typeof CACHE_COLUMNS)[number]; 24 | 25 | export type StateCacheRow = Record; 26 | 27 | /** 28 | * Returns a Kysely query builder routed to the materialized table for the 29 | * provided schema key. Pass `undefined` to opt into a no-op SELECT (useful when 30 | * the physical table might not exist yet). 31 | */ 32 | export function selectFromStateCache( 33 | schemaKey?: string, 34 | columns: readonly StateCacheColumn[] = CACHE_COLUMNS 35 | ): SelectQueryBuilder { 36 | const selectSql = schemaKey 37 | ? buildSelectStatement(schemaKeyToCacheTableName(schemaKey), columns) 38 | : buildEmptySelect(columns); 39 | 40 | const tableExpression = sql`(${sql.raw(selectSql)})`.as( 41 | ROUTED_ALIAS 42 | ); 43 | 44 | return internalQueryBuilder.selectFrom( 45 | tableExpression 46 | ) as unknown as SelectQueryBuilder; 47 | } 48 | 49 | function buildSelectStatement( 50 | tableName: string, 51 | columns: readonly StateCacheColumn[] 52 | ): string { 53 | const quoted = quoteIdentifier(tableName); 54 | const projection = columns.map(quoteIdentifier).join(",\n\t"); 55 | return `SELECT 56 | ${projection} 57 | FROM ${quoted}`; 58 | } 59 | 60 | function buildEmptySelect(columns: readonly StateCacheColumn[]): string { 61 | const projection = columns 62 | .map((column) => `CAST(NULL AS TEXT) AS ${quoteIdentifier(column)}`) 63 | .join(",\n\t"); 64 | return `SELECT 65 | ${projection} 66 | WHERE 0`; 67 | } 68 | 69 | function quoteIdentifier(value: string): string { 70 | return `"${value.replace(/"/g, '""')}"`; 71 | } 72 | -------------------------------------------------------------------------------- /src/change-set/schema-definition.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | FromLixSchemaDefinition, 3 | LixSchemaDefinition, 4 | } from "../schema-definition/definition.js"; 5 | 6 | export type LixChangeSet = FromLixSchemaDefinition; 7 | 8 | export const LixChangeSetSchema = { 9 | "x-lix-key": "lix_change_set", 10 | "x-lix-version": "1.0", 11 | "x-lix-primary-key": ["/id"], 12 | "x-lix-override-lixcols": { 13 | lixcol_file_id: '"lix"', 14 | lixcol_plugin_key: '"lix_sdk"', 15 | lixcol_version_id: '"global"', 16 | }, 17 | type: "object", 18 | properties: { 19 | id: { 20 | type: "string", 21 | "x-lix-default": "lix_uuid_v7()", 22 | }, 23 | }, 24 | required: ["id"], 25 | additionalProperties: false, 26 | } as const; 27 | LixChangeSetSchema satisfies LixSchemaDefinition; 28 | 29 | export type LixChangeSetElement = FromLixSchemaDefinition< 30 | typeof LixChangeSetElementSchema 31 | >; 32 | 33 | export const LixChangeSetElementSchema = { 34 | "x-lix-key": "lix_change_set_element", 35 | "x-lix-version": "1.0", 36 | "x-lix-foreign-keys": [ 37 | { 38 | properties: ["/change_set_id"], 39 | references: { 40 | schemaKey: "lix_change_set", 41 | properties: ["/id"], 42 | }, 43 | }, 44 | { 45 | properties: ["/change_id"], 46 | references: { 47 | schemaKey: "lix_change", 48 | properties: ["/id"], 49 | }, 50 | }, 51 | { 52 | properties: ["/schema_key"], 53 | references: { 54 | schemaKey: "lix_stored_schema", 55 | properties: ["/value/x-lix-key"], 56 | }, 57 | }, 58 | ], 59 | "x-lix-primary-key": ["/change_set_id", "/change_id"], 60 | "x-lix-unique": [["/change_set_id", "/entity_id", "/schema_key", "/file_id"]], 61 | "x-lix-override-lixcols": { 62 | lixcol_file_id: '"lix"', 63 | lixcol_plugin_key: '"lix_sdk"', 64 | lixcol_version_id: '"global"', 65 | }, 66 | type: "object", 67 | properties: { 68 | change_set_id: { type: "string" }, 69 | change_id: { type: "string" }, 70 | entity_id: { type: "string" }, 71 | schema_key: { type: "string" }, 72 | file_id: { type: "string" }, 73 | }, 74 | required: [ 75 | "change_set_id", 76 | "change_id", 77 | "entity_id", 78 | "schema_key", 79 | "file_id", 80 | ], 81 | additionalProperties: false, 82 | } as const; 83 | LixChangeSetElementSchema satisfies LixSchemaDefinition; 84 | -------------------------------------------------------------------------------- /src/diff/select-commit-diff.test.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from "vitest"; 2 | import { openLix } from "../lix/open-lix.js"; 3 | import { createCheckpoint } from "../state/create-checkpoint.js"; 4 | import { selectCommitDiff } from "./select-commit-diff.js"; 5 | 6 | test("selectCommitDiff: added/modified/unchanged between two commits", async () => { 7 | const lix = await openLix({}); 8 | 9 | // Seed initial state: a=1, c=3 10 | await lix.db.insertInto("key_value").values({ key: "a", value: 1 }).execute(); 11 | await lix.db.insertInto("key_value").values({ key: "c", value: 3 }).execute(); 12 | 13 | const c1 = await createCheckpoint({ lix }); 14 | 15 | // Next commit: modify a, add b 16 | await lix.db 17 | .updateTable("key_value") 18 | .set({ value: 2 }) 19 | .where("key", "=", "a") 20 | .execute(); 21 | await lix.db 22 | .insertInto("key_value") 23 | .values({ key: "b", value: 20 }) 24 | .execute(); 25 | 26 | const c2 = await createCheckpoint({ lix }); 27 | 28 | // Diff c1 -> c2 29 | const rows = await selectCommitDiff({ lix, before: c1.id, after: c2.id }) 30 | .where("diff.schema_key", "=", "lix_key_value") 31 | .where("diff.file_id", "=", "lix") 32 | .selectAll() 33 | .execute(); 34 | 35 | // Expect three rows: a modified, b added, c unchanged 36 | const byKey = new Map(rows.map((r) => [r.entity_id, r])); 37 | expect(byKey.get("a")?.status).toBe("modified"); 38 | expect(byKey.get("b")?.status).toBe("added"); 39 | expect(byKey.get("c")?.status).toBe("unchanged"); 40 | 41 | await lix.close(); 42 | }); 43 | 44 | test("selectCommitDiff: removed between two commits", async () => { 45 | const lix = await openLix({}); 46 | 47 | // Seed: b only 48 | await lix.db.insertInto("key_value").values({ key: "b", value: 1 }).execute(); 49 | const c1 = await createCheckpoint({ lix }); 50 | 51 | // Delete b 52 | await lix.db.deleteFrom("key_value").where("key", "=", "b").execute(); 53 | const c2 = await createCheckpoint({ lix }); 54 | 55 | const rows = await selectCommitDiff({ lix, before: c1.id, after: c2.id }) 56 | .where("diff.schema_key", "=", "lix_key_value") 57 | .where("diff.file_id", "=", "lix") 58 | .selectAll() 59 | .execute(); 60 | 61 | const b = rows.find((r) => r.entity_id === "b"); 62 | expect(b?.status).toBe("removed"); 63 | 64 | await lix.close(); 65 | }); 66 | -------------------------------------------------------------------------------- /src/state/vtable/insert-vtable-log.ts: -------------------------------------------------------------------------------- 1 | import { LixLogSchema, type LixLog } from "../../log/schema-definition.js"; 2 | import { uuidV7Sync } from "../../engine/functions/uuid-v7.js"; 3 | import { getTimestampSync } from "../../engine/functions/timestamp.js"; 4 | import type { JSONType } from "../../schema-definition/json-type.js"; 5 | import type { LixEngine } from "../../engine/boot.js"; 6 | import { insertTransactionState } from "../transaction/insert-transaction-state.js"; 7 | 8 | // Track if logging is in progress per Lix instance to prevent recursion 9 | const loggingInProgressMap = new WeakMap< 10 | Pick, 11 | boolean 12 | >(); 13 | 14 | /** 15 | * Insert a log entry directly using insertTransactionState to avoid recursion 16 | * when logging from within the virtual table methods. 17 | * 18 | * This is a minimal wrapper that can be mocked in tests to control timestamps. 19 | */ 20 | export function insertVTableLog(args: { 21 | engine: Pick; 22 | id?: string; 23 | key: string; 24 | message?: string | null; 25 | payload?: JSONType; 26 | level: string; 27 | timestamp?: string; 28 | }): void { 29 | if (loggingInProgressMap.get(args.engine)) { 30 | return; 31 | } 32 | 33 | loggingInProgressMap.set(args.engine, true); 34 | try { 35 | const id = args.id ?? uuidV7Sync({ engine: args.engine as any }); 36 | // Insert into transaction state (untracked) to preserve previous behavior. 37 | // Note: If called outside a vtable write, this may require a later commit to flush. 38 | insertTransactionState({ 39 | engine: args.engine as any, 40 | timestamp: 41 | args.timestamp ?? getTimestampSync({ engine: args.engine as any }), 42 | data: [ 43 | { 44 | entity_id: id, 45 | schema_key: LixLogSchema["x-lix-key"], 46 | file_id: "lix", 47 | plugin_key: "lix_sdk", 48 | snapshot_content: JSON.stringify({ 49 | id, 50 | key: args.key, 51 | message: args.message ?? null, 52 | payload: args.payload ?? null, 53 | level: args.level, 54 | } satisfies LixLog), 55 | schema_version: LixLogSchema["x-lix-version"], 56 | version_id: "global", 57 | untracked: true, 58 | }, 59 | ], 60 | }); 61 | } finally { 62 | loggingInProgressMap.set(args.engine, false); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/test-utilities/simulation-test/out-of-order-sequence-simulation.ts: -------------------------------------------------------------------------------- 1 | import { vi } from "vitest"; 2 | import * as tsModule from "../../engine/functions/timestamp.js"; 3 | import type { SimulationTestDef } from "./simulation-test.js"; 4 | 5 | /** 6 | * Out-of-order sequence simulation - Returns shuffled sequence numbers 7 | * to ensure queries don't rely on ordered IDs or timestamps. 8 | * 9 | * This simulation shuffles sequence numbers to catch any code that incorrectly 10 | * assumes monotonic ordering of IDs, timestamps, or other sequence-based values. 11 | */ 12 | export const outOfOrderSequenceSimulation: SimulationTestDef = { 13 | name: "out-of-order-sequence", 14 | setup: async (lix) => { 15 | // Simple deterministic shuffle function using fixed seed 16 | const createShuffledMapping = ( 17 | maxSequences: number 18 | ): Map => { 19 | const normalSequence = Array.from({ length: maxSequences }, (_, i) => i); 20 | const shuffledSequence = [...normalSequence]; 21 | 22 | // Deterministic shuffle with fixed seed 23 | let seed = 12345; 24 | const random = () => { 25 | seed = (seed * 9301 + 49297) % 233280; 26 | return seed / 233280; 27 | }; 28 | 29 | for (let i = shuffledSequence.length - 1; i > 0; i--) { 30 | const j = Math.floor(random() * (i + 1)); 31 | const temp = shuffledSequence[i]!; 32 | shuffledSequence[i] = shuffledSequence[j]!; 33 | shuffledSequence[j] = temp; 34 | } 35 | 36 | // Create mapping from normal to shuffled 37 | const mapping = new Map(); 38 | for (let i = 0; i < normalSequence.length; i++) { 39 | mapping.set(normalSequence[i]!, shuffledSequence[i]!); 40 | } 41 | 42 | return mapping; 43 | }; 44 | 45 | // Pre-generate mapping for first 1000 sequence numbers 46 | const shuffleMapping = createShuffledMapping(1000); 47 | 48 | // Internal counter to track what the "normal" sequence would be 49 | // This avoids calling the real sequence function which would change database state 50 | let internalCounter = 0; 51 | 52 | // Mock the async getTimestamp to return shuffled timestamps 53 | vi.spyOn(tsModule, "getTimestamp").mockImplementation(async () => { 54 | const normal = internalCounter++; 55 | const shuffled = shuffleMapping.get(normal) ?? normal; 56 | return new Date(shuffled).toISOString(); 57 | }); 58 | 59 | return lix; 60 | }, 61 | }; 62 | -------------------------------------------------------------------------------- /src/observe/lix-observable.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Micro-observable implementation following TC-39 Observable protocol. 3 | * ~25 lines of code for minimal footprint while maintaining spec compliance. 4 | */ 5 | 6 | // Polyfill Symbol.observable if not present 7 | declare global { 8 | interface SymbolConstructor { 9 | readonly observable: symbol; 10 | } 11 | } 12 | 13 | if (typeof Symbol.observable === "undefined") { 14 | (Symbol as any).observable = Symbol("observable"); 15 | } 16 | 17 | interface Observer { 18 | next?: (value: T) => void; 19 | error?: (error: any) => void; 20 | complete?: () => void; 21 | } 22 | 23 | interface Subscription { 24 | unsubscribe(): void; 25 | } 26 | 27 | interface Observable { 28 | subscribe(observer: Partial>): Subscription; 29 | [Symbol.observable](): Observable; 30 | } 31 | 32 | export class LixObservable implements Observable { 33 | constructor( 34 | private subscriber: (observer: Observer) => (() => void) | void 35 | ) {} 36 | 37 | subscribe(observer: Partial>): Subscription { 38 | let closed = false; 39 | const safeObserver: Observer = { 40 | next: (value) => !closed && observer.next?.(value), 41 | error: (err) => { 42 | if (!closed) { 43 | closed = true; 44 | observer.error?.(err); 45 | } 46 | }, 47 | complete: () => { 48 | if (!closed) { 49 | closed = true; 50 | observer.complete?.(); 51 | } 52 | }, 53 | }; 54 | 55 | const cleanup = this.subscriber(safeObserver); 56 | 57 | return { 58 | unsubscribe() { 59 | if (!closed) { 60 | closed = true; 61 | cleanup?.(); 62 | } 63 | }, 64 | }; 65 | } 66 | 67 | [Symbol.observable](): LixObservable { 68 | return this; 69 | } 70 | 71 | subscribeTakeFirst(observer: Partial>): Subscription { 72 | return this.subscribe({ 73 | next: (rows) => observer.next?.(rows[0]), 74 | error: observer.error, 75 | complete: observer.complete, 76 | }); 77 | } 78 | 79 | subscribeTakeFirstOrThrow(observer: Partial>): Subscription { 80 | return this.subscribe({ 81 | next: (rows) => { 82 | if (rows.length === 0) { 83 | observer.error?.(new Error("Query returned no rows")); 84 | } else { 85 | observer.next?.(rows[0]!); 86 | } 87 | }, 88 | error: observer.error, 89 | complete: observer.complete, 90 | }); 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /scripts/embed-sqlite-wasm.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import { promises as fs } from "node:fs"; 3 | import { dirname, join } from "node:path"; 4 | import { fileURLToPath } from "node:url"; 5 | import { createRequire } from "node:module"; 6 | 7 | const require = createRequire(import.meta.url); 8 | 9 | const wasmRelativePath = "sqlite-wasm/jswasm/sqlite3.wasm"; 10 | const TARGETS = [ 11 | { dir: "../src/database/sqlite", baseName: "sqlite-wasm-binary" }, 12 | { dir: "../dist/database/sqlite", baseName: "sqlite-wasm-binary" }, 13 | ]; 14 | const bytesPerLine = 20; 15 | 16 | function chunk(values, size) { 17 | const result = []; 18 | for (let i = 0; i < values.length; i += size) { 19 | result.push(values.slice(i, i + size)); 20 | } 21 | return result; 22 | } 23 | 24 | async function main() { 25 | const pkgPath = require.resolve("@sqlite.org/sqlite-wasm/package.json"); 26 | const packageDir = dirname(pkgPath); 27 | const wasmPath = join(packageDir, wasmRelativePath); 28 | const wasmBytes = await fs.readFile(wasmPath); 29 | const numbers = Array.from(wasmBytes); 30 | 31 | const chunks = chunk(numbers, bytesPerLine); 32 | const literal = chunks 33 | .map((group, index) => { 34 | const suffix = index === chunks.length - 1 ? "" : ","; 35 | return `\t${group.map((value) => value.toString()).join(", ")}${suffix}`; 36 | }) 37 | .join("\n"); 38 | 39 | const header = `/**\n * @file Auto-generated WebAssembly payload for SQLite.\n *\n * Generated by scripts/embed-sqlite-wasm.js — do not edit manually.\n * Run \`node ./scripts/embed-sqlite-wasm.js\` to refresh the bytes from the\n * upstream \`sqlite3.wasm\`.\n */\n\n`; 40 | 41 | const jsBody = `${header}export const wasmBinary = new Uint8Array([\n${literal}\n]).buffer;\n\nexport default wasmBinary;\n`; 42 | const dtsBody = `export declare const wasmBinary: ArrayBuffer;\nexport default wasmBinary;\n`; 43 | 44 | for (const target of TARGETS) { 45 | const baseUrl = new URL(`${target.dir}/`, import.meta.url); 46 | const jsPath = fileURLToPath(new URL(`${target.baseName}.js`, baseUrl)); 47 | const dtsPath = fileURLToPath(new URL(`${target.baseName}.d.ts`, baseUrl)); 48 | 49 | await fs.mkdir(dirname(jsPath), { recursive: true }); 50 | await fs.writeFile(jsPath, jsBody); 51 | await fs.writeFile(dtsPath, dtsBody); 52 | } 53 | 54 | } 55 | 56 | main().catch((error) => { 57 | console.error("[embed-sqlite-wasm] Failed to generate payload:\n", error); 58 | process.exit(1); 59 | }); 60 | -------------------------------------------------------------------------------- /src/engine/cel-environment/cel-environment.ts: -------------------------------------------------------------------------------- 1 | import { Environment } from "@marcbachmann/cel-js"; 2 | import type { LixEngine } from "../boot.js"; 3 | 4 | export type CelEnvironment = { 5 | /** 6 | * Evaluate a CEL expression with the provided context. 7 | * 8 | * @example 9 | * ```ts 10 | * const value = env.evaluate("1 + 1", {}); 11 | * ``` 12 | */ 13 | evaluate: (expression: string, context: Record) => unknown; 14 | }; 15 | 16 | export function createCelEnvironment(args: { 17 | engine: Pick; 18 | }): CelEnvironment { 19 | const env = new Environment({ 20 | unlistedVariablesAreDyn: true, 21 | }); 22 | 23 | const programCache = new Map>(); 24 | 25 | const callSync = (name: string, callArgs?: unknown): unknown => { 26 | const result = args.engine.call(name, callArgs); 27 | if (result instanceof Promise) { 28 | throw new Error( 29 | `CEL helper "${name}" returned a Promise; asynchronous helpers are not supported` 30 | ); 31 | } 32 | return result; 33 | }; 34 | 35 | for (const { name } of args.engine.listFunctions()) { 36 | env.registerFunction(`${name}(): dyn`, () => { 37 | const result = callSync(name); 38 | return normalizeCelValue(result); 39 | }); 40 | } 41 | 42 | const evaluate = ( 43 | expression: string, 44 | context: Record 45 | ): unknown => { 46 | let program = programCache.get(expression); 47 | if (!program) { 48 | program = env.parse(expression); 49 | programCache.set(expression, program); 50 | } 51 | const result = program(context); 52 | return normalizeCelValue(result); 53 | }; 54 | 55 | return { evaluate }; 56 | } 57 | 58 | export function normalizeCelValue(value: unknown): unknown { 59 | if (typeof value === "bigint") { 60 | const numeric = Number(value); 61 | if (Number.isSafeInteger(numeric)) { 62 | return numeric; 63 | } 64 | return value.toString(); 65 | } 66 | 67 | if (Array.isArray(value)) { 68 | return value.map((entry) => normalizeCelValue(entry)); 69 | } 70 | 71 | if ( 72 | value && 73 | typeof value === "object" && 74 | !(value instanceof Uint8Array) && 75 | !(value instanceof Date) 76 | ) { 77 | const result: Record = {}; 78 | for (const [key, nested] of Object.entries(value)) { 79 | result[key] = normalizeCelValue(nested); 80 | } 81 | return result; 82 | } 83 | 84 | return value; 85 | } 86 | -------------------------------------------------------------------------------- /src/state/cache/create-schema-cache-table.test.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from "vitest"; 2 | import { openLix } from "../../lix/open-lix.js"; 3 | import { 4 | createSchemaCacheTable, 5 | schemaKeyToCacheTableName, 6 | } from "./create-schema-cache-table.js"; 7 | import type { LixSchemaDefinition } from "../../schema-definition/definition.js"; 8 | 9 | const TEST_CACHE_SCHEMA = { 10 | "x-lix-key": "lix_test_create", 11 | "x-lix-version": "1.0", 12 | type: "object", 13 | additionalProperties: false, 14 | properties: {}, 15 | } as const satisfies LixSchemaDefinition; 16 | 17 | test("createSchemaCacheTable creates table with core indexes and is idempotent", async () => { 18 | const lix = await openLix({}); 19 | 20 | const tableName = schemaKeyToCacheTableName(TEST_CACHE_SCHEMA["x-lix-key"]); 21 | 22 | // First call should create the table and indexes 23 | createSchemaCacheTable({ engine: lix.engine!, schema: TEST_CACHE_SCHEMA }); 24 | 25 | // Verify table exists and WITHOUT ROWID 26 | const tbl = lix.engine!.sqlite.exec({ 27 | sql: `SELECT name, sql FROM sqlite_schema WHERE type='table' AND name = ?`, 28 | bind: [tableName], 29 | returnValue: "resultRows", 30 | rowMode: "object", 31 | }) as any[]; 32 | 33 | expect(tbl?.[0]?.name).toBe(tableName); 34 | expect(String(tbl?.[0]?.sql || "")).toMatch(/WITHOUT ROWID/); 35 | 36 | // Verify core indexes exist 37 | const idxRows = lix.engine!.sqlite.exec({ 38 | sql: `SELECT name, sql FROM sqlite_schema WHERE type='index' AND tbl_name = ? ORDER BY name`, 39 | bind: [tableName], 40 | returnValue: "resultRows", 41 | rowMode: "object", 42 | }) as { name: string; sql: string }[]; 43 | 44 | const names = new Set(idxRows.map((r) => r.name)); 45 | expect(Array.from(names)).toEqual( 46 | expect.arrayContaining([ 47 | `idx_${tableName}_version_id`, 48 | `idx_${tableName}_vfe`, 49 | `idx_${tableName}_fv`, 50 | ]) 51 | ); 52 | 53 | // Second call should be a no-op (idempotent) 54 | createSchemaCacheTable({ engine: lix.engine!, schema: TEST_CACHE_SCHEMA }); 55 | 56 | const idxRows2 = lix.engine!.sqlite.exec({ 57 | sql: `SELECT name FROM sqlite_schema WHERE type='index' AND tbl_name = ?`, 58 | bind: [tableName], 59 | returnValue: "resultRows", 60 | }) as string[][]; 61 | 62 | // Still contains the same three core indexes (no duplicates) 63 | const idxCount = idxRows2.filter((r) => 64 | String(r?.[0] || "").startsWith(`idx_${tableName}_`) 65 | ).length; 66 | expect(idxCount).toBeGreaterThanOrEqual(3); 67 | }); 68 | -------------------------------------------------------------------------------- /src/change-proposal/create-change-proposal.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from "vitest"; 2 | import { openLix } from "../lix/open-lix.js"; 3 | import { createVersion } from "../version/create-version.js"; 4 | import { createChangeProposal } from "./create-change-proposal.js"; 5 | 6 | test("createChangeProposal creates a global, open proposal with correct refs", async () => { 7 | const lix = await openLix({}); 8 | // Resolve main and create a source version 9 | const main = await lix.db 10 | .selectFrom("version") 11 | .where("name", "=", "main") 12 | .selectAll() 13 | .executeTakeFirstOrThrow(); 14 | 15 | const source = await createVersion({ 16 | lix, 17 | from: main, 18 | name: "cp_source_test", 19 | }); 20 | 21 | const cp = await createChangeProposal({ lix, source, target: main }); 22 | 23 | // Basic shape 24 | expect(typeof cp.id).toBe("string"); 25 | expect(cp.id.length).toBeGreaterThan(0); 26 | expect(cp.source_version_id).toBe(source.id); 27 | expect(cp.target_version_id).toBe(main.id); 28 | expect(cp.status).toBe("open"); 29 | 30 | // Lives in global scope 31 | const rowAll = await lix.db 32 | .selectFrom("change_proposal_by_version") 33 | .where("id", "=", cp.id) 34 | .where("lixcol_version_id", "=", "global") 35 | .selectAll() 36 | .executeTakeFirstOrThrow(); 37 | expect(rowAll.lixcol_version_id).toBe("global"); 38 | 39 | // Also visible via scoped view 40 | const row = await lix.db 41 | .selectFrom("change_proposal") 42 | .where("id", "=", cp.id) 43 | .selectAll() 44 | .executeTakeFirstOrThrow(); 45 | expect(row.status).toBe("open"); 46 | }); 47 | 48 | test("createChangeProposal respects explicit id and status overrides", async () => { 49 | const lix = await openLix({}); 50 | 51 | const main = await lix.db 52 | .selectFrom("version") 53 | .where("name", "=", "main") 54 | .selectAll() 55 | .executeTakeFirstOrThrow(); 56 | 57 | const source = await createVersion({ 58 | lix, 59 | from: main, 60 | name: "cp_source_override", 61 | }); 62 | 63 | const customId = "cp_custom_id_123"; 64 | const cp = await createChangeProposal({ 65 | lix, 66 | id: customId, 67 | source, 68 | target: main, 69 | status: "rejected", 70 | }); 71 | 72 | expect(cp.id).toBe(customId); 73 | expect(cp.status).toBe("rejected"); 74 | 75 | const fetched = await lix.db 76 | .selectFrom("change_proposal") 77 | .where("id", "=", customId) 78 | .selectAll() 79 | .executeTakeFirstOrThrow(); 80 | expect(fetched.status).toBe("rejected"); 81 | }); 82 | --------------------------------------------------------------------------------