@happyvertical/smrt-core 0.40.69 → 0.41.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 (237) hide show
  1. package/AGENTS.md +29 -4
  2. package/README.md +20 -1
  3. package/agents/change-feed.md +1 -1
  4. package/agents/query-bounds.md +45 -0
  5. package/agents/schema-paths.md +786 -0
  6. package/dist/browser.d.ts +1 -0
  7. package/dist/browser.d.ts.map +1 -1
  8. package/dist/browser.js +5 -3
  9. package/dist/cascade.d.ts +120 -0
  10. package/dist/cascade.d.ts.map +1 -0
  11. package/dist/cascade.js +430 -0
  12. package/dist/cascade.js.map +1 -0
  13. package/dist/change-feed.d.ts +34 -2
  14. package/dist/change-feed.d.ts.map +1 -1
  15. package/dist/change-feed.js +52 -11
  16. package/dist/change-feed.js.map +1 -1
  17. package/dist/class.d.ts +36 -3
  18. package/dist/class.d.ts.map +1 -1
  19. package/dist/class.js +87 -9
  20. package/dist/class.js.map +1 -1
  21. package/dist/collection-cache.js +0 -0
  22. package/dist/collection-cache.js.map +1 -1
  23. package/dist/collection.d.ts +130 -2
  24. package/dist/collection.d.ts.map +1 -1
  25. package/dist/collection.js +290 -57
  26. package/dist/collection.js.map +1 -1
  27. package/dist/config.d.ts +10 -0
  28. package/dist/config.d.ts.map +1 -1
  29. package/dist/config.js.map +1 -1
  30. package/dist/database.d.ts +8 -0
  31. package/dist/database.d.ts.map +1 -1
  32. package/dist/database.js +16 -8
  33. package/dist/database.js.map +1 -1
  34. package/dist/db-errors.d.ts +105 -0
  35. package/dist/db-errors.d.ts.map +1 -0
  36. package/dist/db-errors.js +382 -0
  37. package/dist/db-errors.js.map +1 -0
  38. package/dist/decorators/index.d.ts +80 -6
  39. package/dist/decorators/index.d.ts.map +1 -1
  40. package/dist/decorators/index.js +102 -12
  41. package/dist/decorators/index.js.map +1 -1
  42. package/dist/dispatch/bus.d.ts.map +1 -1
  43. package/dist/dispatch/bus.js +4 -3
  44. package/dist/dispatch/bus.js.map +1 -1
  45. package/dist/dispatch/collections/Dispatches.d.ts.map +1 -1
  46. package/dist/dispatch/collections/Dispatches.js +19 -4
  47. package/dist/dispatch/collections/Dispatches.js.map +1 -1
  48. package/dist/dispatch/types.d.ts +5 -0
  49. package/dist/dispatch/types.d.ts.map +1 -1
  50. package/dist/embedded-write-queue.d.ts +46 -0
  51. package/dist/embedded-write-queue.d.ts.map +1 -0
  52. package/dist/embedded-write-queue.js +66 -0
  53. package/dist/embedded-write-queue.js.map +1 -0
  54. package/dist/embeddings/storage.d.ts +7 -0
  55. package/dist/embeddings/storage.d.ts.map +1 -1
  56. package/dist/embeddings/storage.js +29 -12
  57. package/dist/embeddings/storage.js.map +1 -1
  58. package/dist/errors.d.ts +31 -3
  59. package/dist/errors.d.ts.map +1 -1
  60. package/dist/errors.js +34 -2
  61. package/dist/errors.js.map +1 -1
  62. package/dist/generators/changes-route.d.ts.map +1 -1
  63. package/dist/generators/changes-route.js +6 -3
  64. package/dist/generators/changes-route.js.map +1 -1
  65. package/dist/generators/mcp-runtime-template.d.ts +8 -0
  66. package/dist/generators/mcp-runtime-template.d.ts.map +1 -1
  67. package/dist/generators/mcp-runtime-template.js +38 -4
  68. package/dist/generators/mcp-runtime-template.js.map +1 -1
  69. package/dist/generators/mcp.d.ts +16 -0
  70. package/dist/generators/mcp.d.ts.map +1 -1
  71. package/dist/generators/mcp.js +41 -3
  72. package/dist/generators/mcp.js.map +1 -1
  73. package/dist/generators/rest.d.ts +22 -0
  74. package/dist/generators/rest.d.ts.map +1 -1
  75. package/dist/generators/rest.js +34 -3
  76. package/dist/generators/rest.js.map +1 -1
  77. package/dist/hierarchical.js +1 -1
  78. package/dist/index.d.ts +7 -1
  79. package/dist/index.d.ts.map +1 -1
  80. package/dist/index.js +12 -5
  81. package/dist/interceptors.d.ts +21 -0
  82. package/dist/interceptors.d.ts.map +1 -1
  83. package/dist/interceptors.js +27 -1
  84. package/dist/interceptors.js.map +1 -1
  85. package/dist/manifest/generator.d.ts.map +1 -1
  86. package/dist/manifest/generator.js +4 -7
  87. package/dist/manifest/generator.js.map +1 -1
  88. package/dist/manifest/static-manifest.js +10 -10
  89. package/dist/manifest/static-manifest.js.map +1 -1
  90. package/dist/manifest/store.js +1 -1
  91. package/dist/manifest/store.js.map +1 -1
  92. package/dist/manifest.json +19 -19
  93. package/dist/migrations/differ.d.ts +211 -9
  94. package/dist/migrations/differ.d.ts.map +1 -1
  95. package/dist/migrations/differ.js +613 -50
  96. package/dist/migrations/differ.js.map +1 -1
  97. package/dist/migrations/generator.d.ts +31 -4
  98. package/dist/migrations/generator.d.ts.map +1 -1
  99. package/dist/migrations/generator.js +49 -5
  100. package/dist/migrations/generator.js.map +1 -1
  101. package/dist/migrations/index.d.ts +4 -2
  102. package/dist/migrations/index.d.ts.map +1 -1
  103. package/dist/migrations/index.js +6 -3
  104. package/dist/migrations/minor-units.d.ts +162 -0
  105. package/dist/migrations/minor-units.d.ts.map +1 -0
  106. package/dist/migrations/minor-units.js +381 -0
  107. package/dist/migrations/minor-units.js.map +1 -0
  108. package/dist/migrations/orchestrate.js +35 -6
  109. package/dist/migrations/orchestrate.js.map +1 -1
  110. package/dist/migrations/sqlite-rebuild.d.ts +142 -0
  111. package/dist/migrations/sqlite-rebuild.d.ts.map +1 -0
  112. package/dist/migrations/sqlite-rebuild.js +514 -0
  113. package/dist/migrations/sqlite-rebuild.js.map +1 -0
  114. package/dist/migrations/tracker.d.ts +114 -1
  115. package/dist/migrations/tracker.d.ts.map +1 -1
  116. package/dist/migrations/tracker.js +331 -16
  117. package/dist/migrations/tracker.js.map +1 -1
  118. package/dist/migrations/types.d.ts +19 -4
  119. package/dist/migrations/types.d.ts.map +1 -1
  120. package/dist/migrations.js +6 -3
  121. package/dist/object.d.ts +142 -10
  122. package/dist/object.d.ts.map +1 -1
  123. package/dist/object.js +196 -41
  124. package/dist/object.js.map +1 -1
  125. package/dist/postgres-timeouts.d.ts +240 -0
  126. package/dist/postgres-timeouts.d.ts.map +1 -0
  127. package/dist/postgres-timeouts.js +204 -0
  128. package/dist/postgres-timeouts.js.map +1 -0
  129. package/dist/query-bounds.d.ts +101 -0
  130. package/dist/query-bounds.d.ts.map +1 -0
  131. package/dist/query-bounds.js +177 -0
  132. package/dist/query-bounds.js.map +1 -0
  133. package/dist/registry/class-registration.d.ts.map +1 -1
  134. package/dist/registry/class-registration.js +3 -1
  135. package/dist/registry/class-registration.js.map +1 -1
  136. package/dist/registry/manifest-field-merge.d.ts +12 -0
  137. package/dist/registry/manifest-field-merge.d.ts.map +1 -1
  138. package/dist/registry/manifest-field-merge.js +14 -2
  139. package/dist/registry/manifest-field-merge.js.map +1 -1
  140. package/dist/registry/schema-builder.d.ts +22 -1
  141. package/dist/registry/schema-builder.d.ts.map +1 -1
  142. package/dist/registry/schema-builder.js +205 -165
  143. package/dist/registry/schema-builder.js.map +1 -1
  144. package/dist/registry/types.d.ts +35 -3
  145. package/dist/registry/types.d.ts.map +1 -1
  146. package/dist/registry.d.ts +41 -46
  147. package/dist/registry.d.ts.map +1 -1
  148. package/dist/registry.js +61 -83
  149. package/dist/registry.js.map +1 -1
  150. package/dist/scanner/manifest-generator.d.ts +45 -0
  151. package/dist/scanner/manifest-generator.d.ts.map +1 -1
  152. package/dist/scanner/manifest-generator.js +92 -28
  153. package/dist/scanner/manifest-generator.js.map +1 -1
  154. package/dist/scanner/types.d.ts +5 -0
  155. package/dist/scanner/types.d.ts.map +1 -1
  156. package/dist/scanner/types.js.map +1 -1
  157. package/dist/schema/conflict-target.d.ts +104 -0
  158. package/dist/schema/conflict-target.d.ts.map +1 -0
  159. package/dist/schema/conflict-target.js +129 -0
  160. package/dist/schema/conflict-target.js.map +1 -0
  161. package/dist/schema/ddl/base-strategy.d.ts.map +1 -1
  162. package/dist/schema/ddl/base-strategy.js +2 -2
  163. package/dist/schema/ddl/base-strategy.js.map +1 -1
  164. package/dist/schema/ddl/duckdb-strategy.d.ts.map +1 -1
  165. package/dist/schema/ddl/duckdb-strategy.js +2 -1
  166. package/dist/schema/ddl/duckdb-strategy.js.map +1 -1
  167. package/dist/schema/ddl/postgres-strategy.d.ts.map +1 -1
  168. package/dist/schema/ddl/postgres-strategy.js +12 -1
  169. package/dist/schema/ddl/postgres-strategy.js.map +1 -1
  170. package/dist/schema/generator.d.ts +307 -6
  171. package/dist/schema/generator.d.ts.map +1 -1
  172. package/dist/schema/generator.js +510 -87
  173. package/dist/schema/generator.js.map +1 -1
  174. package/dist/schema/index-utils.d.ts +120 -0
  175. package/dist/schema/index-utils.d.ts.map +1 -1
  176. package/dist/schema/index-utils.js +242 -1
  177. package/dist/schema/index-utils.js.map +1 -1
  178. package/dist/schema/index.d.ts +3 -0
  179. package/dist/schema/index.d.ts.map +1 -1
  180. package/dist/schema/index.js +4 -1
  181. package/dist/schema/live-parity.d.ts +90 -0
  182. package/dist/schema/live-parity.d.ts.map +1 -0
  183. package/dist/schema/live-parity.js +602 -0
  184. package/dist/schema/live-parity.js.map +1 -0
  185. package/dist/schema/manifest-schema.d.ts +121 -0
  186. package/dist/schema/manifest-schema.d.ts.map +1 -0
  187. package/dist/schema/manifest-schema.js +267 -0
  188. package/dist/schema/manifest-schema.js.map +1 -0
  189. package/dist/schema/schema-aggregator.d.ts +24 -10
  190. package/dist/schema/schema-aggregator.d.ts.map +1 -1
  191. package/dist/schema/schema-aggregator.js +35 -90
  192. package/dist/schema/schema-aggregator.js.map +1 -1
  193. package/dist/schema/system-table-shapes.d.ts +65 -0
  194. package/dist/schema/system-table-shapes.d.ts.map +1 -0
  195. package/dist/schema/system-table-shapes.js +187 -0
  196. package/dist/schema/system-table-shapes.js.map +1 -0
  197. package/dist/schema/types.d.ts +103 -4
  198. package/dist/schema/types.d.ts.map +1 -1
  199. package/dist/schema/utils.d.ts +2 -1
  200. package/dist/schema/utils.d.ts.map +1 -1
  201. package/dist/schema/utils.js +5 -3
  202. package/dist/schema/utils.js.map +1 -1
  203. package/dist/schema.js +4 -1
  204. package/dist/smrt-knowledge.json +20 -8
  205. package/dist/sync/apply.d.ts.map +1 -1
  206. package/dist/sync/apply.js +9 -16
  207. package/dist/sync/apply.js.map +1 -1
  208. package/dist/system/compatibility.d.ts +42 -0
  209. package/dist/system/compatibility.d.ts.map +1 -1
  210. package/dist/system/compatibility.js +182 -9
  211. package/dist/system/compatibility.js.map +1 -1
  212. package/dist/system/index.d.ts +1 -0
  213. package/dist/system/index.d.ts.map +1 -1
  214. package/dist/system/index.js +3 -2
  215. package/dist/system/retention.d.ts +237 -0
  216. package/dist/system/retention.d.ts.map +1 -0
  217. package/dist/system/retention.js +497 -0
  218. package/dist/system/retention.js.map +1 -0
  219. package/dist/system/schema.d.ts +100 -15
  220. package/dist/system/schema.d.ts.map +1 -1
  221. package/dist/system/schema.js +81 -45
  222. package/dist/system/schema.js.map +1 -1
  223. package/dist/system/types.d.ts +0 -2
  224. package/dist/system/types.d.ts.map +1 -1
  225. package/dist/testing/database.d.ts.map +1 -1
  226. package/dist/testing/database.js +1 -0
  227. package/dist/testing/database.js.map +1 -1
  228. package/dist/vite-plugin/index.d.ts.map +1 -1
  229. package/dist/vite-plugin/index.js +4 -9
  230. package/dist/vite-plugin/index.js.map +1 -1
  231. package/dist/vite-plugin/sveltekit-generator.d.ts.map +1 -1
  232. package/dist/vite-plugin/sveltekit-generator.js +71 -5
  233. package/dist/vite-plugin/sveltekit-generator.js.map +1 -1
  234. package/dist/vite-plugin/web-collections.d.ts.map +1 -1
  235. package/dist/vite-plugin/web-collections.js +6 -4
  236. package/dist/vite-plugin/web-collections.js.map +1 -1
  237. package/package.json +5 -5
