JavaScript API Reference

@lix-js/sdk exports openLix(), createLix(), deleteLix(), the generic JavaScript storage protocol and Value. @lix-js/storage-opfs and @lix-js/storage-filesystem provide concrete storage implementations. openLix() returns a local repository, a thin remote client, or a partial replica with on-demand sync. Plugins are installed by writing a released .lixplugin archive to /.lix/plugins/<plugin-key>.lixplugin in the active branch; see Installing and managing plugins.

import { openLix } from "@lix-js/sdk";

const lix = await openLix();

Hosted repository lifecycle

createLix() provisions a hosted repository and returns { id, url }. Its server.url is the host origin; opening and deleting use the repository URL.

import { createLix, openLix, deleteLix } from "@lix-js/sdk";

const headers = () => ({ Authorization: `Bearer ${token}` });
const repository = await createLix({
  server: { url: "https://example.com", headers },
});
const remote = await openLix({ server: { url: repository.url, headers } });
await remote.close();
await deleteLix({ server: { url: repository.url, headers } });

Supply from: localLix to copy an existing repository instead of creating an empty one. Creation takes one consistent snapshot, including files, branches, history, and untracked rows. It does not attach or change the source handle. Subsequent source edits are not part of that copy. To attach the original durable storage afterward, pause writes during creation, close the local handle, and follow the explicit conversion guide for supported existing replica storage. Opening with server.mode: "partial_replica" does not silently replace unrelated or locally diverged history.

Supply idempotencyKey to recover a creation after a lost response. Retry with the same key and unchanged source snapshot; reusing a key for different content fails. When omitted, the SDK generates a key for that call. Lifecycle requests accept url and headers; custom fetch overrides are not supported.

Browser creation from a local repository requires Fetch request streaming; browsers without it return LIX_UNSUPPORTED_OPERATION. Browser Fetch may also require HTTP/2 or HTTP/3 for these uploads. Lix does not buffer a complete repository as a fallback. Empty creation does not require request streaming.

Opening a missing hosted repository fails; it never provisions one. Deletion removes the hosted resource without deleting local copies. Closing only releases a session. A disconnected replica never recreates a deleted server repository.

openLix()

const lix = await openLix(options?);

Options:

OptionTypeDescription
storageLixStorageLocal storage selected by a provider package. Omit both storage and server for memory.
serverLixServerOptionsDefaults to mode: "remote"; use mode: "partial_replica" with storage for on-demand sync.
telemetryLixTelemetryOptionsOptional onSpan(span) callback that receives telemetry spans. Local and partial-replica modes only.

Connect to a remote server:

const lix = await openLix({
  server: {
    url: "https://example.com/lix/01936f4e-7b6c-7c3d-8f9a-123456789abc",
    headers: () => ({ Authorization: `Bearer ${token}` }),
  },
});

Remote file content, SQL rows, and branches live on the server. Use headers for authentication and fetch when you need a custom fetch implementation.

Open a partial replica with on-demand sync by supplying storage and explicitly selecting server.mode: "partial_replica":

import { OpfsStorage } from "@lix-js/storage-opfs";

const lix = await openLix({
  storage: new OpfsStorage({ name: "atelier" }),
  server: {
    mode: "partial_replica",
    url: "https://example.com/lix/01936f4e-7b6c-7c3d-8f9a-123456789abc",
    headers: () => ({ Authorization: `Bearer ${token}` }),
  },
});

Opening installs bounded metadata. SQL fetches missing native inputs on demand and retains them in local storage. Covered reads and writes with resident dependencies execute locally, including offline. Mutations commit locally and upload in the background; success does not wait for server acceptance.

server.mode defaults to "remote", which rejects storage. Partial-replica mode requires storage. "sync" is not an alias and full "replica" mode is not yet supported. See Collaboration for the complete behavior.

Use OpfsStorage to persist a local browser Lix across reloads:

import { openLix } from "@lix-js/sdk";
import { OpfsStorage } from "@lix-js/storage-opfs";

const lix = await openLix({
  storage: new OpfsStorage({ name: "atelier" }),
});

Use FilesystemStorage for a repository directory backed by RocksDB at <repository>/.lix/.internal/rocksdb:

import { openLix } from "@lix-js/sdk";
import { FilesystemStorage } from "@lix-js/storage-filesystem";

