@happyvertical/smrt-core 0.40.70 → 0.42.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.
Files changed (193) hide show
  1. package/AGENTS.md +3 -3
  2. package/agents/change-feed.md +1 -1
  3. package/agents/schema-paths.md +561 -32
  4. package/dist/browser.js +5 -4
  5. package/dist/cascade.d.ts +120 -0
  6. package/dist/cascade.d.ts.map +1 -0
  7. package/dist/cascade.js +430 -0
  8. package/dist/cascade.js.map +1 -0
  9. package/dist/change-feed.d.ts +34 -2
  10. package/dist/change-feed.d.ts.map +1 -1
  11. package/dist/change-feed.js +54 -13
  12. package/dist/change-feed.js.map +1 -1
  13. package/dist/change-signals.d.ts.map +1 -1
  14. package/dist/change-signals.js +8 -1
  15. package/dist/change-signals.js.map +1 -1
  16. package/dist/class.d.ts +36 -3
  17. package/dist/class.d.ts.map +1 -1
  18. package/dist/class.js +69 -14
  19. package/dist/class.js.map +1 -1
  20. package/dist/collection-cache.js +0 -0
  21. package/dist/collection-cache.js.map +1 -1
  22. package/dist/collection.d.ts +29 -0
  23. package/dist/collection.d.ts.map +1 -1
  24. package/dist/collection.js +68 -16
  25. package/dist/collection.js.map +1 -1
  26. package/dist/config.d.ts +10 -0
  27. package/dist/config.d.ts.map +1 -1
  28. package/dist/config.js.map +1 -1
  29. package/dist/decorators/index.d.ts +63 -5
  30. package/dist/decorators/index.d.ts.map +1 -1
  31. package/dist/decorators/index.js +37 -5
  32. package/dist/decorators/index.js.map +1 -1
  33. package/dist/dispatch/collections/Dispatches.d.ts.map +1 -1
  34. package/dist/dispatch/collections/Dispatches.js +21 -6
  35. package/dist/dispatch/collections/Dispatches.js.map +1 -1
  36. package/dist/dispatch/models/Dispatch.d.ts +1 -1
  37. package/dist/dispatch/models/Dispatch.d.ts.map +1 -1
  38. package/dist/dispatch/models/Dispatch.js +3 -2
  39. package/dist/dispatch/models/Dispatch.js.map +1 -1
  40. package/dist/dispatch/models/DispatchSubscription.d.ts +1 -1
  41. package/dist/dispatch/models/DispatchSubscription.d.ts.map +1 -1
  42. package/dist/dispatch/models/DispatchSubscription.js +2 -1
  43. package/dist/dispatch/models/DispatchSubscription.js.map +1 -1
  44. package/dist/dispatch/types.d.ts +5 -0
  45. package/dist/dispatch/types.d.ts.map +1 -1
  46. package/dist/embedded-write-queue.d.ts +46 -0
  47. package/dist/embedded-write-queue.d.ts.map +1 -0
  48. package/dist/embedded-write-queue.js +66 -0
  49. package/dist/embedded-write-queue.js.map +1 -0
  50. package/dist/embeddings/storage.d.ts +7 -0
  51. package/dist/embeddings/storage.d.ts.map +1 -1
  52. package/dist/embeddings/storage.js +31 -13
  53. package/dist/embeddings/storage.js.map +1 -1
  54. package/dist/errors.d.ts +1 -1
  55. package/dist/hierarchical.js +1 -1
  56. package/dist/index.d.ts +2 -1
  57. package/dist/index.d.ts.map +1 -1
  58. package/dist/index.js +8 -6
  59. package/dist/interceptors.d.ts +21 -0
  60. package/dist/interceptors.d.ts.map +1 -1
  61. package/dist/interceptors.js +27 -1
  62. package/dist/interceptors.js.map +1 -1
  63. package/dist/learning/memory.d.ts.map +1 -1
  64. package/dist/learning/memory.js +5 -4
  65. package/dist/learning/memory.js.map +1 -1
  66. package/dist/manifest/generator.d.ts.map +1 -1
  67. package/dist/manifest/generator.js +4 -7
  68. package/dist/manifest/generator.js.map +1 -1
  69. package/dist/manifest/static-manifest.js +10 -10
  70. package/dist/manifest/static-manifest.js.map +1 -1
  71. package/dist/manifest/store.js +1 -1
  72. package/dist/manifest/store.js.map +1 -1
  73. package/dist/manifest.json +19 -19
  74. package/dist/migrations/differ.d.ts +9 -1
  75. package/dist/migrations/differ.d.ts.map +1 -1
  76. package/dist/migrations/differ.js +12 -7
  77. package/dist/migrations/differ.js.map +1 -1
  78. package/dist/migrations/index.d.ts +2 -0
  79. package/dist/migrations/index.d.ts.map +1 -1
  80. package/dist/migrations/index.js +3 -1
  81. package/dist/migrations/integer-width.d.ts +99 -0
  82. package/dist/migrations/integer-width.d.ts.map +1 -0
  83. package/dist/migrations/integer-width.js +237 -0
  84. package/dist/migrations/integer-width.js.map +1 -0
  85. package/dist/migrations/minor-units.d.ts +162 -0
  86. package/dist/migrations/minor-units.d.ts.map +1 -0
  87. package/dist/migrations/minor-units.js +383 -0
  88. package/dist/migrations/minor-units.js.map +1 -0
  89. package/dist/migrations/tracker.d.ts.map +1 -1
  90. package/dist/migrations/tracker.js +28 -20
  91. package/dist/migrations/tracker.js.map +1 -1
  92. package/dist/migrations.js +3 -1
  93. package/dist/object.d.ts +98 -5
  94. package/dist/object.d.ts.map +1 -1
  95. package/dist/object.js +138 -30
  96. package/dist/object.js.map +1 -1
  97. package/dist/query-bounds.d.ts +5 -1
  98. package/dist/query-bounds.d.ts.map +1 -1
  99. package/dist/query-bounds.js +5 -1
  100. package/dist/query-bounds.js.map +1 -1
  101. package/dist/registry/class-registration.d.ts.map +1 -1
  102. package/dist/registry/class-registration.js +3 -1
  103. package/dist/registry/class-registration.js.map +1 -1
  104. package/dist/registry/manifest-field-merge.d.ts +12 -0
  105. package/dist/registry/manifest-field-merge.d.ts.map +1 -1
  106. package/dist/registry/manifest-field-merge.js +14 -2
  107. package/dist/registry/manifest-field-merge.js.map +1 -1
  108. package/dist/registry/schema-builder.d.ts +22 -1
  109. package/dist/registry/schema-builder.d.ts.map +1 -1
  110. package/dist/registry/schema-builder.js +205 -165
  111. package/dist/registry/schema-builder.js.map +1 -1
  112. package/dist/registry/types.d.ts +3 -2
  113. package/dist/registry/types.d.ts.map +1 -1
  114. package/dist/registry.d.ts +41 -46
  115. package/dist/registry.d.ts.map +1 -1
  116. package/dist/registry.js +61 -83
  117. package/dist/registry.js.map +1 -1
  118. package/dist/scanner/manifest-generator.d.ts +45 -0
  119. package/dist/scanner/manifest-generator.d.ts.map +1 -1
  120. package/dist/scanner/manifest-generator.js +92 -28
  121. package/dist/scanner/manifest-generator.js.map +1 -1
  122. package/dist/schema/conflict-target.d.ts +104 -0
  123. package/dist/schema/conflict-target.d.ts.map +1 -0
  124. package/dist/schema/conflict-target.js +129 -0
  125. package/dist/schema/conflict-target.js.map +1 -0
  126. package/dist/schema/ddl/duckdb-strategy.d.ts.map +1 -1
  127. package/dist/schema/ddl/duckdb-strategy.js +3 -1
  128. package/dist/schema/ddl/duckdb-strategy.js.map +1 -1
  129. package/dist/schema/ddl/postgres-strategy.d.ts.map +1 -1
  130. package/dist/schema/ddl/postgres-strategy.js +14 -1
  131. package/dist/schema/ddl/postgres-strategy.js.map +1 -1
  132. package/dist/schema/generator.d.ts +245 -41
  133. package/dist/schema/generator.d.ts.map +1 -1
  134. package/dist/schema/generator.js +397 -241
  135. package/dist/schema/generator.js.map +1 -1
  136. package/dist/schema/index-utils.d.ts +120 -0
  137. package/dist/schema/index-utils.d.ts.map +1 -1
  138. package/dist/schema/index-utils.js +242 -1
  139. package/dist/schema/index-utils.js.map +1 -1
  140. package/dist/schema/index.d.ts +2 -3
  141. package/dist/schema/index.d.ts.map +1 -1
  142. package/dist/schema/index.js +2 -3
  143. package/dist/schema/live-parity.d.ts +3 -1
  144. package/dist/schema/live-parity.d.ts.map +1 -1
  145. package/dist/schema/live-parity.js +23 -2
  146. package/dist/schema/live-parity.js.map +1 -1
  147. package/dist/schema/system-table-shapes.d.ts +13 -0
  148. package/dist/schema/system-table-shapes.d.ts.map +1 -1
  149. package/dist/schema/system-table-shapes.js +14 -1
  150. package/dist/schema/system-table-shapes.js.map +1 -1
  151. package/dist/schema/types.d.ts +17 -10
  152. package/dist/schema/types.d.ts.map +1 -1
  153. package/dist/schema/utils.d.ts +1 -1
  154. package/dist/schema/utils.d.ts.map +1 -1
  155. package/dist/schema/utils.js +3 -3
  156. package/dist/schema/utils.js.map +1 -1
  157. package/dist/schema.js +2 -3
  158. package/dist/smrt-knowledge.json +9 -9
  159. package/dist/system/compatibility.d.ts +42 -0
  160. package/dist/system/compatibility.d.ts.map +1 -1
  161. package/dist/system/compatibility.js +182 -9
  162. package/dist/system/compatibility.js.map +1 -1
  163. package/dist/system/index.d.ts +1 -0
  164. package/dist/system/index.d.ts.map +1 -1
  165. package/dist/system/index.js +3 -2
  166. package/dist/system/retention.d.ts +237 -0
  167. package/dist/system/retention.d.ts.map +1 -0
  168. package/dist/system/retention.js +497 -0
  169. package/dist/system/retention.js.map +1 -0
  170. package/dist/system/schema.d.ts +102 -16
  171. package/dist/system/schema.d.ts.map +1 -1
  172. package/dist/system/schema.js +85 -47
  173. package/dist/system/schema.js.map +1 -1
  174. package/dist/system/types.d.ts +0 -2
  175. package/dist/system/types.d.ts.map +1 -1
  176. package/dist/testing/database.d.ts.map +1 -1
  177. package/dist/testing/database.js.map +1 -1
  178. package/dist/utils/safe-integer.d.ts +19 -0
  179. package/dist/utils/safe-integer.d.ts.map +1 -0
  180. package/dist/utils/safe-integer.js +31 -0
  181. package/dist/utils/safe-integer.js.map +1 -0
  182. package/dist/utils.d.ts +2 -1
  183. package/dist/utils.d.ts.map +1 -1
  184. package/dist/utils.js +6 -3
  185. package/dist/utils.js.map +1 -1
  186. package/dist/vite-plugin/index.d.ts.map +1 -1
  187. package/dist/vite-plugin/index.js +4 -62
  188. package/dist/vite-plugin/index.js.map +1 -1
  189. package/package.json +4 -4
  190. package/dist/schema/override-system.d.ts +0 -43
  191. package/dist/schema/override-system.d.ts.map +0 -1
  192. package/dist/schema/override-system.js +0 -205
  193. package/dist/schema/override-system.js.map +0 -1
