latticesql 3.4.6 → 4.0.0

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,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, ref: agent }
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, ref: agent }
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}}'
@@ -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, ref: author }
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, ref: user }
63
- assignee_id: { type: uuid, ref: user }
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, ref: ticket }
76
- author_id: { type: uuid, ref: user }
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}}'
@@ -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, `ref: user` automatically creates the `belongsTo` relation:
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, ref: user }
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "latticesql",
3
- "version": "3.4.6",
3
+ "version": "4.0.0",
4
4
  "description": "Persistent structured memory for AI agent systems — pluggable SQLite or Postgres backend, LLM context bridge",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",