const lix = await openLix({
  storage: new FilesystemStorage({ path: "./repository" }),
});

Use selective synchronization when only explicit paths should be imported:

const storage = new FilesystemStorage({
  path: "./repository",
  syncAllFiles: false,
});
const lix = await openLix({ storage });
await storage.importPaths(["notes/today.md"]);

Call storage.syncDiskToLix() to run one manual sync pass that imports pending disk changes into Lix. It returns Promise<void> and requires an open Lix instance.

await storage.syncDiskToLix();

Lix instance

syncHealth()

const health = await lix.syncHealth();
// { state, appliedCursor, observedCursor, failures, terminalError }

Returns the local partial-replica worker's health without querying SQL or fetching remote data. state is inactive, running, stalled, failed, or stopped. failures holds independent descriptor, publication, upload, and lease errors, each with a code and message. terminalError describes a failed worker. Cursors and terminal errors are null when unavailable.

Successful local reads do not clear sync failures. running means no known worker failure, not guaranteed server freshness. Other modes report inactive. Call this method before closing the JavaScript session.

execute()

const result = await lix.execute(sql, params?, options?);

Executes one PostgreSQL-dialect SQL statement against the active Lix session. Pass a single statement. To run several statements atomically, call executeBatch() with an array of { sql, params? } objects. Do not concatenate statements into one SQL string or parse a script on the host.

For a partial replica with on-demand sync, prefetch a view by executing its SELECT on hover, then execute the same SELECT when opening it. Resident inputs stay local. Use ordinary execute() for writes; a read does not promise that all later write validation or commit dependencies are resident.

Cancellable buffered local and partial-replica reads have a 30-second deadline including input hydration, and return at most 64 MiB or 1,000,000 rows per operation. executeBatch() shares the result budget across its read statements. Narrow large queries or request file ranges. Exceeding these bounds reports LIX_READ_DEADLINE_EXCEEDED or LIX_READ_RESOURCE_EXHAUSTED; accepted writes are never canceled or replayed by the read deadline.

Parameters:

ParameterTypeDescription
sqlstringOne statement from Lix's PostgreSQL-dialect subset.
paramsSqlParam[]Optional positional parameters addressed as $1, $2, and so on.
optionsExecuteOptionsOptional execution options. See below.

SqlParam accepts JSON values, Uint8Array, or a Value:

type SqlParam = JsonValue | Uint8Array | Value;

ExecuteOptions:

OptionTypeDescription
maxAutoCommitRetriesnumberMaximum automatic transaction replays after the initial attempt. 0 fails fast; omit to retain default recovery budgets.
originKeystringOptional origin label for the mutation.
idempotencyKeystringStable identity for one logical remote SQL mutation. This is the retry story: supply the same key when retrying after a lost response, and the server applies the mutation only once. Remote Lix generates one per call when omitted. Sent as Idempotency-Key, not SQL options.
rowMode"object" | "array"Return plain objects by default or positional arrays when duplicate column names must remain separately addressable.

Result:

type ExecuteResult<TRow = Record<string, unknown>> = {
  statementIndex?: number;
  label?: string;
  columns: { name: string; type: "null" | "boolean" | "integer" | "real" | "text" | "jsonb" | "row_ref" | "timestamptz" | "blob" }[];
  rows: TRow[];
  rowsAffected: number;
  notices: { code: string; message: string; hint?: string }[];
  commit: { before: string; after: string } | null;
};
FieldDescription
columnsColumn names and SQL value types in result order. Empty for statements that do not return rows.
rowsEnumerable plain objects by default. Property access, destructuring, spread, and JSON serialization work directly.
rowsAffectedNumber of rows affected by write statements.
noticesNon-fatal engine notices with { code, message, hint? }.
commitExact active-branch transition { before, after } for an auto-committed write; null for reads. Equal endpoints mean no active-branch movement. Explicit transaction statements omit this field; batch and transaction receipts return it once.

Example:

const result = await lix.execute(
  "SELECT path, content FROM lix_file WHERE path = $1",
  ["/hello.txt"],
);

const path = result.rows[0]?.path;
const content = result.rows[0]?.content as Uint8Array | undefined;

executeBatch()

const { results, commit } = await lix.executeBatch(statements, options?);