@@ -10,9 +10,9 @@ Written from the 2026-08-17 database-layer gap assessment (epic #2382). Symbol
10
10
  names here are stable; the line numbers the assessment quotes are not, so trust
11
11
  this call graph and re-grep before citing a location.
12
12
 
13
- ## Five entry points, two of which ship
13
+ ## Four entry points, two of which ship
14
14
 
15
- `src/schema/generator.ts` exposes five index-emitting entry points. They do not
15
+ `src/schema/generator.ts` exposes four index-emitting entry points. They do not
16
16
  produce the same schema for the same class.
17
17
 
18
18
  | Entry point | Selected by | Status |
@@ -21,7 +21,15 @@ produce the same schema for the same class.
21
21
  | `generateCTISchemaFromManifest` | `src/scanner/manifest-generator.ts` | **production** |
22
22
  | `generateSTISchemaFromRegistry` | `src/testing/database.ts` (`getTestDatabase()`), `src/schema/utils.ts` (`generateSchema`; `ensureSchema` only as a fallback) | tests + runtime helpers |
23
23
  | `generateSchemaFromRegistry` | the same two callers | tests + runtime helpers |
24
- | `generateSchema` (AST) | the `smrt:schema` virtual module, which has no consumer | dead (#2380) |
24
+
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.
25
33
 
26
34
  Production DDL takes the manifest route:
27
35
 
@@ -35,18 +43,124 @@ Production DDL takes the manifest route:
35
43
  use; no in-repo caller outside its own tests)
36
44
  ```
37
45
 
38
- The suite takes the registry route, and the registry route emits indexes the
39
- manifest route does not — per-column foreign-key indexes, and STI partial FK
40
- indexes filtered by `_meta_type`. Tests therefore run against a richer schema
41
- than any deployment receives. `src/testing/database.ts`'s "Generate schema using
42
- SchemaGenerator (same as migrations)" comment describes an intent, not the code.
43
-
44
- The manifest STI path even populates a `fkColumnsByClass` map and never reads it
45
- — only its registry counterpart iterates one — and the manifest CTI path has no
46
- FK loop at all. Both manifest paths then skip the explicit
47
- `@foreignKey(X, { indexed: true })` opt-in the CTI one under a comment claiming
48
- "FK columns and unique columns get their own indexes", which holds on the
49
- registry paths and not on this one.
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
+ Since #2359 the two families share one set of index helpers and
55
+ `src/schema/schema-path-parity.test.ts` runs the same fixture manifest through
56
+ the manifest paths, through `ObjectRegistry.registerFromManifest()` + the
57
+ registry paths, and through `getAllSchemasAsDefinitions()`, asserting identical
58
+ column and index sets. Extend that fixture with every generator change; a
59
+ divergence is a bug in the generator, not an exception to add to the test.
60
+
61
+ ### Index rules (#2359)
62
+
63
+ - **Reference columns are always indexed.** `ensureReferenceColumnIndexes()`
64
+ runs last on every path and gives each `@foreignKey`, `@crossPackageRef` and
65
+ tenant column `<table>_<column>_idx` unless an UNQUALIFIED index (no `WHERE`,
66
+ no JSON path) already leads with it — the `conflictColumns` unique index or an
67
+ `indexed: true` opt-in, or the column's own inline UNIQUE. A partial
68
+ `WHERE _meta_type = …` index does not count: base-class polymorphic queries
69
+ carry no discriminator predicate. `indexed: true` on a reference column is
70
+ redundant. Roll the index wave out to production with
71
+ `smrt db:migrate --postgres-safe` (concurrent-index mode, #2362): a plain
72
+ atomic batch takes SHARE/ACCESS EXCLUSIVE locks for ~230 index builds. STI FK indexes are plain, one per
73
+ column, not per-class partial.
74
+ - **No index on the primary key.** `<table>_id_idx` is gone from every path,
75
+ and `conflictColumns` equal to the PK column set emit no conflict index
76
+ (`ON CONFLICT (id)` binds to the PK constraint). `SchemaComparer` drops the
77
+ legacy non-unique single-column PK index from existing databases without
78
+ `--drop-indexes` when the live table reports that column as its sole primary
79
+ key (never a UNIQUE one — on PostgreSQL that may back a custom-named PRIMARY
80
+ KEY constraint, and `DROP INDEX` on it would fail the atomic batch).
81
+ - **Slug loading keeps its index.** Custom `conflictColumns` replace the
82
+ `(slug, context)` unique index; `loadFromSlug()`/`getId()`/`getSavedId()`
83
+ still filter on slug/context, so a plain `<table>_slug_context_idx` is kept
84
+ (additive; routing those lookups through the conflict key would change which
85
+ row a slug resolves to). The tenant-led default key below counts as serving
86
+ it (`servesSlugLookup()`): a tenant-scoped slug lookup carries the tenant
87
+ 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).
148
+ - **STI `@field({ unique: true })` is enforced through indexes** (the differ can
149
+ add an index to an existing table, never a column constraint): a full
150
+ `<table>_<col>_unique_idx` when the STI base declares it, one
151
+ `<table>_<col>_<class>_unique_idx WHERE _meta_type = '<qualified>'` per class
152
+ when only descendants do — uniqueness per concrete class, not across the
153
+ subtree. DuckDB/JSON have no partial indexes, so the descendant-scoped shape
154
+ (`isStiSubtypeUniqueIndex`) is not emitted there — degrading it to a full
155
+ UNIQUE would constrain every subtype; the DDL strategy and the differ both
156
+ skip it, while other partial indexes keep degrading to full ones as before. Remember the
157
+ framework serializes an unset text field as `''`, so a unique optional text
158
+ field must be `nullable: true` with a `null` initializer or every unset row
159
+ collides.
160
+ - **Every class in an STI hierarchy carries the schema of the one shared
161
+ table**, generated from the root base (`ManifestGenerator.generateSchemas()`
162
+ resolves the root through `findSTIBaseInfo`), so a child never treats its own
163
+ descendant-only unique field as base-declared.
50
164
 
51
165
  `src/schema/utils.ts` sits in between, and the two exports differ:
52
166
 
@@ -94,10 +208,10 @@ epic's fixes land.
94
208
  ### 1. Verify against the production path, not the test path
95
209
 
96
210
  Any change to column or index emission goes on **all** paths that ship and is
97
- proven by a path-parity test. #2359 adds that test under `src/schema/`; until it
98
- lands, assert the parity yourself in the nearest generator test a green suite
99
- otherwise proves the registry paths only. Read the call graph before believing a
100
- comment: "same as migrations" was wrong for years.
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.
101
215
 
102
216
  ### 2. Every new query predicate ships with its index
103
217
 
@@ -129,17 +243,21 @@ every package; do not sample a few and extrapolate.
129
243
  ### 5. Index intent belongs on both the constraint and the read path
130
244
 
131
245
  A conflict target is not automatically a unique index, and a unique index is not
132
- automatically the index a read path uses. Custom `conflictColumns` replace the
133
- `(slug, context)` index while `loadFromSlug`/`getId` still query slug+context;
134
- STI drops `@field({ unique: true })`. Check the pair, not the declaration.
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.
135
250
 
136
251
  ### 6. Multi-tenancy is a whole-path property
137
252
 
138
253
  Every unique constraint and every conflict target on a tenant-scoped table
139
254
  includes the tenant column — otherwise a second tenant's `save()` of the same
140
- natural key updates the first tenant's row through `DO UPDATE SET` (#2360). And
141
- every read path is interceptor-aware: hydration (`loadFromId`/`loadFromSlug`),
142
- get-by-slug, vector search, and collection memory, not only `list()` (#2365).
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).
143
261
 
144
262
  ### 7. Retry only transient errors
145
263
 
@@ -183,10 +301,10 @@ production down on rollout (#2362).
183
301
 
184
302
  ### 13. Composite indexes are declared, not inferred (#2357)
185
303
 
186
- The generated set only covers foreign keys, unique/conflict columns,
187
- `updated_at`, the STI discriminator, `tenant_id`, and single columns opted in
188
- with `@field({ indexed: true })`. A list workload's access path is composite,
189
- so declare it:
304
+ 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
307
+ workload's access path is composite, so declare it:
190
308
 
191
309
  ```ts
192
310
  @smrt({
@@ -203,9 +321,10 @@ scans a btree either way, so an ascending index also serves the matching
203
321
  `ORDER BY ... DESC` as an ordered scan with no Sort node. `unique` and `where`
204
322
  (partial index) are honoured.
205
323
 
206
- `appendDeclaredIndexes()` runs on all five entry points, before
207
- `ensureTenantIdIndex()`, so a declared composite leading with the tenant column
208
- replaces the automatic standalone `tenant_id` index rather than duplicating it.
324
+ `appendDeclaredIndexes()` runs first on all four entry points, ahead of
325
+ `ensureDefaultListOrderingIndex()` (rule 18) and `ensureReferenceColumnIndexes()`,
326
+ so a declared composite leading with the tenant column (or any reference column)
327
+ replaces the automatic standalone index rather than duplicating it.
209
328
  Unknown columns, malformed entries, and a name collision with a different index
210
329
  all fail generation — a silently dropped index only surfaces later as a
211
330
  production slowdown. Rule 8 above is why this works at runtime at all.
@@ -326,3 +445,413 @@ warning, as `db:migrate` drops them. Do not add a new consumer of the
326
445
  string, and do not write a private CREATE INDEX renderer — the retired ones
327
446
  dropped `where` and `jsonPath` (#2358). Every DDL strategy also spells out
328
447
  `PRIMARY KEY NOT NULL`: SQLite lets a bare non-INTEGER PRIMARY KEY hold NULL.
448
+
449
+ ### 17. The merged table shape is registration-order independent (#2372)
450
+
451
+ `getAllSchemas()` and `getAllSchemasAsDefinitions()` fold every class that
452
+ shares a physical table — the whole STI hierarchy — into one shape. Both route
453
+ through `buildMergedTableSchemas()`, which groups contributors by table and
454
+ then merges them in a **deterministic** order: the STI base first, then
455
+ ancestors before descendants, then by qualified name.
456
+
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.
467
+
468
+ Two invariants keep the two assembly paths agreeing:
469
+
470
+ - `createBaseColumns()` mirrors what `generateSchemaFromManifest` /
471
+ `generateSTISchemaFromManifest` emit for the same table, so a table built
472
+ from runtime field metadata alone has the same NOT NULL/DEFAULT shape as one
473
+ built from a manifest. Note `_meta_type` is `TEXT NOT NULL` with **no**
474
+ default, matching the generator.
475
+ - `fieldsToColumns()` reads `required`, `default`, and `description` from the
476
+ top level *or* `_meta`. Registry fields normalize them into `_meta`
477
+ (`manifest-field-merge.ts`), so reading only the top level silently dropped
478
+ NOT NULL and DEFAULT for every registry-sourced field.
479
+
480
+ STI columns stay nullable regardless of the field's `required` flag
481
+ (`fieldsToColumns(fields, { stiUnionColumns: true })`): the table holds the
482
+ union of all subtypes' fields, so a column only one subtype declares is never
483
+ populated on a sibling's row. Declared defaults are still emitted. This matches
484
+ `generateSTISchemaFromManifest`, which sets `notNull: false` on every non-system
485
+ STI column.
486
+
487
+ When adding a class-level input to the merged shape, take it from the seeding
488
+ contributor rather than "whichever class arrives first", and cover it with a
489
+ child-first/base-first equality test.
490
+
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
534
+
535
+ `save()` upserts on `ObjectRegistry.getConflictColumns()`; the schema must
536
+ carry exactly one unique index over those columns (or they must be the
537
+ primary key). Keep the derivation in `src/schema/conflict-target.ts` and let
538
+ every producer call it — the three manifest pipelines share
539
+ `ManifestGenerator.applyGenerationPasses()` since #2360 because
540
+ `ManifestBuilder` had silently skipped the report passes for months. When you
541
+ add a way for the key to vary (a new decorator option, a new class kind),
542
+ thread it through `getConflictColumns()`, `normalizeConflictColumns()` and the
543
+ generator's `resolveConflictTarget()` together, and extend the parity test's
544
+ "unique index == conflict target" assertion; a key the runtime uses and the
545
+ schema does not index is a hard PostgreSQL error (42P10) on the first save,
546
+ and a key the schema indexes without the tenant column is the silent
547
+ cross-tenant overwrite this rule exists for.
548
+
549
+ ### 20. Every generated index name is length-guarded before it leaves a path (#2374)
550
+
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.
558
+
559
+ `schema/index-utils.ts` owns the guard, and it splits by who owns the name:
560
+
561
+ - **Generated index, trigger and PL/pgSQL function names** →
562
+ `shortenIdentifier()`. Deterministic `<head>_<digest><suffix>`, digest taken
563
+ over the **full** original so a shared prefix still yields distinct names, and
564
+ a recognised suffix (`_idx`, `_unique_idx`, `_key`, `_pkey`) preserved.
565
+ - **Hand-declared `@smrt({ indexes: [{ name }] })`** → `assertIdentifierFits()`,
566
+ a hard error in `validateDeclaredIndex()`. Renaming what a developer wrote is
567
+ worse than refusing it, and `SchemaComparer` matches indexes **by name**
568
+ first, so a 70-byte declaration could never match the 63-byte index
569
+ 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.
581
+
582
+ `enforceIdentifierLimits()` is the single call site per path, placed **after**
583
+ `ensureReferenceColumnIndexes()` — nothing may lengthen a name after it. Doing
584
+ the shortening at the end rather than at each `indexes.push()` is safe because
585
+ the digest covers the whole original name, so entries distinct before shortening
586
+ stay distinct after; the helper still throws if two ever collide. The migrate
587
+ leg's `withConflictIndex()` (`registry/schema-builder.ts`) and the PostgreSQL
588
+ trigger-function name call `shortenIdentifier()` directly, because they compose
589
+ a name outside the generator's index list. Note that an over-long *table* name
590
+ still yields in-limit, distinct *index* names, because the shortening runs over
591
+ the whole composed name.
592
+
593
+ The digest is FNV-1a, not `node:crypto`: `index-utils.ts` is re-exported from
594
+ `schema/utils.ts`, which exists to keep Node built-ins out of browser bundles.
595
+ It only has to be *stable* — a shortened name that changed between releases
596
+ would make every deployment drop and recreate the index — so the parity and
597
+ unit tests pin the literal output rather than recomputing it. Unpaired
598
+ surrogates are folded to U+FFFD before both counting and hashing, so the digest
599
+ is taken over exactly the bytes the driver transmits.
600
+
601
+ Existing databases migrate **by name swap, without a rebuild**: the live index
602
+ still carries the name PostgreSQL truncated it to, the manifest now carries the
603
+ shortened one, and the differ claims it by signature (columns + uniqueness +
604
+ predicate), emitting nothing — including under `includeDroppedIndexes`. See
605
+ `migrations/__tests__/index-drift.test.ts` and the PostgreSQL lane test
606
+ `schema/issue-2374-identifier-length-postgres.optional.test.ts`.
607
+
608
+ Out of scope, deliberately: constraint names PostgreSQL invents for itself. A
609
+ CTI table's inline `UNIQUE` produces an implicit `<table>_<column>_key`, which
610
+ can exceed 63 bytes even when the table and column each fit. SMRT never names
611
+ it, and PostgreSQL disambiguates its own truncations by appending a counter
612
+ rather than collapsing them, so there is no silent-collision hazard there.
613
+
614
+ ### 21. The `_smrt_` prefix does not mean "system table" (#2376)
615
+
616
+ `bootstrapSystemTables()` owns nine hand-written tables; ~25 more `_smrt_*`
617
+ tables belong to `@smrt()` models and are created by `db:migrate` (feature
618
+ flags, prompt overrides, subscription plans, report schedules, field policies,
619
+ jobs). Never classify by prefix — use `SYSTEM_TABLE_NAMES`
620
+ (`schema/system-table-shapes.ts`, derived from the DDL parse) plus
621
+ `FRAMEWORK_OPERATIONAL_TABLES` / `RETIRED_SYSTEM_TABLES` in `system/schema.ts`.
622
+ The change-feed writer skipped by prefix, so clients syncing those domain
623
+ tables through `_changes` never saw an update.
624
+
625
+ Editing `ALL_SYSTEM_TABLES` requires bumping `SMRT_SCHEMA_VERSION` *and*
626
+ appending to `SMRT_SCHEMA_DDL_CHECKSUMS` — the version gates the DDL replay, so
627
+ without a bump no existing database ever applies the change. A new **column**
628
+ additionally needs an `addColumnIfMissing()` entry in `system/compatibility.ts`
629
+ (`CREATE TABLE IF NOT EXISTS` is a no-op on an existing table).
630
+ `system-schema-evolution.test.ts` enforces both, and asserts a legacy database
631
+ upgrades to exactly the shape a fresh install gets.
632
+
633
+ `_smrt_jobs` / `_smrt_job_events` are dual-owned: `db:migrate` creates them,
634
+ the compatibility pass reshapes them. On a fresh install bootstrap runs first,
635
+ so their pass is deferred — `ensureDeferredSystemTableCompatibility()` re-runs
636
+ until the tables exist, then stamps a `<version>+deferred-compat` marker. It
637
+ runs OUTSIDE the bootstrap lock and swallows its own failures: those statements
638
+ target tables the framework does not own, and inside the PostgreSQL transaction
639
+ one failure would roll back system-table creation with it. Only
640
+ `ensureBootstrapSystemTableCompatibility()` (the tables the DDL itself creates)
641
+ belongs inside the lock.
642
+
643
+ Reconciling `_smrt_jobs.task_id` uniqueness reads the live index catalog, which
644
+ is implemented for PostgreSQL and SQLite only; DuckDB and the JSON adapter keep
645
+ the redundant compat index rather than risk dropping the one that enforces the
646
+ upsert conflict target. When reading a PostgreSQL catalog array, cast it
647
+ (`attname::text`) and parse both shapes — a driver with no parser registered for
648
+ the array OID returns the raw `{a,b}` literal, and reading that as "no columns"
649
+ silently inverts an index-existence decision.
650
+
651
+ ## Referential integrity lives in `delete()`, not in the DDL
652
+
653
+ No schema path emits a `FOREIGN KEY` clause on any engine — grep `schema/ddl/*`,
654
+ `schema-manager.ts` and `differ.ts` for `REFERENCES` and you get nothing. That is
655
+ a deliberate position, not an oversight: emitting constraints changes delete
656
+ semantics for every consumer, requires topological table ordering in both
657
+ migrate paths (neither orders today), and needs a plan for the orphans already in
658
+ production databases. Emitting them is tracked separately.
659
+
660
+ What `@foreignKey(..., { onDelete })` therefore means is *application* behaviour,
661
+ applied by `SmrtObject.delete()` through `src/cascade.ts` (#2371):
662
+
663
+ | Reference | Default when `onDelete` is absent |
664
+ |---|---|
665
+ | Column is part of the referencing class's `conflictColumns`, and is not a `@tenantId()` field | `CASCADE` |
666
+ | Polymorphic `(metaType, metaId)` association row | `CASCADE` |
667
+ | Anything else, including every `@tenantId()` field | `NO ACTION` — the row is left alone |
668
+
669
+ The natural-key rule is what cleans junction rows up without any per-package
670
+ annotation: a junction declares
671
+ `@smrt({ conflictColumns: ['content_id', 'asset_id', 'relationship'] })`, so the
672
+ row is *identified* by the content and cannot outlive it. An ordinary child
673
+ (`Order.customerId`) is keyed by `(slug, context)` and keeps its pre-#2371
674
+ behaviour unless it opts in explicitly.
675
+
676
+ **`@tenantId()` is excluded even though it lands in `conflictColumns`.**
677
+ #2360 leads every tenant-scoped class's *default* natural key with the
678
+ tenant column, so without this exclusion, deleting one `Tenant` row would
679
+ recursively CASCADE through every tenant-scoped table in the schema that has
680
+ not declared its own `conflictColumns` — the overwhelming majority. The
681
+ tenant column scopes ownership; it does not identify the row the way a
682
+ junction's foreign key does. Detected via the `__tenancy.isTenantIdField`
683
+ marker on `FieldMeta` (`smrt-core` reads it structurally so it never depends
684
+ on `smrt-tenancy`). `@tenantId()` exposes no `onDelete` option today, so
685
+ this cannot currently be overridden per field — found in review before this
686
+ landed (originally reachable, untested, and undocumented).
687
+
688
+ Properties to keep if you touch that module:
689
+
690
+ - **The plan is registry-derived and rebuilt per delete.** Registration is
691
+ incremental — manifests load lazily and tests register classes between cases —
692
+ so a cached plan would silently skip a table that registered later. Cache it
693
+ only behind an invalidation hook that every registration path calls.
694
+ - **A class with nothing pointing at it skips the transaction entirely — but
695
+ `CascadePlan.isEmpty` requires no polymorphic association class anywhere in
696
+ the process, not just no typed references.** `buildCascadePlan()` pushes
697
+ *every* registered `SmrtPolymorphicAssociation` subclass into
698
+ `plan.polymorphic` unconditionally (`cascade.ts` around
699
+ `isPolymorphicAssociationClass`): a `metaType` column can point at any class
700
+ at runtime, so there is no static metadata to scope it by the target being
701
+ deleted. One registered polymorphic class anywhere makes `isEmpty` false for
702
+ every delete in that process — do not read "the common case skips the
703
+ transaction" as "most deletes in a real app skip it"; in a multi-package app
704
+ that registers even one polymorphic association, almost none do.
705
+ `runCascadeDelete()` builds the plan for `getResolvedQualifiedName()` (not the
706
+ bare constructor name — two packages can register the same simple name).
707
+ - **Cascaded rows are removed set-based.** Their `beforeDelete`/`afterDelete`
708
+ hooks and interceptors do not run and no change-feed tombstone is written for
709
+ them, which is exactly what a DB-level `ON DELETE CASCADE` does. Only the
710
+ object `delete()` was called on runs the lifecycle. Do not "improve" this into
711
+ a per-row model delete without deciding what that means for sync consumers.
712
+ - **Everything is one transaction where the adapter has one**, including the
713
+ object's own `DELETE`, whenever there is anything to cascade. The `RESTRICT`
714
+ checks run first, before any mutation, so a refusal costs nothing; the
715
+ transaction is what makes a refusal *deeper* in the graph safe.
716
+ - **`_smrt_embeddings` and `_smrt_contexts` are matched by id *and* a
717
+ class-name candidate set, not id alone.** Their class columns store the
718
+ *runtime* constructor name, which for an STI hierarchy is a concrete
719
+ subclass rather than the class the cascade planned from — id-alone matching
720
+ looked STI-safe, but let two unrelated classes using `idType: 'text'`
721
+ (non-UUID, not guaranteed globally unique) collide on a shared id value and
722
+ delete each other's rows (review fix). `ownerClassCandidates()` expands to
723
+ every STI hierarchy member of the class the ids actually belong to, in both
724
+ qualified and simple form. A failure to clean them is logged, never raised —
725
+ an application database may predate the table, and losing derived rows must
726
+ not fail a valid delete.
727
+
728
+ `_smrt_changes`, `_smrt_ai_usage`, `_smrt_signals` and the dispatch tables are
729
+ deliberately **not** cascaded. They are append-only logs; the change feed in
730
+ particular receives the delete's own tombstone, so cascading it would erase the
731
+ record that tells sync clients the row is gone.
732
+
733
+ ### 22. System tables get a retention policy, not just a prune function (#2375)
734
+
735
+ Four framework-owned tables grow with traffic and nothing used to remove a row:
736
+ `_smrt_changes` (one per save/delete), `_smrt_ai_usage` (one per AI call, and
737
+ persistence is on by default), `_smrt_contexts` (whose `expires_at` nothing
738
+ enforced) and `_smrt_dispatch` (an operator-only `dispatch:cleanup`).
739
+ `src/system/retention.ts` is now the single place that bounds them.
740
+
741
+ - **`runRetentionSweep(db, policy)` is the entry point.** It runs the four
742
+ built-in tasks in a fixed order, then every task other packages contributed
743
+ via `registerRetentionTask()` — `@happyvertical/smrt-jobs` registers
744
+ `_smrt_jobs`/`_smrt_job_events`, `@happyvertical/smrt-users` registers
745
+ session/magic-link/CLI-auth expiry. A task that throws is recorded on its own
746
+ result and the sweep continues; a missing table reports `unavailable`, so a
747
+ sweep is safe against a partially bootstrapped database.
748
+ - **A contributed task only exists in a process that loaded its package.** Both
749
+ packages register on import from their entry point, and the registry lives on
750
+ `globalThis` (like `ObjectRegistry`) so a duplicated `smrt-core` resolution
751
+ cannot split it. `smrt db:prune` optionally imports both packages for exactly
752
+ this reason — a project that installs neither correctly gets neither task.
753
+ - **Defaults are opt-out, not opt-in.** `DEFAULT_RETENTION_POLICY` covers the
754
+ four built-in tables (changes 30 days, AI usage 90 days, dispatch 30 days
755
+ completed / 90 days failed, contexts strictly by their own `expires_at`), and
756
+ those are the ones `smrt.configure({ retention })` tunes. Contributed tasks
757
+ carry their own defaults and their own window options —
758
+ `DEFAULT_JOB_RETENTION` (7 days terminal / 30 days failed / 30 days events,
759
+ set through `registerJobRetentionTasks()` or the runner's `retention.jobs`),
760
+ and expired credentials, which have no window because an expired credential
761
+ has nothing worth retaining. Every task, built-in or contributed, can be
762
+ turned off: a table set to `false`, a task set to `false` under `tasks`, or
763
+ `enabled: false` for the whole sweep — through `smrt.configure`,
764
+ `smrt db:prune --skip`, or the runner's `retention` config.
765
+ - **Contributed task names are prefixed with the owning package's short name**
766
+ (`jobs-records`, `jobs-events`, `users-sessions`, …) because the registry is
767
+ one process-global namespace.
768
+ - **Scheduling lives outside core.** A running `TaskRunner` sweeps every six
769
+ hours (`retention: false` opts out) and `smrt db:prune` is the cron entry
770
+ point. The first runner sweep is one interval after `start()`, never at
771
+ start: a crash-looping worker must not become a delete loop.
772
+ - **Every prune counts before it deletes.** `rowCount` is not reliably
773
+ populated across the engines SMRT supports, so counting is both what gives a
774
+ usable figure and what lets `dryRun` preview the *same* predicate rather than
775
+ an approximation of it. Count and delete are two statements and deliberately
776
+ not one transaction — a maintenance pass must not hold a write lock over a
777
+ large delete — so the figure is approximate under concurrent writers. Where
778
+ two bounds can select the same row (`pruneChangeFeed`, `pruneAiUsage`), the
779
+ second bound excludes what the first already accounted for, so a dry run does
780
+ not count an entry twice.
781
+ - **Every retention predicate ships with its index** (rule 2 applies to
782
+ maintenance SQL too): `_smrt_contexts(expires_at)`,
783
+ `_smrt_ai_usage(tenant_id, created_at)` — which is also the subscriptions
784
+ billing meter's range scan — `_smrt_dispatch(status, processed_at)` and
785
+ `(status, updated_at)` come from the system DDL, so they reach existing
786
+ databases through the `SMRT_SCHEMA_VERSION` bump that replays it.
787
+ `_smrt_jobs(status, completed_at)` comes from
788
+ `ensureJobsSystemTableCompatibility()` instead, because `_smrt_jobs` is
789
+ generated from a decorated class and does not exist yet when bootstrap runs;
790
+ the jobs collection calls that path on every `initialize()`.
791
+ - **Expiry enforcement is prune-side only.** `recall()`/`recallAll()` keep
792
+ their documented "expiry is not applied at read time" contract — changing it
793
+ would change read semantics for existing callers, which is a different issue
794
+ from bounding storage. `LearningMemory` filters expired rows itself.
795
+
796
+ ### 23. Dead generation surfaces were deleted, not wired (#2380)
797
+
798
+ Rule 9 named three surfaces that read as canonical but were not: the AST
799
+ `generateSchema(objectDef)` entry point, `SchemaOverrideSystem`, and the
800
+ never-emitted `triggers: []`. Resolution, so a future agent does not re-open
801
+ what was deliberately decided:
802
+
803
+ - **The AST path is gone.** `SchemaGenerator.generateSchema(objectDef)` and its
804
+ AST-only private helpers (`generateIndexes`, `generateTriggers`,
805
+ `extractDependencies`, `generateVersion`, `getTableName`,
806
+ `extractPackageName`) were deleted from `schema/generator.ts`, along with
807
+ their sole caller, `generateSchemaModule()` in `vite-plugin/index.ts`, and the
808
+ `smrt:schema` / `@happyvertical/smrt-virt-schema` virtual module registration
809
+ that fed. Nothing else called it — grep the deleted method's exact name
810
+ before assuming a caller was missed; the path-parity fixture and every other
811
+ rule above already speak only of the four surviving entry points.
812
+ - **`SchemaOverrideSystem` is gone**, file and all
813
+ (`schema/override-system.ts` no longer exists). It was never called from
814
+ anywhere in this repository outside its own now-deleted exports, and two of
815
+ its five public methods (`createPraecoContentOverride`,
816
+ `createPraecoMeetingOverride`) hard-coded a schema extension for a
817
+ consuming project outside this monorepo — scaffolding that never belonged in
818
+ the framework, not a generic feature with a missing caller. `SchemaOverride`
819
+ (the type) went with it; `ColumnDefinition`/`IndexDefinition`/
820
+ `TriggerDefinition`, which it merely referenced, did not.
821
+ - **The DDL-strategy trigger machinery was kept, not deleted.**
822
+ `TriggerDefinition`, `SchemaDefinition.triggers`, and every DDL strategy's
823
+ `generateTriggers()` / `generateTriggerStatement()` / `supportsTriggers()`
824
+ (`schema/ddl/*.ts`) are real, engine-uniform, directly-tested rendering code
825
+ that runs on **every** table creation via `strategy.generateTriggers(schema)`
826
+ — unlike the AST path, this is not an orphaned call graph. It is kept for the
827
+ same reason rule 16 keeps the cached `schema.ddl` string: `SchemaDefinition`
828
+ is part of the shape third-party tooling and published manifests may already
829
+ depend on, and `EngineSpecificDDL`/`MultiEngineDDL` (`schema/ddl/types.ts`)
830
+ carry `triggers` as part of that same contract. Deleting a published field is
831
+ a different (and unjustified) risk from deleting a virtual module nothing
832
+ ever imported.
833
+ - **What changed is what is documented, not what runs.** `schema.triggers` is
834
+ now explicitly documented (`schema/types.ts`) as always `[]` on every schema
835
+ a `@smrt()` class can produce, and why: there is no `@smrt()`/`@field()`
836
+ option that populates it (unlike `indexes`, #2357), `updated_at` is
837
+ maintained at the application layer (`SmrtObject.save()`), and
838
+ `migrations/differ.ts` never diffs triggers — so even a hand-populated one
839
+ would only apply to a newly `CREATE TABLE`d table and never retrofit an
840
+ existing one. Wiring live trigger emission was considered and rejected for
841
+ this issue: it is a migration-rollout feature (retrofitting 238+ existing
842
+ production tables needs the same `SMRT_SCHEMA_VERSION`-replay or differ
843
+ support rule 21/rule 22's system-table work required), not a cleanup, and
844
+ nothing in the epic depended on it the way #2359 depended on FK indexes
845
+ actually shipping.
846
+ - **`_smrt_signals` and `ObjectRegistry.persistToDatabase()`/`loadFromDatabase()`**
847
+ — named in the original finding alongside triggers — were already handled by
848
+ #2376 before this issue landed: see rule 21 and `system/schema.ts`'s
849
+ `RETIRED_SYSTEM_TABLES`. Nothing further to do there.
850
+ - **The two config-rebuild-site comments** (`schema/utils.ts`,
851
+ `testing/database.ts`) rule 8 requires were already in place, added by
852
+ #2357/#2360; the `testing/database.ts` "same as migrations" overclaim rule 1
853
+ quotes was already corrected by #2359, and doctor's `experimentalDecorators`
854
+ check was already fixed by #2368/#2399 (see `packages/cli/AGENTS.md`
855
+ Gotchas). Re-verify against current source before repeating any of these —
856
+ the epic's PRs landed across one evening and a stale assessment line is not
857
+ proof a fix is still needed.