latticesql 3.1.0 → 3.2.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.
@@ -0,0 +1,85 @@
1
+ # Multiplayer cloud editing
2
+
3
+ When several people open `lattice gui` against the **same shared cloud
4
+ (Postgres) database**, Lattice 1.16 makes concurrent editing live and
5
+ loss-free. Everything here is **cloud-only** — a local SQLite GUI is a single
6
+ writer and behaves exactly as before.
7
+
8
+ ## How it works
9
+
10
+ Each person runs their own local GUI server, all pointed at one cloud Postgres —
11
+ each connecting as their **own scoped, non-superuser Postgres role** (see
12
+ [cloud.md](cloud.md)). Postgres Row-Level Security confines every read and write to
13
+ the rows that role may see, so collaboration is naturally scoped: you only ever see
14
+ and flash on changes to rows shared with you. Two channels carry change:
15
+
16
+ - **`RealtimeBroker`** holds a dedicated `pg.Client` running
17
+ `LISTEN lattice_changes` and forwards every `NOTIFY` to the browser over SSE
18
+ (`GET /api/realtime/stream`). Use a **session-mode** connection (e.g. the
19
+ Supabase pooler on port 5432) — transaction-mode poolers silently drop
20
+ `LISTEN`.
21
+ - The **`__lattice_changes`** table is the append-only change feed: each row carries
22
+ a monotonic `seq`, the `table_name`, the `pk`, the `op` (`upsert`/`delete`), the
23
+ `owner_role`, and `created_at`. The per-table RLS trigger writes one entry per
24
+ insert/update/delete; an `AFTER INSERT` trigger emits the NOTIFY carrying only that
25
+ metadata — **never row content** — so clients re-fetch the affected row _through
26
+ RLS_ (which keeps the payload tiny and never broadcasts another member's data).
27
+
28
+ ## What you see
29
+
30
+ - **Live share / un-share** — when a row's owner changes its visibility
31
+ (`private` ↔ `everyone`, via `/api/cloud/share`), the row appears or disappears in
32
+ every other member's view on the next broadcast; no page reload. Sharing is
33
+ per-**row** under RLS, not per-table.
34
+ - **Last edited by** — the row detail shows `Last edited by <role> · <time ago>`,
35
+ resolved from the change feed's `owner_role` + `created_at`
36
+ (`GET /api/tables/:t/last-edited`).
37
+ - **Flash + counts** — a row visible in the current view flashes when another
38
+ editor changes it (honoring `prefers-reduced-motion`); changes to other
39
+ tables bump a per-table unseen-change badge in the sidebar.
40
+ - **Offline editing** — see below.
41
+
42
+ ## Offline editing
43
+
44
+ When the cloud is unreachable (the realtime channel is disconnected), row edits
45
+ are persisted to an IndexedDB queue instead of being lost, and a top-bar pill
46
+ shows the pending count. On reconnect, `drainQueue()` replays them **in
47
+ edit-timestamp (`client_ts`) order**, each carrying its `X-Lattice-Edit-Id` and
48
+ `X-Lattice-Client-Ts` headers. The server records `client_ts` on the envelope
49
+ (preserving true edit order) and **no-ops a re-sent `edit_id`**
50
+ (`findEnvelopeByEditId`), so a replay after a flaky reconnect can't double-apply.
51
+
52
+ > Optimistic local re-render of a queued edit _before_ it syncs is a documented
53
+ > follow-on; today a queued edit is captured + toasted ("saved offline") and
54
+ > appears once it replays.
55
+
56
+ ## Conflict policy
57
+
58
+ **Row edits — last-write-wins by edit timestamp, every version recoverable.**
59
+ Concurrent edits to the same row are applied in arrival order; the live row
60
+ reflects the last write, and _every_ prior version is retained in the
61
+ `__lattice_changes` feed and readable via
62
+ `GET /api/tables/:table/rows/:id/history` (newest first). Nothing is silently
63
+ destroyed — an overwritten value is always recoverable.
64
+
65
+ **A row that was made private out from under you** simply stops being returned —
66
+ Postgres RLS excludes it, so a subsequent read returns nothing and a write affects
67
+ zero rows. Only the row's owner may change its visibility; the owner always retains
68
+ access to their own rows.
69
+
70
+ **Sharing changes** go through the owner-only `lattice_set_row_visibility` SQL
71
+ function, which updates `__lattice_owners.visibility` for a single row. Because each
72
+ change is one row's visibility flip recorded in the change feed, clients converge on
73
+ the latest state and the history is recoverable.
74
+
75
+ **Data-model edits** use `schema_version` as an optimistic-concurrency token. Each
76
+ table's `schemaVersion` is surfaced on `/api/entities`; a client edit carries its
77
+ base version so a stale edit can be rejected and the client re-fetch + re-issue
78
+ against the current schema.
79
+
80
+ ## Ordering & clock skew
81
+
82
+ `seq` is the **only** authoritative ordering key — history queries order by it.
83
+ `client_ts` is for display and to preserve recorded edit order across an offline
84
+ replay; it is **never** used for correctness ordering, so a client with a wrong
85
+ clock can't reorder the canonical log.
@@ -0,0 +1,416 @@
1
+ # Configuration Guide
2
+
3
+ Complete reference for `lattice.config.yml` — the YAML schema config format introduced in v0.4.
4
+
5
+ ---
6
+
7
+ ## Table of Contents
8
+
9
+ - [Overview](#overview)
10
+ - [Top-level structure](#top-level-structure)
11
+ - [Field types](#field-types)
12
+ - [Field options](#field-options)
13
+ - [Relationships (`ref`)](#relationships-ref)
14
+ - [Render specs](#render-specs)
15
+ - [Primary keys](#primary-keys)
16
+ - [Output file paths](#output-file-paths)
17
+ - [Multiple entities](#multiple-entities)
18
+ - [Programmatic config API](#programmatic-config-api)
19
+ - [Complete example](#complete-example)
20
+
21
+ ---
22
+
23
+ ## Overview
24
+
25
+ `lattice.config.yml` is a declarative schema file. It defines:
26
+
27
+ - Where the SQLite database lives
28
+ - What tables exist and what columns they have
29
+ - How each table's rows are rendered into LLM context files
30
+ - Where those context files are written
31
+
32
+ Place it at the root of your project (or anywhere — pass `--config` to override):
33
+
34
+ ```
35
+ my-project/
36
+ ├── lattice.config.yml
37
+ ├── data/
38
+ │ └── app.db
39
+ └── context/
40
+ ├── AGENTS.md
41
+ └── TASKS.md
42
+ ```
43
+
44
+ ---
45
+
46
+ ## Top-level structure
47
+
48
+ ```yaml
49
+ db: ./data/app.db # Required — path to SQLite file
50
+ entities: # Required — one key per table
51
+ table_name:
52
+ fields: ...
53
+ render: ...
54
+ outputFile: ...
55
+ ```
56
+
57
+ | Key | Type | Required | Description |
58
+ | ---------- | ------ | -------- | --------------------------------------------------------- |
59
+ | `db` | string | yes | Path to the SQLite database, relative to this config file |
60
+ | `entities` | object | yes | Map of entity (table) name → entity definition |
61
+
62
+ The `db` path is resolved relative to the directory containing `lattice.config.yml`, not `process.cwd()`. Using `:memory:` for `db` is valid for testing.
63
+
64
+ ---
65
+
66
+ ## Field types
67
+
68
+ Each field in an entity's `fields` map must have a `type`. The supported types and their mappings:
69
+
70
+ | YAML type | SQLite column type | TypeScript type |
71
+ | ---------- | ------------------ | --------------- |
72
+ | `uuid` | `TEXT` | `string` |
73
+ | `text` | `TEXT` | `string` |
74
+ | `integer` | `INTEGER` | `number` |
75
+ | `int` | `INTEGER` | `number` |
76
+ | `real` | `REAL` | `number` |
77
+ | `float` | `REAL` | `number` |
78
+ | `boolean` | `INTEGER` | `boolean` |
79
+ | `bool` | `INTEGER` | `boolean` |
80
+ | `datetime` | `TEXT` | `string` |
81
+ | `date` | `TEXT` | `string` |
82
+ | `blob` | `BLOB` | `Buffer` |
83
+
84
+ `uuid` and `text` are both stored as `TEXT` — the distinction is semantic: use `uuid` for ID columns, `text` for everything else. Use `boolean`/`bool` for true/false values stored as SQLite integers.
85
+
86
+ ---
87
+
88
+ ## Field options
89
+
90
+ Every field accepts these options in addition to `type`:
91
+
92
+ ```yaml
93
+ entities:
94
+ task:
95
+ fields:
96
+ id: { type: uuid, primaryKey: true }
97
+ title: { type: text, required: true }
98
+ status: { type: text, default: open }
99
+ priority: { type: integer, default: 1 }
100
+ assignee_id: { type: uuid, ref: user }
101
+ deleted_at: { type: datetime }
102
+ ```
103
+
104
+ | Option | Type | Description |
105
+ | ------------ | ------------------------- | --------------------------------------------------------------------------------- |
106
+ | `type` | `LatticeFieldType` | **Required.** Column data type (see table above) |
107
+ | `primaryKey` | `boolean` | Mark this field as the primary key. Generates `TEXT PRIMARY KEY` (for uuid/text) |
108
+ | `required` | `boolean` | Column is `NOT NULL`. Cannot be used together with `primaryKey` |
109
+ | `default` | string / number / boolean | SQL `DEFAULT` value. Strings are quoted; numbers are unquoted |
110
+ | `ref` | string | Foreign-key reference to another entity (see [Relationships](#relationships-ref)) |
111
+
112
+ ### Generated SQL
113
+
114
+ ```yaml
115
+ id: { type: uuid, primaryKey: true } → "id" TEXT PRIMARY KEY
116
+ title: { type: text, required: true } → "title" TEXT NOT NULL
117
+ status: { type: text, default: open } → "status" TEXT DEFAULT 'open'
118
+ priority: { type: integer, default: 1 } → "priority" INTEGER DEFAULT 1
119
+ notes: { type: text } → "notes" TEXT
120
+ ```
121
+
122
+ ---
123
+
124
+ ## Relationships (`ref`)
125
+
126
+ Adding `ref: <entity>` to a field automatically creates a `belongsTo` relationship in the compiled `TableDefinition`.
127
+
128
+ ```yaml
129
+ entities:
130
+ ticket:
131
+ fields:
132
+ id: { type: uuid, primaryKey: true }
133
+ assignee_id: { type: uuid, ref: user }
134
+ ```
135
+
136
+ This generates:
137
+
138
+ - Column: `"assignee_id" TEXT`
139
+ - Relation: `assignee: { type: 'belongsTo', table: 'user', foreignKey: 'assignee_id' }`
140
+
141
+ **Relation name derivation:** If the field name ends with `_id`, the suffix is stripped to form the relation name. Otherwise the full field name is used:
142
+
143
+ | Field name | Relation name |
144
+ | ------------- | ------------- |
145
+ | `assignee_id` | `assignee` |
146
+ | `project_id` | `project` |
147
+ | `parent_id` | `parent` |
148
+ | `author` | `author` |
149
+
150
+ The relation name is used in `{{relationName.field}}` interpolation strings inside render templates.
151
+
152
+ SQLite does not enforce foreign key constraints by default. Lattice stores `ref` as metadata for template rendering — no `FOREIGN KEY` constraint is added to the SQL schema.
153
+
154
+ ---
155
+
156
+ ## Render specs
157
+
158
+ The `render` key controls how rows are turned into context file content. It accepts three forms:
159
+
160
+ ### Form 1 — Built-in template name (string)
161
+
162
+ ```yaml
163
+ entities:
164
+ agent:
165
+ render: default-table
166
+ ```
167
+
168
+ Available built-in templates: `default-list`, `default-table`, `default-detail`, `default-json`.
169
+
170
+ ### Form 2 — Template with formatRow hook (object)
171
+
172
+ ```yaml
173
+ entities:
174
+ ticket:
175
+ render:
176
+ template: default-list
177
+ formatRow: '{{title}} ({{status}}) — assigned to {{assignee.name}}'
178
+ ```
179
+
180
+ The `formatRow` string uses `{{field}}` interpolation. Use `{{relationName.field}}` to pull in a field from a `belongsTo` related row.
181
+
182
+ ### Form 3 — Default (omitted)
183
+
184
+ If `render` is omitted, it defaults to `default-list`.
185
+
186
+ ```yaml
187
+ entities:
188
+ note:
189
+ fields:
190
+ id: { type: uuid, primaryKey: true }
191
+ body: { type: text }
192
+ outputFile: context/NOTES.md
193
+ # render defaults to 'default-list'
194
+ ```
195
+
196
+ See [Template Rendering](./templates.md) for the full guide.
197
+
198
+ ---
199
+
200
+ ## Primary keys
201
+
202
+ ### Field-level `primaryKey`
203
+
204
+ The simplest form — mark one field with `primaryKey: true`:
205
+
206
+ ```yaml
207
+ entities:
208
+ user:
209
+ fields:
210
+ id: { type: uuid, primaryKey: true }
211
+ name: { type: text }
212
+ ```
213
+
214
+ This sets the table's primary key to `id`. When inserting without an `id`, a UUID v4 is auto-generated.
215
+
216
+ ### Custom single PK
217
+
218
+ Use a non-`id` field as the primary key:
219
+
220
+ ```yaml
221
+ entities:
222
+ setting:
223
+ fields:
224
+ key: { type: text, primaryKey: true }
225
+ value: { type: text }
226
+ ```
227
+
228
+ Callers must supply `key` on every insert. No UUID is auto-generated.
229
+
230
+ ### Entity-level `primaryKey` override
231
+
232
+ For composite keys or when you want to separate the PK declaration from field definitions:
233
+
234
+ ```yaml
235
+ entities:
236
+ line_item:
237
+ fields:
238
+ order_id: { type: uuid }
239
+ seq: { type: integer }
240
+ qty: { type: integer }
241
+ primaryKey: [order_id, seq]
242
+ ```
243
+
244
+ When entity-level `primaryKey` is set, it overrides any field-level `primaryKey: true`. The caller must supply all PK columns on every insert.
245
+
246
+ ---
247
+
248
+ ## Output file paths
249
+
250
+ The `outputFile` path is resolved relative to the **config file's directory** (not `process.cwd()`).
251
+
252
+ ```yaml
253
+ # lattice.config.yml lives at /project/lattice.config.yml
254
+ entities:
255
+ agent:
256
+ outputFile: context/AGENTS.md # → /project/context/AGENTS.md
257
+ ```
258
+
259
+ This means the context files are co-located with the config file, regardless of where you run commands from.
260
+
261
+ When you call `db.render(outputDir)` or `db.watch(outputDir)` programmatically, the `outputFile` is resolved relative to `outputDir` — not the config file directory. The config file form resolves differently (see below).
262
+
263
+ > **Note:** When using `new Lattice({ config: '...' })`, the `outputFile` paths in the YAML are pre-resolved to absolute paths at parse time. `render()` / `watch()` receive these absolute paths and write there directly, ignoring `outputDir`.
264
+
265
+ ---
266
+
267
+ ## Multiple entities
268
+
269
+ Define as many entities as needed. They are parsed and registered in the order they appear in the YAML:
270
+
271
+ ```yaml
272
+ db: ./data/app.db
273
+ entities:
274
+ user:
275
+ fields:
276
+ id: { type: uuid, primaryKey: true }
277
+ name: { type: text, required: true }
278
+ email: { type: text }
279
+ render: default-table
280
+ outputFile: context/USERS.md
281
+
282
+ project:
283
+ fields:
284
+ id: { type: uuid, primaryKey: true }
285
+ name: { type: text, required: true }
286
+ owner_id: { type: uuid, ref: user }
287
+ render:
288
+ template: default-list
289
+ formatRow: '{{name}} (owner: {{owner.name}})'
290
+ outputFile: context/PROJECTS.md
291
+
292
+ task:
293
+ fields:
294
+ id: { type: uuid, primaryKey: true }
295
+ title: { type: text, required: true }
296
+ status: { type: text, default: open }
297
+ priority: { type: integer, default: 1 }
298
+ project_id: { type: uuid, ref: project }
299
+ assignee_id: { type: uuid, ref: user }
300
+ render:
301
+ template: default-list
302
+ formatRow: '{{title}} [{{status}}] → {{assignee.name}}'
303
+ outputFile: context/TASKS.md
304
+ ```
305
+
306
+ ---
307
+
308
+ ## Programmatic config API
309
+
310
+ You can use the parser directly without going through the `Lattice` constructor:
311
+
312
+ ```ts
313
+ import { parseConfigFile, parseConfigString } from 'latticesql';
314
+
315
+ // From a file:
316
+ const { dbPath, tables } = parseConfigFile('./lattice.config.yml');
317
+
318
+ // From a string (useful in tests):
319
+ const { dbPath, tables } = parseConfigString(yamlContent, '/path/to/config/dir');
320
+
321
+ // tables is: ReadonlyArray<{ name: string; definition: TableDefinition }>
322
+ for (const { name, definition } of tables) {
323
+ db.define(name, definition);
324
+ }
325
+ ```
326
+
327
+ Both functions throw with descriptive messages on validation errors.
328
+
329
+ ---
330
+
331
+ ## Complete example
332
+
333
+ ```yaml
334
+ # lattice.config.yml
335
+ db: ./data/app.db
336
+
337
+ entities:
338
+ # --- Users ---
339
+ user:
340
+ fields:
341
+ id: { type: uuid, primaryKey: true }
342
+ name: { type: text, required: true }
343
+ email: { type: text, required: true }
344
+ role: { type: text, default: member }
345
+ created_at: { type: datetime }
346
+ render: default-table
347
+ outputFile: context/USERS.md
348
+
349
+ # --- Projects ---
350
+ project:
351
+ fields:
352
+ id: { type: uuid, primaryKey: true }
353
+ name: { type: text, required: true }
354
+ status: { type: text, default: active }
355
+ owner_id: { type: uuid, ref: user }
356
+ render:
357
+ template: default-list
358
+ formatRow: '**{{name}}** [{{status}}] — {{owner.name}}'
359
+ outputFile: context/PROJECTS.md
360
+
361
+ # --- Tasks ---
362
+ task:
363
+ fields:
364
+ id: { type: uuid, primaryKey: true }
365
+ title: { type: text, required: true }
366
+ description: { type: text }
367
+ status: { type: text, default: open }
368
+ priority: { type: integer, default: 1 }
369
+ project_id: { type: uuid, ref: project }
370
+ assignee_id: { type: uuid, ref: user }
371
+ created_at: { type: datetime }
372
+ due_at: { type: datetime }
373
+ render:
374
+ template: default-list
375
+ formatRow: '{{title}} [P{{priority}}/{{status}}] → {{assignee.name}}'
376
+ outputFile: context/TASKS.md
377
+
378
+ # --- Comments ---
379
+ comment:
380
+ fields:
381
+ id: { type: uuid, primaryKey: true }
382
+ body: { type: text, required: true }
383
+ task_id: { type: uuid, ref: task }
384
+ author_id: { type: uuid, ref: user }
385
+ created_at: { type: datetime }
386
+ render:
387
+ template: default-list
388
+ formatRow: '{{author.name}}: {{body}}'
389
+ outputFile: context/COMMENTS.md
390
+ ```
391
+
392
+ Run `lattice generate` to produce TypeScript interfaces and a SQL migration file from this config. Then use `new Lattice({ config: './lattice.config.yml' })` at runtime to connect, define tables, and start syncing.
393
+
394
+ ## Full-text search (`fts`)
395
+
396
+ Opt a table into an indexed full-text search by adding an `fts` block to its
397
+ entity definition. Omit `fields` to auto-detect text columns (excluding
398
+ identifiers and bookkeeping columns):
399
+
400
+ ```yaml
401
+ entities:
402
+ articles:
403
+ fields:
404
+ id: { type: uuid, primaryKey: true }
405
+ title: { type: text }
406
+ body: { type: text }
407
+ fts: { fields: [title, body] } # or just `fts: {}` to auto-detect
408
+ outputFile: articles.md
409
+ ```
410
+
411
+ On `init`, Lattice builds and maintains an inverted index
412
+ (`__lattice_fts_<table>`) — SQLite FTS5 / Postgres `tsvector` + GIN. Tables
413
+ without `fts` are still searchable via the LIKE fallback. Indexes are created
414
+ **only** for opt-in tables, so a library consumer with no `fts` config incurs
415
+ no index and no write-path overhead. See `docs/api-reference.md` →
416
+ _Full-text search_ for the `fullTextSearch` API.