Executes multiple statements atomically in one call. Returns { results, commit }: one statement-result array and one commit span for the whole transaction. Read-only batches return commit: null; individual results have no commit field. statements is a non-empty array of { sql, params?, label? } objects — one statement per entry, already split by the caller. Lix does not parse a multi-statement script. options accepts the same originKey, idempotencyKey, and maxAutoCommitRetries as execute(). Results preserve input order and include a zero-based statementIndex. A supplied label is echoed unchanged; labels are opaque and may repeat. If a label is omitted, the result has no label property.

const { results, commit } = await lix.executeBatch([
  {
    label: "create",
    sql: "INSERT INTO lix_file (path, content) VALUES ($1, $2)",
    params: ["/a.txt", bytes],
  },
  { sql: "SELECT count(*) AS n FROM lix_file" },
]);

console.log(results[0].statementIndex, results[0].label); // 0, "create"
console.log(results[1].statementIndex, results[1].label); // 1, undefined

const { results: returning } = await lix.executeBatch([
  {
    label: "update",
    sql: "UPDATE task SET done = true WHERE id = $1 RETURNING id, done",
    params: ["task-1"],
  },
]);
console.log(returning[0].rows[0]?.done);

observe()

const events = lix.observe(sql, params?, { signal }?);

Observes a SQL query as a standard async iterator. Each value is { sequence, mutationSequence, result }, beginning with the initial result. Consume it with for await...of; manual next() returns { value, done }.

const controller = new AbortController();
try {
  for await (const event of lix.observe("SELECT path FROM lix_file", [], {
    signal: controller.signal,
  })) {
    console.log(event.result.rows.length);
    // break exits iteration and releases the observation.
  }
} catch (error) {
  console.error("Observation failed", error);
}
// A component or task owner can abort from outside the loop:
controller.abort();

Aborting, reaching EOF, or closing Lix ends iteration and settles pending reads. Breaking or returning from the loop releases the observation through the standard iterator return() method. Errors reject and terminate the observation. Cancellation does not cancel work already started by your loop body; check the signal before publishing the result of additional asynchronous work. Result coalescing is unchanged: observations represent current query results, not a lossless mutation log.

The custom ObserveEvents export and observation close() method are removed.

beginTransaction()

const tx = await lix.beginTransaction();

Starts an independent transaction context on this handle's current branch and account. Execute statements that belong to the transaction through tx.execute(); these reads see its staged writes. Ordinary lix.execute() reads and lix.observe() remain available on the original handle and see committed data. Observers publish relevant updates after commit; rolled-back writes are never published. Changing the original handle's branch does not retarget the transaction. Local transactions retain the caller's previously acknowledged plugin-file view, so plugins can merge stale content against the correct base.

Each handle permits one opening or active explicit transaction at a time. Use openAnotherSession() for another independent handle when needed.

Commit or roll back the transaction before closing the original handle. Closing with an opening or active transaction still fails with LIX_INVALID_TRANSACTION_STATE.

SQL UPDATE and DELETE decisions, and successful explicit SQL reads used to decide later writes, are protected until commit. When another commit lands on the branch after this transaction opens, Lix re-checks exactly what those decisions depended on: every row an explicit read or UPDATE/DELETE predicate returned or could have matched, and every row the transaction writes. Commits that touched none of them are rebased onto the new head and the transaction commits normally, so concurrent writes to unrelated rows, other files, or other tables do not interfere. If a concurrent commit changed, inserted, or deleted one of those rows, committing fails with LIX_TRANSACTION_CONFLICT and nothing is published; this prevents lost updates and write skew on rows the transaction read. lix_file statements that select files by id are checked per file; a path predicate is resolved through the branch's whole path index and still conflicts with concurrent file writes. Plugin-backed file content is checked per file: a plugin re-derives all of a file's rows from its content, so any concurrent change to the same file conflicts. Reads of branch, history, change, or checkpoint state (lix_branch, lix_change, lix_history, lix_diff, lix_as_of, lix_commit_ancestry, lix_active_branch_commit_id(), lix_root_commit_id(), lix_working_diff_checkpoint_commit_id(), checkpoint functions) cannot be validated row by row; a transaction that read them conflicts with any concurrent change to its branch or to shared/global state. A concurrent schema registration or change also conflicts, because the transaction's statements were planned against the previous schema catalog. Constraint checks (uniqueness, row references) and ON DELETE actions are re-evaluated against the latest state when a transaction is rebased. Read-only transactions always commit successfully and return { commit: null }. A successfully planned update or delete keeps its rows protected even if it matches no rows or subsequently fails and the transaction continues with other writes.

