latticesql 3.4.7 → 4.0.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 +18 -12
- package/dist/cli.js +18541 -16884
- package/dist/index.cjs +8022 -6370
- package/dist/index.d.cts +296 -136
- package/dist/index.d.ts +296 -136
- package/dist/index.js +8001 -6342
- package/docs/MIGRATING-4.0.md +407 -0
- package/docs/bugs/2026-06-19-cloud-switch-hang-per-table-migration.md +64 -0
- package/docs/cli.md +3 -1
- package/docs/configuration.md +66 -29
- package/docs/consistency.md +117 -0
- package/docs/examples/agent-system.md +6 -2
- package/docs/examples/cms.md +3 -1
- package/docs/examples/ticket-tracker.md +10 -4
- package/docs/scaling.md +56 -0
- package/docs/templates.md +5 -2
- package/package.json +1 -1
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# Data Consistency
|
|
2
|
+
|
|
3
|
+
How `latticesql` keeps the database, the rendered context tree, and offline edits
|
|
4
|
+
in agreement — the true model, the invariants the library guarantees (each backed
|
|
5
|
+
by a test), and the things it deliberately does **not** guarantee.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## The model
|
|
10
|
+
|
|
11
|
+
There are three places data can live, and exactly one relationship between each
|
|
12
|
+
pair:
|
|
13
|
+
|
|
14
|
+
1. **The database** — the single source of truth. One active substrate at a time:
|
|
15
|
+
SQLite **xor** Postgres, never both. Every read and write goes here first.
|
|
16
|
+
|
|
17
|
+
2. **The rendered context tree** — a **forward-render mirror** of the database
|
|
18
|
+
(`db → files`). It is derived, not authoritative: a render reads the DB and
|
|
19
|
+
writes Markdown files plus a manifest (`.lattice/manifest.json`) that records,
|
|
20
|
+
per file, the content hash and the source row's version. The tree **lags** the
|
|
21
|
+
DB until a render settles; it is never read back as truth except through the
|
|
22
|
+
explicit reverse-sync path below.
|
|
23
|
+
|
|
24
|
+
3. **Offline edits** — a **client-side queue** (not a live mirror of the DB). Edits
|
|
25
|
+
made while disconnected are buffered and replayed, in order, when connectivity
|
|
26
|
+
returns.
|
|
27
|
+
|
|
28
|
+
Everything below is about the three edges between these: DB ⇄ rendered tree, and
|
|
29
|
+
DB ⇄ offline queue.
|
|
30
|
+
|
|
31
|
+
---
|
|
32
|
+
|
|
33
|
+
## Guaranteed invariants (each test-backed)
|
|
34
|
+
|
|
35
|
+
### 1. Reverse-sync never silently overwrites a concurrent change
|
|
36
|
+
|
|
37
|
+
When an external edit to a rendered file is swept back into the database, the
|
|
38
|
+
engine first checks whether the underlying row changed since that file was
|
|
39
|
+
rendered — an optimistic-concurrency check against the row version captured in the
|
|
40
|
+
manifest at render time. If the row changed (a concurrent DB edit), the file edit
|
|
41
|
+
is **rejected and reported as a conflict**, never applied over the newer value.
|
|
42
|
+
The reject is surfaced to the editor so the change can be re-applied against the
|
|
43
|
+
current record.
|
|
44
|
+
|
|
45
|
+
_Why it matters:_ a file edit and a concurrent database edit to the same record
|
|
46
|
+
can no longer race to a silent data loss — the database wins and the conflict is
|
|
47
|
+
made visible.
|
|
48
|
+
|
|
49
|
+
### 2. A render is manifest-atomic and fails loudly
|
|
50
|
+
|
|
51
|
+
The manifest is written **last**, as a single atomic file (temp + rename). It is
|
|
52
|
+
the commit point: a render either completes and commits a manifest describing a
|
|
53
|
+
fully-written tree, or it throws **before** committing — leaving the **prior**
|
|
54
|
+
manifest as the truthful record. Before writing anything, a render probes that its
|
|
55
|
+
target directories are writable (a disk-full or read-only mount throws _before_ a
|
|
56
|
+
single live file is touched). A write failure mid-render is re-raised loudly, never
|
|
57
|
+
swallowed; the next render reconciles (unchanged files are skipped, and orphan
|
|
58
|
+
cleanup runs only against a committed manifest).
|
|
59
|
+
|
|
60
|
+
_Guarantee level:_ **manifest-atomic + tree-eventually-consistent.** A render is
|
|
61
|
+
not a single atomic multi-file swap (a file tree cannot offer one without orphaning
|
|
62
|
+
user-edited and attached files the tree interleaves), but the manifest — the one
|
|
63
|
+
unit that must be atomic for correctness — is, and a failed render is self-healing
|
|
64
|
+
rather than silently divergent.
|
|
65
|
+
|
|
66
|
+
### 3. Render concurrency is single-owner per output directory
|
|
67
|
+
|
|
68
|
+
Within a process, the auto-render scheduler and a background render share one
|
|
69
|
+
in-flight guard, so they never overlap on the same output directory; a render
|
|
70
|
+
scheduled while one is in flight is deferred and coalesced. One-shot CLI renders
|
|
71
|
+
construct their own instance and do not auto-render, so they cannot interleave
|
|
72
|
+
either.
|
|
73
|
+
|
|
74
|
+
### 4. Migration reports what it leaves behind
|
|
75
|
+
|
|
76
|
+
Migrating a workspace into a fresh database asserts, per table, that the target row
|
|
77
|
+
count matches the source after copy (a mismatch aborts loudly, before the source is
|
|
78
|
+
archived). Files whose canonical bytes are owned-and-local — and therefore are not
|
|
79
|
+
carried by a row copy — are counted and reported, so the operator is told exactly
|
|
80
|
+
how many files reference bytes that were left behind rather than discovering dangling
|
|
81
|
+
references later.
|
|
82
|
+
|
|
83
|
+
### 5. Offline replay is idempotent and ordered
|
|
84
|
+
|
|
85
|
+
Queued edits carry a stable edit id and a client timestamp. Replay deduplicates by
|
|
86
|
+
edit id and applies in timestamp order, so a replay that is retried (or partially
|
|
87
|
+
re-sent) converges to the same state rather than double-applying.
|
|
88
|
+
|
|
89
|
+
---
|
|
90
|
+
|
|
91
|
+
## What is deliberately NOT guaranteed
|
|
92
|
+
|
|
93
|
+
- **Full render-tree atomicity.** See invariant 2 — the manifest is atomic; the
|
|
94
|
+
surrounding file tree is eventually-consistent and self-healing, not
|
|
95
|
+
all-or-nothing.
|
|
96
|
+
|
|
97
|
+
- **Multi-process renders to the same output directory.** Two processes rendering
|
|
98
|
+
the same context directory at once (for example, a long-running server plus a
|
|
99
|
+
separate one-shot render of the same directory) is **unsupported** — the manifest
|
|
100
|
+
write is last-writer-wins across processes. Point each long-running renderer at
|
|
101
|
+
its own output directory.
|
|
102
|
+
|
|
103
|
+
- **Cross-key encryption round-trips.** Encrypted columns round-trip through
|
|
104
|
+
decrypt-on-read / encrypt-on-write; both sides of a migration must share the same
|
|
105
|
+
encryption key, or encrypted values arrive unreadable.
|
|
106
|
+
|
|
107
|
+
---
|
|
108
|
+
|
|
109
|
+
## Where each invariant is enforced
|
|
110
|
+
|
|
111
|
+
| Invariant | Enforced in |
|
|
112
|
+
| ---------------------------------------------- | ----------------------------------------------------------------------- |
|
|
113
|
+
| 1 — reverse-sync conflict gate | `src/reverse-sync/engine.ts` (row-version check), manifest `rowVersion` |
|
|
114
|
+
| 2 — manifest-atomic render + writability probe | `src/render/engine.ts`, `src/render/writer.ts` |
|
|
115
|
+
| 3 — single-owner render concurrency | `src/render/auto-render.ts` (single-flight) |
|
|
116
|
+
| 4 — migration row-count + blob surfacing | `src/framework/cloud-migration.ts` |
|
|
117
|
+
| 5 — offline replay idempotency | the edit-id dedup + client-timestamp ordering on replay |
|
|
@@ -60,9 +60,11 @@ entities:
|
|
|
60
60
|
description: { type: text }
|
|
61
61
|
status: { type: text, default: pending }
|
|
62
62
|
priority: { type: integer, default: 1 }
|
|
63
|
-
agent_id: { type: uuid
|
|
63
|
+
agent_id: { type: uuid }
|
|
64
64
|
created_at: { type: datetime }
|
|
65
65
|
completed_at: { type: datetime }
|
|
66
|
+
relations:
|
|
67
|
+
agent: { type: belongsTo, table: agent, foreignKey: agent_id }
|
|
66
68
|
render:
|
|
67
69
|
template: default-list
|
|
68
70
|
formatRow: '[{{status}}] {{title}} → {{agent.name}}'
|
|
@@ -72,9 +74,11 @@ entities:
|
|
|
72
74
|
fields:
|
|
73
75
|
id: { type: uuid, primaryKey: true }
|
|
74
76
|
type: { type: text, required: true }
|
|
75
|
-
agent_id: { type: uuid
|
|
77
|
+
agent_id: { type: uuid }
|
|
76
78
|
payload: { type: text }
|
|
77
79
|
created_at: { type: datetime }
|
|
80
|
+
relations:
|
|
81
|
+
agent: { type: belongsTo, table: agent, foreignKey: agent_id }
|
|
78
82
|
render:
|
|
79
83
|
template: default-list
|
|
80
84
|
formatRow: '{{created_at}} [{{type}}] {{agent.name}}'
|
package/docs/examples/cms.md
CHANGED
|
@@ -70,10 +70,12 @@ entities:
|
|
|
70
70
|
title: { type: text, required: true }
|
|
71
71
|
excerpt: { type: text }
|
|
72
72
|
status: { type: text, default: draft }
|
|
73
|
-
author_id: { type: uuid
|
|
73
|
+
author_id: { type: uuid }
|
|
74
74
|
word_count: { type: integer, default: 0 }
|
|
75
75
|
published_at: { type: datetime }
|
|
76
76
|
updated_at: { type: datetime }
|
|
77
|
+
relations:
|
|
78
|
+
author: { type: belongsTo, table: author, foreignKey: author_id }
|
|
77
79
|
render:
|
|
78
80
|
template: default-detail
|
|
79
81
|
formatRow: '**{{title}}** [{{status}}] by {{author.name}} — {{word_count}} words'
|
|
@@ -59,10 +59,13 @@ entities:
|
|
|
59
59
|
description: { type: text }
|
|
60
60
|
status: { type: text, default: open }
|
|
61
61
|
priority: { type: integer, default: 2 }
|
|
62
|
-
reporter_id: { type: uuid
|
|
63
|
-
assignee_id: { type: uuid
|
|
62
|
+
reporter_id: { type: uuid }
|
|
63
|
+
assignee_id: { type: uuid }
|
|
64
64
|
created_at: { type: datetime }
|
|
65
65
|
closed_at: { type: datetime }
|
|
66
|
+
relations:
|
|
67
|
+
reporter: { type: belongsTo, table: user, foreignKey: reporter_id }
|
|
68
|
+
assignee: { type: belongsTo, table: user, foreignKey: assignee_id }
|
|
66
69
|
render:
|
|
67
70
|
template: default-list
|
|
68
71
|
formatRow: '[{{status}}] P{{priority}} {{title}} — {{assignee.name}}'
|
|
@@ -72,9 +75,12 @@ entities:
|
|
|
72
75
|
fields:
|
|
73
76
|
id: { type: uuid, primaryKey: true }
|
|
74
77
|
body: { type: text, required: true }
|
|
75
|
-
ticket_id: { type: uuid
|
|
76
|
-
author_id: { type: uuid
|
|
78
|
+
ticket_id: { type: uuid }
|
|
79
|
+
author_id: { type: uuid }
|
|
77
80
|
created_at: { type: datetime }
|
|
81
|
+
relations:
|
|
82
|
+
ticket: { type: belongsTo, table: ticket, foreignKey: ticket_id }
|
|
83
|
+
author: { type: belongsTo, table: user, foreignKey: author_id }
|
|
78
84
|
render:
|
|
79
85
|
template: default-list
|
|
80
86
|
formatRow: '{{author.name}} ({{created_at}}): {{body}}'
|
package/docs/scaling.md
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# Scaling: connection pooling & bounded reads
|
|
2
|
+
|
|
3
|
+
This note documents how `latticesql` behaves under concurrency — the Postgres
|
|
4
|
+
connection-pool contract and the read-bounding posture — for deployments serving many
|
|
5
|
+
simultaneous cloud users. It is the recorded outcome of the 4.0 cloud-scale review.
|
|
6
|
+
|
|
7
|
+
## Connection pooling
|
|
8
|
+
|
|
9
|
+
- **One pool per `Lattice` instance.** The Postgres adapter creates a single
|
|
10
|
+
`pg.Pool` with `max = poolSize` (default **10**). Configure it per instance:
|
|
11
|
+
|
|
12
|
+
```ts
|
|
13
|
+
const db = new Lattice('postgres://…', { poolSize: 20 });
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
- **The data path reuses a long-lived instance, not a per-request one.** A GUI
|
|
17
|
+
workspace opens exactly one `Lattice` (cached as the active DB) and serves every
|
|
18
|
+
request for that workspace from its pool. The only short-lived instances are
|
|
19
|
+
**transient probes** during connect/credential checks (a workspace open's peek and
|
|
20
|
+
the connection-test probe) — they are not on the per-request data path and are
|
|
21
|
+
disposed promptly, so there is no per-request connection churn.
|
|
22
|
+
|
|
23
|
+
- **The multi-tenant model is one connection identity per member.** Each cloud
|
|
24
|
+
member's GUI connects to the shared Postgres **as that member's own role** through
|
|
25
|
+
its own `Lattice`/pool. "Hundreds of concurrent members" therefore means hundreds of
|
|
26
|
+
independent role-scoped connections governed by Postgres `max_connections` (and any
|
|
27
|
+
external pooler such as PgBouncer), **not** contention on a single shared application
|
|
28
|
+
pool. Size `max_connections` / the pooler for the expected concurrent-member count;
|
|
29
|
+
keep each instance's `poolSize` modest (the default 10 is appropriate for a single
|
|
30
|
+
workspace's request concurrency).
|
|
31
|
+
|
|
32
|
+
- **Statements are parameterized** (`pool.query(sql, params)`), so the driver reuses
|
|
33
|
+
prepared statements per connection. The one deliberate exception is the member-grant
|
|
34
|
+
reconcile, which uses the unparameterized simple-query protocol to batch a table's
|
|
35
|
+
GRANTs into a single round-trip (see `cloud/member-access.ts`).
|
|
36
|
+
|
|
37
|
+
## Bounded reads (Rule of thumb: no unbounded whole-table read on a hot path)
|
|
38
|
+
|
|
39
|
+
The main GUI list endpoints are bounded at the route layer via `parsePageParam` /
|
|
40
|
+
`MAX_ROWS_PAGE` (`/api/tables/:t/rows`, `/api/system-tables/:t/rows`). The read API
|
|
41
|
+
(`getActive`, `queryTable`) accepts optional `{ limit, offset }` bounds.
|
|
42
|
+
|
|
43
|
+
A sweep of the remaining whole-table reads (empty-filter `query`/`queryTable`) found
|
|
44
|
+
only these, each acceptable for the reason given — **none scale with row count on a
|
|
45
|
+
per-request path**:
|
|
46
|
+
|
|
47
|
+
| Site | Why it is acceptable |
|
|
48
|
+
| ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
49
|
+
| `read-routes.ts` GUI metadata (`_lattice_gui_meta`, `_lattice_gui_column_meta`) | Bounded by **schema** size (≈ one row per entity/column), not data volume. |
|
|
50
|
+
| `reverse-seed/engine.ts` | Recovery path — runs only against an **empty / missing** entity table. |
|
|
51
|
+
| `reverse-sync/engine.ts` | Inherent: diffs the full entity set against the rendered files; **debounced**, not per-request. |
|
|
52
|
+
| `dedup-service.ts` | Inherent: duplicate detection must scan the candidate set. **Known limitation** for very large tables — a future windowed/indexed dedup would bound it. |
|
|
53
|
+
|
|
54
|
+
When adding a read in a request handler, scheduler, or per-item loop, filter + bound it
|
|
55
|
+
in SQL; if a whole-table read is genuinely required, justify it in a comment like the
|
|
56
|
+
ones above so the next sweep can sign it off quickly.
|
package/docs/templates.md
CHANGED
|
@@ -267,7 +267,8 @@ When rendering, Lattice:
|
|
|
267
267
|
1. Executes `SELECT * FROM users WHERE id = assignee_id` for each ticket
|
|
268
268
|
2. Makes all `users` columns available as `{{assignee.<column>}}`
|
|
269
269
|
|
|
270
|
-
In YAML config,
|
|
270
|
+
In YAML config, declare the `belongsTo` relation in an entity-level `relations:`
|
|
271
|
+
block (the relation name — `assignee` here — is what you reference in the template):
|
|
271
272
|
|
|
272
273
|
```yaml
|
|
273
274
|
entities:
|
|
@@ -275,7 +276,9 @@ entities:
|
|
|
275
276
|
fields:
|
|
276
277
|
id: { type: uuid, primaryKey: true }
|
|
277
278
|
title: { type: text, required: true }
|
|
278
|
-
assignee_id: { type: uuid
|
|
279
|
+
assignee_id: { type: uuid }
|
|
280
|
+
relations:
|
|
281
|
+
assignee: { type: belongsTo, table: user, foreignKey: assignee_id }
|
|
279
282
|
render:
|
|
280
283
|
template: default-list
|
|
281
284
|
formatRow: '{{title}} → {{assignee.name}}'
|
package/package.json
CHANGED