@happyvertical/smrt-core 0.45.1 → 0.45.2

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.
@@ -4,16 +4,11 @@ Module semantics for `src/schema/` — which `SchemaGenerator` entry point reach
4
4
  a real database, what each one emits, and the rules that keep them in step.
5
5
  Package orientation, the cross-module invariants, and the traps that apply
6
6
  before editing anything live in [../AGENTS.md](../AGENTS.md) — read that first;
7
- its "Schema paths" section is the short form of everything below.
8
-
9
- Written from the 2026-08-17 database-layer gap assessment (epic #2382). Symbol
10
- names here are stable; the line numbers the assessment quotes are not, so trust
11
- this call graph and re-grep before citing a location.
7
+ it links the relevant runtime and generation contracts.
12
8
 
13
9
  ## Four entry points, two of which ship
14
10
 
15
- `src/schema/generator.ts` exposes four index-emitting entry points. They do not
16
- produce the same schema for the same class.
11
+ `src/schema/generator.ts` exposes four index-emitting entry points. Their columns and indexes must agree for the same class.
17
12
 
18
13
  | Entry point | Selected by | Status |
19
14
  |---|---|---|
@@ -22,15 +17,6 @@ produce the same schema for the same class.
22
17
  | `generateSTISchemaFromRegistry` | `src/testing/database.ts` (`getTestDatabase()`), `src/schema/utils.ts` (`generateSchema`; `ensureSchema` only as a fallback) | tests + runtime helpers |
23
18
  | `generateSchemaFromRegistry` | the same two callers | tests + runtime helpers |
24
19
 
25
- A fifth entry point, the build-time AST `generateSchema(objectDef)`, existed
26
- until #2380: it fed only the `smrt:schema` virtual module, which had no
27
- consumer, had rotted relative to the four paths above (an `idx_`-prefixed
28
- naming scheme none of the others use, and no conflict-index emission at all),
29
- and was deleted rather than wired up. `SchemaOverrideSystem`
30
- (`schema/override-system.ts`) — unwired, and its two non-generic methods
31
- hard-coded a schema extension for a project outside this monorepo — was
32
- deleted alongside it. See rule 9 and the new rule at the end of this file.
33
-
34
20
  Production DDL takes the manifest route:
35
21
 
36
22
  ```
@@ -43,14 +29,6 @@ Production DDL takes the manifest route:
43
29
  use; no in-repo caller outside its own tests)
44
30
  ```
45
31
 
46
- The suite takes the registry route. Before #2359 the registry route emitted
47
- indexes the manifest route did not — per-column foreign-key indexes, and STI
48
- partial FK indexes filtered by `_meta_type` — so tests ran against a richer
49
- schema than any deployment received, the manifest STI path populated a
50
- `fkColumnsByClass` map it never read, and the manifest CTI path had no FK loop
51
- at all. `src/testing/database.ts`'s "same as migrations" comment described an
52
- intent, not the code.
53
-
54
32
  Since #2359 the two families share one set of index helpers and
55
33
  `src/schema/schema-path-parity.test.ts` runs the same fixture manifest through
56
34
  the manifest paths, through `ObjectRegistry.registerFromManifest()` + the
@@ -85,66 +63,40 @@ divergence is a bug in the generator, not an exception to add to the test.
85
63
  row a slug resolves to). The tenant-led default key below counts as serving
86
64
  it (`servesSlugLookup()`): a tenant-scoped slug lookup carries the tenant
