latticesql 3.4.7 → 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,407 @@
1
+ # Migrating to 4.0
2
+
3
+ **Most upgrades need no action — the GUI silently migrates an existing 3.0+ config
4
+ and its data forward on open.** This guide documents what each upgrade does, plus
5
+ the manual SQL for library/non-GUI consumers who want explicit control.
6
+
7
+ ## Auto-upgrade on open (the common case)
8
+
9
+ When the GUI opens a workspace, it silently brings a 3.0+ config + database to the
10
+ 4.0 shape, preserving comments and data:
11
+
12
+ - **`ref:` config shorthand** → rewritten in place to an explicit `relations:` block
13
+ (and the parser accepts `ref:` regardless, so a config opens whether or not the
14
+ rewrite has run yet).
15
+ - **`deleted_at = ''`** → normalized to `NULL` across every table that has the
16
+ column (so a live row never reads as deleted).
17
+ - **`files.path`-only rows** → backfilled into the reference model
18
+ (`ref_kind='local_ref'`, `ref_uri=path`) so their bytes stay resolvable. The
19
+ legacy `path` / `kind` columns are left in place (dropping them is optional).
20
+ - **Cloud (Postgres) member group** → the per-cloud group + its grants self-heal,
21
+ and the cloud's own members (from its invite registry) are re-granted the new
22
+ group on the owner's next open.
23
+
24
+ Each on-open migration is **idempotent** and gated once-per-database, so reopening
25
+ is cheap and a database created by 4.0 is untouched. Render manifests also
26
+ self-upgrade on the first render (an old v1 `manifest.json` is read for cleanup,
27
+ then rewritten in the v2 shape).
28
+
29
+ These on-disk rewrites let real-world configs migrate forward, so a **future major**
30
+ can cleanly drop the back-compat tolerance once configs have upgraded.
31
+
32
+ ## Manual migration (library / non-GUI consumers)
33
+
34
+ If you use `latticesql` as a library WITHOUT the GUI open path, the on-open
35
+ migrations above don't run automatically — apply the equivalent SQL yourself (or
36
+ ship it in your consumer's migrations). The most data-safety-critical is
37
+ normalizing `deleted_at = ''` BEFORE upgrading; the rest can be done when
38
+ convenient. Each is detailed below.
39
+
40
+ ---
41
+
42
+ ## 4.0.0 — Soft-delete predicate simplified to `deleted_at IS NULL`
43
+
44
+ > **GUI users: no action needed.** The GUI normalizes `deleted_at = '' → NULL` on
45
+ > open (once per database, before anything reads the data), so a live row never
46
+ > reads as deleted. The rest of this section is for **library / non-GUI consumers**,
47
+ > who should run the normalization themselves.
48
+
49
+ ### Library consumers — normalize BEFORE upgrading
50
+
51
+ > If you open the database WITHOUT the GUI (the library `init()` path), upgrading
52
+ > first will HIDE any live row whose `deleted_at` is the empty string (`''`) until
53
+ > you normalize it — and during that window a natural-key upsert against a hidden
54
+ > row can **INSERT A DUPLICATE**. Normalize every `deleted_at` table to `NULL`,
55
+ > verify zero empty-string rows, _then_ upgrade. The numbered steps below are in
56
+ > mandatory order; do not reverse them.
57
+
58
+ ### What changed
59
+
60
+ Prior versions treated a row as "live" when `deleted_at` was **either** `NULL`
61
+ **or** the empty string `''`. That empty-string branch was a back-compat shim for
62
+ legacy / pre-soft-delete data — current code has only ever written a timestamp
63
+ (on delete) or `NULL` (on insert/restore), never `''`.
64
+
65
+ In 4.0 the live predicate is the single, consistent form used everywhere:
66
+
67
+ ```sql
68
+ WHERE deleted_at IS NULL
69
+ ```
70
+
71
+ The legacy `OR deleted_at = ''` branch is removed from the **last three** read
72
+ paths that still carried it: the natural-key lookup family, the seed resolver,
73
+ and full-text search (both indexed and LIKE). Everything else — the main `query`
74
+ read path, `getActive` / `countActive`, the report builder, the GUI count, and
75
+ the entire `{ col: 'deleted_at', op: 'isNull' }` structured-filter family —
76
+ already used bare `deleted_at IS NULL`, so for them nothing changes. This release
77
+ simply makes the codebase consistent.
78
+
79
+ ### Breaking behavior
80
+
81
+ After upgrading, **a LIVE row whose `deleted_at` holds the empty string `''`
82
+ reads as DELETED.** It disappears from:
83
+
84
+ - natural-key lookups (`getByNaturalKey`, `upsertByNaturalKey`,
85
+ `enrichByNaturalKey`, `softDeleteMissing`),
86
+ - the seed link/resolve path,
87
+ - full-text search (both the indexed path and the LIKE path).
88
+
89
+ Only legacy or externally / manually inserted rows can hold `''`; a database that
90
+ has only ever used this library to soft-delete has none, and the migration below
91
+ is a harmless no-op for it. Run it anyway — a single missed `''` row vanishes
92
+ silently.
93
+
94
+ ### Required migration (run FIRST, then upgrade)
95
+
96
+ **Step 1 — Normalize EVERY `deleted_at` table. Do not copy a fixed list —
97
+ introspect.**
98
+
99
+ The normalization must cover every table that has a `deleted_at` column: the
100
+ framework-native tables **and** every user-defined entity table (the GUI's
101
+ `CREATE TABLE` always adds `deleted_at`). The printed names further down are
102
+ illustrative only; **the authoritative list is whatever the introspection query
103
+ returns**.
104
+
105
+ Enumerate them with schema introspection — this is the primary, authoritative
106
+ step:
107
+
108
+ - **SQLite:**
109
+
110
+ ```sql
111
+ SELECT m.name
112
+ FROM sqlite_master m
113
+ JOIN pragma_table_info(m.name) c
114
+ WHERE m.type = 'table' AND c.name = 'deleted_at';
115
+ ```
116
+
117
+ - **Postgres:**
118
+
119
+ ```sql
120
+ SELECT table_name
121
+ FROM information_schema.columns
122
+ WHERE table_schema = 'public' AND column_name = 'deleted_at';
123
+ ```
124
+
125
+ Then, for each table name the query returned:
126
+
127
+ ```sql
128
+ UPDATE "<table>" SET deleted_at = NULL WHERE deleted_at = '';
129
+ ```
130
+
131
+ On Postgres you can generate and run all of the `UPDATE`s in one pass with
132
+ `psql`'s `\gexec`:
133
+
134
+ ```sql
135
+ SELECT format('UPDATE %I SET deleted_at = NULL WHERE deleted_at = '''';', table_name)
136
+ FROM information_schema.columns
137
+ WHERE table_schema = 'public' AND column_name = 'deleted_at'
138
+ \gexec
139
+ ```
140
+
141
+ > **Illustrative only — do not treat this as the list.** The framework-native
142
+ > `deleted_at` tables are `secrets`, `files`, `notes`, `chat_threads`, and
143
+ > `chat_messages`. Your real list is whatever the introspection above returns: it
144
+ > includes these **plus** every user-defined entity table your app or the GUI
145
+ > created. Application-defined tables are **not** framework-native — they exist
146
+ > only if your app declared them — so you MUST rely on the introspection result,
147
+ > never a hardcoded list.
148
+
149
+ **Step 2 — Verify zero empty-string rows on every table (HARD GATE — do not
150
+ proceed until all return 0):**
151
+
152
+ ```sql
153
+ SELECT COUNT(*) FROM "<table>" WHERE deleted_at = '';
154
+ ```
155
+
156
+ Run this on **every** table the Step 1 introspection returned. Every count must
157
+ be `0`. Do not move on to Step 3 while any table still reports a non-zero count.
158
+
159
+ **Step 3 — Only now upgrade:**
160
+
161
+ ```bash
162
+ npm install latticesql@4.0
163
+ ```
164
+
165
+ ### If you already upgraded before normalizing
166
+
167
+ The rows are not lost — only hidden by the predicate. Run the Step 1
168
+ normalization immediately and they reappear. Then audit for duplicate rows
169
+ created by any natural-key upsert that ran during the hidden window: for each
170
+ affected table, group by the natural key and look for more than one live row per
171
+ key. Duplicates created in that window are **not** auto-reconciled — you must
172
+ merge or remove them by hand.
173
+
174
+ ---
175
+
176
+ ## 4.0.0 — `ref:` field shorthand deprecated (auto-upgraded, not removed)
177
+
178
+ > **No action needed.** 4.0 still parses the per-field `ref:` shorthand (it derives
179
+ > the same `belongsTo` it always did — relation name = the field name with a trailing
180
+ > `_id` stripped), so an existing config opens unchanged. When the GUI opens the
181
+ > config it **silently rewrites** `ref:` to the explicit `relations:` block below,
182
+ > preserving comments — so configs migrate forward and a **future major** can drop
183
+ > the shorthand cleanly.
184
+
185
+ The explicit `relations:` form is the going-forward shape (and lets you name the
186
+ relation yourself instead of relying on the `_id`-stripping rule). The GUI writes it
187
+ for any link you create; the auto-upgrade rewrites legacy `ref:` into it on open.
188
+
189
+ **Before (3.x shorthand — still accepted, auto-rewritten on open):**
190
+
191
+ ```yaml
192
+ db: ./app.db
193
+ entities:
194
+ ticket:
195
+ fields:
196
+ id: { type: uuid, primaryKey: true }
197
+ title: { type: text, required: true }
198
+ assignee_id: { type: uuid, ref: user } # belongsTo derived automatically, relation named "assignee"
199
+ outputFile: tickets.md
200
+ ```
201
+
202
+ **After (4.0):**
203
+
204
+ ```yaml
205
+ db: ./app.db
206
+ entities:
207
+ ticket:
208
+ fields:
209
+ id: { type: uuid, primaryKey: true }
210
+ title: { type: text, required: true }
211
+ assignee_id: { type: uuid } # plain FK column
212
+ relations:
213
+ assignee: # relation name you choose
214
+ type: belongsTo
215
+ table: user
216
+ foreignKey: assignee_id
217
+ # references: id # optional; defaults to the target's primary key
218
+ outputFile: tickets.md
219
+ ```
220
+
221
+ A malformed _explicit_ `relations:` entry (not an object, missing
222
+ `type`/`table`/`foreignKey`, a non-`belongsTo` `type`, or an empty `references`)
223
+ still fails loudly rather than silently producing no relation — only the legacy
224
+ `ref:` shorthand is tolerated, not a broken `relations:` block.
225
+
226
+ **Library / non-GUI consumers:** a `ref:` config parses fine, but the on-disk
227
+ rewrite only happens through the GUI open path. If you never open the config in the
228
+ GUI and want to retire `ref:` before a future major drops it, rewrite it to the
229
+ `relations:` form above yourself (the conversion is exactly the `_id`-stripping rule
230
+ shown).
231
+
232
+ ---
233
+
234
+ ## 4.0.0 — `files.path` and `files.kind` no longer native columns
235
+
236
+ > **GUI users: no action needed.** On open the GUI backfills any legacy
237
+ > `path`-only file row into the reference model (`ref_kind='local_ref'`,
238
+ > `ref_uri=path`) so its bytes stay resolvable. The legacy `path` / `kind` columns
239
+ > are left in place (dropping them is optional + destructive, so it is never
240
+ > automatic — see the manual step below). Library / non-GUI consumers should run the
241
+ > backfill themselves.
242
+
243
+ ### What changed
244
+
245
+ The native `files` entity no longer declares the legacy `path` and `kind`
246
+ columns. File resolution now flows entirely through the content-addressed and
247
+ reference columns that have shipped alongside them since 2.0:
248
+
249
+ - **`sha256` / `blob_path`** — for files whose bytes Lattice owns (a
250
+ content-addressed copy under `<lattice-root>/data/blobs/`).
251
+ - **`ref_kind` / `ref_uri` / `ref_provider`** — the reference model, for files
252
+ that live elsewhere. `ref_kind` is the single discriminator:
253
+ - `'blob'` — an owned local copy (bytes under `data/blobs/`, resolved via
254
+ `blob_path`).
255
+ - `'local_ref'` — a file referenced **in place** on this machine; `ref_uri` is
256
+ its absolute OS path, served straight from disk (no copy made).
257
+ - `'cloud_ref'` — a file that lives remotely (an `s3://bucket/key` object or an
258
+ external URL in `ref_uri`).
259
+
260
+ What `path` and `kind` used to carry now maps to the reference model: an ingested
261
+ local file is recorded as a `local_ref` whose `ref_uri` is the absolute path
262
+ (previously stored in `path`), and an owned copy is identified by `sha256` /
263
+ `blob_path` rather than a free-form `kind`.
264
+
265
+ ### Breaking behavior
266
+
267
+ - Reads and writes against `files.path` or `files.kind` no longer resolve to a
268
+ declared native column. Any consumer code that read `row.path` / `row.kind` on
269
+ a native `files` row must read `ref_uri` (for a `local_ref`) or `blob_path` (for
270
+ an owned blob) instead.
271
+ - For an existing row that only ever populated the legacy `path` column, file
272
+ resolution now falls back to the reference columns: a row with no `ref_kind`
273
+ and no `blob_path` resolves as unavailable rather than reading `path`. Backfill
274
+ such rows into the reference model before upgrading (see below).
275
+
276
+ ### Migration
277
+
278
+ If your physical `files` table still carries the legacy columns, drop them:
279
+
280
+ ```sql
281
+ ALTER TABLE files DROP COLUMN path;
282
+ ALTER TABLE files DROP COLUMN kind;
283
+ ```
284
+
285
+ > **`DROP COLUMN` support:** SQLite added `ALTER TABLE … DROP COLUMN` in
286
+ > **3.35.0** (March 2021) — make sure your SQLite build is at least that version.
287
+ > PostgreSQL has supported it for far longer, so no version concern there.
288
+
289
+ Before dropping `path`, backfill any rows that relied on it into the reference
290
+ model so their bytes stay resolvable — a row whose only on-disk pointer was
291
+ `path` should become a `local_ref`:
292
+
293
+ ```sql
294
+ UPDATE files
295
+ SET ref_kind = 'local_ref',
296
+ ref_uri = path,
297
+ ref_provider = 'fs'
298
+ WHERE path IS NOT NULL
299
+ AND ref_kind IS NULL
300
+ AND blob_path IS NULL;
301
+ ```
302
+
303
+ (Adjust for rows whose `path` already pointed inside `data/blobs/` — those are
304
+ owned blobs and should instead set `ref_kind = 'blob'`, `blob_path = path`.) After
305
+ the backfill verifies clean, run the `DROP COLUMN` statements above.
306
+
307
+ ## Render manifest is v2-only
308
+
309
+ The render manifest (`.lattice/manifest.json`) is now written exclusively in the
310
+ hashed v2 shape — each entity's files entry is a `{ filename: { hash, ... } }`
311
+ map (content hashes power change detection for the file → DB write-back), never
312
+ the older bare `["FILE.md", ...]` filename array.
313
+
314
+ **No action is required.** An old v1 `manifest.json` still on disk is handled
315
+ gracefully: its filenames are read for cleanup (so orphaned files are still
316
+ detected), and because a v1 entry carries no content hashes there is no baseline
317
+ to compare against, so write-back simply skips those entries — exactly as before.
318
+ The first render after upgrading rewrites the manifest in the v2 shape, upgrading
319
+ it automatically.
320
+
321
+ If you would rather force a clean v2 render immediately, delete the manifest and
322
+ re-render — it will be regenerated from scratch:
323
+
324
+ ```sh
325
+ rm .lattice/manifest.json
326
+ ```
327
+
328
+ ---
329
+
330
+ ## 4.0.0 — Cloud member group is now per-cloud (BREAKING, cloud + members only)
331
+
332
+ **Applies only to a Postgres cloud that has provisioned members.** Single-user /
333
+ SQLite deployments, and clouds with no members, need no action.
334
+
335
+ ### What changed
336
+
337
+ Postgres roles and role membership are **cluster-global** — shared by every
338
+ database and schema on a Postgres cluster. Prior versions put every cloud's members
339
+ in one hard-coded group role, `lattice_members`. Two unrelated Lattice clouds that
340
+ happened to share a Postgres cluster therefore shared one members group, and
341
+ concurrent member provisioning across them contended on that single role's catalog.
342
+
343
+ In 4.0 the group name is **derived from the cloud's own `(database, schema)`
344
+ namespace**:
345
+
346
+ ```
347
+ lattice_m_<first 20 hex of md5(current_database() || ':' || current_schema())>
348
+ ```
349
+
350
+ Each cloud gets its own group — genuine cross-cloud isolation, and no shared-catalog
351
+ contention. The name is deterministic and stable: the same cloud always resolves the
352
+ same group, so install / provision / reconcile all agree. The library exposes
353
+ `memberGroupFor(db)` (the resolver) and `LEGACY_MEMBER_GROUP` (the old `'lattice_members'`
354
+ constant, kept only to recognize a pre-4.0 cloud). The previously exported
355
+ `MEMBER_GROUP` constant is **removed**.
356
+
357
+ ### Breaking behavior
358
+
359
+ A cloud provisioned before 4.0 has its members in `lattice_members`, and its table /
360
+ view / bookkeeping privileges granted to `lattice_members`. After upgrading, the
361
+ owner connection installs and reconciles against the **new per-cloud group** — so:
362
+
363
+ - The new per-cloud group and all of its table / view / bookkeeping / EXECUTE grants
364
+ are recreated automatically on the owner's next open (install + reconcile are
365
+ idempotent and run on every owner open).
366
+ - The cloud's **own members are automatically re-added** to the new group on that
367
+ same owner open — `reconcileCloudMemberAccess` re-grants the per-cloud group to
368
+ every role in the cloud's invite registry (`__lattice_member_invites`). It is
369
+ deliberately scoped to the cloud's OWN members, never the cluster-global legacy
370
+ group, so members are never cross-pollinated between unrelated clouds on one
371
+ cluster.
372
+
373
+ So a cloud whose members were provisioned through Lattice fully self-heals on the
374
+ owner's next open — **no action needed.**
375
+
376
+ ### Migration (manual fallback — only if a member isn't in the invite registry)
377
+
378
+ A member role created out-of-band (e.g. by a DBA, never recorded in
379
+ `__lattice_member_invites`) won't be picked up by the automatic re-grant. Add it
380
+ explicitly. Open the cloud once as the owner so the per-cloud group + grants exist,
381
+ then, connected to the cloud, scope the grant to the cloud's OWN members — NOT the
382
+ cluster-global `lattice_members` (which would pull in other clouds' members):
383
+
384
+ ```sql
385
+ -- Re-grant the per-cloud group to THIS cloud's own members (its invite registry).
386
+ SELECT format(
387
+ 'GRANT %I TO %I',
388
+ 'lattice_m_' || substr(md5(current_database() || ':' || current_schema()), 1, 20),
389
+ i."role"
390
+ )
391
+ FROM "__lattice_member_invites" i
392
+ JOIN pg_roles r ON r.rolname = i."role"
393
+ \gexec
394
+ ```
395
+
396
+ Equivalently, re-running your provisioning flow for each member (which issues
397
+ `GRANT <per-cloud group> TO <member>`) achieves the same result.
398
+
399
+ **Step 3 — (optional) retire the legacy group.** Once every cloud on the cluster
400
+ has migrated its members and `lattice_members` has no members left, you may drop it:
401
+
402
+ ```sql
403
+ DROP OWNED BY lattice_members; -- removes any stale grants it still holds
404
+ DROP ROLE IF EXISTS lattice_members;
405
+ ```
406
+
407
+ Leave it in place if any un-migrated cloud on the same cluster still relies on it.
@@ -0,0 +1,64 @@
1
+ # Cloud workspace switch hangs (20s timeout) — per-table migration loop
2
+
3
+ **Date:** 2026-06-19
4
+ **Severity:** high (a cloud workspace with many tables could not be opened/switched to)
5
+
6
+ ## Symptom
7
+
8
+ Switching to a cloud (Postgres) workspace in the GUI hung and then failed with:
9
+
10
+ > Switch failed: Opening "<workspace>" timed out after 20s — the
11
+ > database may be slow or unreachable. Staying on the current workspace.
12
+
13
+ The database was **not** slow or unreachable.
14
+
15
+ ## Root cause
16
+
17
+ The owner-open maintenance runs synchronously inside `openConfig` (it is awaited
18
+ before the open resolves), and the whole open is wrapped in a 20s timeout
19
+ (`SWITCH_OPEN_TIMEOUT_MS`). The silent data-upgrade added in the backwards-compat
20
+ work (`framework/data-upgrade.ts`, `normalizeEmptyDeletedAt`) issued **one
21
+ `db.migrate(...)` call per table** to normalize legacy `deleted_at = '' → NULL`.
22
+
23
+ `applyMigrationsAsync` opens a transaction and takes a transaction-scoped advisory
24
+ lock **once per call**. So one `db.migrate` per table = one pooled transaction +
25
+ advisory lock + commit per table. The affected cloud had **117 tables** and
26
+ connects through the Supabase transaction pooler; measured ~14ms for a simple
27
+ round-trip but far more for a full transaction setup/teardown through the pooler.
28
+ 117 sequential migration transactions blew past the 20s open budget, so the switch
29
+ timed out. (A simple `SELECT 1` to the same cloud was 164ms — the DB was fine; the
30
+ open path was doing ~100+ pooled transactions.)
31
+
32
+ ## Fix
33
+
34
+ Normalize in a **single server-side pass** on Postgres: one `db.migrate` whose SQL
35
+ is a `DO` block that loops `information_schema.columns` for `deleted_at` tables and
36
+ `EXECUTE`s the `UPDATE` per table **in-database** — one migration transaction, gated
37
+ by a single `…:v1:all` sentinel. SQLite (local, no pooler/network cost, and its
38
+ adapter rejects multi-statement migration SQL) keeps per-table single-statement
39
+ migrations but applies them in one `db.migrate([...])` pass.
40
+
41
+ Measured after the fix (same 117-table cloud): total owner-maintenance **~1.9s**
42
+ (was >20s); `upgradeLegacyData` ~389ms on the first open (server-side normalize +
43
+ files-path check), ~30ms on subsequent opens once the sentinel is stamped. No other
44
+ open phase is a bottleneck (`installCloudRls` 104ms, `reconcileCloudMemberAccess`
45
+ 281ms).
46
+
47
+ ## Lessons
48
+
49
+ - **On a remote/pooled cloud, prefer ONE round-trip / one server-side pass over an
50
+ O(N-tables) client loop.** `db.migrate` per item means one transaction + advisory
51
+ lock per item; batch into one call, or push the loop server-side with a `DO`
52
+ block. This is the same class as the existing Phase-4 note that cloud reconcile
53
+ "loops over every table" — keep table-count work off the per-open critical path.
54
+ - Anything awaited inside `openConfig` counts against the 20s switch timeout. New
55
+ per-open work must be O(1)-ish in round-trips, or deferred to the background.
56
+
57
+ ## Regression tests
58
+
59
+ - `tests/integration/data-upgrade-postgres.test.ts` — asserts the Postgres path
60
+ normalizes every `deleted_at` table AND records **exactly one** `…:v1:all`
61
+ sentinel (never a per-table sentinel — i.e. one migration, not N), plus
62
+ idempotency on a second run.
63
+ - `tests/integration/data-upgrade.test.ts` — the SQLite path still normalizes
64
+ per-table and is a no-op on a 4.0-native DB.
package/docs/cli.md CHANGED
@@ -389,8 +389,10 @@ entities:
389
389
  fields:
390
390
  id: { type: uuid, primaryKey: true }
391
391
  body: { type: text, required: true }
392
- task_id: { type: uuid, ref: task }
392
+ task_id: { type: uuid }
393
393
  score: { type: integer, default: 0 }
394
+ relations:
395
+ task: { type: belongsTo, table: task, foreignKey: task_id }
394
396
  ```
395
397
 
396
398
  Generates:
@@ -10,7 +10,7 @@ Complete reference for `lattice.config.yml` — the YAML schema config format in
10
10
  - [Top-level structure](#top-level-structure)
11
11
  - [Field types](#field-types)
12
12
  - [Field options](#field-options)
13
- - [Relationships (`ref`)](#relationships-ref)
13
+ - [Relationships (`relations`)](#relationships-relations)
14
14
  - [Render specs](#render-specs)
15
15
  - [Primary keys](#primary-keys)
16
16
  - [Output file paths](#output-file-paths)
@@ -97,17 +97,22 @@ entities:
97
97
  title: { type: text, required: true }
98
98
  status: { type: text, default: open }
99
99
  priority: { type: integer, default: 1 }
100
- assignee_id: { type: uuid, ref: user }
100
+ assignee_id: { type: uuid }
101
101
  deleted_at: { type: datetime }
102
+ relations:
103
+ assignee: { type: belongsTo, table: user, foreignKey: assignee_id }
102
104
  ```
103
105
 
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)) |
106
+ A foreign-key field is just a plain column (e.g. `assignee_id: { type: uuid }`). To
107
+ turn it into a navigable `belongsTo` relationship, declare an entity-level
108
+ `relations:` block (see [Relationships](#relationships-relations)).
109
+
110
+ | Option | Type | Description |
111
+ | ------------ | ------------------------- | -------------------------------------------------------------------------------- |
112
+ | `type` | `LatticeFieldType` | **Required.** Column data type (see table above) |
113
+ | `primaryKey` | `boolean` | Mark this field as the primary key. Generates `TEXT PRIMARY KEY` (for uuid/text) |
114
+ | `required` | `boolean` | Column is `NOT NULL`. Cannot be used together with `primaryKey` |
115
+ | `default` | string / number / boolean | SQL `DEFAULT` value. Strings are quoted; numbers are unquoted |
111
116
 
112
117
  ### Generated SQL
113
118
 
@@ -121,16 +126,24 @@ notes: { type: text } → "notes" TEXT
121
126
 
122
127
  ---
123
128
 
124
- ## Relationships (`ref`)
129
+ ## Relationships (`relations`)
125
130
 
126
- Adding `ref: <entity>` to a field automatically creates a `belongsTo` relationship in the compiled `TableDefinition`.
131
+ A `belongsTo` relationship is declared with an entity-level `relations:` block. The
132
+ foreign-key column stays a plain field; the `relations:` entry wires it to the
133
+ related table.
127
134
 
128
135
  ```yaml
129
136
  entities:
130
137
  ticket:
131
138
  fields:
132
139
  id: { type: uuid, primaryKey: true }
133
- assignee_id: { type: uuid, ref: user }
140
+ assignee_id: { type: uuid } # plain FK column
141
+ relations:
142
+ assignee: # relation name — you choose it
143
+ type: belongsTo
144
+ table: user
145
+ foreignKey: assignee_id
146
+ # references: id # optional; defaults to the related table's PK
134
147
  ```
135
148
 
136
149
  This generates:
@@ -138,18 +151,29 @@ This generates:
138
151
  - Column: `"assignee_id" TEXT`
139
152
  - Relation: `assignee: { type: 'belongsTo', table: 'user', foreignKey: 'assignee_id' }`
140
153
 
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:
154
+ Each key under `relations:` is the **relation name**, and its value is a relation
155
+ definition with these fields:
156
+
157
+ | Field | Type | Description |
158
+ | ------------ | ------------- | ------------------------------------------------------------------------------------------------- |
159
+ | `type` | `'belongsTo'` | **Required.** The relation kind. `belongsTo` points from the FK-holding table to its parent. |
160
+ | `table` | string | **Required.** The related (parent) entity/table name. |
161
+ | `foreignKey` | string | **Required.** The column on this entity that holds the related row's key (e.g. `assignee_id`). |
162
+ | `references` | string | Optional. The column on the related table to match against. Defaults to that table's primary key. |
142
163
 
143
- | Field name | Relation name |
144
- | ------------- | ------------- |
145
- | `assignee_id` | `assignee` |
146
- | `project_id` | `project` |
147
- | `parent_id` | `parent` |
148
- | `author` | `author` |
164
+ **Choosing the relation name:** The relation name is whatever key you put under
165
+ `relations:` you name it explicitly. (In earlier versions this name was derived
166
+ automatically by stripping a trailing `_id` from the FK field; now you choose it.)
167
+ By convention it is the FK column with any trailing `_id` removed — `assignee_id`
168
+ `assignee`, `project_id` → `project`, `parent_id` `parent` — but you are free
169
+ to use any name.
149
170
 
150
- The relation name is used in `{{relationName.field}}` interpolation strings inside render templates.
171
+ The relation name is used in `{{relationName.field}}` interpolation strings inside
172
+ render templates (e.g. `{{assignee.name}}`).
151
173
 
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.
174
+ SQLite does not enforce foreign key constraints by default. Lattice stores the
175
+ relation as metadata for template rendering — no `FOREIGN KEY` constraint is added
176
+ to the SQL schema.
153
177
 
154
178
  ---
155
179
 
@@ -283,7 +307,9 @@ entities:
283
307
  fields:
284
308
  id: { type: uuid, primaryKey: true }
285
309
  name: { type: text, required: true }
286
- owner_id: { type: uuid, ref: user }
310
+ owner_id: { type: uuid }
311
+ relations:
312
+ owner: { type: belongsTo, table: user, foreignKey: owner_id }
287
313
  render:
288
314
  template: default-list
289
315
  formatRow: '{{name}} (owner: {{owner.name}})'
@@ -295,8 +321,11 @@ entities:
295
321
  title: { type: text, required: true }
296
322
  status: { type: text, default: open }
297
323
  priority: { type: integer, default: 1 }
298
- project_id: { type: uuid, ref: project }
299
- assignee_id: { type: uuid, ref: user }
324
+ project_id: { type: uuid }
325
+ assignee_id: { type: uuid }
326
+ relations:
327
+ project: { type: belongsTo, table: project, foreignKey: project_id }
328
+ assignee: { type: belongsTo, table: user, foreignKey: assignee_id }
300
329
  render:
301
330
  template: default-list
302
331
  formatRow: '{{title}} [{{status}}] → {{assignee.name}}'
@@ -352,7 +381,9 @@ entities:
352
381
  id: { type: uuid, primaryKey: true }
353
382
  name: { type: text, required: true }
354
383
  status: { type: text, default: active }
355
- owner_id: { type: uuid, ref: user }
384
+ owner_id: { type: uuid }
385
+ relations:
386
+ owner: { type: belongsTo, table: user, foreignKey: owner_id }
356
387
  render:
357
388
  template: default-list
358
389
  formatRow: '**{{name}}** [{{status}}] — {{owner.name}}'
@@ -366,10 +397,13 @@ entities:
366
397
  description: { type: text }
367
398
  status: { type: text, default: open }
368
399
  priority: { type: integer, default: 1 }
369
- project_id: { type: uuid, ref: project }
370
- assignee_id: { type: uuid, ref: user }
400
+ project_id: { type: uuid }
401
+ assignee_id: { type: uuid }
371
402
  created_at: { type: datetime }
372
403
  due_at: { type: datetime }
404
+ relations:
405
+ project: { type: belongsTo, table: project, foreignKey: project_id }
406
+ assignee: { type: belongsTo, table: user, foreignKey: assignee_id }
373
407
  render:
374
408
  template: default-list
375
409
  formatRow: '{{title}} [P{{priority}}/{{status}}] → {{assignee.name}}'
@@ -380,9 +414,12 @@ entities:
380
414
  fields:
381
415
  id: { type: uuid, primaryKey: true }
382
416
  body: { type: text, required: true }
383
- task_id: { type: uuid, ref: task }
384
- author_id: { type: uuid, ref: user }
417
+ task_id: { type: uuid }
418
+ author_id: { type: uuid }
385
419
  created_at: { type: datetime }
420
+ relations:
421
+ task: { type: belongsTo, table: task, foreignKey: task_id }
422
+ author: { type: belongsTo, table: user, foreignKey: author_id }
386
423
  render:
387
424
  template: default-list
388
425
  formatRow: '{{author.name}}: {{body}}'