@happyvertical/smrt-core 0.40.70 → 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.
- package/AGENTS.md +2 -2
- package/agents/change-feed.md +1 -1
- package/agents/schema-paths.md +487 -29
- package/dist/browser.js +5 -4
- package/dist/cascade.d.ts +120 -0
- package/dist/cascade.d.ts.map +1 -0
- package/dist/cascade.js +430 -0
- package/dist/cascade.js.map +1 -0
- package/dist/change-feed.d.ts +34 -2
- package/dist/change-feed.d.ts.map +1 -1
- package/dist/change-feed.js +52 -11
- package/dist/change-feed.js.map +1 -1
- package/dist/class.d.ts +36 -3
- package/dist/class.d.ts.map +1 -1
- package/dist/class.js +59 -5
- package/dist/class.js.map +1 -1
- package/dist/collection-cache.js +0 -0
- package/dist/collection-cache.js.map +1 -1
- package/dist/collection.d.ts +29 -0
- package/dist/collection.d.ts.map +1 -1
- package/dist/collection.js +66 -14
- package/dist/collection.js.map +1 -1
- package/dist/config.d.ts +10 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js.map +1 -1
- package/dist/decorators/index.d.ts +63 -5
- package/dist/decorators/index.d.ts.map +1 -1
- package/dist/decorators/index.js +37 -5
- package/dist/decorators/index.js.map +1 -1
- package/dist/dispatch/collections/Dispatches.d.ts.map +1 -1
- package/dist/dispatch/collections/Dispatches.js +19 -4
- package/dist/dispatch/collections/Dispatches.js.map +1 -1
- package/dist/dispatch/types.d.ts +5 -0
- package/dist/dispatch/types.d.ts.map +1 -1
- package/dist/embedded-write-queue.d.ts +46 -0
- package/dist/embedded-write-queue.d.ts.map +1 -0
- package/dist/embedded-write-queue.js +66 -0
- package/dist/embedded-write-queue.js.map +1 -0
- package/dist/embeddings/storage.d.ts +7 -0
- package/dist/embeddings/storage.d.ts.map +1 -1
- package/dist/embeddings/storage.js +29 -12
- package/dist/embeddings/storage.js.map +1 -1
- package/dist/errors.d.ts +1 -1
- package/dist/hierarchical.js +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -6
- package/dist/interceptors.d.ts +21 -0
- package/dist/interceptors.d.ts.map +1 -1
- package/dist/interceptors.js +27 -1
- package/dist/interceptors.js.map +1 -1
- package/dist/manifest/generator.d.ts.map +1 -1
- package/dist/manifest/generator.js +4 -7
- package/dist/manifest/generator.js.map +1 -1
- package/dist/manifest/static-manifest.js +10 -10
- package/dist/manifest/static-manifest.js.map +1 -1
- package/dist/manifest/store.js +1 -1
- package/dist/manifest/store.js.map +1 -1
- package/dist/manifest.json +19 -19
- package/dist/migrations/differ.d.ts +9 -1
- package/dist/migrations/differ.d.ts.map +1 -1
- package/dist/migrations/differ.js +10 -5
- package/dist/migrations/differ.js.map +1 -1
- package/dist/migrations/index.d.ts +1 -0
- package/dist/migrations/index.d.ts.map +1 -1
- package/dist/migrations/index.js +2 -1
- package/dist/migrations/minor-units.d.ts +162 -0
- package/dist/migrations/minor-units.d.ts.map +1 -0
- package/dist/migrations/minor-units.js +381 -0
- package/dist/migrations/minor-units.js.map +1 -0
- package/dist/migrations.js +2 -1
- package/dist/object.d.ts +98 -5
- package/dist/object.d.ts.map +1 -1
- package/dist/object.js +138 -30
- package/dist/object.js.map +1 -1
- package/dist/query-bounds.d.ts +5 -1
- package/dist/query-bounds.d.ts.map +1 -1
- package/dist/query-bounds.js +5 -1
- package/dist/query-bounds.js.map +1 -1
- package/dist/registry/class-registration.d.ts.map +1 -1
- package/dist/registry/class-registration.js +3 -1
- package/dist/registry/class-registration.js.map +1 -1
- package/dist/registry/manifest-field-merge.d.ts +12 -0
- package/dist/registry/manifest-field-merge.d.ts.map +1 -1
- package/dist/registry/manifest-field-merge.js +14 -2
- package/dist/registry/manifest-field-merge.js.map +1 -1
- package/dist/registry/schema-builder.d.ts +22 -1
- package/dist/registry/schema-builder.d.ts.map +1 -1
- package/dist/registry/schema-builder.js +205 -165
- package/dist/registry/schema-builder.js.map +1 -1
- package/dist/registry/types.d.ts +3 -2
- package/dist/registry/types.d.ts.map +1 -1
- package/dist/registry.d.ts +41 -46
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +61 -83
- package/dist/registry.js.map +1 -1
- package/dist/scanner/manifest-generator.d.ts +45 -0
- package/dist/scanner/manifest-generator.d.ts.map +1 -1
- package/dist/scanner/manifest-generator.js +92 -28
- package/dist/scanner/manifest-generator.js.map +1 -1
- package/dist/schema/conflict-target.d.ts +104 -0
- package/dist/schema/conflict-target.d.ts.map +1 -0
- package/dist/schema/conflict-target.js +129 -0
- package/dist/schema/conflict-target.js.map +1 -0
- package/dist/schema/ddl/duckdb-strategy.d.ts.map +1 -1
- package/dist/schema/ddl/duckdb-strategy.js +2 -1
- package/dist/schema/ddl/duckdb-strategy.js.map +1 -1
- package/dist/schema/ddl/postgres-strategy.d.ts.map +1 -1
- package/dist/schema/ddl/postgres-strategy.js +12 -1
- package/dist/schema/ddl/postgres-strategy.js.map +1 -1
- package/dist/schema/generator.d.ts +252 -16
- package/dist/schema/generator.d.ts.map +1 -1
- package/dist/schema/generator.js +411 -106
- package/dist/schema/generator.js.map +1 -1
- package/dist/schema/index-utils.d.ts +120 -0
- package/dist/schema/index-utils.d.ts.map +1 -1
- package/dist/schema/index-utils.js +242 -1
- package/dist/schema/index-utils.js.map +1 -1
- package/dist/schema/index.d.ts +1 -1
- package/dist/schema/index.d.ts.map +1 -1
- package/dist/schema/index.js +2 -2
- package/dist/schema/live-parity.d.ts.map +1 -1
- package/dist/schema/live-parity.js +4 -2
- package/dist/schema/live-parity.js.map +1 -1
- package/dist/schema/system-table-shapes.d.ts +13 -0
- package/dist/schema/system-table-shapes.d.ts.map +1 -1
- package/dist/schema/system-table-shapes.js +14 -1
- package/dist/schema/system-table-shapes.js.map +1 -1
- package/dist/schema/utils.d.ts +1 -1
- package/dist/schema/utils.d.ts.map +1 -1
- package/dist/schema/utils.js +3 -3
- package/dist/schema/utils.js.map +1 -1
- package/dist/schema.js +2 -2
- package/dist/smrt-knowledge.json +9 -9
- package/dist/system/compatibility.d.ts +42 -0
- package/dist/system/compatibility.d.ts.map +1 -1
- package/dist/system/compatibility.js +182 -9
- package/dist/system/compatibility.js.map +1 -1
- package/dist/system/index.d.ts +1 -0
- package/dist/system/index.d.ts.map +1 -1
- package/dist/system/index.js +3 -2
- package/dist/system/retention.d.ts +237 -0
- package/dist/system/retention.d.ts.map +1 -0
- package/dist/system/retention.js +497 -0
- package/dist/system/retention.js.map +1 -0
- package/dist/system/schema.d.ts +100 -15
- package/dist/system/schema.d.ts.map +1 -1
- package/dist/system/schema.js +81 -45
- package/dist/system/schema.js.map +1 -1
- package/dist/system/types.d.ts +0 -2
- package/dist/system/types.d.ts.map +1 -1
- package/dist/testing/database.d.ts.map +1 -1
- package/dist/testing/database.js.map +1 -1
- package/dist/vite-plugin/index.d.ts.map +1 -1
- package/dist/vite-plugin/index.js +4 -9
- package/dist/vite-plugin/index.js.map +1 -1
- package/package.json +4 -4
package/AGENTS.md
CHANGED
|
@@ -213,8 +213,8 @@ Production DDL comes from the **manifest** paths
|
|
|
213
213
|
**registry** paths feed `getTestDatabase()` and emit foreign-key indexes
|
|
214
214
|
production never gets: the suite runs on a richer schema than it ships.
|
|
215
215
|
|
|
216
|
-
- Change column/index emission on every shipping path, proven by
|
|
217
|
-
test (#2359
|
|
216
|
+
- Change column/index emission on every shipping path, proven by the path-parity
|
|
217
|
+
test `src/schema/schema-path-parity.test.ts` (#2359; index rules in the module doc). A "same as migrations" comment is a claim to check.
|
|
218
218
|
- Every new query predicate ships with its index, or a reason it doesn't.
|
|
219
219
|
- Numeric types, uuid casts, conflict targets, timestamps, migrations: run the
|
|
220
220
|
`test:postgres` lane — SQLite affinity accepts what PostgreSQL rejects.
|
package/agents/change-feed.md
CHANGED
|
@@ -13,4 +13,4 @@ Adapter-agnostic change-observation spine (`src/change-feed.ts`) — the server
|
|
|
13
13
|
- `getChangesSince(db, { since, tables?, tenantId?, limit? }) → { changes, cursor, resyncRequired?, resyncCursor? }`: strictly monotonic cursor; polling with returned cursors misses no committed change and never repeats one. A cursor that cannot be served incrementally — pruned below the retained `[floor..horizon]` run, or foreign/ahead of the horizon — gets `resyncRequired: true` with empty `changes`, an unadvanced `cursor`, and `resyncCursor` set to the current horizon so clients can full-refetch then resume incrementally; detection runs on the UNFILTERED log so `tables`/`tenantId` filters never trigger or mask it. `getTenantScopedChangesSince()` resolves tenant via the DispatchBus resolver hook (fail-closed: tenancy on + no context → global rows only; tenant `T` sees `T` + global rows, never another tenant).
|
|
14
14
|
- `getTableVersion(db, table) → number`: the per-table change version (`MAX(seq)` for the table, replica-stable — no per-process divergence), the ETag source for zero-query conditional GETs (#1765). Advances on any framework write to the table (CRUD and sync-apply, which all `save()`/`delete()`). A table with no retained entry of its own falls back to the global horizon (never a resettable low value) so an all-pruned table cannot false-304 a stale client; only 0 when the feed is empty.
|
|
15
15
|
- Generated `_changes` routes: REST (`GET {basePath}/_changes`, requires `authMiddleware`, otherwise 401 — per-model `api.public` does NOT apply) and SvelteKit (`{routesDir}/_changes/+server.ts`, requires an authenticated principal on `locals`; opt out via `sveltekit.changesRoute.enabled: false`). Query params: `since`, `tables` (comma-separated), `limit`. Responses stay HTTP 200 in the resync state — `resyncRequired` is protocol state, not an error, and `resyncCursor` is the resume cursor after the client completes a full refetch.
|
|
16
|
-
- Retention: `pruneChangeFeed(db, { maxAgeMs?, maxRows? })` —
|
|
16
|
+
- Retention: `pruneChangeFeed(db, { maxAgeMs?, maxRows?, dryRun? })` — scheduled since #2375 by `runRetentionSweep()` (30-day default), so nothing needs to call it directly; `dryRun` counts the same predicate instead of deleting. Pruning deletes oldest-first and always retains the newest entry (a non-empty feed is never emptied), which is what makes pruned-cursor detection provable and keeps caught-up consumers polling normally. Raw-SQL writes are invisible to the feed (same documented gap as the #1499 cache); `bumpChangeFeed(db, { table, rowId? })` is the manual escape hatch.
|
package/agents/schema-paths.md
CHANGED
|
@@ -35,18 +35,124 @@ Production DDL takes the manifest route:
|
|
|
35
35
|
use; no in-repo caller outside its own tests)
|
|
36
36
|
```
|
|
37
37
|
|
|
38
|
-
The suite takes the registry route
|
|
39
|
-
manifest route
|
|
40
|
-
indexes filtered by `_meta_type
|
|
41
|
-
than any deployment
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
registry paths and
|
|
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.
|
|
50
156
|
|
|
51
157
|
`src/schema/utils.ts` sits in between, and the two exports differ:
|
|
52
158
|
|
|
@@ -94,10 +200,10 @@ epic's fixes land.
|
|
|
94
200
|
### 1. Verify against the production path, not the test path
|
|
95
201
|
|
|
96
202
|
Any change to column or index emission goes on **all** paths that ship and is
|
|
97
|
-
proven by
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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.
|
|
101
207
|
|
|
102
208
|
### 2. Every new query predicate ships with its index
|
|
103
209
|
|
|
@@ -129,17 +235,21 @@ every package; do not sample a few and extrapolate.
|
|
|
129
235
|
### 5. Index intent belongs on both the constraint and the read path
|
|
130
236
|
|
|
131
237
|
A conflict target is not automatically a unique index, and a unique index is not
|
|
132
|
-
automatically the index a read path uses. Custom `conflictColumns`
|
|
133
|
-
`(slug, context)` index while `loadFromSlug`/`getId` still
|
|
134
|
-
STI
|
|
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.
|
|
135
242
|
|
|
136
243
|
### 6. Multi-tenancy is a whole-path property
|
|
137
244
|
|
|
138
245
|
Every unique constraint and every conflict target on a tenant-scoped table
|
|
139
246
|
includes the tenant column — otherwise a second tenant's `save()` of the same
|
|
140
|
-
natural key updates the first tenant's row through `DO UPDATE SET` (#2360
|
|
141
|
-
|
|
142
|
-
|
|
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).
|
|
143
253
|
|
|
144
254
|
### 7. Retry only transient errors
|
|
145
255
|
|
|
@@ -183,10 +293,10 @@ production down on rollout (#2362).
|
|
|
183
293
|
|
|
184
294
|
### 13. Composite indexes are declared, not inferred (#2357)
|
|
185
295
|
|
|
186
|
-
The generated set only covers foreign keys, unique/conflict columns,
|
|
187
|
-
|
|
188
|
-
with `@field({ indexed: true })`. A list
|
|
189
|
-
so declare it:
|
|
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:
|
|
190
300
|
|
|
191
301
|
```ts
|
|
192
302
|
@smrt({
|
|
@@ -203,9 +313,10 @@ scans a btree either way, so an ascending index also serves the matching
|
|
|
203
313
|
`ORDER BY ... DESC` as an ordered scan with no Sort node. `unique` and `where`
|
|
204
314
|
(partial index) are honoured.
|
|
205
315
|
|
|
206
|
-
`appendDeclaredIndexes()` runs on all five entry points,
|
|
207
|
-
`
|
|
208
|
-
|
|
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.
|
|
209
320
|
Unknown columns, malformed entries, and a name collision with a different index
|
|
210
321
|
all fail generation — a silently dropped index only surfaces later as a
|
|
211
322
|
production slowdown. Rule 8 above is why this works at runtime at all.
|
|
@@ -326,3 +437,350 @@ warning, as `db:migrate` drops them. Do not add a new consumer of the
|
|
|
326
437
|
string, and do not write a private CREATE INDEX renderer — the retired ones
|
|
327
438
|
dropped `where` and `jsonPath` (#2358). Every DDL strategy also spells out
|
|
328
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.
|
package/dist/browser.js
CHANGED
|
@@ -3,13 +3,13 @@ import { DEFAULT_AI_COST_RATES, estimateAiUsageCost } from "./adapters/cost-rate
|
|
|
3
3
|
import { MetricsAdapter } from "./adapters/metrics.js";
|
|
4
4
|
import { PubSubAdapter } from "./adapters/pubsub.js";
|
|
5
5
|
import "./adapters/index.js";
|
|
6
|
+
import { classifyDatabaseError, classifyDialectMessage, isAbortedTransactionError, isDeterministicDatabaseError, isNotNullViolationError, isTransientDatabaseError, isUniqueViolationError } from "./db-errors.js";
|
|
7
|
+
import { AIError, ConfigurationError, DatabaseError, ErrorUtils, FilesystemError, NetworkError, RuntimeError, SmrtError, TenantIsolationError, ValidationError, ValidationReport, ValidationUtils } from "./errors.js";
|
|
6
8
|
import { config } from "./config.js";
|
|
7
9
|
import { SignalSanitizer } from "./signals/sanitizer.js";
|
|
8
10
|
import { SignalBus } from "./signals/bus.js";
|
|
9
|
-
import { assertPostgresSystemTimestampsCurrent, ensureDispatchSubscriptionsSystemTableCompatibility, ensureDispatchSystemTableCompatibility, ensureJobEventsSystemTableCompatibility, ensureJobsSystemTableCompatibility, ensureLegacySystemTableCompatibility, getDatabaseEngine, migratePostgresSystemTimestamps, planPostgresSystemTimestampMigrations, tableExists } from "./system/compatibility.js";
|
|
11
|
+
import { assertPostgresSystemTimestampsCurrent, ensureBootstrapSystemTableCompatibility, ensureDeferredSystemTableCompatibility, ensureDispatchSubscriptionsSystemTableCompatibility, ensureDispatchSystemTableCompatibility, ensureJobEventsSystemTableCompatibility, ensureJobsSystemTableCompatibility, ensureLegacySystemTableCompatibility, getDatabaseEngine, migratePostgresSystemTimestamps, planPostgresSystemTimestampMigrations, tableExists } from "./system/compatibility.js";
|
|
10
12
|
import { SmrtClass } from "./class.js";
|
|
11
|
-
import { classifyDatabaseError, classifyDialectMessage, isAbortedTransactionError, isDeterministicDatabaseError, isNotNullViolationError, isTransientDatabaseError, isUniqueViolationError } from "./db-errors.js";
|
|
12
|
-
import { AIError, ConfigurationError, DatabaseError, ErrorUtils, FilesystemError, NetworkError, RuntimeError, SmrtError, TenantIsolationError, ValidationError, ValidationReport, ValidationUtils } from "./errors.js";
|
|
13
13
|
import { convertTypeToJsonSchema, generateToolFromMethod, generateToolManifest, shouldIncludeMethod } from "./tools/tool-generator.js";
|
|
14
14
|
import { executeToolCall, executeToolCalls, formatToolResults, validateToolCall } from "./tools/tool-executor.js";
|
|
15
15
|
import { SmrtObject } from "./object.js";
|
|
@@ -20,6 +20,7 @@ import { SmrtHierarchical } from "./hierarchical.js";
|
|
|
20
20
|
import { SmrtJunction } from "./junction.js";
|
|
21
21
|
import { SmrtPolymorphicAssociation } from "./polymorphic-association.js";
|
|
22
22
|
import "./signals/index.js";
|
|
23
|
+
import { DEFAULT_RETENTION_POLICY, clearRetentionTasks, getRetentionTasks, pruneAiUsage, pruneExpiredContexts, registerRetentionTask, runRetentionSweep, unregisterRetentionTask } from "./system/retention.js";
|
|
23
24
|
import "./system/index.js";
|
|
24
25
|
import "./tools/index.js";
|
|
25
|
-
export { AIError, AiUsageCollector, AiUsagePersistenceHandler, ConfigurationError, DEFAULT_AI_COST_RATES, DatabaseError, ErrorUtils, FilesystemError, MetricsAdapter, NetworkError, ObjectRegistry, PubSubAdapter, RuntimeError, SMRT_COLLECTION_BASE_NAMES, SignalBus, SignalSanitizer, SmrtClass, SmrtCollection, SmrtError, SmrtHierarchical, SmrtJunction, SmrtObject, SmrtPolymorphicAssociation, TenantIsolationError, ValidationError, ValidationReport, ValidationUtils, assertPostgresSystemTimestampsCurrent, classifyDatabaseError, classifyDialectMessage, config, convertTypeToJsonSchema, ensureDispatchSubscriptionsSystemTableCompatibility, ensureDispatchSystemTableCompatibility, ensureJobEventsSystemTableCompatibility, ensureJobsSystemTableCompatibility, ensureLegacySystemTableCompatibility, estimateAiUsageCost, executeToolCall, executeToolCalls, formatToolResults, generateToolFromMethod, generateToolManifest, getDatabaseEngine, isAbortedTransactionError, isDeterministicDatabaseError, isNotNullViolationError, isSmrtCollectionExtendsName, isTransientDatabaseError, isUniqueViolationError, migratePostgresSystemTimestamps, planPostgresSystemTimestampMigrations, shouldIncludeMethod, smrt, smrt as smrtRegistry, tableExists, validateToolCall };
|
|
26
|
+
export { AIError, AiUsageCollector, AiUsagePersistenceHandler, ConfigurationError, DEFAULT_AI_COST_RATES, DEFAULT_RETENTION_POLICY, DatabaseError, ErrorUtils, FilesystemError, MetricsAdapter, NetworkError, ObjectRegistry, PubSubAdapter, RuntimeError, SMRT_COLLECTION_BASE_NAMES, SignalBus, SignalSanitizer, SmrtClass, SmrtCollection, SmrtError, SmrtHierarchical, SmrtJunction, SmrtObject, SmrtPolymorphicAssociation, TenantIsolationError, ValidationError, ValidationReport, ValidationUtils, assertPostgresSystemTimestampsCurrent, classifyDatabaseError, classifyDialectMessage, clearRetentionTasks, config, convertTypeToJsonSchema, ensureBootstrapSystemTableCompatibility, ensureDeferredSystemTableCompatibility, ensureDispatchSubscriptionsSystemTableCompatibility, ensureDispatchSystemTableCompatibility, ensureJobEventsSystemTableCompatibility, ensureJobsSystemTableCompatibility, ensureLegacySystemTableCompatibility, estimateAiUsageCost, executeToolCall, executeToolCalls, formatToolResults, generateToolFromMethod, generateToolManifest, getDatabaseEngine, getRetentionTasks, isAbortedTransactionError, isDeterministicDatabaseError, isNotNullViolationError, isSmrtCollectionExtendsName, isTransientDatabaseError, isUniqueViolationError, migratePostgresSystemTimestamps, planPostgresSystemTimestampMigrations, pruneAiUsage, pruneExpiredContexts, registerRetentionTask, runRetentionSweep, shouldIncludeMethod, smrt, smrt as smrtRegistry, tableExists, unregisterRetentionTask, validateToolCall };
|