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