A conflict error's details say what overlapped: reason is readSetChanged or writeSetChanged with overlaps listing up to eight { branchId, schemaKey, fileId, rowPk } identities (and overlapCount the total), unvalidatedReadChanged with source naming the read that has no row-level validation (including a concurrent schema catalog change), staleSnapshotNotRebased when the transaction's writes (for example untracked or global rows) cannot be rebased onto a newer head, or commitRaced when another commit won the final atomic publication. retryable is true in every case. Start a new transaction and rerun its statements against current state, or use lix.transaction() to do that automatically.

Rows returned by RETURNING inside a transaction are provisional. Report a publication as successful only after commit() succeeds. Automatic execute() and executeBatch() can rerun the whole statement or batch after a known failed transaction. Explicit transactions remain caller-controlled.

Set maxAutoCommitRetries to cap these replays across both transaction contention and expired transaction snapshots. The initial attempt does not count; 0 returns the first failure without re-executing the transaction. When omitted, Lix permits up to 16 contention retries and separately bounds expired-snapshot recovery by its existing time budget. An explicit cap cannot extend that expiry budget. This option does not control pure-read recovery, idempotency receipt lookups, or network retries.

await lix.execute(sql, params, { maxAutoCommitRetries: 0 });
await lix.executeBatch(statements, { maxAutoCommitRetries: 2 });

Rust callers use .with_max_auto_commit_retries(0) on lix.execute(...) or lix.execute_batch(...), including remote handles.

Re-execution reevaluates predicates against current state. An update guarded by an expected revision may therefore succeed with zero affected rows after a retry; check rowsAffected as well as commit success. Retries do not provide general serializable isolation for arbitrary reads or cross-branch dependencies.

Lix never automatically re-executes an unknown commit outcome or an operation marked as having completed execution/publication. Remote mutation recovery first looks up its idempotency receipt: a durable matching receipt replays the saved result; a known transaction conflict with no receipt may retry with the same identity. Absence after an unknown outcome is not proof that the write failed.

Errors exiting the automatic transaction retry loop retain their code and details and add autoCommitRetryCount and autoCommitRetryStopReason; an explicit cap is included as maxAutoCommitRetries. Debug tracing records each replay and its error code. The count covers re-executions, not receipt lookups or transport work.

Unconditional INSERT ... ON CONFLICT DO UPDATE file saves and explicit branch merges retain their collaboration semantics. Use UPDATE ... WHERE with an expected revision and require commit success when publication depends on that revision remaining current.

const tx = await lix.beginTransaction();
try {
  await tx.execute("INSERT INTO lix_file (path, content) VALUES ($1, $2)", [
    "/hello.txt",
    new TextEncoder().encode("hello"),
  ]);
  await tx.commit();
} catch (error) {
  await tx.rollback();
  throw error;
}

transaction()

const { value, commit, retries } = await lix.transaction(
  async (tx) => {
    const { rows } = await tx.execute(
      "SELECT value FROM lix_key_value WHERE key = 'counter'",
    );
    const next = (rows[0]?.value as number) + 1;
    await tx.execute(
      "UPDATE lix_key_value SET value = $1 WHERE key = 'counter'",
      [next],
    );
    return next;
  },
  { maxRetries: 3 },
);

Runs the callback in an explicit transaction and commits it. If the commit (or a statement) fails with LIX_TRANSACTION_CONFLICT, the transaction is rolled back and the callback runs again on a fresh transaction, up to maxRetries times (default 3; 0 never reruns). Any other error rolls the transaction back and rejects immediately. After the last allowed rerun, the conflict rejects with details.transactionRetryCount and details.maxTransactionRetries added.

Returns { value, commit, retries }: the callback's return value from the attempt that committed, that attempt's CommitSpan (or null for a read-only transaction), and how many reruns were needed. The callback must not commit or roll back tx itself, and side effects outside the transaction must tolerate being repeated.

activeBranchId()

const branchId = await lix.activeBranchId();

Returns the id of the branch the Lix instance is currently reading and writing.

activeAccountId()

const accountId = await lix.activeAccountId();

Returns the id of the active account.

subscribeActiveBranch()

const unsubscribe = lix.subscribeActiveBranch(listener);

