@lix-js/sdk 0.6.0-preview.0 → 0.6.0-preview.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -0
- package/SKILL.md +483 -0
- package/dist/engine-wasm/index.d.ts +15 -11
- package/dist/engine-wasm/index.js +105 -38
- package/dist/engine-wasm/wasm/lix_engine.d.ts +9 -2
- package/dist/engine-wasm/wasm/lix_engine.js +10 -5
- package/dist/engine-wasm/wasm/lix_engine.wasm +0 -0
- package/dist/engine-wasm/wasm/lix_engine.wasm.d.ts +1 -1
- package/dist/generated/builtin-schemas.d.ts +31 -41
- package/dist/generated/builtin-schemas.js +52 -56
- package/dist/open-lix.d.ts +38 -10
- package/dist/open-lix.js +196 -35
- package/package.json +3 -2
package/README.md
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# @lix-js/sdk
|
|
2
|
+
|
|
3
|
+
WASM-backed JavaScript SDK for Lix.
|
|
4
|
+
|
|
5
|
+
## Agent Guidance
|
|
6
|
+
|
|
7
|
+
If you are an AI coding agent using this package, read [`SKILL.md`](./SKILL.md) before building examples, demos, tests, or applications with `@lix-js/sdk`.
|
|
8
|
+
|
|
9
|
+
The skill documents the current preview API, recommended SQLite backend setup, schema registration flow, entity-table writes, version workflows, merge behavior, and known sharp edges.
|
package/SKILL.md
ADDED
|
@@ -0,0 +1,483 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: lix-js-sdk
|
|
3
|
+
description: Use this skill when building examples, demos, tests, or applications with @lix-js/sdk — opening a Lix, registering schemas, writing entities through generated SQL tables, branching state into named versions, merging, and querying the change history.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Lix JS SDK Skill
|
|
7
|
+
|
|
8
|
+
## What Is Lix
|
|
9
|
+
|
|
10
|
+
Lix is an embeddable version control system. The primary use case is **versioning files of any kind** — `.docx`, CAD drawings, PDFs, JSON catalogs, source code — by parsing them into typed entities on write. Once parsed, the file's contents become rows in your schema tables, and Lix versions, branches, and merges those rows. A registered file plugin owns the parse-and-serialize round trip; the engine handles versioning, branching, three-way merge, and an immutable change journal.
|
|
11
|
+
|
|
12
|
+
Apps can also use the entity layer **directly**, without files in the picture. That's the appealing path for app-first products: define a schema, write rows, get versioning for free. Same engine, same primitives.
|
|
13
|
+
|
|
14
|
+
Every row in every registered schema is a tracked entity, every write happens on a named **version** (Lix's word for "branch"), and `mergeVersion` performs a three-way merge over those entities. The whole engine runs in-process, normally against a local SQLite file.
|
|
15
|
+
|
|
16
|
+
**Merge granularity is per-entity (per-row), not per-field.** Two versions editing *different rows* (or different entities of the same schema) merge cleanly. Two versions editing the *same row* — even on disjoint fields — surface as a conflict today. Field-level merge is on the roadmap; until it ships, model your data as multiple small entities (sections, blocks, paragraphs, message keys, line items) when you want concurrent reviewers to compose without conflict.
|
|
17
|
+
|
|
18
|
+
**Lix targets non-developers as the primary end-user.** That shapes the vocabulary: what Git calls a "branch" is called a **version** in Lix, because "you can have multiple versions of this document" reads naturally to non-devs while "branch" does not. Use "version" — not "branch" — in user-facing copy, demo names, and example values.
|
|
19
|
+
|
|
20
|
+
**Where Lix sits next to neighbors:** *not Yjs* (Lix is entity-grained, not character-CRDT), *not Git* (typed rows + a queryable change journal, not text diffs), *not plain SQLite* (branches and merges are primitives, not something you build on top).
|
|
21
|
+
|
|
22
|
+
## When To Use Lix
|
|
23
|
+
|
|
24
|
+
Reach for Lix when an app needs branchable, mergeable, auditable structured state:
|
|
25
|
+
|
|
26
|
+
- **Document / CMS / no-code editors with drafts and review.** A user clicks "Propose changes" → that's `createVersion({ name: "Marketing edit" })`. Reviewers diff via `lix_change`, accept via `mergeVersion`. Like Google Docs "suggesting mode", but durable, branchable, and over typed entities.
|
|
27
|
+
- **AI agent sandboxes.** Spawn an agent on its own version, let it mutate freely, the human reviews the diff and merges or discards. Cheap rollback, no shadow tables.
|
|
28
|
+
- **Real-time and local-first multiplayer.** Lix already journals every change with author, timestamp, and entity identity — that is exactly what a sync protocol needs. Each peer is a version; sync is merge. Conflicts surface as exceptions instead of silent last-writer-wins.
|
|
29
|
+
- **Scenario / what-if branching** in spreadsheets, budgets, OKR planners, pricing tables.
|
|
30
|
+
- **Translation / localization workflows.** Each translator works on a version of the message catalog; merges flow back to main.
|
|
31
|
+
- **Auditable records** (compliance, clinical, legal). `lix_change` is an immutable journal queryable as SQL — replaces a hand-rolled audit log.
|
|
32
|
+
|
|
33
|
+
Anti-fits: high-throughput OLTP (payments, telemetry, ad bidding), pure analytics ingest, and read-only caches. Lix tracks every write — that is dead weight when there is nothing to version.
|
|
34
|
+
|
|
35
|
+
## API Surface
|
|
36
|
+
|
|
37
|
+
```ts
|
|
38
|
+
import { openLix } from "@lix-js/sdk";
|
|
39
|
+
import { createBetterSqlite3Backend } from "@lix-js/sdk/sqlite";
|
|
40
|
+
|
|
41
|
+
const lix = await openLix({
|
|
42
|
+
backend: createBetterSqlite3Backend({ path: "/path/to/file.lix" }),
|
|
43
|
+
});
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
`better-sqlite3` is an optional peer dependency of `@lix-js/sdk`; install it in any project that imports `@lix-js/sdk/sqlite`:
|
|
47
|
+
|
|
48
|
+
```sh
|
|
49
|
+
npm i @lix-js/sdk better-sqlite3
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Use the version of this skill that ships with the installed `@lix-js/sdk` package; do not copy version-specific snippets from older releases.
|
|
53
|
+
|
|
54
|
+
The default `openLix()` (no `backend`) is in-memory and dies with the process. For anything that needs to persist — demos, scripts, tests, real apps — pass a `better-sqlite3` backend with a real file path. Each successful `execute()` is durable; `lix.close()` releases the backend handle. Reopening with the same path picks up where you left off.
|
|
55
|
+
|
|
56
|
+
For tests and demos, use an isolated temp dir per run:
|
|
57
|
+
|
|
58
|
+
```ts
|
|
59
|
+
import { mkdtempSync } from "node:fs";
|
|
60
|
+
import { tmpdir } from "node:os";
|
|
61
|
+
import path from "node:path";
|
|
62
|
+
|
|
63
|
+
const dir = mkdtempSync(path.join(tmpdir(), "lix-"));
|
|
64
|
+
const lix = await openLix({
|
|
65
|
+
backend: createBetterSqlite3Backend({ path: path.join(dir, "demo.lix") }),
|
|
66
|
+
});
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
The handle is intentionally small:
|
|
70
|
+
|
|
71
|
+
```ts
|
|
72
|
+
type Lix = {
|
|
73
|
+
execute(sql: string, params?: readonly unknown[]): Promise<ExecuteResult>;
|
|
74
|
+
activeVersionId(): Promise<string>;
|
|
75
|
+
createVersion(options: { id?: string; name: string }): Promise<{ versionId: string }>;
|
|
76
|
+
switchVersion(options: { versionId: string }): Promise<{ versionId: string }>;
|
|
77
|
+
mergeVersion(options: { sourceVersionId: string }): Promise<MergeVersionResult>;
|
|
78
|
+
close(): Promise<void>;
|
|
79
|
+
};
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Use the public `@lix-js/sdk` API only. Do not import from `engine-wasm`, do not call `initLix` / `createWasmSqliteBackend`, do not open SQLite directly against a `.lix` file.
|
|
83
|
+
|
|
84
|
+
If behavior is unclear, read source before guessing:
|
|
85
|
+
|
|
86
|
+
- [packages/js-sdk/src/open-lix.ts](https://github.com/opral/lix/blob/561f92b5bc3fa68e48a863ed3a02129645a57011/packages/js-sdk/src/open-lix.ts) — current JS API.
|
|
87
|
+
- [packages/js-sdk/src/open-lix.test.ts](https://github.com/opral/lix/blob/561f92b5bc3fa68e48a863ed3a02129645a57011/packages/js-sdk/src/open-lix.test.ts) — canonical end-to-end flow.
|
|
88
|
+
- [packages/js-sdk/src/sqlite/index.ts](https://github.com/opral/lix/blob/561f92b5bc3fa68e48a863ed3a02129645a57011/packages/js-sdk/src/sqlite/index.ts) — `better-sqlite3` backend factory.
|
|
89
|
+
- [packages/engine/src/schema/builtin](https://github.com/opral/lix/tree/561f92b5bc3fa68e48a863ed3a02129645a57011/packages/engine/src/schema/builtin) — built-in entity table shapes.
|
|
90
|
+
- [packages/engine/src/sql2/udfs](https://github.com/opral/lix/tree/561f92b5bc3fa68e48a863ed3a02129645a57011/packages/engine/src/sql2/udfs) — registered SQL functions.
|
|
91
|
+
|
|
92
|
+
## Canonical End-To-End Example
|
|
93
|
+
|
|
94
|
+
This is the demo to imitate. It shows the four things that make Lix Lix in one script: **isolation** (one SELECT against `<schema>_by_version` returns all versions side by side), **clean parallel merges** (two reviewers editing *different entities* both land on Published), **the audit journal** (`lix_change` is a queryable SQL table), and **conflicts** (two versions edit the *same* entity → `mergeVersion` throws).
|
|
95
|
+
|
|
96
|
+
The schema models a brochure as a list of **section entities** (headline, body, disclaimer) — not one row with multiple fields. That matches Lix's per-row merge granularity: Marketing edits the headline section, Legal edits the disclaimer section, both merge cleanly because they touched different rows.
|
|
97
|
+
|
|
98
|
+
Note: every registered schema `X` automatically gets a sibling table `X_by_version` exposing a `lixcol_version_id` column. Use it for cross-version reads and writes — you almost never need `switchVersion` for demos or read paths.
|
|
99
|
+
|
|
100
|
+
```ts
|
|
101
|
+
import { mkdtempSync } from "node:fs";
|
|
102
|
+
import { tmpdir } from "node:os";
|
|
103
|
+
import path from "node:path";
|
|
104
|
+
import { openLix } from "@lix-js/sdk";
|
|
105
|
+
import { createBetterSqlite3Backend } from "@lix-js/sdk/sqlite";
|
|
106
|
+
|
|
107
|
+
const dir = mkdtempSync(path.join(tmpdir(), "lix-"));
|
|
108
|
+
const lix = await openLix({
|
|
109
|
+
backend: createBetterSqlite3Backend({ path: path.join(dir, "demo.lix") }),
|
|
110
|
+
});
|
|
111
|
+
const published = await lix.activeVersionId();
|
|
112
|
+
|
|
113
|
+
// 1. Register a section schema. A brochure is a *collection* of sections.
|
|
114
|
+
await lix.execute(
|
|
115
|
+
"INSERT INTO lix_registered_schema (value) VALUES (lix_json($1))",
|
|
116
|
+
[JSON.stringify({
|
|
117
|
+
$schema: "https://json-schema.org/draft/2020-12/schema",
|
|
118
|
+
"x-lix-key": "acme_section",
|
|
119
|
+
"x-lix-version": "1",
|
|
120
|
+
"x-lix-primary-key": ["/id"],
|
|
121
|
+
type: "object",
|
|
122
|
+
required: ["id", "brochure_id", "kind", "text"],
|
|
123
|
+
properties: {
|
|
124
|
+
id: { type: "string" },
|
|
125
|
+
brochure_id: { type: "string" },
|
|
126
|
+
kind: { type: "string" }, // "headline" | "body" | "disclaimer"
|
|
127
|
+
text: { type: "string" },
|
|
128
|
+
},
|
|
129
|
+
additionalProperties: false,
|
|
130
|
+
})],
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
// 2. Seed three sections of the live brochure on Published.
|
|
134
|
+
await lix.execute(
|
|
135
|
+
`INSERT INTO acme_section (id, brochure_id, kind, text) VALUES
|
|
136
|
+
($1,$2,$3,$4),($5,$6,$7,$8),($9,$10,$11,$12)`,
|
|
137
|
+
[
|
|
138
|
+
"s-headline", "spring-2026", "headline", "Meet the Acme X1",
|
|
139
|
+
"s-body", "spring-2026", "body", "A fast, friendly bike for everyone.",
|
|
140
|
+
"s-disclaimer", "spring-2026", "disclaimer", "Specs subject to change.",
|
|
141
|
+
],
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
// 3. Create all three reviewer versions up front from the same Published base.
|
|
145
|
+
// (Forking later — after a merge — would change the merge base and hide the conflict.)
|
|
146
|
+
const marketing = await lix.createVersion({ name: "Marketing edit" });
|
|
147
|
+
const legal = await lix.createVersion({ name: "Legal review" });
|
|
148
|
+
const competing = await lix.createVersion({ name: "Competing headline" });
|
|
149
|
+
|
|
150
|
+
// Marketing rewrites the headline section.
|
|
151
|
+
await lix.execute(
|
|
152
|
+
`UPDATE acme_section_by_version
|
|
153
|
+
SET text = $1
|
|
154
|
+
WHERE id = $2 AND lixcol_version_id = $3`,
|
|
155
|
+
["The Acme X1 — built for your weekend", "s-headline", marketing.versionId],
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
// Legal rewrites the disclaimer section. Different entity → won't conflict with Marketing.
|
|
159
|
+
await lix.execute(
|
|
160
|
+
`UPDATE acme_section_by_version
|
|
161
|
+
SET text = $1
|
|
162
|
+
WHERE id = $2 AND lixcol_version_id = $3`,
|
|
163
|
+
["Specifications and pricing subject to change. See acme.com/legal.",
|
|
164
|
+
"s-disclaimer", legal.versionId],
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
// Competing rewrites the *same* headline section as Marketing. This will conflict on merge.
|
|
168
|
+
await lix.execute(
|
|
169
|
+
`UPDATE acme_section_by_version
|
|
170
|
+
SET text = $1
|
|
171
|
+
WHERE id = $2 AND lixcol_version_id = $3`,
|
|
172
|
+
["Acme X1: now with carbon fork", "s-headline", competing.versionId],
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
// 4. THE AHA: one SELECT, four versions side by side.
|
|
176
|
+
const sideBySide = await lix.execute(
|
|
177
|
+
`SELECT v.name, s.kind, s.text
|
|
178
|
+
FROM acme_section_by_version s
|
|
179
|
+
JOIN lix_version v ON v.id = s.lixcol_version_id
|
|
180
|
+
WHERE s.brochure_id = $1
|
|
181
|
+
AND s.lixcol_version_id IN ($2, $3, $4, $5)
|
|
182
|
+
ORDER BY v.name, s.kind`,
|
|
183
|
+
["spring-2026", published, marketing.versionId, legal.versionId, competing.versionId],
|
|
184
|
+
);
|
|
185
|
+
if (sideBySide.kind === "rows") {
|
|
186
|
+
console.log("Same brochure, four versions:");
|
|
187
|
+
let lastVersion = "";
|
|
188
|
+
for (const row of sideBySide.rows.rows) {
|
|
189
|
+
const v = row[0].asText()!;
|
|
190
|
+
if (v !== lastVersion) { console.log(` [${v}]`); lastVersion = v; }
|
|
191
|
+
console.log(` ${row[1].asText()!.padEnd(11)} → ${row[2].asText()}`);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// 5. Merge Marketing and Legal into Published. Different entities → both succeed.
|
|
196
|
+
const m1 = await lix.mergeVersion({ sourceVersionId: marketing.versionId });
|
|
197
|
+
const m2 = await lix.mergeVersion({ sourceVersionId: legal.versionId });
|
|
198
|
+
console.log(`\nMarketing merge: ${m1.outcome} (+${m1.appliedChangeCount} changes)`);
|
|
199
|
+
console.log(`Legal merge: ${m2.outcome} (+${m2.appliedChangeCount} changes)`);
|
|
200
|
+
|
|
201
|
+
const finalState = await lix.execute(
|
|
202
|
+
`SELECT kind, text FROM acme_section
|
|
203
|
+
WHERE brochure_id = $1 ORDER BY kind`,
|
|
204
|
+
["spring-2026"],
|
|
205
|
+
);
|
|
206
|
+
if (finalState.kind === "rows") {
|
|
207
|
+
console.log("\nPublished, after both merges (Marketing's headline + Legal's disclaimer):");
|
|
208
|
+
for (const row of finalState.rows.rows) {
|
|
209
|
+
console.log(` ${row[0].asText()!.padEnd(11)} → ${row[1].asText()}`);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// 6. The change journal is just a SQL table. Audit log, blame, undo — all queries.
|
|
214
|
+
// snapshot_content currently comes back as TEXT (a JSON string), so JSON.parse it.
|
|
215
|
+
const history = await lix.execute(
|
|
216
|
+
`SELECT created_at, entity_id, snapshot_content
|
|
217
|
+
FROM lix_change
|
|
218
|
+
WHERE schema_key = $1
|
|
219
|
+
ORDER BY created_at`,
|
|
220
|
+
["acme_section"],
|
|
221
|
+
);
|
|
222
|
+
if (history.kind === "rows") {
|
|
223
|
+
console.log(`\nAudit trail (${history.rows.rows.length} entries):`);
|
|
224
|
+
for (const row of history.rows.rows) {
|
|
225
|
+
const snap = row[2].asText();
|
|
226
|
+
const parsed = snap ? JSON.parse(snap) : null;
|
|
227
|
+
console.log(` ${row[0].asText()} ${row[1].asText()} ${JSON.stringify(parsed)}`);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// 7. Try to merge Competing — it edited the same s-headline that Marketing already merged.
|
|
232
|
+
// The engine detects the entity-level conflict and throws.
|
|
233
|
+
try {
|
|
234
|
+
await lix.mergeVersion({ sourceVersionId: competing.versionId });
|
|
235
|
+
} catch (err) {
|
|
236
|
+
console.log(`\nConflict surfaced (expected): ${(err as Error).message}`);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
await lix.close();
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
What gets printed, in order:
|
|
243
|
+
|
|
244
|
+
- One SELECT shows the same brochure across four versions, each section with its own per-version text — versioning lives *in the query layer*.
|
|
245
|
+
- Two `mergeCommitted` outcomes with non-zero `appliedChangeCount`.
|
|
246
|
+
- A final Published brochure with **Marketing's headline AND Legal's disclaimer** — clean per-row merge.
|
|
247
|
+
- An audit trail straight out of `lix_change`, ordered by `created_at`.
|
|
248
|
+
- A caught conflict from the version that re-edited Marketing's section.
|
|
249
|
+
|
|
250
|
+
That output is the elevator pitch. Imitate this shape when building demos: model the domain as **collections of small entities** (sections, blocks, paragraphs, message keys, line items) so reviewers naturally edit different rows; create all versions up front from the same base; use `<schema>_by_version` for cross-version reads and writes (you almost never need `switchVersion`); name versions in the user's vocabulary (`"Marketing edit"`, `"Legal review"`, `"Editor's pass"`, `"Q3 pricing draft"` — never `"Draft"` or `"branch-1"`); always include a `SELECT FROM lix_change` to surface the audit trail; and a deliberate `try/catch` conflict path.
|
|
251
|
+
|
|
252
|
+
## Cross-Version Reads And Writes
|
|
253
|
+
|
|
254
|
+
Every registered schema `X` gets a sibling table `X_by_version` with a `lixcol_version_id` column. Use it whenever a query needs more than one version, or whenever you want to write to a non-active version without `switchVersion`.
|
|
255
|
+
|
|
256
|
+
- **Reads** filter by `lixcol_version_id` (or omit the filter to scan all versions, joining on `lix_version` for the version name).
|
|
257
|
+
- **INSERTs** require `lixcol_version_id` — without it the engine errors with `INSERT into <key>_by_version requires lixcol_version_id`.
|
|
258
|
+
- **UPDATEs / DELETEs** must include `lixcol_version_id` in the WHERE clause; DELETEs without it error with `DELETE FROM <key>_by_version requires an explicit lixcol_version_id predicate`.
|
|
259
|
+
- The non-suffixed table (`acme_brochure`) is the **active-version view** — convenient for app code that always operates on the current version, but `_by_version` is the right tool for demos, sync, agent inspection, and side-by-side diffs.
|
|
260
|
+
|
|
261
|
+
The same pattern applies to built-ins: `lix_change_by_version`, `lix_commit_by_version`, `lix_directory_by_version`, etc.
|
|
262
|
+
|
|
263
|
+
## Files (`lix_file`)
|
|
264
|
+
|
|
265
|
+
Files are first-class in Lix — and this is a core USP, not a side feature. The built-in `lix_file` table lets you write any file (text, JSON, markdown, binary) into the lix and get versioning, branching, merging, and history over it for free. Parent directories are created automatically.
|
|
266
|
+
|
|
267
|
+
```ts
|
|
268
|
+
// Write a file. `data` is a blob (bytes).
|
|
269
|
+
await lix.execute(
|
|
270
|
+
"INSERT INTO lix_file (id, path, data) VALUES ($1, $2, $3)",
|
|
271
|
+
["file-readme", "/docs/readme.md", new TextEncoder().encode("# Hello\n")],
|
|
272
|
+
);
|
|
273
|
+
|
|
274
|
+
// Read it back.
|
|
275
|
+
const r = await lix.execute(
|
|
276
|
+
"SELECT path, data FROM lix_file WHERE id = $1",
|
|
277
|
+
["file-readme"],
|
|
278
|
+
);
|
|
279
|
+
if (r.kind === "rows") {
|
|
280
|
+
const [path, data] = r.rows.rows[0]!;
|
|
281
|
+
console.log(path.asText(), new TextDecoder().decode(data.asBlob()!));
|
|
282
|
+
}
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
Columns on `lix_file`:
|
|
286
|
+
|
|
287
|
+
| Column | Type | What it is |
|
|
288
|
+
|--------|------|------------|
|
|
289
|
+
| `id` | text | Stable identity for the file (you choose it). |
|
|
290
|
+
| `path` | text | Absolute path like `/docs/readme.md`. Parent directories are auto-created in `lix_directory`. |
|
|
291
|
+
| `data` | blob | File contents as bytes. |
|
|
292
|
+
| `hidden` | bool | UI hint; doesn't affect storage. |
|
|
293
|
+
| `lixcol_*` | various | Version metadata (`lixcol_version_id`, `lixcol_global`, `lixcol_untracked`, `lixcol_change_id`, `lixcol_commit_id`, ...). |
|
|
294
|
+
|
|
295
|
+
`lix_file_by_version` exists for cross-version file reads/writes, exactly like any other entity surface.
|
|
296
|
+
|
|
297
|
+
**Files-as-entities (upcoming).** A future plugin API will let a registered parser turn a file's contents into rows in your schema tables on write — so `messages.json` becomes per-key entities and two translators editing different keys merge cleanly. Not shipped through `@lix-js/sdk` yet; don't promise it in demos. Today, `lix_file` versions bytes only.
|
|
298
|
+
|
|
299
|
+
## Registering Schemas
|
|
300
|
+
|
|
301
|
+
Use stable, namespaced `x-lix-key` and `x-lix-version`. Prefer names that describe a domain entity (`acme_brochure`, `crm_contact`, `cms_page`) — never generic ones (`task`, `item`).
|
|
302
|
+
|
|
303
|
+
Include `x-lix-primary-key` so the engine can derive entity identity. Each entry is a **JSON Pointer (RFC 6901)** into the entity, leading slash required:
|
|
304
|
+
|
|
305
|
+
- `["/id"]` — top-level `id` property.
|
|
306
|
+
- `["/owner/email"]` — nested property `owner.email`.
|
|
307
|
+
- `["/owner", "/slug"]` — composite key over two top-level fields.
|
|
308
|
+
|
|
309
|
+
Without `x-lix-primary-key`, table-style INSERTs fail with `requires lixcol_entity_id because the schema has no x-lix-primary-key`.
|
|
310
|
+
|
|
311
|
+
## Reading Results
|
|
312
|
+
|
|
313
|
+
`lix.execute()` returns a discriminated `ExecuteResult`:
|
|
314
|
+
|
|
315
|
+
```ts
|
|
316
|
+
type ExecuteResult =
|
|
317
|
+
| { kind: "rows"; rows: { columns: string[]; rows: Value[][] } }
|
|
318
|
+
| { kind: "affectedRows"; affectedRows: number };
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
`SELECT` returns `kind: "rows"`. `INSERT` / `UPDATE` / `DELETE` return `kind: "affectedRows"`. Always narrow on `result.kind` before reading `result.rows`.
|
|
322
|
+
|
|
323
|
+
Cells in `result.rows.rows[i][j]` are `Value` instances (also exported from `@lix-js/sdk`), not raw JS primitives. Use the typed accessors:
|
|
324
|
+
|
|
325
|
+
```ts
|
|
326
|
+
import { openLix, Value } from "@lix-js/sdk";
|
|
327
|
+
|
|
328
|
+
const r = await lix.execute("SELECT id, headline, price_usd FROM acme_brochure");
|
|
329
|
+
if (r.kind === "rows") {
|
|
330
|
+
for (const row of r.rows.rows) {
|
|
331
|
+
const id = row[0].asText(); // string | undefined
|
|
332
|
+
const headline = row[1].asText(); // string | undefined
|
|
333
|
+
const price = row[2].asReal(); // number | undefined
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
| Method | Returns | Use for |
|
|
339
|
+
|--------|---------|---------|
|
|
340
|
+
| `asText()` | `string \| undefined` | `string` (note: `asText`, not `asString`) |
|
|
341
|
+
| `asBoolean()` | `boolean \| undefined` | `boolean` |
|
|
342
|
+
| `asInteger()` | `number \| undefined` | `integer` |
|
|
343
|
+
| `asReal()` | `number \| undefined` | `number` |
|
|
344
|
+
| `asJson()` | `JsonValue \| undefined` | `object` / `array` |
|
|
345
|
+
| `asBlob()` | `Uint8Array \| undefined` | binary |
|
|
346
|
+
| `kindValue()` | `"text" \| "bool" \| "int" \| "float" \| "json" \| "blob" \| "null"` | discriminator |
|
|
347
|
+
|
|
348
|
+
Each accessor returns `undefined` if the cell's kind doesn't match — branch on `kindValue()` first if you need to handle multiple types. Note the naming mismatch: accessors are `asInteger` / `asReal`, but `kindValue()` returns the short forms `"int"` / `"float"`.
|
|
349
|
+
|
|
350
|
+
## Versions
|
|
351
|
+
|
|
352
|
+
Capture the initial active version id rather than hardcoding `"main"`:
|
|
353
|
+
|
|
354
|
+
```ts
|
|
355
|
+
const published = await lix.activeVersionId();
|
|
356
|
+
```
|
|
357
|
+
|
|
358
|
+
Create a version with a name from the user's vocabulary:
|
|
359
|
+
|
|
360
|
+
```ts
|
|
361
|
+
const draft = await lix.createVersion({ name: "Marketing edit" });
|
|
362
|
+
```
|
|
363
|
+
|
|
364
|
+
For demos, agent flows, sync, and any read path that touches more than one version, **write through `<schema>_by_version` with `lixcol_version_id`** (see the canonical demo) — you don't need to switch.
|
|
365
|
+
|
|
366
|
+
`switchVersion` is for app code that has a "current working version" concept and wants subsequent unqualified writes (`UPDATE acme_section SET …`) to land there. `mergeVersion` always merges *into the active version*, so if you're merging into something other than the currently active version, switch first.
|
|
367
|
+
|
|
368
|
+
## Merging
|
|
369
|
+
|
|
370
|
+
`mergeVersion()` merges the source version into the **currently active** version (no `targetVersionId`):
|
|
371
|
+
|
|
372
|
+
```ts
|
|
373
|
+
const merge = await lix.mergeVersion({ sourceVersionId: draft.versionId });
|
|
374
|
+
```
|
|
375
|
+
|
|
376
|
+
The result is a structured receipt:
|
|
377
|
+
|
|
378
|
+
```ts
|
|
379
|
+
type MergeVersionResult = {
|
|
380
|
+
outcome: "alreadyUpToDate" | "mergeCommitted";
|
|
381
|
+
appliedChangeCount: number;
|
|
382
|
+
targetVersionId: string;
|
|
383
|
+
sourceVersionId: string;
|
|
384
|
+
mergeBaseCommitId: string | null;
|
|
385
|
+
targetHeadBeforeCommitId: string;
|
|
386
|
+
sourceHeadBeforeCommitId: string;
|
|
387
|
+
targetHeadAfterCommitId: string;
|
|
388
|
+
createdMergeCommitId: string | null;
|
|
389
|
+
};
|
|
390
|
+
```
|
|
391
|
+
|
|
392
|
+
- `outcome: "alreadyUpToDate"` — the source has no commits the target lacks (including self-merge). Nothing applied.
|
|
393
|
+
- `outcome: "mergeCommitted"` — a new merge commit was created; `appliedChangeCount > 0` and `createdMergeCommitId` is set.
|
|
394
|
+
|
|
395
|
+
**Conflicts throw.** If both versions modified the same entity (the same row, identified by `x-lix-key` + primary key) since their merge base, `mergeVersion` raises a `LixError` with a message like `engine2 merge_version found N tracked-state conflict(s)`. Conflicts are detected at row identity, not at field level — disjoint-field edits to the same row still conflict. The current SDK does not expose programmatic conflict resolution — wrap in `try/catch` and surface the error to the user (see the canonical demo above).
|
|
396
|
+
|
|
397
|
+
## SQL Parameters and UDFs
|
|
398
|
+
|
|
399
|
+
Use DataFusion-style numbered placeholders. Bare `?` is rejected with `Failed to parse placeholder id`:
|
|
400
|
+
|
|
401
|
+
```ts
|
|
402
|
+
await lix.execute("SELECT * FROM acme_brochure WHERE id = $1", ["spring-2026"]);
|
|
403
|
+
```
|
|
404
|
+
|
|
405
|
+
The only UDF the canonical demo uses is `lix_json($1)` — it parses a TEXT parameter as a JSON value, required when writing JSON-typed columns like `lix_registered_schema.value`. Other UDFs (`lix_json_get`, `lix_json_get_text`, `lix_uuid_v7`, `lix_text_encode`/`_decode`, `lix_empty_blob`, …) live in [packages/engine/src/sql2/udfs](https://github.com/opral/lix/tree/561f92b5bc3fa68e48a863ed3a02129645a57011/packages/engine/src/sql2/udfs) — read source when you need them.
|
|
406
|
+
|
|
407
|
+
## Built-in Queryable Tables
|
|
408
|
+
|
|
409
|
+
The four tables demos actually touch:
|
|
410
|
+
|
|
411
|
+
| Table | What it gives you |
|
|
412
|
+
|-------|-------------------|
|
|
413
|
+
| `lix_version` | List of versions (`id`, `name`, `hidden`, `commit_id`). Use this instead of hardcoding `"main"`. |
|
|
414
|
+
| `lix_change` | The immutable journal — every change with `entity_id`, `schema_key`, `snapshot_content`, `created_at`. See the next section. |
|
|
415
|
+
| `lix_file` | Built-in file storage (covered above). |
|
|
416
|
+
| `lix_registered_schema` | Your registered schemas (and built-ins). |
|
|
417
|
+
|
|
418
|
+
The engine ships ~20 more built-ins (commit graph, change sets, key-value, labels, file/directory descriptors, low-level state, etc.) — see [packages/engine/src/schema/builtin](https://github.com/opral/lix/tree/561f92b5bc3fa68e48a863ed3a02129645a57011/packages/engine/src/schema/builtin) when you need them. `lix_account` / `lix_change_author` are declared but **not yet implemented; don't rely on them.**
|
|
419
|
+
|
|
420
|
+
## The Change Journal (`lix_change`)
|
|
421
|
+
|
|
422
|
+
`lix_change` is the most reliable feature in the SDK and the most under-marketed one. Every write — INSERT, UPDATE, DELETE, on any registered schema, on any version — appends an immutable row. That row is **just SQL you can query**. Audit logs, blame, undo, "what changed since Tuesday", "show me everything Marketing edited" — none of this is a separate subsystem; it's all `SELECT FROM lix_change`.
|
|
423
|
+
|
|
424
|
+
Columns that matter: `id`, `entity_id`, `schema_key`, `schema_version`, `snapshot_content` (TEXT JSON — `JSON.parse(asText())`, not `asJson()`), `created_at`, plus `lixcol_*` for version metadata.
|
|
425
|
+
|
|
426
|
+
```ts
|
|
427
|
+
// Audit log: every change to a single entity, oldest to newest.
|
|
428
|
+
await lix.execute(
|
|
429
|
+
`SELECT created_at, snapshot_content
|
|
430
|
+
FROM lix_change
|
|
431
|
+
WHERE schema_key = $1 AND entity_id = $2
|
|
432
|
+
ORDER BY created_at`,
|
|
433
|
+
["acme_section", "s-headline"],
|
|
434
|
+
);
|
|
435
|
+
|
|
436
|
+
// Blame: who/what last touched this entity.
|
|
437
|
+
await lix.execute(
|
|
438
|
+
`SELECT created_at, snapshot_content
|
|
439
|
+
FROM lix_change
|
|
440
|
+
WHERE schema_key = $1 AND entity_id = $2
|
|
441
|
+
ORDER BY created_at DESC
|
|
442
|
+
LIMIT 1`,
|
|
443
|
+
["acme_section", "s-headline"],
|
|
444
|
+
);
|
|
445
|
+
|
|
446
|
+
// Activity feed: latest 20 changes across the whole schema.
|
|
447
|
+
await lix.execute(
|
|
448
|
+
`SELECT created_at, entity_id, snapshot_content
|
|
449
|
+
FROM lix_change
|
|
450
|
+
WHERE schema_key = $1
|
|
451
|
+
ORDER BY created_at DESC
|
|
452
|
+
LIMIT 20`,
|
|
453
|
+
["acme_section"],
|
|
454
|
+
);
|
|
455
|
+
```
|
|
456
|
+
|
|
457
|
+
Reach for `lix_change` whenever an app needs history, audit, undo, or activity feeds. It replaces a hand-rolled audit table.
|
|
458
|
+
|
|
459
|
+
## Gotchas
|
|
460
|
+
|
|
461
|
+
A short list of things that will burn a fresh agent on the first run.
|
|
462
|
+
|
|
463
|
+
- **`type: "number"` columns require float literals.** JS numerics bind as Int64 by default, so seeding a `number` column with `899` fails with `expected Float64 but found Int64`. JS has no integer/float distinction, so `899.0` doesn't help either — pass a value with a fractional part (e.g. `899.5`), or model whole-number fields as `type: "integer"` and use `asInteger()` to read.
|
|
464
|
+
- **`lix_change.snapshot_content` is currently a TEXT column** (a JSON string), not native JSON. Reading it with `Value.asJson()` returns `undefined`. Use `JSON.parse(cell.asText())` instead.
|
|
465
|
+
- **Merge is per-entity, not per-field.** Two versions modifying the same row conflict even if their fields are disjoint. Model concurrent-edit domains as collections of small entities (see the canonical demo).
|
|
466
|
+
- **Conflict reproduction is order-dependent.** Fork *all* contending versions from the same base before any merge happens. A version forked *after* a merge has the merged state as its base, so its edit becomes a clean fast-forward instead of a conflict.
|
|
467
|
+
- **`mergeVersion` always merges into the active version.** There is no `targetVersionId` parameter. If you need a non-active target, `switchVersion` first.
|
|
468
|
+
- **Bare `?` placeholders are rejected** with `Failed to parse placeholder id`. Always use `$1`, `$2`, ...
|
|
469
|
+
|
|
470
|
+
## Safety Rules
|
|
471
|
+
|
|
472
|
+
- Never use `sqlite3`, `sql.js`, or raw database access against a `.lix`; use `createBetterSqlite3Backend()` instead of opening the file yourself.
|
|
473
|
+
- Never import from `@lix-js/sdk/engine-wasm` or other private internals.
|
|
474
|
+
- Use `$1`, `$2`, `$3` placeholders, never bare `?`.
|
|
475
|
+
- Schema keys: stable, namespaced, lowercase-snake-case, domain-shaped (`acme_brochure`, not `task`).
|
|
476
|
+
- Always include `x-lix-primary-key` and `additionalProperties: false` on registered schemas.
|
|
477
|
+
- Name versions in the end-user's vocabulary (`"Marketing edit"`, `"Q3 pricing draft"`), never developer jargon (`"Draft"`, `"branch-1"`).
|
|
478
|
+
- Wrap `mergeVersion` in `try/catch` if there is any chance of a conflicting edit.
|
|
479
|
+
- Close handles in scripts/tests with `await lix.close()`.
|
|
480
|
+
|
|
481
|
+
## Reporting SDK Friction
|
|
482
|
+
|
|
483
|
+
If you encounter an SDK bug, missing API, confusing error, documentation gap, or large implementation friction while using this skill, pause and ask the user whether they want you to open a GitHub issue via the `gh` CLI installed on their computer. Do not file the issue without confirmation. If they approve, include a minimal reproduction, expected behavior, actual behavior, the installed `@lix-js/sdk` package version, runtime/version details, and any relevant error output.
|
|
@@ -4,18 +4,18 @@ import type { InitInput } from "./wasm/lix_engine.js";
|
|
|
4
4
|
export type JsonValue = null | boolean | number | string | JsonValue[] | {
|
|
5
5
|
[key: string]: JsonValue;
|
|
6
6
|
};
|
|
7
|
-
export type ValueKind = "null" | "
|
|
7
|
+
export type ValueKind = "null" | "boolean" | "integer" | "real" | "text" | "json" | "blob";
|
|
8
8
|
export type LixValue = {
|
|
9
9
|
kind: "null";
|
|
10
10
|
value: null;
|
|
11
11
|
} | {
|
|
12
|
-
kind: "
|
|
12
|
+
kind: "boolean";
|
|
13
13
|
value: boolean;
|
|
14
14
|
} | {
|
|
15
|
-
kind: "
|
|
15
|
+
kind: "integer";
|
|
16
16
|
value: number;
|
|
17
17
|
} | {
|
|
18
|
-
kind: "
|
|
18
|
+
kind: "real";
|
|
19
19
|
value: number;
|
|
20
20
|
} | {
|
|
21
21
|
kind: "text";
|
|
@@ -40,7 +40,6 @@ export declare class Value {
|
|
|
40
40
|
static json(value: JsonValue): Value;
|
|
41
41
|
static blob(value: Uint8Array): Value;
|
|
42
42
|
static from(raw: unknown): Value;
|
|
43
|
-
kindValue(): ValueKind;
|
|
44
43
|
asInteger(): number | undefined;
|
|
45
44
|
asBoolean(): boolean | undefined;
|
|
46
45
|
asReal(): number | undefined;
|
|
@@ -49,16 +48,20 @@ export declare class Value {
|
|
|
49
48
|
asBlob(): Uint8Array | undefined;
|
|
50
49
|
toJSON(): LixValue;
|
|
51
50
|
}
|
|
52
|
-
export type
|
|
53
|
-
rows: LixValue[][];
|
|
51
|
+
export type ExecuteResult = {
|
|
54
52
|
columns: string[];
|
|
53
|
+
rows: LixValue[][];
|
|
54
|
+
rowsAffected: number;
|
|
55
|
+
notices: LixNotice[];
|
|
55
56
|
};
|
|
56
|
-
export type
|
|
57
|
-
|
|
57
|
+
export type LixNotice = {
|
|
58
|
+
code: string;
|
|
59
|
+
message: string;
|
|
60
|
+
hint?: string;
|
|
58
61
|
};
|
|
59
62
|
/**
|
|
60
63
|
* Error thrown by the Lix engine. Extends the standard `Error` with a
|
|
61
|
-
* machine-readable `code
|
|
64
|
+
* machine-readable `code`, optional `hint`, and optional structured `details`.
|
|
62
65
|
*
|
|
63
66
|
* Hints follow the Postgres/rustc convention: `message` states what went
|
|
64
67
|
* wrong in factual terms; `hint` offers a fix when one is known. Consumers
|
|
@@ -68,10 +71,11 @@ export type ExecuteResult = {
|
|
|
68
71
|
export interface LixError extends Error {
|
|
69
72
|
code: string;
|
|
70
73
|
hint?: string;
|
|
74
|
+
details?: unknown;
|
|
71
75
|
}
|
|
72
76
|
/**
|
|
73
77
|
* Type guard: returns `true` when `err` is a Lix-produced error carrying a
|
|
74
|
-
* structured `code` field (all engine codes start with `
|
|
78
|
+
* structured `code` field (all engine codes start with `LIX_`).
|
|
75
79
|
*/
|
|
76
80
|
export declare function isLixError(err: unknown): err is LixError;
|
|
77
81
|
/**
|