87
65
  predicate (#2365) and is served by the prefix, so no second index.
88
- - **Tenant-scoped tables key per tenant (#2360).** A tenant-scoped class with
89
- no explicit `conflictColumns` upserts on, and indexes,
90
- `(tenant_id, slug, context)` — `(tenant_id, slug, context, _meta_type)` for
91
- an STI hierarchy — resolved by one rule on both paths:
92
- `ManifestGenerator.normalizeConflictColumns()` materializes it into
93
- `decoratorConfig.conflictColumns` for the manifest paths (so the manifest,
94
- the schema, `smrt-knowledge.json` and the runtime read one value), and
95
- `ObjectRegistry.getConflictColumns()` derives the same value at runtime from
96
- the schema owner's `tenantScoped` config (`ObjectRegistry.getTenantColumn()`;
97
- an STI child resolves through its root; a `@report` class through its
98
- group/bucket columns; a custom primary key through that key). Explicit
99
- `conflictColumns` are never rewritten. `src/schema/conflict-target.ts` holds
100
- the shared helpers. Consequences: the index NAME stays
101
- `<table>_slug_context_idx` / `_slug_context_meta_type_idx`, so the differ
102
- swaps the columns of an existing global unique in place by name (a superset
103
- key — creating it cannot fail on existing rows); the tenant-led key also
104
- serves the tenant column, so `<table>_tenant_id_idx` is no longer emitted
105
- for those tables (an existing one is an orphan the differ drops only with
106
- `--drop-indexes`); NULL-tenant rows (`mode: 'optional'` outside a tenant
107
- context) dedup among themselves through the SDK's null-aware upsert
108
- (`IS NOT DISTINCT FROM` under a PostgreSQL advisory lock / an in-process
109
- lock on SQLite) — application-enforced now, where the old global index was
110
- database-enforced: the tenant-led index treats NULLs as distinct, so raw SQL
111
- can insert two global rows with one slug, and a raw
112
- `ON CONFLICT (slug, context…)` against such a table no longer binds (use
113
- `WHERE NOT EXISTS`, plus an advisory lock on PostgreSQL). Emitting
114
- `NULLS NOT DISTINCT` on PostgreSQL ≥ 15 (the SDK already detects it) would
115
- restore the database arbiter — a follow-up. The `save()` path serializes an
116
- unset tenant field as an explicit `NULL` whatever its registered type,
117
- because the SDK rejects an upsert whose conflict column is missing from the
118
- row.
119
- - **Rolling the tenant-led key out (#2360).** There is no mixed-version state:
120
- new code against the old index fails every NEW-object create on a
121
- tenant-scoped default-key table (PostgreSQL 42P10, SQLite "ON CONFLICT
122
- clause does not match…"), and old code against the new index fails the same
123
- way, because the conflict target must match the unique index's column set
124
- exactly; only persisted objects (upsert on `id`) keep saving. Deploy the code
125
- and run `smrt db:migrate` in the same maintenance step. The plan is one
126
- `DROP INDEX` + `CREATE UNIQUE INDEX` per table under the SAME name (a
127
- superset key, so the build cannot fail when the old same-name index was a
128
- valid UNIQUE over the subset key; a #1165-class table whose old index was
129
- non-unique or missing may hold duplicates that a superset UNIQUE rejects —
130
- `db:diff` shows which tables' old index is non-unique or missing; dedupe
131
- those rows before migrating). Atomic mode swaps every table in one
132
- transaction: `DROP INDEX` takes ACCESS EXCLUSIVE and holds it until commit,
133
- which blocks ALL access to those tables — reads included — for the batch;
134
- size `statementTimeout` for the largest tenant-scoped table. That is the
135
- maintenance window this rollout requires anyway (no mixed-version state), so
136
- run this wave — the #2359 index wave included — in atomic mode inside it;
137
- the "roll out with `--postgres-safe`" advice above applies to a #2359-only
138
- wave, because `--postgres-safe` runs the two statements sequentially per
139
- table, so each table has NO conflict index between them and a failed rebuild
140
- leaves it without one until the re-run. The recreate has no automatic
141
- DOWN: reverting the code means re-creating the old index by hand. And
142
- legacy NULL-tenant rows fork rather than get adopted — a tenant-context save
143
- whose slug matches a `(NULL, slug, ctx)` row now inserts `(tenant, slug,
144
- ctx)` beside it, and that tenant no longer sees the legacy row — so backfill
145
- `tenant_id` (anytown: `SET tenant_id = context::uuid`) BEFORE this release.
146
- Ingestion that relied on natural-key dedup across tenants now inserts one
147
- row per tenant (release note).
66
+ - **Tenant default keys** are `(tenant_id, slug, context)`, plus `_meta_type`
67
+ for STI. `ManifestGenerator.normalizeConflictColumns()` and
68
+ `ObjectRegistry.getConflictColumns()` share `src/schema/conflict-target.ts`:
69
+ resolve tenant fields through the schema owner/STI root, report group/bucket
70
+ columns through the report, and custom PKs through their key. Explicit
71
+ `conflictColumns` remain unchanged. The manifest, schema, knowledge, and
72
+ runtime must carry the same value.
73
+ Names remain `<table>_slug_context_idx` / `_slug_context_meta_type_idx`, so
74
+ migration replaces a same-name global unique with tenant-led columns. That
75
+ prefix serves tenant and tenant-scoped slug reads; a legacy standalone tenant
76
+ index is dropped only with `--drop-indexes`.
77
+ - **Optional NULL tenants** dedup through SDK null-aware upsert (PostgreSQL
78
+ `IS NOT DISTINCT FROM` plus advisory lock; SQLite process lock), not the
79
+ unique index: raw SQL can duplicate NULL-tenant keys. Raw global inserts need
80
+ `WHERE NOT EXISTS` and a PostgreSQL advisory lock; an old global `ON CONFLICT`
81
+ target no longer binds. Save serializes an unset tenant explicitly as NULL,
82
+ because every conflict column must be present. PostgreSQL `NULLS NOT DISTINCT`
83
+ remains a potential follow-up, not current enforcement.
84
+ - **Tenant-key rollout requires a maintenance window.** Old code/new indexes
85
+ and new code/old indexes both fail new-object saves because conflict column
86
+ sets must match exactly; persisted ID-based saves still work. Backfill legacy
87
+ NULL tenants first or scoped ingestion creates separate rows and cannot see
88
+ the old global ones. Cross-tenant natural-key dedup now creates one row per
89
+ tenant. Deploy code and migrate together in atomic mode: each table drops
90
+ and recreates its same-name unique index, holding ACCESS EXCLUSIVE locks
91
+ (including against reads) until commit. Size `statementTimeout` for the
92
+ largest table. A valid old subset unique guarantees the superset build;
93
+ missing/nonunique old indexes may contain duplicates and need dedup first.
94
+ Include the reference-index wave in that atomic window. `--postgres-safe` is
95
+ suitable for an additive reference-index-only wave, but a key replacement
96
+ leaves a per-table gap between drop/build and a failed build leaves no arbiter
97
+ until rerun. There is no automatic DOWN; reverting code requires deliberately
98
+ recreating its old indexes.
99
+
148
100
  - **STI `@field({ unique: true })` is enforced through indexes** (the differ can
149
101
  add an index to an existing table, never a column constraint): a full
150
102
  `<table>_<col>_unique_idx` when the STI base declares it, one
@@ -172,138 +124,31 @@ divergence is a bug in the generator, not an exception to add to the test.
172
124
  `getAllSchemasAsDefinitions()` table definition, and only falls back to
173
125
  `generateSchema()` when no schema is registered at all.
174
126
 
175
- So a normal build keeps the manifest schema through `db:setup`, and a
176
- registry-derived schema is a dev/test artifact. `smrt-content` shows what one
177
- looks like: `packages/content/src/hooks.server.ts` `bootstrapSchema()` calls
178
- `generateSchema()` for every registered class and then `ensureSchema()` from the
179
- SvelteKit `handle` hook on any `/api/*` request, so that process holds
180
- registry-derived schemas rather than the manifest ones. It reaches only that
181
- package's own `vite dev` app — the library build excludes the file and the
182
- package never exports it — but it is the shape to recognize. Check which route a
183
- process actually took before trusting a reproduction.
184
-
185
- ## Why the drift stayed invisible
186
-
187
- Every drift oracle compares a database with the same artifact that dropped the
188
- index:
189
-
190
- - `verifyPersistenceTable()` (`src/schema/table-verifier.ts`) calls
191
- `db.tableExists()` and nothing else. "Runtime verifies schema" has always meant
192
- existence-only — no column, type, constraint, or index comparison.
193
- - `smrt doctor` never opens a database connection.
194
- - `db:status` and `db:diff` diff the live database against
195
- `getAllSchemasAsDefinitions()`, i.e. the manifest projection.
196
-
197
- An index the manifest never emitted is "in sync" by construction. That is how a
198
- production database reached 164 unindexed `tenant_id` columns while `db:status`
199
- reported no drift (#2356 → #2359). The assessment's other counts — 196/231
200
- `@foreignKey` and 91/92 `@crossPackageRef` columns with no production index,
201
- 238/238 tables carrying a redundant index on the primary key, zero DB-level
202
- foreign-key constraints on any engine — come from regenerating every package's
203
- schema against a live database, so re-measure rather than quote them once the
204
- epic's fixes land.
205
-
206
- ## Rules
207
-
208
- ### 1. Verify against the production path, not the test path
209
-
210
- Any change to column or index emission goes on **all** paths that ship and is
211
- proven by the path-parity test (`src/schema/schema-path-parity.test.ts`, #2359)
212
- — extend its fixture; a green suite otherwise proves the registry paths only.
213
- Read the call graph before believing a comment: "same as migrations" was wrong
214
- for years.
215
-
216
- ### 2. Every new query predicate ships with its index
217
-
218
- Collection methods, poll loops, auth lookups, junction right-side filters, and
219
- polymorphic owner lookups all count — or write down why the predicate does not
220
- need one. For list workloads, EXPLAIN on a PostgreSQL snapshot; the measured
221
- spread on the assessed workload was 21 ms → 0.1 ms.
222
-
223
- ### 3. Run the PostgreSQL lane
224
-
225
- Anything touching numeric types, uuid casts, upsert conflict targets, timestamps,
226
- or migrations runs the package's `test:postgres` script:
227
-
228
- ```bash
229
- pnpm --filter @happyvertical/smrt-<pkg> test:postgres
230
- ```
231
-
232
- core, cli, users, sales, marketing, analytics, and vitest carry the lane.
233
- SQLite's type affinity accepts values PostgreSQL rejects — a money field declared
234
- `number = 0` compiles to INTEGER and only fails on PG (#2361).
235
-
236
- ### 4. Read the built artifact, not the source
237
-
238
- What a decorator produced is in `dist/manifest.json` and in regenerated schemas:
239
- `integer` vs `decimal`, the actual index list, the actual conflict columns. When
240
- the question is "how many tables/columns/indexes", regenerate and count across
241
- every package; do not sample a few and extrapolate.
242
-
243
- ### 5. Index intent belongs on both the constraint and the read path
244
-
245
- A conflict target is not automatically a unique index, and a unique index is not
246
- automatically the index a read path uses. Custom `conflictColumns` used to
247
- replace the `(slug, context)` index while `loadFromSlug`/`getId` still queried
248
- slug+context, and STI dropped `@field({ unique: true })` — both fixed in #2359,
249
- see "Index rules" above. Check the pair, not the declaration.
250
-
251
- ### 6. Multi-tenancy is a whole-path property
252
-
253
- Every unique constraint and every conflict target on a tenant-scoped table
254
- includes the tenant column — otherwise a second tenant's `save()` of the same
255
- natural key updates the first tenant's row through `DO UPDATE SET` (#2360; the
256
- default key now does, see "Index rules" — an explicit `conflictColumns` that
257
- omits the tenant column is the class author's own key and is not rewritten).
258
- And every read path is interceptor-aware: hydration
259
- (`loadFromId`/`loadFromSlug`), get-by-slug, vector search, and collection
260
- memory, not only `list()` (#2365).
261
-
262
- ### 7. Retry only transient errors
263
-
264
- Classify through the cause chain (SQLSTATE), never on a message substring, and
265
- never retry inside an aborted PostgreSQL transaction (`25P02`). Test the
266
- contract end to end against a real database, not only the classifier (#2366).
127
+ ## Verification
267
128
 
268
- ### 8. Thread new decorator options through every config-rebuild site
129
+ Extend `src/schema/schema-path-parity.test.ts` for every generator change;
130
+ manifest, registry, and merged migration schemas must agree. Inspect regenerated
131
+ `dist/manifest.json` and schemas across affected packages, not only decorators.
132
+ Runtime `verifyPersistenceTable()` checks table existence only. Database drift
133
+ checks compare with generated artifacts; they cannot detect an omission shared
134
+ by those artifacts. Use `smrt doctor --db` / `db:status --parity` for live parity.
269
135
 
270
- A new `@smrt()` or `@field()` option that affects schema must reach the
271
- `SchemaGeneratorConfig` type in `src/schema/generator.ts` and every site that
272
- rebuilds that config — `src/schema/utils.ts` and `src/testing/database.ts` — or
273
- it is silently dropped on the paths that rebuild it (#2357).
136
+ Every new query predicate needs its index or an explicit reason none is needed.
137
+ Run `pnpm --filter @happyvertical/smrt-core test:postgres` for numeric types,
138
+ UUID casts, conflict targets, timestamps, or migrations. Schema-affecting options
139
+ must reach `SchemaGeneratorConfig` and both config rebuild sites:
140
+ `src/schema/utils.ts` and `src/testing/database.ts`.
274
141
 
275
- ### 9. Delete or wire dead paths, and write docs to what the code does
142
+ Tenant uniqueness and conflict targets must include the tenant column; explicit
143
+ `conflictColumns` are author-owned and never rewritten. All reads, including
144
+ hydration, slug lookup, vector search, and memory, remain interceptor-aware.
145
+ Retry only transient errors classified through the cause chain; never retry an
146
+ aborted PostgreSQL transaction (`25P02`).
276
147
 
277
- Dead code that looks canonical misleads the next agent: the AST `generateSchema`
278
- path, `SchemaOverrideSystem`, and the never-emitted `triggers: []` all read as
279
- supported surfaces (#2380). Documentation follows the implementation, not the
280
- intent — say "verifies the table exists" when that is what runs.
281
-
282
- ### 10. Untracked "known limitation" comments are bugs nobody will read
283
-
284
- File the issue and link it from the comment. A `products` comment explaining why
285
- a conflict-column change was refrained from sat there for months — and
286
- misdescribed the failure mode the whole time.
287
-
288
- ### 11. Consumer repair scripts are signals
289
-
290
- Downstream repair tooling (anytown's `db-repair-plan.ts` carried column-type
291
- repairs, missing STI columns and indexes, and `tenant_id` backfills since April)
292
- is the consumer-side record of framework gaps. Mine it during triage.
293
-
294
- ### 12. Try to falsify before filing, and treat operations as correctness
295
-
296
- Re-verify a finding at source before it becomes an issue — one assessment
297
- candidate claimed conflict indexes past two columns were narrowed to two
298
- columns, when only the index *name* is shortened. And an index fix that ships
299
- without a bounded-timeout, `CONCURRENTLY`-capable migrate path can take
300
- production down on rollout (#2362).
301
-
302
- ### 13. Composite indexes are declared, not inferred (#2357)
148
+ ### Composite indexes are declared, not inferred (#2357)
303
149
 
304
150
  The generated set only covers foreign keys, unique/conflict columns, the STI
305
- discriminator, reference columns (#2359), the default list ordering (rule 18
306
- below), and single columns opted in with `@field({ indexed: true })`. A list
151
+ discriminator, reference columns (#2359), default list ordering, and single columns opted in with `@field({ indexed: true })`. A list
307
152
  workload's access path is composite, so declare it:
308
153
 
309
154
  ```ts
@@ -322,14 +167,14 @@ scans a btree either way, so an ascending index also serves the matching
322
167
  (partial index) are honoured.
323
168
 
324
169
  `appendDeclaredIndexes()` runs first on all four entry points, ahead of
325
- `ensureDefaultListOrderingIndex()` (rule 18) and `ensureReferenceColumnIndexes()`,
170
+ `ensureDefaultListOrderingIndex()` (default ordering below) and `ensureReferenceColumnIndexes()`,
326
171
  so a declared composite leading with the tenant column (or any reference column)
327
172
  replaces the automatic standalone index rather than duplicating it.
328
173
  Unknown columns, malformed entries, and a name collision with a different index
329
174
  all fail generation — a silently dropped index only surfaces later as a
330
- production slowdown. Rule 8 above is why this works at runtime at all.
175
+ production slowdown. Keep both config rebuild sites aligned.
331
176
 
332
- ### 14. Relationship targets resolve to a class name on both paths
177
+ ### Relationship targets resolve to a class name on both paths
333
178
 
334
179
  `@foreignKey`/`@oneToMany`/`@manyToMany` accept a class, a name string, or a
335
180
  `() => Target` thunk. The decorator invokes the thunk and throws when the target
@@ -339,7 +184,7 @@ costs the relationship edge, `loadRelated()`, and the FK-derived index (#2379).
339
184
  A thunk resolves at decoration time, so a target declared later in the same
340
185
  module is still in its temporal dead zone — use the string form there.
341
186
 
342
- ### 15. A SQLite type change is a table rebuild (#2370)
187
+ ### A SQLite type change is a table rebuild (#2370)
343
188
 
344
189
  SQLite has no `ALTER TABLE ... ALTER COLUMN ... TYPE`, so
345
190
  `src/migrations/sqlite-rebuild.ts` answers a `type_upgrade` on SQLite with the
@@ -417,7 +262,7 @@ and always reports what it will not touch:
417
262
  on a populated one it is added nullable and the `NOT NULL` is reported as a
418
263
  manual follow-up on every engine.
419
264
  - **SQLite** has no `ALTER COLUMN`: nullability/default alterations are manual
420
- (comment SQL → `db:migrate` exit 1). The #2370 rebuild (rule 15) consumes
265
+ (comment SQL → `db:migrate` exit 1). The SQLite rebuild consumes
421
266
  only `type_upgrade` placeholders today; extending it to rewrite constraints
422
267
  would lift this.
423
268
  - Defaults compare through `canonicalizeDefault()`, which folds engine
@@ -427,7 +272,7 @@ and always reports what it will not touch:
427
272
  round-trip test (create from each DDL strategy → compare → zero changes) in
428
273
  `src/migrations/__tests__/issue-2369-*.test.ts` guards this.
429
274
 
430
- ### 16. `schema.ddl` is a preview, not the table
275
+ ### `schema.ddl` is a preview, not the table
431
276
 
432
277
  `SchemaDefinition.ddl` / `manifest.json` `schema.ddl` is the engine-neutral
433
278
  CREATE TABLE string from `SchemaGenerator.generateSQL()` with no engine: no
@@ -446,7 +291,7 @@ string, and do not write a private CREATE INDEX renderer — the retired ones
446
291
  dropped `where` and `jsonPath` (#2358). Every DDL strategy also spells out
447
292
  `PRIMARY KEY NOT NULL`: SQLite lets a bare non-INTEGER PRIMARY KEY hold NULL.
448
293
 
449
- ### 17. The merged table shape is registration-order independent (#2372)
294
+ ### The merged table shape is registration-order independent (#2372)
450
295
 
451
296
  `getAllSchemas()` and `getAllSchemasAsDefinitions()` fold every class that
452
297
  shares a physical table — the whole STI hierarchy — into one shape. Both route
@@ -454,16 +299,9 @@ through `buildMergedTableSchemas()`, which groups contributors by table and
454
299
  then merges them in a **deterministic** order: the STI base first, then
455
300
  ancestors before descendants, then by qualified name.
456
301
 
457
- That order matters because the first contributor seeds the table: it supplies
458
- the fallback base columns, the `idType`, the conflict columns and the cached
459
- DDL, and its columns win every merge conflict. When registration order decided
460
- it, an STI child that carries no manifest `schema` — the external- and
461
- consumer-manifest case — seeded the table from bare fallback columns and the
462
- base class's richer ones were skipped when it registered later, yielding
463
- `context TEXT` instead of `context TEXT NOT NULL DEFAULT ''` and timestamps
464
- with no NOT NULL/DEFAULT. The shipped content manifest lists `Article` before
465
- `Content`, so the losing order was the one that shipped, and the differ
466
- compares types only, so the weak fresh-create was never repaired.
302
+ The first contributor supplies fallback columns, `idType`, conflict columns,
303
+ cached DDL, and wins column conflicts. Keep base-first ordering even when a
304
+ child without manifest schema registers first.
467
305
 
468
306
  Two invariants keep the two assembly paths agreeing:
469
307
 
@@ -488,49 +326,26 @@ When adding a class-level input to the merged shape, take it from the seeding
488
326
  contributor rather than "whichever class arrives first", and cover it with a
489
327
  child-first/base-first equality test.
490
328
 
491
- ### 18. The generator owns the index for its own default ordering (#2363)
492
-
493
- Every generated list surface — REST, MCP, the SvelteKit list route — pages with
494
- `ORDER BY created_at DESC, <pk> ASC` (`DEFAULT_LIST_ORDER_BY`, #2367), and
495
- until #2363 no schema path indexed `created_at` (the AST path, deleted in
496
- #2380, indexed `updated_at`), so the framework's own default page was a
497
- sequential scan plus a top-N sort. `ensureDefaultListOrderingIndex()` now runs
498
- on all four entry points and emits:
499
-
500
- - `(<tenant column>, created_at)` on a tenant-scoped table — the tenancy
501
- interceptor puts `tenant_id = ?` in front of every list, so the tenant column
502
- leads and `created_at` orders within it. This composite **replaces** the
503
- standalone tenant index from #2359: a B-tree serves every prefix of its
504
- column list, so `ensureDefaultListOrderingIndex()` is called first and
505
- `ensureReferenceColumnIndexes()` then sees the column as already served. The
506
- tenant column is found by `referenceKind === 'tenantId'`, never by the
507
- `tenant_id` spelling — `@smrt({ tenantScoped: { field } })` renames it.
508
- - `(created_at)` otherwise.
509
-
510
- Three deliberate omissions, so nobody "fixes" them later:
511
-
512
- - **No `DESC`.** `IndexDefinition` carries no per-column direction and
513
- PostgreSQL scans a B-tree backwards just as cheaply.
514
- - **No primary-key tiebreak column.** The default order mixes directions
515
- (`created_at DESC, id ASC`), so no single-direction index satisfies the whole
516
- key; the leading columns already turn a full sort into an index scan plus an
517
- incremental sort over rows sharing a timestamp.
518
- - **Not scoped per STI subtype.** `(_meta_type, created_at)` would serve a
519
- child collection's list but not the base class's polymorphic one, which
520
- carries no discriminator predicate — the same reasoning that keeps STI
521
- reference indexes plain (#2359). One unqualified index per shared table.
522
-
523
- An existing UNQUALIFIED index that already leads with the same columns
524
- suppresses it — a partial or JSON-path index never counts. That is how a
525
- declared `@smrt({ indexes: [...] })` composite (#2357) takes over: declaring
526
- `(tenant_id, created_at, status)` replaces the generated pair, while declaring
527
- a different sort column such as `(tenant_id, publish_date)` sits **beside** it,
528
- because that index cannot order the default page. Declared indexes are appended
529
- before this helper for exactly that reason; anything that appends an index in
530
- future goes in the same slot, ahead of `ensureDefaultListOrderingIndex()` and
531
- `ensureReferenceColumnIndexes()`.
532
-
533
- ### 19. One conflict-target rule, applied on every producer
329
+ ### Default list ordering indexes
330
+
331
+ All four generators index `DEFAULT_LIST_ORDER_BY` (`created_at DESC, <pk> ASC`):
332
+ `ensureDefaultListOrderingIndex()` emits `(tenant column, created_at)` when
333
+ scoped, otherwise `(created_at)`. Resolve tenant columns by `referenceKind ===
334
+ 'tenantId'`, not spelling. The tenant-leading pair also serves the reference
335
+ index requirement.
336
+
337
+ Only an unqualified, non-JSON-path index with the same leading columns suppresses
338
+ it. Append declared composites first, then default ordering, then reference
339
+ indexes. `(tenant_id, created_at, status)` replaces the default pair;
340
+ `(tenant_id, publish_date)` does not. Emit one plain index per STI table, since
341
+ base polymorphic reads lack `_meta_type` predicates.
342
+
343
+ Do not add direction or PK columns by inference: `IndexDefinition` has no
344
+ per-column directions, backward B-tree scans serve descending timestamps, and
345
+ the mixed-direction PK tie-break still needs incremental sorting within equal
346
+ timestamps.
347
+
348
+ ### One conflict-target rule, applied on every producer
534
349
 
535
350
  `save()` upserts on `ObjectRegistry.getConflictColumns()`; the schema must
536
351
  carry exactly one unique index over those columns (or they must be the
@@ -546,15 +361,10 @@ schema does not index is a hard PostgreSQL error (42P10) on the first save,
546
361
  and a key the schema indexes without the tenant column is the silent
547
362
  cross-tenant overwrite this rule exists for.
548
363
 
549
- ### 20. Every generated index name is length-guarded before it leaves a path (#2374)
364
+ ### Every generated index name is length-guarded before it leaves a path (#2374)
550
365
 
551
- PostgreSQL truncates any identifier past 63 **bytes** and reports nothing;
552
- SQLite and DuckDB do not, so the entire test suite was blind to it. The 66-byte
553
- `content_contribution_revisions_contribution_id_revision_number_idx` shipped
554
- that way — only the differ's signature-equivalence check kept it from emitting
555
- `add_index` on every run. Two names agreeing for 63 bytes is the real hazard:
556
- `CREATE INDEX IF NOT EXISTS` no-ops against the wrong index, and the second
557
- index is never created.
366
+ PostgreSQL truncates identifiers beyond 63 bytes; two generated names sharing
367
+ that prefix can make `CREATE INDEX IF NOT EXISTS` silently skip an index.
558
368
 
559
369
  `schema/index-utils.ts` owns the guard, and it splits by who owns the name:
560
370
 
@@ -567,17 +377,9 @@ index is never created.
567
377
  worse than refusing it, and `SchemaComparer` matches indexes **by name**
568
378
  first, so a 70-byte declaration could never match the 63-byte index
569
379
  PostgreSQL stored and `db:migrate` would emit `add_index` forever.
570
- - **Table and column names** → deliberately **not** guarded. PostgreSQL
571
- truncates identifiers *consistently on every reference*: `CREATE TABLE
572
- "<80 bytes>"` and a later `SELECT ... FROM "<the same 80 bytes>"` both resolve
573
- to the same stored 63-byte name, so one long name round-trips fine end to end.
574
- `smrt-users` depends on this — it ships an intentional 80-byte
575
- `@smrt({ tableName })` (`permission_policy_table_name_that_is_far_too_long…`)
576
- and derives unique Postgres RLS policy names from it. An earlier revision of
577
- this rule hard-errored here on the theory that the runtime resolves tables by
578
- name and would break; that theory is wrong for the reason above, and the error
579
- broke `packages/users`. The residual collision risk is over a name the
580
- developer chose, not one the generator manufactured.
380
+ - **Table and column names** are not guarded: PostgreSQL truncates their
381
+ declarations and references consistently. `smrt-users` tests intentionally
382
+ long table names; collision risk remains with the author.
581
383
 
582
384
  `enforceIdentifierLimits()` is the single call site per path, placed **after**
583
385
  `ensureReferenceColumnIndexes()` — nothing may lengthen a name after it. Doing
@@ -611,7 +413,7 @@ can exceed 63 bytes even when the table and column each fit. SMRT never names
611
413
  it, and PostgreSQL disambiguates its own truncations by appending a counter
612
414
  rather than collapsing them, so there is no silent-collision hazard there.
613
415
 
614
- ### 21. The `_smrt_` prefix does not mean "system table" (#2376)
416
+ ### The `_smrt_` prefix does not mean "system table" (#2376)
615
417
 
616
418
  `bootstrapSystemTables()` owns nine hand-written tables; ~25 more `_smrt_*`
617
419
  tables belong to `@smrt()` models and are created by `db:migrate` (feature
@@ -685,6 +487,12 @@ and indexes but deliberately emits no physical constraint, avoiding circular
685
487
  package DDL. Tenant markers follow the same non-constraint rule because a
686
488
  tenant is a scope, not an ownership edge.
687
489
 
490
+ Same-package archival identifiers may explicitly use `@foreignKey(Target, {
491
+ constraint: false })`: preserve relationship loading and indexing, but omit
492
+ physical constraints, schema dependencies, and application cascade/preflight
493
+ so the identifier survives parent deletion. Document the retention reason at
494
+ the field; ordinary references remain constrained.
495
+
688
496
  For a same-package relationship whose semantics are portable but whose physical
689
497
  constraint shape is not, `@foreignKey(Target, { constraint: { engines: [...] } })`
690
498
  is the public exception. The allowlist scopes physical DDL and dependency
@@ -700,6 +508,13 @@ tables first and adds their named constraints afterward. DuckDB refuses cycles,
700
508
  self-references, `CASCADE`, and `SET NULL` with an actionable error because its
701
509
  current ALTER/constraint support cannot enforce those shapes safely.
702
510
 
511
+ Rollback drops children before parents, removes deferred PostgreSQL cycle
512
+ constraints first, and defers SQLite checks while dropping populated cycles.
513
+ Aggregation that filters a parent also removes a retained child's physical FK.
514
+ PostgreSQL deferred constraint adds are idempotent. Generated `ON UPDATE
515
+ CASCADE` remains the default; DuckDB/JSON must refuse unsupported actions
516
+ rather than silently stripping them.
517
+
703
518
  For existing tables, PostgreSQL checks the exact child table/column against the
704
519
  exact referenced table/column before adding a constraint as `NOT VALID` and
705
520
  then validating it. The probe uses distinct child/parent aliases and, when both
@@ -717,16 +532,9 @@ no-op.
717
532
 
718
533
  ### Pre-R11 `text` ids converge to `uuid` before any FK statement (#2608)
719
534
 
720
- R11 made SMRT identifiers and references native `uuid` on PostgreSQL. A
721
- database created before that change still stores its `id` columns as `text`
722
- while every reference column added afterwards materializes as `uuid`.
723
- PostgreSQL cannot implement a foreign key across two different physical types —
724
- FK DDL admits no cast — so `ADD CONSTRAINT … NOT VALID` fails with SQLSTATE
725
- 42804 and aborts every later statement in the same migration batch.
726
-
727
- Two rails handle it, and both are PostgreSQL-only. SQLite stores UUIDs as text
728
- by design and DuckDB cannot rewrite a column type in place, so neither engine
729
- emits anything for this drift.
535
+ PostgreSQL FK columns must have matching physical types. Legacy text IDs may
536
+ meet newer native UUID references; neither SQLite (text UUID by design) nor
537
+ DuckDB (no in-place type rewrite) emits this convergence.
730
538
 
731
539
  **The runtime guard fails closed.** `SchemaManager.ensurePostgresForeignKey()`
732
540
  reads both live column types and refuses to emit `ADD CONSTRAINT` when they
@@ -744,12 +552,8 @@ tolerated pre-R11 deployment and is left alone; its foreign keys are
744
552
  type-compatible today, and the R11 uuid/text equivalence in
745
553
  `migrations/differ.ts` keeps it out of the column diff.
746
554
 
747
- Components, not individual pairs, are the unit of decision: one legacy `text`
748
- primary key can be referenced by several children, and converting it for one
749
- of them would break every sibling that is still `text`. A self-referential
750
- table falls out of the same grouping because both endpoints land in one
751
- component. Convergence is relationship-driven, so a legacy `text` id that
752
- nothing references keeps its R11 tolerance.
555
+ Converge entire relationship components, including siblings and self-references;
556
+ an unreferenced legacy text ID retains its UUID/text equivalence tolerance.
753
557
 
754
558
  The planner never coerces data. Before emitting anything it probes each column
755
559
  it would rewrite for values that are not uuid-shaped (the same `~*` canonical
@@ -814,173 +618,79 @@ align them deliberately.
814
618
  The conversion is one-time and idempotent: once the column is native `uuid`,
815
619
  the component is uniformly UUID and the planner emits nothing.
816
620
 
817
- Properties to keep if you touch that module:
818
-
819
- - **The plan is registry-derived and rebuilt per delete.** Registration is
820
- incremental — manifests load lazily and tests register classes between cases —
821
- so a cached plan would silently skip a table that registered later. Cache it
822
- only behind an invalidation hook that every registration path calls.
823
- - **A class with nothing pointing at it skips the transaction entirely — but
824
- `CascadePlan.isEmpty` requires no polymorphic association class anywhere in
825
- the process, not just no typed references.** `buildCascadePlan()` pushes
826
- *every* registered `SmrtPolymorphicAssociation` subclass into
827
- `plan.polymorphic` unconditionally (`cascade.ts` around
828
- `isPolymorphicAssociationClass`): a `metaType` column can point at any class
829
- at runtime, so there is no static metadata to scope it by the target being
830
- deleted. One registered polymorphic class anywhere makes `isEmpty` false for
831
- every delete in that process — do not read "the common case skips the
832
- transaction" as "most deletes in a real app skip it"; in a multi-package app
833
- that registers even one polymorphic association, almost none do.
834
- `runCascadeDelete()` builds the plan for `getResolvedQualifiedName()` (not the
835
- bare constructor name — two packages can register the same simple name).
836
- - **Cascaded rows are removed set-based.** Their `beforeDelete`/`afterDelete`
837
- hooks and interceptors do not run and no change-feed tombstone is written for
838
- them, which is exactly what a DB-level `ON DELETE CASCADE` does. Only the
839
- object `delete()` was called on runs the lifecycle. Do not "improve" this into
840
- a per-row model delete without deciding what that means for sync consumers.
841
- - **Everything is one transaction where the adapter has one**, including the
842
- object's own `DELETE`, whenever there is anything to cascade. The `RESTRICT`
843
- checks run first, before any mutation, so a refusal costs nothing; the
844
- transaction is what makes a refusal *deeper* in the graph safe.
845
- - **`_smrt_embeddings` and `_smrt_contexts` are matched by id *and* a
846
- class-name candidate set, not id alone.** Their class columns store the
847
- *runtime* constructor name, which for an STI hierarchy is a concrete
848
- subclass rather than the class the cascade planned from — id-alone matching
849
- looked STI-safe, but let two unrelated classes using `idType: 'text'`
850
- (non-UUID, not guaranteed globally unique) collide on a shared id value and
851
- delete each other's rows (review fix). `ownerClassCandidates()` expands to
852
- every STI hierarchy member of the class the ids actually belong to, in both
853
- qualified and simple form. A failure to clean them is logged, never raised —
854
- an application database may predate the table, and losing derived rows must
855
- not fail a valid delete.
856
-
857
- `_smrt_changes`, `_smrt_ai_usage`, `_smrt_signals` and the dispatch tables are
858
- deliberately **not** cascaded. They are append-only logs; the change feed in
859
- particular receives the delete's own tombstone, so cascading it would erase the
860
- record that tells sync clients the row is gone.
861
-
862
- ### 22. System tables get a retention policy, not just a prune function (#2375)
863
-
864
- Four framework-owned tables grow with traffic and nothing used to remove a row:
865
- `_smrt_changes` (one per save/delete), `_smrt_ai_usage` (one per AI call, and
866
- persistence is on by default), `_smrt_contexts` (whose `expires_at` nothing
867
- enforced) and `_smrt_dispatch` (an operator-only `dispatch:cleanup`).
868
- `src/system/retention.ts` is now the single place that bounds them.
869
-
870
- - **`runRetentionSweep(db, policy)` is the entry point.** It runs the four
871
- built-in tasks in a fixed order, then every task other packages contributed
872
- via `registerRetentionTask()` — `@happyvertical/smrt-jobs` registers
873
- `_smrt_jobs`/`_smrt_job_events`, `@happyvertical/smrt-users` registers
874
- session/magic-link/CLI-auth expiry. A task that throws is recorded on its own
875
- result and the sweep continues; a missing table reports `unavailable`, so a
876
- sweep is safe against a partially bootstrapped database.
877
- - **A contributed task only exists in a process that loaded its package.** Both
878
- packages register on import from their entry point, and the registry lives on
879
- `globalThis` (like `ObjectRegistry`) so a duplicated `smrt-core` resolution
880
- cannot split it. `smrt db:prune` optionally imports both packages for exactly
881
- this reason — a project that installs neither correctly gets neither task.
882
- - **Defaults are opt-out, not opt-in.** `DEFAULT_RETENTION_POLICY` covers the
883
- four built-in tables (changes 30 days, AI usage 90 days, dispatch 30 days
884
- completed / 90 days failed, contexts strictly by their own `expires_at`), and
885
- those are the ones `smrt.configure({ retention })` tunes. Contributed tasks
886
- carry their own defaults and their own window options —
887
- `DEFAULT_JOB_RETENTION` (7 days terminal / 30 days failed / 30 days events,
888
- set through `registerJobRetentionTasks()` or the runner's `retention.jobs`),
889
- and expired credentials, which have no window because an expired credential
890
- has nothing worth retaining. Every task, built-in or contributed, can be
891
- turned off: a table set to `false`, a task set to `false` under `tasks`, or
892
- `enabled: false` for the whole sweep — through `smrt.configure`,
893
- `smrt db:prune --skip`, or the runner's `retention` config.
894
- - **Contributed task names are prefixed with the owning package's short name**
895
- (`jobs-records`, `jobs-events`, `users-sessions`, …) because the registry is
896
- one process-global namespace.
897
- - **Scheduling lives outside core.** A running `TaskRunner` sweeps every six
898
- hours (`retention: false` opts out) and `smrt db:prune` is the cron entry
899
- point. The first runner sweep is one interval after `start()`, never at
900
- start: a crash-looping worker must not become a delete loop.
901
- - **Every prune counts before it deletes.** `rowCount` is not reliably
902
- populated across the engines SMRT supports, so counting is both what gives a
903
- usable figure and what lets `dryRun` preview the *same* predicate rather than
904
- an approximation of it. Count and delete are two statements and deliberately
905
- not one transaction — a maintenance pass must not hold a write lock over a
906
- large delete — so the figure is approximate under concurrent writers. Where
907
- two bounds can select the same row (`pruneChangeFeed`, `pruneAiUsage`), the
908
- second bound excludes what the first already accounted for, so a dry run does
909
- not count an entry twice.
910
- - **Every retention predicate ships with its index** (rule 2 applies to
911
- maintenance SQL too): `_smrt_contexts(expires_at)`,
912
- `_smrt_ai_usage(tenant_id, created_at)` — which is also the subscriptions
913
- billing meter's range scan — `_smrt_dispatch(status, processed_at)` and
914
- `(status, updated_at)` come from the system DDL, so they reach existing
915
- databases through the `SMRT_SCHEMA_VERSION` bump that replays it.
916
- `_smrt_jobs(status, completed_at)` comes from
917
- `ensureJobsSystemTableCompatibility()` instead, because `_smrt_jobs` is
918
- generated from a decorated class and does not exist yet when bootstrap runs;
919
- the jobs collection calls that path on every `initialize()`.
920
- - **Expiry enforcement is prune-side only.** `recall()`/`recallAll()` keep
921
- their documented "expiry is not applied at read time" contract — changing it
922
- would change read semantics for existing callers, which is a different issue
923
- from bounding storage. `LearningMemory` filters expired rows itself.
924
-
925
- ### 23. Dead generation surfaces were deleted, not wired (#2380)
926
-
927
- Rule 9 named three surfaces that read as canonical but were not: the AST
928
- `generateSchema(objectDef)` entry point, `SchemaOverrideSystem`, and the
929
- never-emitted `triggers: []`. Resolution, so a future agent does not re-open
930
- what was deliberately decided:
931
-
932
- - **The AST path is gone.** `SchemaGenerator.generateSchema(objectDef)` and its
933
- AST-only private helpers (`generateIndexes`, `generateTriggers`,
934
- `extractDependencies`, `generateVersion`, `getTableName`,
935
- `extractPackageName`) were deleted from `schema/generator.ts`, along with
936
- their sole caller, `generateSchemaModule()` in `vite-plugin/index.ts`, and the
937
- `smrt:schema` / `@happyvertical/smrt-virt-schema` virtual module registration
938
- that fed. Nothing else called it — grep the deleted method's exact name
939
- before assuming a caller was missed; the path-parity fixture and every other
940
- rule above already speak only of the four surviving entry points.
941
- - **`SchemaOverrideSystem` is gone**, file and all
942
- (`schema/override-system.ts` no longer exists). It was never called from
943
- anywhere in this repository outside its own now-deleted exports, and two of
944
- its five public methods (`createPraecoContentOverride`,
945
- `createPraecoMeetingOverride`) hard-coded a schema extension for a
946
- consuming project outside this monorepo — scaffolding that never belonged in
947
- the framework, not a generic feature with a missing caller. `SchemaOverride`
948
- (the type) went with it; `ColumnDefinition`/`IndexDefinition`/
949
- `TriggerDefinition`, which it merely referenced, did not.
950
- - **The DDL-strategy trigger machinery was kept, not deleted.**
951
- `TriggerDefinition`, `SchemaDefinition.triggers`, and every DDL strategy's
952
- `generateTriggers()` / `generateTriggerStatement()` / `supportsTriggers()`
953
- (`schema/ddl/*.ts`) are real, engine-uniform, directly-tested rendering code
954
- that runs on **every** table creation via `strategy.generateTriggers(schema)`
955
- — unlike the AST path, this is not an orphaned call graph. It is kept for the
956
- same reason rule 16 keeps the cached `schema.ddl` string: `SchemaDefinition`
957
- is part of the shape third-party tooling and published manifests may already
958
- depend on, and `EngineSpecificDDL`/`MultiEngineDDL` (`schema/ddl/types.ts`)
959
- carry `triggers` as part of that same contract. Deleting a published field is
960
- a different (and unjustified) risk from deleting a virtual module nothing
961
- ever imported.
962
- - **What changed is what is documented, not what runs.** `schema.triggers` is
963
- now explicitly documented (`schema/types.ts`) as always `[]` on every schema
964
- a `@smrt()` class can produce, and why: there is no `@smrt()`/`@field()`
965
- option that populates it (unlike `indexes`, #2357), `updated_at` is
966
- maintained at the application layer (`SmrtObject.save()`), and
967
- `migrations/differ.ts` never diffs triggers — so even a hand-populated one
968
- would only apply to a newly `CREATE TABLE`d table and never retrofit an
969
- existing one. Wiring live trigger emission was considered and rejected for
970
- this issue: it is a migration-rollout feature (retrofitting 238+ existing
971
- production tables needs the same `SMRT_SCHEMA_VERSION`-replay or differ
972
- support rule 21/rule 22's system-table work required), not a cleanup, and
973
- nothing in the epic depended on it the way #2359 depended on FK indexes
974
- actually shipping.
975
- - **`_smrt_signals` and `ObjectRegistry.persistToDatabase()`/`loadFromDatabase()`**
976
- — named in the original finding alongside triggers — were already handled by
977
- #2376 before this issue landed: see rule 21 and `system/schema.ts`'s
978
- `RETIRED_SYSTEM_TABLES`. Nothing further to do there.
979
- - **The two config-rebuild-site comments** (`schema/utils.ts`,
980
- `testing/database.ts`) rule 8 requires were already in place, added by
981
- #2357/#2360; the `testing/database.ts` "same as migrations" overclaim rule 1
982
- quotes was already corrected by #2359, and doctor's `experimentalDecorators`
983
- check was already fixed by #2368/#2399 (see `packages/cli/AGENTS.md`
984
- Gotchas). Re-verify against current source before repeating any of these —
985
- the epic's PRs landed across one evening and a stale assessment line is not
986
- proof a fix is still needed.
621
+ ### Application cascade invariants (`src/cascade.ts`)
622
+
623
+ - Rebuild the registry-derived plan on every delete; manifests register lazily.
624
+ Caching requires invalidation across every registration path.
625
+ - Plan from `getResolvedQualifiedName()`. Every registered polymorphic
626
+ association class participates, since its runtime target can be any class;
627
+ `CascadePlan.isEmpty` requires no such class anywhere and no typed references.
628
+ Only an empty plan skips the transaction.
629
+ - Cascades are set-based: child hooks/interceptors and change-feed tombstones do
630
+ not run. Only the explicitly deleted object runs its lifecycle. RESTRICT
631
+ checks precede mutations; the parent DELETE and cascades share one transaction
632
+ where supported, so deeper refusals roll back.
633
+ - Derived `_smrt_embeddings` / `_smrt_contexts` cleanup matches IDs AND
634
+ `ownerClassCandidates()` (qualified and simple STI member names), never IDs
635
+ alone: unrelated text-ID classes can collide. Cleanup failures are logged,
636
+ not raised, because these tables may not exist in older databases.
637
+ - Never cascade append-only `_smrt_changes`, `_smrt_ai_usage`, `_smrt_signals`,
638
+ or dispatch logs; deleting change tombstones would break sync.
639
+
640
+ ### Retention (`src/system/retention.ts`)
641
+
642
+ `runRetentionSweep(db, policy)` runs four built-ins in fixed order, then
643
+ `registerRetentionTask()` contributions. A failed task records its result and
644
+ continues; a missing table is `unavailable`. The `globalThis` registry avoids
645
+ split registrations under duplicate core resolution; package tasks exist only
646
+ after importing the package. CLI prune optionally imports jobs/users.
647
+
648
+ Defaults are opt-out: changes 30 days, AI usage 90 days, completed dispatch 30
649
+ days/failed dispatch 90 days, contexts by `expires_at`. `smrt.configure({
650
+ retention })` tunes built-ins; contributed tasks own their defaults/options.
651
+ Jobs defaults are 7 days terminal, 30 failed, 30 events via
652
+ `registerJobRetentionTasks()` or runner `retention.jobs`; expired credentials
653
+ have no extra window. Disable a table/task with `false` or the whole policy with
654
+ `enabled: false`; CLI `--skip` and runner configuration expose these controls.
655
+ Task names use package prefixes (`jobs-records`, `users-sessions`).
656
+
657
+ Core does not schedule sweeps. TaskRunner runs every six hours, first one
658
+ interval after start; `retention: false` opts out. `smrt db:prune` supports cron.
659
+ Every prune counts then deletes using the same predicate; `rowCount` is not
660
+ portable. The two statements deliberately are not transactional, so counts are
661
+ approximate under concurrency. Overlapping change/AI-usage bounds exclude rows
662
+ already counted, including dry runs.
663
+
664
+ Retention indexes belong in system DDL and its versioned replay:
665
+ `_smrt_contexts(expires_at)`, `_smrt_ai_usage(tenant_id, created_at)`, and dispatch
666
+ `(status, processed_at)` / `(status, updated_at)`. Jobs `(status, completed_at)`
667
+ belongs in `ensureJobsSystemTableCompatibility()` on each collection initialize,
668
+ since decorated jobs tables do not exist at bootstrap.
669
+
670
+ Expiry remains prune-side for object/collection `recall()`/`recallAll()`;
671
+ `LearningMemory` separately filters it at read time.
672
+
673
+ ## Supported generation surfaces
674
+
675
+ The four entry points above are the supported generator paths. The unused AST
676
+ `generateSchema(objectDef)`, `smrt:schema` / `@happyvertical/smrt-virt-schema`
677
+ virtual modules, and project-specific `SchemaOverrideSystem` were removed.
678
+
679
+ Keep published `SchemaDefinition.triggers`, `TriggerDefinition`, and the DDL
680
+ strategies' trigger renderers: they support hand-authored new-table schemas.
681
+ Generated `@smrt()` schemas always emit `triggers: []`; no decorator populates
682
+ it, `save()` maintains `updated_at`, and the differ never retrofits triggers.
683
+ Adding live trigger generation requires a migration rollout design.
684
+ `_smrt_signals` and database-persisted registry APIs are retired; see
685
+ `RETIRED_SYSTEM_TABLES` in `src/system/schema.ts`.
686
+
687
+ ## PostgreSQL migration execution
688
+
689
+ `MigrationTracker.applyAll({ atomic: true })` sets local lock/statement timeouts
690
+ before any DDL. `postgresSafe: true` commits non-index DDL atomically, then runs
691
+ indexes CONCURRENTLY on `db.acquireSession()` so settings and DDL share a
692
+ connection. This mode is not atomic. Unfinished indexes are `failed`, not
693
+ `running`; `[smrt: concurrent-index phase 1 committed]` in `error_message`
694
+ allows reruns to resume index work without replaying committed DDL. Inspect
695
+ `pg_index.indisvalid` and drop INVALID indexes before rebuild (`pg_indexes`
696
+ alone cannot detect them). Operational commands: `packages/cli/AGENTS.md`.