Subscribes to successful branch switches made through this Lix handle. The listener is a function with no arguments. Returns an unsubscribe function.

Checkpoints

Checkpointing uses the canonical SQL surface rather than a separate typed SDK method:

const result = await lix.execute(
  "SELECT commit_id FROM lix_create_checkpoint($1, $2)",
  ["Validate imports", { _type: "zettel_doc", blocks: [] }],
);
const commitId = result.rows[0].commit_id;

Pass null for either argument when no title or comment is needed. If both are null, lix_log().conversation_id is also NULL.

See Checkpoints for scoped row-reference selections.

Undo and redo

Undo and redo are SQL commands. Execute SELECT commit_id FROM lix_undo() or SELECT commit_id FROM lix_redo() and read the returned receipt row. See Undo and redo for explicit targets and row-reference selections.

createBranch()

const branch = await lix.createBranch({
  name: "Explore",
});

Creates a branch.

Options:

OptionTypeDescription
namestringBranch name.
idstringOptional explicit branch id.
fromCommitIdstringOptional commit id to start from.

Result:

type CreateBranchReceipt = {
  id: string;
  name: string;
  hidden: boolean;
  commitId: string;
};

switchBranch()

await lix.switchBranch({ branchId });

Switches the Lix instance to another branch. Plain SQL tables read and write the active branch.

type SwitchBranchReceipt = { branchId: string };

mergeBranchPreview()

const preview = await lix.mergeBranchPreview({
  sourceBranchId: draft.id,
});

Computes the merge result from sourceBranchId into the active branch without applying it.

Result:

type MergeBranchPreview = {
  outcome: "alreadyUpToDate" | "fastForward" | "mergeCommitted";
  targetBranchId: string;
  sourceBranchId: string;
  baseCommitId: string;
  targetHeadCommitId: string;
  sourceHeadCommitId: string;
  changeStats: MergeChangeStats;
};

mergeBranch()

const merge = await lix.mergeBranch({
  sourceBranchId: draft.id,
});

Merges sourceBranchId into the active branch.

Result:

type MergeBranchReceipt = {
  outcome: "alreadyUpToDate" | "fastForward" | "mergeCommitted";
  targetBranchId: string;
  sourceBranchId: string;
  baseCommitId: string;
  targetHeadBeforeCommitId: string;
  sourceHeadBeforeCommitId: string;
  targetHeadAfterCommitId: string;
  createdMergeCommitId: string | null;
  changeStats: MergeChangeStats;
};

MergeChangeStats:

type MergeChangeStats = {
  total: number;
  added: number;
  modified: number;
  removed: number;
};

close()

await lix.close();

Closes the Lix handle and its storage resources.

Transaction

Transaction execute() returns StatementResult<TRow>, defined as Omit<ExecuteResult<TRow>, "commit">. The durable receipt arrives only from commit(), which returns CommitReceipt = { commit: CommitSpan | null }.

Transactions expose:

MethodDescription
execute(sql, params?, options?)Execute SQL inside the transaction. Same ExecuteOptions as lix.execute().
commit()Commit and close the transaction, returning { commit } with its durable span or null for a read-only transaction.
rollback()Roll back the transaction and close the transaction handle.

Result rows

execute() returns ordinary JavaScript objects.

const row = result.rows[0]!;

Use row.column_name, row[dynamicColumn], destructuring, spread, or JSON.stringify(row) directly. Duplicate output names use the last value in object mode while every descriptor remains in columns; pass { rowMode: "array" } to execute() or executeBatch() when positional duplicates are required.

Value

Value constructs explicitly typed SQL parameters. Returned values are native JavaScript values and their SQL types are described by result.columns.

Accessors:

MethodReturn typeDescription
toJS()unknownReturns a defensive copy of the native JS value.
asBytes()Uint8Array | undefinedReturns a defensive copy for blob values.

Constructors:

MethodDescription
Value.null()Create a SQL null value.
Value.integer(value)Create an integer value.
Value.boolean(value)Create a boolean value.
Value.real(value)Create a real number value.
Value.text(value)Create a text value.
Value.jsonb(value)Create a JSONB value.
Value.timestamptz(value)Create a timestamptz value from an RFC 3339 string.
Value.blob(value)Create a blob value from Uint8Array.
Value.from(raw)Convert a JSON-compatible JS value, Uint8Array, or Value into a Value.