@@ -0,0 +1,786 @@
1
+ # smrt-core/schema paths
2
+
3
+ Module semantics for `src/schema/` — which `SchemaGenerator` entry point reaches
4
+ a real database, what each one emits, and the rules that keep them in step.
5
+ Package orientation, the cross-module invariants, and the traps that apply
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.
12
+
13
+ ## Five entry points, two of which ship
14
+
15
+ `src/schema/generator.ts` exposes five index-emitting entry points. They do not
16
+ produce the same schema for the same class.
17
+
18
+ | Entry point | Selected by | Status |
19
+ |---|---|---|
20
+ | `generateSTISchemaFromManifest` | `src/scanner/manifest-generator.ts` | **production** |
21
+ | `generateCTISchemaFromManifest` | `src/scanner/manifest-generator.ts` | **production** |
22
+ | `generateSTISchemaFromRegistry` | `src/testing/database.ts` (`getTestDatabase()`), `src/schema/utils.ts` (`generateSchema`; `ensureSchema` only as a fallback) | tests + runtime helpers |
23
+ | `generateSchemaFromRegistry` | the same two callers | tests + runtime helpers |
24
+ | `generateSchema` (AST) | the `smrt:schema` virtual module, which has no consumer | dead (#2380) |
25
+
26
+ Production DDL takes the manifest route:
27
+
28
+ ```
29
+ @smrt() class ─▶ scanner ─▶ manifest.json ─▶ generate{STI,CTI}SchemaFromManifest
30
+ ─▶ registered `schema` ─▶ ObjectRegistry.getAllSchemasAsDefinitions()
31
+ ├─▶ smrt db:migrate | db:diff | db:status
32
+ │ (the CLI drives SchemaComparer + MigrationTracker directly)
33
+ └─▶ migrateSmrtSchemas() / getPendingSchemaStatements()
34
+ (src/migrations/orchestrate.ts — exported for programmatic
35
+ use; no in-repo caller outside its own tests)
36
+ ```
37
+
38
+ The suite takes the registry route. Before #2359 the registry route emitted
39
+ indexes the manifest route did not — per-column foreign-key indexes, and STI
40
+ partial FK indexes filtered by `_meta_type` — so tests ran against a richer
41
+ schema than any deployment received, the manifest STI path populated a
42
+ `fkColumnsByClass` map it never read, and the manifest CTI path had no FK loop
43
+ at all. `src/testing/database.ts`'s "same as migrations" comment described an
44
+ intent, not the code.
45
+
46
+ Since #2359 the two families share one set of index helpers and
47
+ `src/schema/schema-path-parity.test.ts` runs the same fixture manifest through
48
+ the manifest paths, through `ObjectRegistry.registerFromManifest()` + the
49
+ registry paths, and through `getAllSchemasAsDefinitions()`, asserting identical
50
+ column and index sets. Extend that fixture with every generator change; a
51
+ divergence is a bug in the generator, not an exception to add to the test.
52
+
53
+ ### Index rules (#2359)
54
+
55
+ - **Reference columns are always indexed.** `ensureReferenceColumnIndexes()`
56
+ runs last on every path and gives each `@foreignKey`, `@crossPackageRef` and
57
+ tenant column `<table>_<column>_idx` unless an UNQUALIFIED index (no `WHERE`,
58
+ no JSON path) already leads with it — the `conflictColumns` unique index or an
59
+ `indexed: true` opt-in, or the column's own inline UNIQUE. A partial
60
+ `WHERE _meta_type = …` index does not count: base-class polymorphic queries
61
+ carry no discriminator predicate. `indexed: true` on a reference column is
62
+ redundant. Roll the index wave out to production with
63
+ `smrt db:migrate --postgres-safe` (concurrent-index mode, #2362): a plain
64
+ atomic batch takes SHARE/ACCESS EXCLUSIVE locks for ~230 index builds. STI FK indexes are plain, one per
65
+ column, not per-class partial.
66
+ - **No index on the primary key.** `<table>_id_idx` is gone from every path,
67
+ and `conflictColumns` equal to the PK column set emit no conflict index
68
+ (`ON CONFLICT (id)` binds to the PK constraint). `SchemaComparer` drops the
69
+ legacy non-unique single-column PK index from existing databases without
70
+ `--drop-indexes` when the live table reports that column as its sole primary
71
+ key (never a UNIQUE one — on PostgreSQL that may back a custom-named PRIMARY
72
+ KEY constraint, and `DROP INDEX` on it would fail the atomic batch).
73
+ - **Slug loading keeps its index.** Custom `conflictColumns` replace the
74
+ `(slug, context)` unique index; `loadFromSlug()`/`getId()`/`getSavedId()`
75
+ still filter on slug/context, so a plain `<table>_slug_context_idx` is kept
76
+ (additive; routing those lookups through the conflict key would change which
77
+ row a slug resolves to). The tenant-led default key below counts as serving
78
+ it (`servesSlugLookup()`): a tenant-scoped slug lookup carries the tenant
79
+ predicate (#2365) and is served by the prefix, so no second index.
80
+ - **Tenant-scoped tables key per tenant (#2360).** A tenant-scoped class with
81
+ no explicit `conflictColumns` upserts on, and indexes,
82
+ `(tenant_id, slug, context)` — `(tenant_id, slug, context, _meta_type)` for
83
+ an STI hierarchy — resolved by one rule on both paths:
84
+ `ManifestGenerator.normalizeConflictColumns()` materializes it into
85
+ `decoratorConfig.conflictColumns` for the manifest paths (so the manifest,
86
+ the schema, `smrt-knowledge.json` and the runtime read one value), and
87
+ `ObjectRegistry.getConflictColumns()` derives the same value at runtime from
88
+ the schema owner's `tenantScoped` config (`ObjectRegistry.getTenantColumn()`;
89
+ an STI child resolves through its root; a `@report` class through its
90
+ group/bucket columns; a custom primary key through that key). Explicit
91
+ `conflictColumns` are never rewritten. `src/schema/conflict-target.ts` holds
92
+ the shared helpers. Consequences: the index NAME stays
93
+ `<table>_slug_context_idx` / `_slug_context_meta_type_idx`, so the differ
94
+ swaps the columns of an existing global unique in place by name (a superset
95
+ key — creating it cannot fail on existing rows); the tenant-led key also
96
+ serves the tenant column, so `<table>_tenant_id_idx` is no longer emitted
97
+ for those tables (an existing one is an orphan the differ drops only with
98
+ `--drop-indexes`); NULL-tenant rows (`mode: 'optional'` outside a tenant
99
+ context) dedup among themselves through the SDK's null-aware upsert
100
+ (`IS NOT DISTINCT FROM` under a PostgreSQL advisory lock / an in-process
101
+ lock on SQLite) — application-enforced now, where the old global index was
102
+ database-enforced: the tenant-led index treats NULLs as distinct, so raw SQL
103
+ can insert two global rows with one slug, and a raw
104
+ `ON CONFLICT (slug, context…)` against such a table no longer binds (use
105
+ `WHERE NOT EXISTS`, plus an advisory lock on PostgreSQL). Emitting
106
+ `NULLS NOT DISTINCT` on PostgreSQL ≥ 15 (the SDK already detects it) would
107
+ restore the database arbiter — a follow-up. The `save()` path serializes an
108
+ unset tenant field as an explicit `NULL` whatever its registered type,
109
+ because the SDK rejects an upsert whose conflict column is missing from the
110
+ row.
111
+ - **Rolling the tenant-led key out (#2360).** There is no mixed-version state:
112
+ new code against the old index fails every NEW-object create on a
113
+ tenant-scoped default-key table (PostgreSQL 42P10, SQLite "ON CONFLICT
114
+ clause does not match…"), and old code against the new index fails the same
115
+ way, because the conflict target must match the unique index's column set
116
+ exactly; only persisted objects (upsert on `id`) keep saving. Deploy the code
117
+ and run `smrt db:migrate` in the same maintenance step. The plan is one
118
+ `DROP INDEX` + `CREATE UNIQUE INDEX` per table under the SAME name (a
119
+ superset key, so the build cannot fail when the old same-name index was a
120
+ valid UNIQUE over the subset key; a #1165-class table whose old index was
121
+ non-unique or missing may hold duplicates that a superset UNIQUE rejects —
122
+ `db:diff` shows which tables' old index is non-unique or missing; dedupe
123
+ those rows before migrating). Atomic mode swaps every table in one
124
+ transaction: `DROP INDEX` takes ACCESS EXCLUSIVE and holds it until commit,
125
+ which blocks ALL access to those tables — reads included — for the batch;
126
+ size `statementTimeout` for the largest tenant-scoped table. That is the
127
+ maintenance window this rollout requires anyway (no mixed-version state), so
128
+ run this wave — the #2359 index wave included — in atomic mode inside it;
129
+ the "roll out with `--postgres-safe`" advice above applies to a #2359-only
130
+ wave, because `--postgres-safe` runs the two statements sequentially per
131
+ table, so each table has NO conflict index between them and a failed rebuild
132
+ leaves it without one until the re-run. The recreate has no automatic
133
+ DOWN: reverting the code means re-creating the old index by hand. And
134
+ legacy NULL-tenant rows fork rather than get adopted — a tenant-context save
135
+ whose slug matches a `(NULL, slug, ctx)` row now inserts `(tenant, slug,
136
+ ctx)` beside it, and that tenant no longer sees the legacy row — so backfill
137
+ `tenant_id` (anytown: `SET tenant_id = context::uuid`) BEFORE this release.
138
+ Ingestion that relied on natural-key dedup across tenants now inserts one
139
+ row per tenant (release note).
140
+ - **STI `@field({ unique: true })` is enforced through indexes** (the differ can
141
+ add an index to an existing table, never a column constraint): a full
142
+ `<table>_<col>_unique_idx` when the STI base declares it, one
143
+ `<table>_<col>_<class>_unique_idx WHERE _meta_type = '<qualified>'` per class
144
+ when only descendants do — uniqueness per concrete class, not across the
145
+ subtree. DuckDB/JSON have no partial indexes, so the descendant-scoped shape
146
+ (`isStiSubtypeUniqueIndex`) is not emitted there — degrading it to a full
147
+ UNIQUE would constrain every subtype; the DDL strategy and the differ both
148
+ skip it, while other partial indexes keep degrading to full ones as before. Remember the
149
+ framework serializes an unset text field as `''`, so a unique optional text
150
+ field must be `nullable: true` with a `null` initializer or every unset row
151
+ collides.
152
+ - **Every class in an STI hierarchy carries the schema of the one shared
153
+ table**, generated from the root base (`ManifestGenerator.generateSchemas()`
154
+ resolves the root through `findSTIBaseInfo`), so a child never treats its own
155
+ descendant-only unique field as base-declared.
156
+
157
+ `src/schema/utils.ts` sits in between, and the two exports differ:
158
+
159
+ - `generateSchema()` (reached from `SmrtCollection.generateSchema()`) always
160
+ rebuilds from the registry and writes the result back into the registry,
161
+ replacing whatever the manifest registered for that class.
162
+ - `ensureSchema()` (reached from the deprecated `smrt db:setup`) is
163
+ manifest-first: it takes `ObjectRegistry.getSchema()` plus the merged
164
+ `getAllSchemasAsDefinitions()` table definition, and only falls back to
165
+ `generateSchema()` when no schema is registered at all.
166
+
167
+ So a normal build keeps the manifest schema through `db:setup`, and a
168
+ registry-derived schema is a dev/test artifact. `smrt-content` shows what one
169
+ looks like: `packages/content/src/hooks.server.ts` `bootstrapSchema()` calls
170
+ `generateSchema()` for every registered class and then `ensureSchema()` from the
171
+ SvelteKit `handle` hook on any `/api/*` request, so that process holds
172
+ registry-derived schemas rather than the manifest ones. It reaches only that
173
+ package's own `vite dev` app — the library build excludes the file and the
174
+ package never exports it — but it is the shape to recognize. Check which route a
175
+ process actually took before trusting a reproduction.
176
+
177
+ ## Why the drift stayed invisible
178
+
179
+ Every drift oracle compares a database with the same artifact that dropped the
180
+ index:
181
+
182
+ - `verifyPersistenceTable()` (`src/schema/table-verifier.ts`) calls
183
+ `db.tableExists()` and nothing else. "Runtime verifies schema" has always meant
184
+ existence-only — no column, type, constraint, or index comparison.
185
+ - `smrt doctor` never opens a database connection.
186
+ - `db:status` and `db:diff` diff the live database against
187
+ `getAllSchemasAsDefinitions()`, i.e. the manifest projection.
188
+
189
+ An index the manifest never emitted is "in sync" by construction. That is how a
190
+ production database reached 164 unindexed `tenant_id` columns while `db:status`
191
+ reported no drift (#2356 → #2359). The assessment's other counts — 196/231
192
+ `@foreignKey` and 91/92 `@crossPackageRef` columns with no production index,
193
+ 238/238 tables carrying a redundant index on the primary key, zero DB-level
194
+ foreign-key constraints on any engine — come from regenerating every package's
195
+ schema against a live database, so re-measure rather than quote them once the
196
+ epic's fixes land.
197
+
198
+ ## Rules
199
+
200
+ ### 1. Verify against the production path, not the test path
201
+
202
+ Any change to column or index emission goes on **all** paths that ship and is
203
+ proven by the path-parity test (`src/schema/schema-path-parity.test.ts`, #2359)
204
+ — extend its fixture; a green suite otherwise proves the registry paths only.
205
+ Read the call graph before believing a comment: "same as migrations" was wrong
206
+ for years.
207
+
208
+ ### 2. Every new query predicate ships with its index
209
+
210
+ Collection methods, poll loops, auth lookups, junction right-side filters, and
211
+ polymorphic owner lookups all count — or write down why the predicate does not
212
+ need one. For list workloads, EXPLAIN on a PostgreSQL snapshot; the measured
213
+ spread on the assessed workload was 21 ms → 0.1 ms.
214
+
215
+ ### 3. Run the PostgreSQL lane
216
+
217
+ Anything touching numeric types, uuid casts, upsert conflict targets, timestamps,
218
+ or migrations runs the package's `test:postgres` script:
219
+
220
+ ```bash
221
+ pnpm --filter @happyvertical/smrt-<pkg> test:postgres
222
+ ```
223
+
224
+ core, cli, users, sales, marketing, analytics, and vitest carry the lane.
225
+ SQLite's type affinity accepts values PostgreSQL rejects — a money field declared
226
+ `number = 0` compiles to INTEGER and only fails on PG (#2361).
227
+
228
+ ### 4. Read the built artifact, not the source
229
+
230
+ What a decorator produced is in `dist/manifest.json` and in regenerated schemas:
231
+ `integer` vs `decimal`, the actual index list, the actual conflict columns. When
232
+ the question is "how many tables/columns/indexes", regenerate and count across
233
+ every package; do not sample a few and extrapolate.
234
+
235
+ ### 5. Index intent belongs on both the constraint and the read path
236
+
237
+ A conflict target is not automatically a unique index, and a unique index is not
238
+ automatically the index a read path uses. Custom `conflictColumns` used to
239
+ replace the `(slug, context)` index while `loadFromSlug`/`getId` still queried
240
+ slug+context, and STI dropped `@field({ unique: true })` — both fixed in #2359,
241
+ see "Index rules" above. Check the pair, not the declaration.
242
+
243
+ ### 6. Multi-tenancy is a whole-path property
244
+
245
+ Every unique constraint and every conflict target on a tenant-scoped table
246
+ includes the tenant column — otherwise a second tenant's `save()` of the same
247
+ natural key updates the first tenant's row through `DO UPDATE SET` (#2360; the
248
+ default key now does, see "Index rules" — an explicit `conflictColumns` that
249
+ omits the tenant column is the class author's own key and is not rewritten).
250
+ And every read path is interceptor-aware: hydration
251
+ (`loadFromId`/`loadFromSlug`), get-by-slug, vector search, and collection
252
+ memory, not only `list()` (#2365).
253
+
254
+ ### 7. Retry only transient errors
255
+
256
+ Classify through the cause chain (SQLSTATE), never on a message substring, and
257
+ never retry inside an aborted PostgreSQL transaction (`25P02`). Test the
258
+ contract end to end against a real database, not only the classifier (#2366).
259
+
260
+ ### 8. Thread new decorator options through every config-rebuild site
261
+
262
+ A new `@smrt()` or `@field()` option that affects schema must reach the
263
+ `SchemaGeneratorConfig` type in `src/schema/generator.ts` and every site that
264
+ rebuilds that config — `src/schema/utils.ts` and `src/testing/database.ts` — or
265
+ it is silently dropped on the paths that rebuild it (#2357).
266
+
267
+ ### 9. Delete or wire dead paths, and write docs to what the code does
268
+
269
+ Dead code that looks canonical misleads the next agent: the AST `generateSchema`
270
+ path, `SchemaOverrideSystem`, and the never-emitted `triggers: []` all read as
271
+ supported surfaces (#2380). Documentation follows the implementation, not the
272
+ intent — say "verifies the table exists" when that is what runs.
273
+
274
+ ### 10. Untracked "known limitation" comments are bugs nobody will read
275
+
276
+ File the issue and link it from the comment. A `products` comment explaining why
277
+ a conflict-column change was refrained from sat there for months — and
278
+ misdescribed the failure mode the whole time.
279
+
280
+ ### 11. Consumer repair scripts are signals
281
+
282
+ Downstream repair tooling (anytown's `db-repair-plan.ts` carried column-type
283
+ repairs, missing STI columns and indexes, and `tenant_id` backfills since April)
284
+ is the consumer-side record of framework gaps. Mine it during triage.
285
+
286
+ ### 12. Try to falsify before filing, and treat operations as correctness
287
+
288
+ Re-verify a finding at source before it becomes an issue — one assessment
289
+ candidate claimed conflict indexes past two columns were narrowed to two
290
+ columns, when only the index *name* is shortened. And an index fix that ships
291
+ without a bounded-timeout, `CONCURRENTLY`-capable migrate path can take
292
+ production down on rollout (#2362).
293
+
294
+ ### 13. Composite indexes are declared, not inferred (#2357)
295
+
296
+ The generated set only covers foreign keys, unique/conflict columns, the STI
297
+ discriminator, reference columns (#2359), the default list ordering (rule 18
298
+ below), and single columns opted in with `@field({ indexed: true })`. A list
299
+ workload's access path is composite, so declare it:
300
+
301
+ ```ts
302
+ @smrt({
303
+ indexes: [
304
+ { name: 'contents_tenant_id_publish_date_idx',
305
+ columns: ['tenantId', 'publish_date'] },
306
+ ],
307
+ })
308
+ ```
309
+
310
+ `columns` takes field names or column names in access-path order — filter
311
+ columns first, sort column last. Declare columns, not a direction: PostgreSQL
312
+ scans a btree either way, so an ascending index also serves the matching
313
+ `ORDER BY ... DESC` as an ordered scan with no Sort node. `unique` and `where`
314
+ (partial index) are honoured.
315
+
316
+ `appendDeclaredIndexes()` runs first on all five entry points, ahead of
317
+ `ensureDefaultListOrderingIndex()` (rule 18) and `ensureReferenceColumnIndexes()`,
318
+ so a declared composite leading with the tenant column (or any reference column)
319
+ replaces the automatic standalone index rather than duplicating it.
320
+ Unknown columns, malformed entries, and a name collision with a different index
321
+ all fail generation — a silently dropped index only surfaces later as a
322
+ production slowdown. Rule 8 above is why this works at runtime at all.
323
+
324
+ ### 14. Relationship targets resolve to a class name on both paths
325
+
326
+ `@foreignKey`/`@oneToMany`/`@manyToMany` accept a class, a name string, or a
327
+ `() => Target` thunk. The decorator invokes the thunk and throws when the target
328
+ cannot be resolved (never `related: ''`); the scanner unwraps the same thunk
329
+ from raw source (never `related: '() => Target'`). An unresolved target silently
330
+ costs the relationship edge, `loadRelated()`, and the FK-derived index (#2379).
331
+ A thunk resolves at decoration time, so a target declared later in the same
332
+ module is still in its temporal dead zone — use the string form there.
333
+
334
+ ### 15. A SQLite type change is a table rebuild (#2370)
335
+
336
+ SQLite has no `ALTER TABLE ... ALTER COLUMN ... TYPE`, so
337
+ `src/migrations/sqlite-rebuild.ts` answers a `type_upgrade` on SQLite with the
338
+ statement list SQLite's own docs prescribe: stage a new table under
339
+ `_smrt_rebuild_<table>`, copy, drop, rename, replay the indexes and triggers.
340
+ `SchemaComparer.compareTable` swaps that plan in for the differ's
341
+ "requires table recreation" placeholder, so `db:migrate` applies it inside the
342
+ normal atomic batch instead of exiting 1 forever.
343
+
344
+ Four properties of that module are load-bearing; keep them if you touch it:
345
+
346
+ - **The target shape comes from the live `sqlite_master` DDL**, retyping only
347
+ the drifted columns. It is not regenerated from the manifest, so the rebuild
348
+ never becomes an implicit `DROP COLUMN`, and it preserves table constraints,
349
+ `CHECK`s, and `WITHOUT ROWID`/`STRICT`.
350
+ - **The rebuild is hoisted ahead of the table's other column changes.** Its
351
+ staging DDL and copy list are captured at diff time, and the differ emits
352
+ changes in manifest field order, so a new field declared above the retyped
353
+ one would otherwise run `ALTER TABLE ... ADD COLUMN` first and have the
354
+ rebuild silently drop it — both statements succeed and the batch commits.
355
+ Rebuild first, then add columns to the rebuilt table.
356
+ - **The copy carries no `CAST`.** SQLite applies the destination column's
357
+ affinity on insert — the same conversion a fresh table performs. An explicit
358
+ cast is worse: non-numeric TEXT cast to REAL/INTEGER silently becomes `0`,
359
+ and an ISO timestamp cast to NUMERIC-affinity `DATETIME` becomes its year.
360
+ - **It refuses when any table has a foreign key onto the target and
361
+ `PRAGMA foreign_keys` is ON** (the SMRT adapter's default). `DROP TABLE`
362
+ performs an implicit `DELETE FROM` that fires `ON DELETE CASCADE` on
363
+ children, and `defer_foreign_keys` defers constraint *checks*, not FK
364
+ *actions* — verified: the child rows go. The target's own self-reference
365
+ counts, because the staging table copies that clause and becomes a child of
366
+ the table being dropped (verified: a two-row self-referencing table finishes
367
+ the rebuild holding one row). Such a column stays manual drift.
368
+ - **`PRAGMA legacy_alter_table` brackets the rename**, because SQLite ≥ 3.25
369
+ re-parses the schema on `ALTER TABLE ... RENAME` and a view still pointing at
370
+ the just-dropped table makes it fail outright. It is restored immediately
371
+ after; a rolled-back batch leaves it set on that connection, which is inert
372
+ here only because nothing else in SMRT renames a table.
373
+
374
+ All the drifted columns of one table share a single rebuild: the first change
375
+ carries the plan and the rest become `no change needed` comments that the CLI
376
+ classifies as no-ops.
377
+
378
+ ## What the differ compares (#2369)
379
+
380
+ `SchemaComparer` (`src/migrations/differ.ts`) compares each manifest column's
381
+ type, then — unless the type itself is drifting — its nullability and default,
382
+ and always reports what it will not touch:
383
+
384
+ - **Strengthening** (`SET NOT NULL`, `SET DEFAULT`) is executable on
385
+ PostgreSQL/DuckDB. `SET NOT NULL` is preceded by an `UPDATE … WHERE c IS NULL`
386
+ backfill of the manifest default; without a default the live data is probed
387
+ and, if NULLs exist, the change is reported (comment SQL + `advisory`) instead
388
+ of emitting an ALTER that would abort the atomic batch.
389
+ - **Relaxing** (`DROP NOT NULL`, `DROP DEFAULT`) is a report-only advisory until
390
+ the caller passes `relaxColumns` (`db:migrate --relax-columns`). The manifest
391
+ can be under-specified (#2372 registration-order weakness), so a live column
392
+ that is stricter than the manifest is never weakened silently.
393
+ - **Orphans** — DB columns absent from the manifest, DB tables no manifest
394
+ declares (`SchemaDiff.orphan_tables`), and unclaimed `*_key` unique constraint
395
+ indexes — are always reported. A NOT NULL orphan without a default is a
396
+ `warning` advisory (every ORM insert fails on it); `includeDroppedColumns`
397
+ (`--drop-columns`) drops it, `relaxColumns` relaxes it. Advisory-only changes
398
+ carry no SQL, never reach the tracker, and do not fail `db:migrate`.
399
+ - **ADD COLUMN** is planned per engine: DuckDB rejects every inline constraint
400
+ (add with `DEFAULT`, then `SET NOT NULL`, `CREATE UNIQUE INDEX`); SQLite
401
+ rejects inline `UNIQUE` (separate `CREATE UNIQUE INDEX <table>_<col>_key`, the
402
+ PostgreSQL constraint-index name, so the orphan sweep leaves it alone) and
403
+ `NOT NULL` without a default on a populated table; PostgreSQL keeps constraints
404
+ inline. DuckDB has no `ADD CONSTRAINT`, so the separate index is the only
405
+ way to add uniqueness there; the bundled DuckDB 1.4.x resolves
406
+ `ON CONFLICT (col)` through that index (the old #12684 limitation the DuckDB
407
+ strategy's `requiresInlineUnique()` note describes no longer reproduces —
408
+ the #2369 DuckDB test pins the upsert), older DuckDB builds may not. A required column with no default is enforced only on an empty table;
409
+ on a populated one it is added nullable and the `NOT NULL` is reported as a
410
+ manual follow-up on every engine.
411
+ - **SQLite** has no `ALTER COLUMN`: nullability/default alterations are manual
412
+ (comment SQL → `db:migrate` exit 1). The #2370 rebuild (rule 15) consumes
413
+ only `type_upgrade` placeholders today; extending it to rewrite constraints
414
+ would lift this.
415
+ - Defaults compare through `canonicalizeDefault()`, which folds engine
416
+ renderings (`'x'::text`, `CAST('t' AS BOOLEAN)`, `CURRENT_TIMESTAMP` vs
417
+ `now()`) by manifest type; an unclassifiable rendering skips the comparison
418
+ rather than risking a false positive that would churn every run. The
419
+ round-trip test (create from each DDL strategy → compare → zero changes) in
420
+ `src/migrations/__tests__/issue-2369-*.test.ts` guards this.
421
+
422
+ ### 16. `schema.ddl` is a preview, not the table
423
+
424
+ `SchemaDefinition.ddl` / `manifest.json` `schema.ddl` is the engine-neutral
425
+ CREATE TABLE string from `SchemaGenerator.generateSQL()` with no engine: no
426
+ indexes, no triggers, abstract `REAL`/`JSON`/`UUID`/`TIMESTAMP`. It is kept for
427
+ backward compatibility only. Everything that needs an executable table renders
428
+ `columns` + `indexes` through `getDDLStrategy(engine)` — `db:migrate`
429
+ (`migrations/orchestrate.ts`), `MigrationGenerator` (default
430
+ `materializeStructuredSchema: true`; `false` is a deprecated opt-out),
431
+ `SchemaAggregator`, and `createIsolatedTestDbFromManifest` in smrt-vitest, the
432
+ last two via `src/schema/manifest-schema.ts` (`collectManifestTables` /
433
+ `renderCollectedManifestTable`). The cached string is merged in only for a
434
+ table whose contributors expose no structured columns (hand-authored
435
+ manifests); table constraints that exist only in the string are dropped with a
436
+ warning, as `db:migrate` drops them. Do not add a new consumer of the
437
+ string, and do not write a private CREATE INDEX renderer — the retired ones
438
+ dropped `where` and `jsonPath` (#2358). Every DDL strategy also spells out
439
+ `PRIMARY KEY NOT NULL`: SQLite lets a bare non-INTEGER PRIMARY KEY hold NULL.
440
+
441
+ ### 17. The merged table shape is registration-order independent (#2372)
442
+
443
+ `getAllSchemas()` and `getAllSchemasAsDefinitions()` fold every class that
444
+ shares a physical table — the whole STI hierarchy — into one shape. Both route
445
+ through `buildMergedTableSchemas()`, which groups contributors by table and
446
+ then merges them in a **deterministic** order: the STI base first, then
447
+ ancestors before descendants, then by qualified name.
448
+
449
+ That order matters because the first contributor seeds the table: it supplies
450
+ the fallback base columns, the `idType`, the conflict columns and the cached
451
+ DDL, and its columns win every merge conflict. When registration order decided
452
+ it, an STI child that carries no manifest `schema` — the external- and
453
+ consumer-manifest case — seeded the table from bare fallback columns and the
454
+ base class's richer ones were skipped when it registered later, yielding
455
+ `context TEXT` instead of `context TEXT NOT NULL DEFAULT ''` and timestamps
456
+ with no NOT NULL/DEFAULT. The shipped content manifest lists `Article` before
457
+ `Content`, so the losing order was the one that shipped, and the differ
458
+ compares types only, so the weak fresh-create was never repaired.
459
+
460
+ Two invariants keep the two assembly paths agreeing:
461
+
462
+ - `createBaseColumns()` mirrors what `generateSchemaFromManifest` /
463
+ `generateSTISchemaFromManifest` emit for the same table, so a table built
464
+ from runtime field metadata alone has the same NOT NULL/DEFAULT shape as one
465
+ built from a manifest. Note `_meta_type` is `TEXT NOT NULL` with **no**
466
+ default, matching the generator.
467
+ - `fieldsToColumns()` reads `required`, `default`, and `description` from the
468
+ top level *or* `_meta`. Registry fields normalize them into `_meta`
469
+ (`manifest-field-merge.ts`), so reading only the top level silently dropped
470
+ NOT NULL and DEFAULT for every registry-sourced field.
471
+
472
+ STI columns stay nullable regardless of the field's `required` flag
473
+ (`fieldsToColumns(fields, { stiUnionColumns: true })`): the table holds the
474
+ union of all subtypes' fields, so a column only one subtype declares is never
475
+ populated on a sibling's row. Declared defaults are still emitted. This matches
476
+ `generateSTISchemaFromManifest`, which sets `notNull: false` on every non-system
477
+ STI column.
478
+
479
+ When adding a class-level input to the merged shape, take it from the seeding
480
+ contributor rather than "whichever class arrives first", and cover it with a
481
+ child-first/base-first equality test.
482
+
483
+ ### 18. The generator owns the index for its own default ordering (#2363)
484
+
485
+ Every generated list surface — REST, MCP, the SvelteKit list route — pages with
486
+ `ORDER BY created_at DESC, <pk> ASC` (`DEFAULT_LIST_ORDER_BY`, #2367), and
487
+ until #2363 no schema path indexed `created_at` (the dead AST path indexed
488
+ `updated_at`), so the framework's own default page was a sequential scan plus a
489
+ top-N sort. `ensureDefaultListOrderingIndex()` now runs on all five paths and
490
+ emits:
491
+
492
+ - `(<tenant column>, created_at)` on a tenant-scoped table — the tenancy
493
+ interceptor puts `tenant_id = ?` in front of every list, so the tenant column
494
+ leads and `created_at` orders within it. This composite **replaces** the
495
+ standalone tenant index from #2359: a B-tree serves every prefix of its
496
+ column list, so `ensureDefaultListOrderingIndex()` is called first and
497
+ `ensureReferenceColumnIndexes()` then sees the column as already served. The
498
+ tenant column is found by `referenceKind === 'tenantId'`, never by the
499
+ `tenant_id` spelling — `@smrt({ tenantScoped: { field } })` renames it.
500
+ - `(created_at)` otherwise.
501
+
502
+ Three deliberate omissions, so nobody "fixes" them later:
503
+
504
+ - **No `DESC`.** `IndexDefinition` carries no per-column direction and
505
+ PostgreSQL scans a B-tree backwards just as cheaply.
506
+ - **No primary-key tiebreak column.** The default order mixes directions
507
+ (`created_at DESC, id ASC`), so no single-direction index satisfies the whole
508
+ key; the leading columns already turn a full sort into an index scan plus an
509
+ incremental sort over rows sharing a timestamp.
510
+ - **Not scoped per STI subtype.** `(_meta_type, created_at)` would serve a
511
+ child collection's list but not the base class's polymorphic one, which
512
+ carries no discriminator predicate — the same reasoning that keeps STI
513
+ reference indexes plain (#2359). One unqualified index per shared table.
514
+
515
+ An existing UNQUALIFIED index that already leads with the same columns
516
+ suppresses it — a partial or JSON-path index never counts. That is how a
517
+ declared `@smrt({ indexes: [...] })` composite (#2357) takes over: declaring
518
+ `(tenant_id, created_at, status)` replaces the generated pair, while declaring
519
+ a different sort column such as `(tenant_id, publish_date)` sits **beside** it,
520
+ because that index cannot order the default page. Declared indexes are appended
521
+ before this helper for exactly that reason; anything that appends an index in
522
+ future goes in the same slot, ahead of `ensureDefaultListOrderingIndex()` and
523
+ `ensureReferenceColumnIndexes()`.
524
+
525
+ ### 19. One conflict-target rule, applied on every producer
526
+
527
+ `save()` upserts on `ObjectRegistry.getConflictColumns()`; the schema must
528
+ carry exactly one unique index over those columns (or they must be the
529
+ primary key). Keep the derivation in `src/schema/conflict-target.ts` and let
530
+ every producer call it — the three manifest pipelines share
531
+ `ManifestGenerator.applyGenerationPasses()` since #2360 because
532
+ `ManifestBuilder` had silently skipped the report passes for months. When you
533
+ add a way for the key to vary (a new decorator option, a new class kind),
534
+ thread it through `getConflictColumns()`, `normalizeConflictColumns()` and the
535
+ generator's `resolveConflictTarget()` together, and extend the parity test's
536
+ "unique index == conflict target" assertion; a key the runtime uses and the
537
+ schema does not index is a hard PostgreSQL error (42P10) on the first save,
538
+ and a key the schema indexes without the tenant column is the silent
539
+ cross-tenant overwrite this rule exists for.
540
+
541
+ ### 20. Every generated index name is length-guarded before it leaves a path (#2374)
542
+
543
+ PostgreSQL truncates any identifier past 63 **bytes** and reports nothing;
544
+ SQLite and DuckDB do not, so the entire test suite was blind to it. The 66-byte
545
+ `content_contribution_revisions_contribution_id_revision_number_idx` shipped
546
+ that way — only the differ's signature-equivalence check kept it from emitting
547
+ `add_index` on every run. Two names agreeing for 63 bytes is the real hazard:
548
+ `CREATE INDEX IF NOT EXISTS` no-ops against the wrong index, and the second
549
+ index is never created.
550
+
551
+ `schema/index-utils.ts` owns the guard, and it splits by who owns the name:
552
+
553
+ - **Generated index, trigger and PL/pgSQL function names** →
554
+ `shortenIdentifier()`. Deterministic `<head>_<digest><suffix>`, digest taken
555
+ over the **full** original so a shared prefix still yields distinct names, and
556
+ a recognised suffix (`_idx`, `_unique_idx`, `_key`, `_pkey`) preserved.
557
+ - **Hand-declared `@smrt({ indexes: [{ name }] })`** → `assertIdentifierFits()`,
558
+ a hard error in `validateDeclaredIndex()`. Renaming what a developer wrote is
559
+ worse than refusing it, and `SchemaComparer` matches indexes **by name**
560
+ first, so a 70-byte declaration could never match the 63-byte index
561
+ PostgreSQL stored and `db:migrate` would emit `add_index` forever.
562
+ - **Table and column names** → deliberately **not** guarded. PostgreSQL
563
+ truncates identifiers *consistently on every reference*: `CREATE TABLE
564
+ "<80 bytes>"` and a later `SELECT ... FROM "<the same 80 bytes>"` both resolve
565
+ to the same stored 63-byte name, so one long name round-trips fine end to end.
566
+ `smrt-users` depends on this — it ships an intentional 80-byte
567
+ `@smrt({ tableName })` (`permission_policy_table_name_that_is_far_too_long…`)
568
+ and derives unique Postgres RLS policy names from it. An earlier revision of
569
+ this rule hard-errored here on the theory that the runtime resolves tables by
570
+ name and would break; that theory is wrong for the reason above, and the error
571
+ broke `packages/users`. The residual collision risk is over a name the
572
+ developer chose, not one the generator manufactured.
573
+
574
+ `enforceIdentifierLimits()` is the single call site per path, placed **after**
575
+ `ensureReferenceColumnIndexes()` — nothing may lengthen a name after it. Doing
576
+ the shortening at the end rather than at each `indexes.push()` is safe because
577
+ the digest covers the whole original name, so entries distinct before shortening
578
+ stay distinct after; the helper still throws if two ever collide. The migrate
579
+ leg's `withConflictIndex()` (`registry/schema-builder.ts`) and the PostgreSQL
580
+ trigger-function name call `shortenIdentifier()` directly, because they compose
581
+ a name outside the generator's index list. Note that an over-long *table* name
582
+ still yields in-limit, distinct *index* names, because the shortening runs over
583
+ the whole composed name.
584
+
585
+ The digest is FNV-1a, not `node:crypto`: `index-utils.ts` is re-exported from
586
+ `schema/utils.ts`, which exists to keep Node built-ins out of browser bundles.
587
+ It only has to be *stable* — a shortened name that changed between releases
588
+ would make every deployment drop and recreate the index — so the parity and
589
+ unit tests pin the literal output rather than recomputing it. Unpaired
590
+ surrogates are folded to U+FFFD before both counting and hashing, so the digest
591
+ is taken over exactly the bytes the driver transmits.
592
+
593
+ Existing databases migrate **by name swap, without a rebuild**: the live index
594
+ still carries the name PostgreSQL truncated it to, the manifest now carries the
595
+ shortened one, and the differ claims it by signature (columns + uniqueness +
596
+ predicate), emitting nothing — including under `includeDroppedIndexes`. See
597
+ `migrations/__tests__/index-drift.test.ts` and the PostgreSQL lane test
598
+ `schema/issue-2374-identifier-length-postgres.optional.test.ts`.
599
+
600
+ Out of scope, deliberately: constraint names PostgreSQL invents for itself. A
601
+ CTI table's inline `UNIQUE` produces an implicit `<table>_<column>_key`, which
602
+ can exceed 63 bytes even when the table and column each fit. SMRT never names
603
+ it, and PostgreSQL disambiguates its own truncations by appending a counter
604
+ rather than collapsing them, so there is no silent-collision hazard there.
605
+
606
+ ### 21. The `_smrt_` prefix does not mean "system table" (#2376)
607
+
608
+ `bootstrapSystemTables()` owns nine hand-written tables; ~25 more `_smrt_*`
609
+ tables belong to `@smrt()` models and are created by `db:migrate` (feature
610
+ flags, prompt overrides, subscription plans, report schedules, field policies,
611
+ jobs). Never classify by prefix — use `SYSTEM_TABLE_NAMES`
612
+ (`schema/system-table-shapes.ts`, derived from the DDL parse) plus
613
+ `FRAMEWORK_OPERATIONAL_TABLES` / `RETIRED_SYSTEM_TABLES` in `system/schema.ts`.
614
+ The change-feed writer skipped by prefix, so clients syncing those domain
615
+ tables through `_changes` never saw an update.
616
+
617
+ Editing `ALL_SYSTEM_TABLES` requires bumping `SMRT_SCHEMA_VERSION` *and*
618
+ appending to `SMRT_SCHEMA_DDL_CHECKSUMS` — the version gates the DDL replay, so
619
+ without a bump no existing database ever applies the change. A new **column**
620
+ additionally needs an `addColumnIfMissing()` entry in `system/compatibility.ts`
621
+ (`CREATE TABLE IF NOT EXISTS` is a no-op on an existing table).
622
+ `system-schema-evolution.test.ts` enforces both, and asserts a legacy database
623
+ upgrades to exactly the shape a fresh install gets.
624
+
625
+ `_smrt_jobs` / `_smrt_job_events` are dual-owned: `db:migrate` creates them,
626
+ the compatibility pass reshapes them. On a fresh install bootstrap runs first,
627
+ so their pass is deferred — `ensureDeferredSystemTableCompatibility()` re-runs
628
+ until the tables exist, then stamps a `<version>+deferred-compat` marker. It
629
+ runs OUTSIDE the bootstrap lock and swallows its own failures: those statements
630
+ target tables the framework does not own, and inside the PostgreSQL transaction
631
+ one failure would roll back system-table creation with it. Only
632
+ `ensureBootstrapSystemTableCompatibility()` (the tables the DDL itself creates)
633
+ belongs inside the lock.
634
+
635
+ Reconciling `_smrt_jobs.task_id` uniqueness reads the live index catalog, which
636
+ is implemented for PostgreSQL and SQLite only; DuckDB and the JSON adapter keep
637
+ the redundant compat index rather than risk dropping the one that enforces the
638
+ upsert conflict target. When reading a PostgreSQL catalog array, cast it
639
+ (`attname::text`) and parse both shapes — a driver with no parser registered for
640
+ the array OID returns the raw `{a,b}` literal, and reading that as "no columns"
641
+ silently inverts an index-existence decision.
642
+
643
+ ## Referential integrity lives in `delete()`, not in the DDL
644
+
645
+ No schema path emits a `FOREIGN KEY` clause on any engine — grep `schema/ddl/*`,
646
+ `schema-manager.ts` and `differ.ts` for `REFERENCES` and you get nothing. That is
647
+ a deliberate position, not an oversight: emitting constraints changes delete
648
+ semantics for every consumer, requires topological table ordering in both
649
+ migrate paths (neither orders today), and needs a plan for the orphans already in
650
+ production databases. Emitting them is tracked separately.
651
+
652
+ What `@foreignKey(..., { onDelete })` therefore means is *application* behaviour,
653
+ applied by `SmrtObject.delete()` through `src/cascade.ts` (#2371):
654
+
655
+ | Reference | Default when `onDelete` is absent |
656
+ |---|---|
657
+ | Column is part of the referencing class's `conflictColumns`, and is not a `@tenantId()` field | `CASCADE` |
658
+ | Polymorphic `(metaType, metaId)` association row | `CASCADE` |
659
+ | Anything else, including every `@tenantId()` field | `NO ACTION` — the row is left alone |
660
+
661
+ The natural-key rule is what cleans junction rows up without any per-package
662
+ annotation: a junction declares
663
+ `@smrt({ conflictColumns: ['content_id', 'asset_id', 'relationship'] })`, so the
664
+ row is *identified* by the content and cannot outlive it. An ordinary child
665
+ (`Order.customerId`) is keyed by `(slug, context)` and keeps its pre-#2371
666
+ behaviour unless it opts in explicitly.
667
+
668
+ **`@tenantId()` is excluded even though it lands in `conflictColumns`.**
669
+ #2360 leads every tenant-scoped class's *default* natural key with the
670
+ tenant column, so without this exclusion, deleting one `Tenant` row would
671
+ recursively CASCADE through every tenant-scoped table in the schema that has
672
+ not declared its own `conflictColumns` — the overwhelming majority. The
673
+ tenant column scopes ownership; it does not identify the row the way a
674
+ junction's foreign key does. Detected via the `__tenancy.isTenantIdField`
675
+ marker on `FieldMeta` (`smrt-core` reads it structurally so it never depends
676
+ on `smrt-tenancy`). `@tenantId()` exposes no `onDelete` option today, so
677
+ this cannot currently be overridden per field — found in review before this
678
+ landed (originally reachable, untested, and undocumented).
679
+
680
+ Properties to keep if you touch that module:
681
+
682
+ - **The plan is registry-derived and rebuilt per delete.** Registration is
683
+ incremental — manifests load lazily and tests register classes between cases —
684
+ so a cached plan would silently skip a table that registered later. Cache it
685
+ only behind an invalidation hook that every registration path calls.
686
+ - **A class with nothing pointing at it skips the transaction entirely — but
687
+ `CascadePlan.isEmpty` requires no polymorphic association class anywhere in
688
+ the process, not just no typed references.** `buildCascadePlan()` pushes
689
+ *every* registered `SmrtPolymorphicAssociation` subclass into
690
+ `plan.polymorphic` unconditionally (`cascade.ts` around
691
+ `isPolymorphicAssociationClass`): a `metaType` column can point at any class
692
+ at runtime, so there is no static metadata to scope it by the target being
693
+ deleted. One registered polymorphic class anywhere makes `isEmpty` false for
694
+ every delete in that process — do not read "the common case skips the
695
+ transaction" as "most deletes in a real app skip it"; in a multi-package app
696
+ that registers even one polymorphic association, almost none do.
697
+ `runCascadeDelete()` builds the plan for `getResolvedQualifiedName()` (not the
698
+ bare constructor name — two packages can register the same simple name).
699
+ - **Cascaded rows are removed set-based.** Their `beforeDelete`/`afterDelete`
700
+ hooks and interceptors do not run and no change-feed tombstone is written for
701
+ them, which is exactly what a DB-level `ON DELETE CASCADE` does. Only the
702
+ object `delete()` was called on runs the lifecycle. Do not "improve" this into
703
+ a per-row model delete without deciding what that means for sync consumers.
704
+ - **Everything is one transaction where the adapter has one**, including the
705
+ object's own `DELETE`, whenever there is anything to cascade. The `RESTRICT`
706
+ checks run first, before any mutation, so a refusal costs nothing; the
707
+ transaction is what makes a refusal *deeper* in the graph safe.
708
+ - **`_smrt_embeddings` and `_smrt_contexts` are matched by id *and* a
709
+ class-name candidate set, not id alone.** Their class columns store the
710
+ *runtime* constructor name, which for an STI hierarchy is a concrete
711
+ subclass rather than the class the cascade planned from — id-alone matching
712
+ looked STI-safe, but let two unrelated classes using `idType: 'text'`
713
+ (non-UUID, not guaranteed globally unique) collide on a shared id value and
714
+ delete each other's rows (review fix). `ownerClassCandidates()` expands to
715
+ every STI hierarchy member of the class the ids actually belong to, in both
716
+ qualified and simple form. A failure to clean them is logged, never raised —
717
+ an application database may predate the table, and losing derived rows must
718
+ not fail a valid delete.
719
+
720
+ `_smrt_changes`, `_smrt_ai_usage`, `_smrt_signals` and the dispatch tables are
721
+ deliberately **not** cascaded. They are append-only logs; the change feed in
722
+ particular receives the delete's own tombstone, so cascading it would erase the
723
+ record that tells sync clients the row is gone.
724
+
725
+ ### 22. System tables get a retention policy, not just a prune function (#2375)
726
+
727
+ Four framework-owned tables grow with traffic and nothing used to remove a row:
728
+ `_smrt_changes` (one per save/delete), `_smrt_ai_usage` (one per AI call, and
729
+ persistence is on by default), `_smrt_contexts` (whose `expires_at` nothing
730
+ enforced) and `_smrt_dispatch` (an operator-only `dispatch:cleanup`).
731
+ `src/system/retention.ts` is now the single place that bounds them.
732
+
733
+ - **`runRetentionSweep(db, policy)` is the entry point.** It runs the four
734
+ built-in tasks in a fixed order, then every task other packages contributed
735
+ via `registerRetentionTask()` — `@happyvertical/smrt-jobs` registers
736
+ `_smrt_jobs`/`_smrt_job_events`, `@happyvertical/smrt-users` registers
737
+ session/magic-link/CLI-auth expiry. A task that throws is recorded on its own
738
+ result and the sweep continues; a missing table reports `unavailable`, so a
739
+ sweep is safe against a partially bootstrapped database.
740
+ - **A contributed task only exists in a process that loaded its package.** Both
741
+ packages register on import from their entry point, and the registry lives on
742
+ `globalThis` (like `ObjectRegistry`) so a duplicated `smrt-core` resolution
743
+ cannot split it. `smrt db:prune` optionally imports both packages for exactly
744
+ this reason — a project that installs neither correctly gets neither task.
745
+ - **Defaults are opt-out, not opt-in.** `DEFAULT_RETENTION_POLICY` covers the
746
+ four built-in tables (changes 30 days, AI usage 90 days, dispatch 30 days
747
+ completed / 90 days failed, contexts strictly by their own `expires_at`), and
748
+ those are the ones `smrt.configure({ retention })` tunes. Contributed tasks
749
+ carry their own defaults and their own window options —
750
+ `DEFAULT_JOB_RETENTION` (7 days terminal / 30 days failed / 30 days events,
751
+ set through `registerJobRetentionTasks()` or the runner's `retention.jobs`),
752
+ and expired credentials, which have no window because an expired credential
753
+ has nothing worth retaining. Every task, built-in or contributed, can be
754
+ turned off: a table set to `false`, a task set to `false` under `tasks`, or
755
+ `enabled: false` for the whole sweep — through `smrt.configure`,
756
+ `smrt db:prune --skip`, or the runner's `retention` config.
757
+ - **Contributed task names are prefixed with the owning package's short name**
758
+ (`jobs-records`, `jobs-events`, `users-sessions`, …) because the registry is
759
+ one process-global namespace.
760
+ - **Scheduling lives outside core.** A running `TaskRunner` sweeps every six
761
+ hours (`retention: false` opts out) and `smrt db:prune` is the cron entry
762
+ point. The first runner sweep is one interval after `start()`, never at
763
+ start: a crash-looping worker must not become a delete loop.
764
+ - **Every prune counts before it deletes.** `rowCount` is not reliably
765
+ populated across the engines SMRT supports, so counting is both what gives a
766
+ usable figure and what lets `dryRun` preview the *same* predicate rather than
767
+ an approximation of it. Count and delete are two statements and deliberately
768
+ not one transaction — a maintenance pass must not hold a write lock over a
769
+ large delete — so the figure is approximate under concurrent writers. Where
770
+ two bounds can select the same row (`pruneChangeFeed`, `pruneAiUsage`), the
771
+ second bound excludes what the first already accounted for, so a dry run does
772
+ not count an entry twice.
773
+ - **Every retention predicate ships with its index** (rule 2 applies to
774
+ maintenance SQL too): `_smrt_contexts(expires_at)`,
775
+ `_smrt_ai_usage(tenant_id, created_at)` — which is also the subscriptions
776
+ billing meter's range scan — `_smrt_dispatch(status, processed_at)` and
777
+ `(status, updated_at)` come from the system DDL, so they reach existing
778
+ databases through the `SMRT_SCHEMA_VERSION` bump that replays it.
779
+ `_smrt_jobs(status, completed_at)` comes from
780
+ `ensureJobsSystemTableCompatibility()` instead, because `_smrt_jobs` is
781
+ generated from a decorated class and does not exist yet when bootstrap runs;
782
+ the jobs collection calls that path on every `initialize()`.
783
+ - **Expiry enforcement is prune-side only.** `recall()`/`recallAll()` keep
784
+ their documented "expiry is not applied at read time" contract — changing it
785
+ would change read semantics for existing callers, which is a different issue
786
+ from bounding storage. `LearningMemory` filters expired rows itself.