@happyvertical/smrt-core 0.42.6 → 0.43.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (134) hide show
  1. package/AGENTS.md +39 -43
  2. package/agents/collection-reads.md +40 -0
  3. package/agents/data-query.md +21 -0
  4. package/agents/latest-related.md +35 -0
  5. package/agents/memory.md +16 -0
  6. package/agents/schema-paths.md +29 -15
  7. package/dist/browser.js +2 -2
  8. package/dist/cascade.d.ts +7 -6
  9. package/dist/cascade.d.ts.map +1 -1
  10. package/dist/cascade.js +19 -21
  11. package/dist/cascade.js.map +1 -1
  12. package/dist/class.d.ts +17 -0
  13. package/dist/class.d.ts.map +1 -1
  14. package/dist/class.js +30 -0
  15. package/dist/class.js.map +1 -1
  16. package/dist/collection.d.ts +164 -3
  17. package/dist/collection.d.ts.map +1 -1
  18. package/dist/collection.js +345 -10
  19. package/dist/collection.js.map +1 -1
  20. package/dist/decorators/index.d.ts +33 -15
  21. package/dist/decorators/index.d.ts.map +1 -1
  22. package/dist/decorators/index.js +16 -11
  23. package/dist/decorators/index.js.map +1 -1
  24. package/dist/generators/rest.d.ts.map +1 -1
  25. package/dist/generators/rest.js +10 -2
  26. package/dist/generators/rest.js.map +1 -1
  27. package/dist/generators/tool-schema.d.ts +14 -0
  28. package/dist/generators/tool-schema.d.ts.map +1 -1
  29. package/dist/generators/tool-schema.js +13 -1
  30. package/dist/generators/tool-schema.js.map +1 -1
  31. package/dist/index.d.ts +1 -0
  32. package/dist/index.d.ts.map +1 -1
  33. package/dist/index.js +3 -2
  34. package/dist/interceptors.d.ts +2 -1
  35. package/dist/interceptors.d.ts.map +1 -1
  36. package/dist/interceptors.js.map +1 -1
  37. package/dist/manifest/static-manifest.d.ts.map +1 -1
  38. package/dist/manifest/static-manifest.js +53 -1
  39. package/dist/manifest/static-manifest.js.map +1 -1
  40. package/dist/manifest/store.js +1 -1
  41. package/dist/manifest/store.js.map +1 -1
  42. package/dist/manifest.json +62 -1
  43. package/dist/migrations/differ.d.ts +2 -0
  44. package/dist/migrations/differ.d.ts.map +1 -1
  45. package/dist/migrations/differ.js +75 -0
  46. package/dist/migrations/differ.js.map +1 -1
  47. package/dist/migrations/generator.d.ts.map +1 -1
  48. package/dist/migrations/generator.js +19 -2
  49. package/dist/migrations/generator.js.map +1 -1
  50. package/dist/migrations/orchestrate.d.ts.map +1 -1
  51. package/dist/migrations/orchestrate.js +4 -1
  52. package/dist/migrations/orchestrate.js.map +1 -1
  53. package/dist/object.d.ts +4 -4
  54. package/dist/object.js +4 -4
  55. package/dist/object.js.map +1 -1
  56. package/dist/prebuild/index.d.ts.map +1 -1
  57. package/dist/prebuild/index.js +9 -0
  58. package/dist/prebuild/index.js.map +1 -1
  59. package/dist/registry/schema-builder.d.ts +6 -0
  60. package/dist/registry/schema-builder.d.ts.map +1 -1
  61. package/dist/registry/schema-builder.js +78 -8
  62. package/dist/registry/schema-builder.js.map +1 -1
  63. package/dist/registry.d.ts +5 -7
  64. package/dist/registry.d.ts.map +1 -1
  65. package/dist/registry.js +6 -8
  66. package/dist/registry.js.map +1 -1
  67. package/dist/runtime/types.d.ts +2 -2
  68. package/dist/runtime/types.d.ts.map +1 -1
  69. package/dist/scanner/manifest-generator.d.ts.map +1 -1
  70. package/dist/scanner/manifest-generator.js +62 -18
  71. package/dist/scanner/manifest-generator.js.map +1 -1
  72. package/dist/scanner/types.d.ts +5 -1
  73. package/dist/scanner/types.d.ts.map +1 -1
  74. package/dist/scanner/types.js.map +1 -1
  75. package/dist/schema/ddl/base-strategy.d.ts.map +1 -1
  76. package/dist/schema/ddl/base-strategy.js +2 -0
  77. package/dist/schema/ddl/base-strategy.js.map +1 -1
  78. package/dist/schema/ddl/duckdb-strategy.d.ts +1 -0
  79. package/dist/schema/ddl/duckdb-strategy.d.ts.map +1 -1
  80. package/dist/schema/ddl/duckdb-strategy.js +17 -1
  81. package/dist/schema/ddl/duckdb-strategy.js.map +1 -1
  82. package/dist/schema/ddl/postgres-strategy.js +1 -1
  83. package/dist/schema/foreign-key-ddl.d.ts +9 -0
  84. package/dist/schema/foreign-key-ddl.d.ts.map +1 -0
  85. package/dist/schema/foreign-key-ddl.js +53 -0
  86. package/dist/schema/foreign-key-ddl.js.map +1 -0
  87. package/dist/schema/foreign-key-planner.d.ts +18 -0
  88. package/dist/schema/foreign-key-planner.d.ts.map +1 -0
  89. package/dist/schema/foreign-key-planner.js +109 -0
  90. package/dist/schema/foreign-key-planner.js.map +1 -0
  91. package/dist/schema/foreign-key-policy.d.ts +21 -0
  92. package/dist/schema/foreign-key-policy.d.ts.map +1 -0
  93. package/dist/schema/foreign-key-policy.js +41 -0
  94. package/dist/schema/foreign-key-policy.js.map +1 -0
  95. package/dist/schema/generator.d.ts +3 -0
  96. package/dist/schema/generator.d.ts.map +1 -1
  97. package/dist/schema/generator.js +54 -10
  98. package/dist/schema/generator.js.map +1 -1
  99. package/dist/schema/index.d.ts +3 -0
  100. package/dist/schema/index.d.ts.map +1 -1
  101. package/dist/schema/index.js +4 -1
  102. package/dist/schema/manifest-schema.d.ts.map +1 -1
  103. package/dist/schema/manifest-schema.js +20 -3
  104. package/dist/schema/manifest-schema.js.map +1 -1
  105. package/dist/schema/schema-aggregator.d.ts +7 -1
  106. package/dist/schema/schema-aggregator.d.ts.map +1 -1
  107. package/dist/schema/schema-aggregator.js +54 -9
  108. package/dist/schema/schema-aggregator.js.map +1 -1
  109. package/dist/schema/schema-manager.d.ts +1 -6
  110. package/dist/schema/schema-manager.d.ts.map +1 -1
  111. package/dist/schema/schema-manager.js +26 -30
  112. package/dist/schema/schema-manager.js.map +1 -1
  113. package/dist/schema/types.d.ts +8 -5
  114. package/dist/schema/types.d.ts.map +1 -1
  115. package/dist/schema/utils.d.ts.map +1 -1
  116. package/dist/schema/utils.js +14 -1
  117. package/dist/schema/utils.js.map +1 -1
  118. package/dist/schema.js +4 -1
  119. package/dist/smrt-knowledge.json +63 -8
  120. package/dist/testing/database.d.ts +11 -1
  121. package/dist/testing/database.d.ts.map +1 -1
  122. package/dist/testing/database.js +80 -9
  123. package/dist/testing/database.js.map +1 -1
  124. package/dist/vite-plugin/index.d.ts.map +1 -1
  125. package/dist/vite-plugin/index.js +12 -3
  126. package/dist/vite-plugin/index.js.map +1 -1
  127. package/dist/vite-plugin/sveltekit-generator.d.ts.map +1 -1
  128. package/dist/vite-plugin/sveltekit-generator.js +24 -3
  129. package/dist/vite-plugin/sveltekit-generator.js.map +1 -1
  130. package/dist/vite-plugin/web-collections.d.ts +3 -1
  131. package/dist/vite-plugin/web-collections.d.ts.map +1 -1
  132. package/dist/vite-plugin/web-collections.js +24 -4
  133. package/dist/vite-plugin/web-collections.js.map +1 -1
  134. package/package.json +4 -4
package/AGENTS.md CHANGED
@@ -18,7 +18,8 @@ subsystem you are editing. This file keeps what holds across all of them.
18
18
  | `src/change-signals.ts` + the generated `_events` SSE route | the push companion to the change feed — the signal bus, cross-replica fan-out, the SSE route, and its documented gaps | [agents/change-signals.md](agents/change-signals.md) |
19
19
  | `src/generators/` + `src/vite-plugin/web-collections.ts` | REST/CLI/MCP/web-collection generation, the `manifestHash` emission sites, and generated conditional-GET / ETag v2 semantics | [agents/generators.md](agents/generators.md) |
20
20
  | `src/schema/` | the four `SchemaGenerator` entry points, which two reach production, why schema drift stayed invisible, and the #2382 index/tenancy rules | [agents/schema-paths.md](agents/schema-paths.md) |
21
- | `src/data-query.ts` | canonical bounded data-query normalizer: allowlisted fields, deterministic fingerprints, output validation, and transport-neutral envelope (#2444) | |
21
+ | `src/data-query.ts` | canonical bounded data-query normalizer and transport-neutral envelope (#2444) | [agents/data-query.md](agents/data-query.md) |
22
+ | `src/collection.ts` | bounded collection reads, projections, latest-related hydration, facets, counts, and read plans | [agents/collection-reads.md](agents/collection-reads.md) |
22
23
 
23
24
  ## SmrtObject Lifecycle
24
25
 
@@ -37,7 +38,8 @@ subsystem you are editing. This file keeps what holds across all of them.
37
38
  `_smrt_contexts` plus optional injected semantic search. `capture()` reinforces
38
39
  successes and decays failures while updating outcome counters; `recall()`
39
40
  applies confidence, expiry, time-decay, and hierarchical-scope filters and
40
- refreshes `last_used_at`. Keep semantic search behind the
41
+ refreshes `last_used_at`. Detailed persistence and search semantics are in
42
+ [agents/memory.md](agents/memory.md). Keep semantic search behind the
41
43
  `SmrtCollection.semanticSearch`-compatible injection boundary.
42
44
 
43
45
  ## SmrtCollection Query
@@ -49,13 +51,8 @@ await collection.list({
49
51
  });
50
52
  ```
51
53
 
52
- Projection primitive (#1902): pass `select: ['id', 'title', 'tenantId']` to
53
- `list()` when an admin/list workflow needs compact rows. `select` uses SMRT
54
- field names, maps them to DB columns internally, and returns plain objects keyed
55
- by the same SMRT field names without hydrating `SmrtObject` instances. It
56
- composes with `where`, `orderBy`, `limit`, and `offset`; `beforeList`
57
- interceptors still run. It is for column-backed fields only and cannot combine
58
- with `include`/relationship eager loading.
54
+ Projection, latest-related, facets, counts, and bounded read plans are
55
+ documented in [agents/collection-reads.md](agents/collection-reads.md).
59
56
 
60
57
  `list()` and `query()` hydrate model instances serially in result order because
61
58
  an `initialize()` hook may query through the same transaction-bound PostgreSQL
@@ -79,43 +76,18 @@ operator against a database to keep the two in step.
79
76
 
80
77
  STI child collections auto-filter by `_meta_type`. Query bounds — `LIMIT 1` on `get()`, the `limit`/`offset` parser, the `orderBy` whitelist and sensitive/permission refusals, and the deterministic generated-list ordering (#2367) — are in [agents/query-bounds.md](agents/query-bounds.md).
81
78
 
82
- ## Bounded Collection Read Plans
83
-
84
- Use `executeCollectionReadPlan()` when one operation needs several independent
85
- collections. It bounds top-level `collection.list()` concurrency while keeping
86
- all reads on the normal registry/collection path. Callers must choose an
87
- explicit positive `maxConcurrency` and pass their normal shared
88
- `collectionOptions` when database or tenant context matters.
89
-
90
- The executor deliberately does not compose SQL, cache the plan, or change pool
91
- defaults. On failure it stops starting queued entries, drains operations already
92
- in flight, and rethrows the first error.
93
-
94
79
  ## Canonical Bounded Data Queries (#2444)
95
80
 
96
- `normalizeDataQueryRequest()` and `normalizeDataQueryResult()` are the trust
97
- boundary for the transport-neutral table/report/content query envelope. An
98
- authenticated adapter supplies a trusted `DataQuerySchema`; the caller only
99
- gets its declared projectable/sortable/filterable/facetable fields. The helpers
100
- never execute a query or decide tenant/principal access.
101
-
102
- Use `createDataQueryFingerprint()` for cache and result correlation. It omits
103
- the request id and page position, canonicalizes equivalent filter/projection/
104
- facet forms, and adds the identity sort tie-break. Keep data-query values
105
- scalar, requests/pages/facets positive and bounded, results within the schema
106
- byte cap with declared field types preserved. Datetimes must be valid RFC 3339
107
- instants, identity fields must be string/number/datetime-compatible, and JSON
108
- result fields are depth/container/string/byte bounded before cloning. Return
109
- only normalized `DataQueryResult` envelopes to REST, MCP,
110
- WebMCP, and browser consumers. Adapter-specific report/content context wraps
111
- the base envelope; it does not add unsafe fields or SQL-like controls to it.
81
+ The normalizers and fingerprint are the trust boundary for the
82
+ transport-neutral query envelope; full bounds, schema, and output rules live in
83
+ [agents/data-query.md](agents/data-query.md). Adapters own tenant/principal
84
+ access and query execution.
112
85
 
113
86
  ## Object Memory & Semantic Search
114
87
 
115
- Two persistence primitives every `SmrtObject`/`SmrtCollection` inherits load-bearing for learning agents, usable by any object. Full guide: `docs/content/core.md` → "Context Memory System".
116
-
117
- - **Context memory** (`remember`/`recall`/`recallAll`/`forget`/`forgetScope`, table `_smrt_contexts`): stores any JSON value keyed by `(owner_class, owner_id, scope, key, version)` with a `confidence` score (0–1) and a stored `expiresAt` (metadata — `recall()` does **not** filter expired rows; expiry is caller-managed). `recall()` returns the highest-confidence match with an optional `minConfidence` floor and **opt-in** hierarchical scope fallback (`includeAncestors: true` → `'a/b/c' → 'a/b' → 'a' → 'global'`; default off); `recallAll()` returns a `Map`. Typical use: cache a learned strategy (e.g. a working selector per host) and reuse it across sessions. `success_count`/`failure_count` columns exist for outcome-weighting: `SmrtObject.remember()` leaves them untouched, `SmrtCollection.remember()` resets them to zero, and neither recall path updates them. `LearningMemory` is the layer that maintains them (and that does filter expired rows).
118
- - **Semantic search** (on `SmrtCollection`, table `_smrt_embeddings`): `semanticSearch(query)`, `findSimilar(object)`, `findSimilarToEmbedding(vector)` — cosine ranking over embeddings of the fields declared in `@smrt({ embeddings })`. Native pgvector/HNSW when configured, in-memory `CosineSimilarity` fallback otherwise; default local model `Xenova/bge-base-en-v1.5` (768-dim) or AI `text-embedding-3-small`. Hits hydrate via `list({ 'id in': … })`, so `@TenantScoped` isolation applies to results.
88
+ Context memory and semantic search are persistence primitives inherited by
89
+ `SmrtObject`/`SmrtCollection`; their storage, scope, expiry, and tenant
90
+ invariants are in [agents/memory.md](agents/memory.md).
119
91
 
120
92
  ## @smrt() Decorator Options
121
93
 
@@ -235,12 +207,35 @@ bootstrap prevents missing-table probes from poisoning that transaction.
235
207
  Production DDL comes from the **manifest** paths
236
208
  (`generateSTISchemaFromManifest`/`generateCTISchemaFromManifest`, selected in
237
209
  `src/scanner/manifest-generator.ts` → registered `schema` → `db:migrate`). The
238
- **registry** paths feed `getTestDatabase()` and emit foreign-key indexes
239
- production never gets: the suite runs on a richer schema than it ships.
210
+ **registry** paths feed `getTestDatabase()`. Manifest and registry schemas must
211
+ agree on same-package foreign keys as well as columns and indexes:
212
+ `@foreignKey` emits a named physical constraint, while `@crossPackageRef` and
213
+ `@tenantId` remain indexed runtime relationships without physical constraints.
214
+ Natural-key references default to `CASCADE`; ordinary references default to
215
+ immediate `NO ACTION`, matching `SmrtObject.delete()`.
216
+
217
+ Same-package archival/audit identifiers that intentionally outlive their
218
+ parent may use `@foreignKey(Target, { constraint: false })`. This explicit
219
+ exception retains relationship loading, indexing, and application-side delete
220
+ metadata while omitting the physical constraint, schema dependency, and
221
+ app-side cascade/preflight action so the stored identifier survives deletion;
222
+ document the retention reason at the field, and keep ordinary same-package
223
+ relationships constrained.
240
224
 
241
225
  - Change column/index emission on every shipping path, proven by the path-parity
242
226
  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.
243
227
  - Every new query predicate ships with its index, or a reason it doesn't.
228
+ - Creation is dependency-planned on every entry point. PostgreSQL defers mutual
229
+ cycle constraints until both tables exist; SQLite keeps cycles inline;
230
+ DuckDB refuses unsupported cycles/actions rather than silently omitting them.
231
+ In particular, generated same-package constraints retain the compatibility
232
+ default `ON UPDATE CASCADE`; DuckDB/JSON cannot enforce that action and must
233
+ return an actionable refusal instead of stripping the clause.
234
+ PostgreSQL deferred adds are idempotent and probe the exact child/parent
235
+ columns for orphans before `NOT VALID` + validation. Rollback drops children
236
+ before parents, removes deferred PostgreSQL cycle constraints first, and
237
+ defers SQLite checks while dropping populated cycles. Schema aggregation that
238
+ deliberately filters a parent also removes the retained child's physical FK.
244
239
  - Numeric types, uuid casts, conflict targets, timestamps, migrations: run the
245
240
  `test:postgres` lane — SQLite affinity accepts what PostgreSQL rejects.
246
241
  - Read `dist/manifest.json`/regenerated schemas for what a decorator produced;
@@ -255,6 +250,7 @@ production never gets: the suite runs on a richer schema than it ships.
255
250
  ## Gotchas
256
251
 
257
252
  - **Filesystem support is a lazy boundary (#1979)**: `SmrtClass` acquires `options.fs` adapters via `createFilesystemAdapter()` (`src/filesystem-loader.ts`), never a static `@happyvertical/files` import — the files SDK statically pulls @aws-sdk/client-s3 and reaches googleapis, and a static edge here would land it in every downstream SSR bundle. Node/tsx/vite-dev runtimes resolve it on first use; fully-bundled deployments import `@happyvertical/smrt-core/filesystem` at startup. Use `importOptionalDependency()` (`src/lazy-external.ts`) for any similar optional heavyweight dependency.
253
+ - **Transaction-bound instances**: `SmrtClass.withDatabase(db, callback)` temporarily binds an initialized instance (including its public `options.db`) to a supplied transaction database and restores the original binding on success or failure. Use it when domain validation and persistence must share one transaction; never reach into `_db`, and do not use the same instance concurrently during the callback.
258
254
  - **Never override toJSON()** — handles STI discriminator + meta field extraction. Use `transformJSON()`
259
255
  - **Property init order**: TypeScript initializers run first, then `initialize()` applies option values (options win)
260
256
  - **No runtime schema creation**: application tables must be prepared explicitly via migrations/tooling; runtime verification is `tableExists()` only (`src/schema/table-verifier.ts`) — no column, type, or index check
@@ -0,0 +1,40 @@
1
+ <!-- Module doc for packages/core/AGENTS.md. Linked from the Modules table there. -->
2
+
3
+ # Collection reads
4
+
5
+ This module covers bounded collection reads beyond the basic query contract in
6
+ `packages/core/AGENTS.md`.
7
+
8
+ ## Projections and related rows
9
+
10
+ `list({ select })` uses SMRT field names, maps them to database columns, and
11
+ returns plain rows without hydrating objects. It composes with `where`,
12
+ `orderBy`, `limit`, and `offset`, runs normal `beforeList`/tenant interceptors,
13
+ and is limited to column-backed fields; it cannot combine with `include`.
14
+
15
+ For one child per parent, use
16
+ [`latest-related.md`](latest-related.md). It uses a portable ranked CTE,
17
+ declared primary keys, adapter-specific offset-only syntax, explicit aliases,
18
+ and hydrates only the visible parent page.
19
+
20
+ ## Facets, counts, and read plans
21
+
22
+ `collection.facets({ fields, where })` runs one bounded `GROUP BY` per requested
23
+ field and returns `{ field, values: [{ value, count }] }`. It accepts at most 20
24
+ fields, clamps value limits to 1,000 and the collection ceiling, never hydrates
25
+ objects, and applies the same read/tenant/sensitive-field rails as `select`.
26
+ Stored array/string-list values are grouped as stored; they are not unnested.
27
+ `collection.counts({ where })` returns `{ total, filtered }` through two scoped
28
+ `COUNT(*)` queries. Local coverage is SQLite/DuckDB; optional scalar PostgreSQL
29
+ coverage requires `SMRT_TEST_POSTGRES_URL`.
30
+
31
+ `executeCollectionReadPlan()` bounds concurrent reads across independent
32
+ collections while preserving the normal registry and collection options. The
33
+ caller supplies a positive `maxConcurrency`; the executor does not compose SQL,
34
+ cache, or alter pool defaults, and drains already-started work before returning
35
+ the first error.
36
+
37
+ `where` operators must remain aligned with `@happyvertical/sql`'s `buildWhere`:
38
+ `=`, `>`, `<`, `>=`, `<=`, `!=`, `in`, `not in`, and `like`. Arrays imply `IN`,
39
+ and null values render `IS NULL`/`IS NOT NULL`. `contains` and dot-notation JSON
40
+ paths are intentionally rejected until the SQL layer supports them.
@@ -0,0 +1,21 @@
1
+ <!-- Module doc for packages/core/AGENTS.md. Linked from the Modules table there. -->
2
+
3
+ # Bounded data queries (#2444)
4
+
5
+ `normalizeDataQueryRequest()` and `normalizeDataQueryResult()` define the trust
6
+ boundary for the transport-neutral table/report/content query envelope. A
7
+ trusted adapter supplies `DataQuerySchema`; callers receive only its declared
8
+ projectable, sortable, filterable, and facetable fields. The helpers validate
9
+ and normalize but never execute SQL or decide tenant/principal access.
10
+
11
+ Use `createDataQueryFingerprint()` for cache and result correlation. It omits
12
+ request id and page position, canonicalizes equivalent filter/projection/facet
13
+ forms, and adds the identity sort tie-break. Keep values scalar and request,
14
+ page, and facet sizes positive and bounded. Results must stay within the schema
15
+ byte cap with declared field types preserved; datetimes are RFC 3339 instants,
16
+ identity fields are string/number/datetime-compatible, and JSON values are
17
+ bounded by depth, container count, string size, and bytes before cloning.
18
+
19
+ Only normalized `DataQueryResult` envelopes cross REST, MCP, WebMCP, and browser
20
+ boundaries. Adapter-specific report/content context wraps the base envelope; it
21
+ does not add unsafe fields or SQL-like controls.
@@ -0,0 +1,35 @@
1
+ <!-- Module doc for packages/core/AGENTS.md. Linked from the Modules table there. -->
2
+
3
+ # Latest-related reads (#1903)
4
+
5
+ Use `SmrtCollection.listWithLatestRelated()` when a page needs one row from a
6
+ declared `@oneToMany` relationship without N+1 queries or hydrating unrelated
7
+ children. `latestRelated.orderBy` ranks rows within each parent, `select`
8
+ chooses the returned child fields (defaulting to the related model's declared
9
+ primary key), and an optional `sortBy` orders parents by the winning child
10
+ before the parent `limit`/`offset` are applied. The result is
11
+ `{ parent, latestRelated }`: `parent` is hydrated and `latestRelated` is a
12
+ plain projection or `null` when the parent has no child.
13
+
14
+ The read plan uses a portable `ROW_NUMBER()` CTE with explicit null-last
15
+ ordering and the declared primary keys for every tie-break, join, and result
16
+ map. The parent primary key is never assumed to be `id`; the related primary
17
+ key is the default projection when `select` is omitted. Only the visible
18
+ parent page is hydrated.
19
+
20
+ Pagination is adapter-aware: SQLite uses `LIMIT -1` for offset-only reads,
21
+ while DuckDB and PostgreSQL use `LIMIT ALL`. In-memory DuckDB and JSON adapters
22
+ retain their engine hint even when no URL or database type is present, so the
23
+ generated syntax remains legal for the actual driver.
24
+
25
+ Internal result aliases are bounded `__smrt_lr_N` identifiers. The allocator
26
+ reserves declared and live parent table columns, and parent columns are
27
+ projected explicitly (using the public table-schema API where available) rather
28
+ than `parent.*`, so externally added columns cannot collide or corrupt the
29
+ latest-related projection. JSON adapters without live schema introspection use
30
+ the declared schema as their fallback.
31
+
32
+ The primitive preserves normal read interceptors, tenant and STI scope, and
33
+ does not expose a cache option until cache invalidation is implemented. Tests
34
+ cover custom primary keys, SQLite/DuckDB offset-only pagination, long field
35
+ names, external alias collisions, and cleanup of temporary database files.
@@ -0,0 +1,16 @@
1
+ <!-- Module doc for packages/core/AGENTS.md. Linked from the Modules table there. -->
2
+
3
+ # Object memory and semantic search
4
+
5
+ Context memory (`remember`, `recall`, `recallAll`, `forget`, `forgetScope`) is
6
+ stored in `_smrt_contexts`, keyed by owner, scope, key, and version. Values have
7
+ a 0–1 confidence and optional expiry metadata; `recall()` does not filter
8
+ expired rows. Ancestor fallback is opt-in (`includeAncestors: true`) and walks
9
+ `a/b/c → a/b → a → global`. `LearningMemory` owns outcome counters and expiry
10
+ filtering; object/collection recall does not update them.
11
+
12
+ Semantic search uses `_smrt_embeddings` and cosine ranking over fields declared
13
+ by `@smrt({ embeddings })`, with native pgvector/HNSW or an in-memory fallback.
14
+ Results hydrate through `list({ 'id in': … })`, so normal tenant isolation still
15
+ applies. Keep injected search behind the `SmrtCollection.semanticSearch`
16
+ boundary.
@@ -648,30 +648,26 @@ upsert conflict target. When reading a PostgreSQL catalog array, cast it
648
648
  the array OID returns the raw `{a,b}` literal, and reading that as "no columns"
649
649
  silently inverts an index-existence decision.
650
650
 
651
- ## Referential integrity lives in `delete()`, not in the DDL
651
+ ## Same-package referential integrity uses two matching rails
652
652
 
653
- No schema path emits a `FOREIGN KEY` clause on any engine — grep `schema/ddl/*`,
654
- `schema-manager.ts` and `differ.ts` for `REFERENCES` and you get nothing. That is
655
- a deliberate position, not an oversight: emitting constraints changes delete
656
- semantics for every consumer, requires topological table ordering in both
657
- migrate paths (neither orders today), and needs a plan for the orphans already in
658
- production databases. Emitting them is tracked separately.
659
-
660
- What `@foreignKey(..., { onDelete })` therefore means is *application* behaviour,
661
- applied by `SmrtObject.delete()` through `src/cascade.ts` (#2371):
653
+ `@foreignKey(Target)` emits a physical database constraint when the target is
654
+ in the same package and applies the same action through `SmrtObject.delete()`
655
+ in `src/cascade.ts`. A shared delete-action resolver keeps both paths aligned;
656
+ the established generated `ON UPDATE CASCADE` default remains unchanged:
662
657
 
663
658
  | Reference | Default when `onDelete` is absent |
664
659
  |---|---|
665
660
  | Column is part of the referencing class's `conflictColumns`, and is not a `@tenantId()` field | `CASCADE` |
666
661
  | Polymorphic `(metaType, metaId)` association row | `CASCADE` |
667
- | Anything else, including every `@tenantId()` field | `NO ACTION` — the row is left alone |
662
+ | Ordinary same-package reference | `NO ACTION` — deletion is refused while references remain |
663
+ | Every `@tenantId()` field | Excluded from physical constraints and delete cascades |
668
664
 
669
665
  The natural-key rule is what cleans junction rows up without any per-package
670
666
  annotation: a junction declares
671
667
  `@smrt({ conflictColumns: ['content_id', 'asset_id', 'relationship'] })`, so the
672
668
  row is *identified* by the content and cannot outlive it. An ordinary child
673
- (`Order.customerId`) is keyed by `(slug, context)` and keeps its pre-#2371
674
- behaviour unless it opts in explicitly.
669
+ (`Order.customerId`) is keyed by `(slug, context)` and therefore defaults to
670
+ immediate `NO ACTION` unless it opts in explicitly.
675
671
 
676
672
  **`@tenantId()` is excluded even though it lands in `conflictColumns`.**
677
673
  #2360 leads every tenant-scoped class's *default* natural key with the
@@ -682,8 +678,26 @@ tenant column scopes ownership; it does not identify the row the way a
682
678
  junction's foreign key does. Detected via the `__tenancy.isTenantIdField`
683
679
  marker on `FieldMeta` (`smrt-core` reads it structurally so it never depends
684
680
  on `smrt-tenancy`). `@tenantId()` exposes no `onDelete` option today, so
685
- this cannot currently be overridden per field — found in review before this
686
- landed (originally reachable, untested, and undocumented).
681
+ this cannot currently be overridden per field.
682
+
683
+ `@crossPackageRef()` remains runtime-only: it registers relationship loading
684
+ and indexes but deliberately emits no physical constraint, avoiding circular
685
+ package DDL. Tenant markers follow the same non-constraint rule because a
686
+ tenant is a scope, not an ownership edge.
687
+
688
+ Every schema creation entry point uses the same deterministic dependency
689
+ planner. Parents are created before children. SQLite keeps cycle constraints
690
+ inline because it can create them safely. PostgreSQL creates mutually dependent
691
+ tables first and adds their named constraints afterward. DuckDB refuses cycles,
692
+ self-references, `CASCADE`, and `SET NULL` with an actionable error because its
693
+ current ALTER/constraint support cannot enforce those shapes safely.
694
+
695
+ For existing tables, PostgreSQL checks the exact child table/column against the
696
+ exact referenced table/column before adding a constraint as `NOT VALID` and
697
+ then validating it. If orphans exist (or the probe cannot run), migration stops
698
+ with detector and repair SQL. SQLite requires a deliberate table rebuild;
699
+ DuckDB reports the unsupported ALTER path. Neither engine treats an unsupported
700
+ constraint addition as a successful no-op.
687
701
 
688
702
  Properties to keep if you touch that module:
689
703
 
package/dist/browser.js CHANGED
@@ -16,7 +16,7 @@ import { executeToolCall, executeToolCalls, formatToolResults, validateToolCall
16
16
  import { SmrtObject } from "./object.js";
17
17
  import { SMRT_COLLECTION_BASE_NAMES, isSmrtCollectionExtendsName } from "./registry/collection-resolution.js";
18
18
  import { ObjectRegistry, smrt } from "./registry.js";
19
- import { SmrtCollection } from "./collection.js";
19
+ import { DEFAULT_FACET_LIMIT, MAX_FACET_FIELDS, MAX_FACET_LIMIT, SmrtCollection } from "./collection.js";
20
20
  import { SmrtHierarchical } from "./hierarchical.js";
21
21
  import { SmrtJunction } from "./junction.js";
22
22
  import { SmrtPolymorphicAssociation } from "./polymorphic-association.js";
@@ -24,4 +24,4 @@ import "./signals/index.js";
24
24
  import { DEFAULT_RETENTION_POLICY, clearRetentionTasks, getRetentionTasks, pruneAiUsage, pruneExpiredContexts, registerRetentionTask, runRetentionSweep, unregisterRetentionTask } from "./system/retention.js";
25
25
  import "./system/index.js";
26
26
  import "./tools/index.js";
27
- 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, ensureSystemTables, 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 };
27
+ export { AIError, AiUsageCollector, AiUsagePersistenceHandler, ConfigurationError, DEFAULT_AI_COST_RATES, DEFAULT_FACET_LIMIT, DEFAULT_RETENTION_POLICY, DatabaseError, ErrorUtils, FilesystemError, MAX_FACET_FIELDS, MAX_FACET_LIMIT, 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, ensureSystemTables, 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 };
package/dist/cascade.d.ts CHANGED
@@ -1,13 +1,14 @@
1
1
  import { DatabaseInterface } from '@happyvertical/sql';
2
2
  import { ObjectRegistry } from './registry.js';
3
+ import { ForeignKeyAction } from './schema/types.js';
3
4
  /**
4
5
  * Referential action applied to rows pointing at a deleted object.
5
6
  *
6
- * Same vocabulary as SQL's `ON DELETE`, enforced by the framework instead of
7
- * the engine. `NO ACTION` means "leave the rows alone" SMRT emits no
8
- * constraint, so nothing raises.
7
+ * Same vocabulary as SQL's `ON DELETE`, enforced by the framework before the
8
+ * engine. `NO ACTION` is preflighted like an immediate restrictive action so
9
+ * application-side and database-side enforcement agree.
9
10
  */
10
- export type OnDeleteAction = 'CASCADE' | 'SET NULL' | 'RESTRICT' | 'NO ACTION';
11
+ export type OnDeleteAction = ForeignKeyAction;
11
12
  /** One reference that must be resolved before the target row can be removed. */
12
13
  export interface CascadeReference {
13
14
  /** Registered name of the class holding the referencing column. */
@@ -18,8 +19,8 @@ export interface CascadeReference {
18
19
  fieldName: string;
19
20
  /** Column (snake_case) holding the reference. */
20
21
  column: string;
21
- /** Resolved action, never `NO ACTION` (those are dropped from the plan). */
22
- action: Exclude<OnDeleteAction, 'NO ACTION'>;
22
+ /** Resolved action. `NO ACTION` is preflighted like an immediate RESTRICT. */
23
+ action: OnDeleteAction;
23
24
  /** `true` when the action was declared rather than derived from the key. */
24
25
  declared: boolean;
25
26
  }
@@ -1 +1 @@
1
- {"version":3,"file":"cascade.d.ts","sourceRoot":"","sources":["../src/cascade.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkEG;AAGH,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAC;AAK5D,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,eAAe,CAAC;AAMpD;;;;;;GAMG;AACH,MAAM,MAAM,cAAc,GAAG,SAAS,GAAG,UAAU,GAAG,UAAU,GAAG,WAAW,CAAC;AAsB/E,gFAAgF;AAChF,MAAM,WAAW,gBAAgB;IAC/B,mEAAmE;IACnE,SAAS,EAAE,MAAM,CAAC;IAClB,0CAA0C;IAC1C,SAAS,EAAE,MAAM,CAAC;IAClB,uDAAuD;IACvD,SAAS,EAAE,MAAM,CAAC;IAClB,iDAAiD;IACjD,MAAM,EAAE,MAAM,CAAC;IACf,4EAA4E;IAC5E,MAAM,EAAE,OAAO,CAAC,cAAc,EAAE,WAAW,CAAC,CAAC;IAC7C,4EAA4E;IAC5E,QAAQ,EAAE,OAAO,CAAC;CACnB;AAED,mEAAmE;AACnE,MAAM,WAAW,2BAA2B;IAC1C,gDAAgD;IAChD,SAAS,EAAE,MAAM,CAAC;IAClB,0CAA0C;IAC1C,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,2EAA2E;AAC3E,MAAM,WAAW,WAAW;IAC1B,4EAA4E;IAC5E,UAAU,EAAE,gBAAgB,EAAE,CAAC;IAC/B,mEAAmE;IACnE,WAAW,EAAE,2BAA2B,EAAE,CAAC;IAC3C,yEAAyE;IACzE,SAAS,EAAE,MAAM,EAAE,CAAC;IACpB,+EAA+E;IAC/E,OAAO,EAAE,OAAO,CAAC;CAClB;AAED;;;;;;GAMG;AACH,MAAM,MAAM,mBAAmB,GAAG,IAAI,CACpC,OAAO,cAAc,EACnB,oBAAoB,GACpB,WAAW,GACX,oBAAoB,GACpB,cAAc,GACd,uBAAuB,GACvB,UAAU,GACV,YAAY,GACZ,gBAAgB,CACnB,CAAC;AASF;;;;;;GAMG;AACH,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,OAAO,GAAG,cAAc,GAAG,SAAS,CAM5E;AASD;;;;;;;;;;GAUG;AACH,wBAAgB,gBAAgB,CAC9B,QAAQ,EAAE,mBAAmB,EAC7B,SAAS,EAAE,MAAM,GAChB,WAAW,CAwHb;AA8UD,8EAA8E;AAC9E,MAAM,WAAW,aAAa;IAC5B,4EAA4E;IAC5E,cAAc,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;IAC5B;;;;OAIG;IACH,oBAAoB,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CAC3C;AAED;;;;;;GAMG;AACH,wBAAsB,mBAAmB,CACvC,EAAE,EAAE,iBAAiB,EACrB,QAAQ,EAAE,mBAAmB,EAC7B,MAAM,EAAE;IAAE,SAAS,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,EAAE,CAAA;CAAE,GAC9D,OAAO,CAAC,aAAa,CAAC,CAwBxB;AASD;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAsB,gBAAgB,CACpC,EAAE,EAAE,iBAAiB,EACrB,QAAQ,EAAE,mBAAmB,EAC7B,MAAM,EAAE;IACN,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;IAClB,+DAA+D;IAC/D,EAAE,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,CAAC;CAC/B,EACD,UAAU,EAAE,CAAC,EAAE,EAAE,iBAAiB,KAAK,OAAO,CAAC,IAAI,CAAC,GACnD,OAAO,CAAC,aAAa,CAAC,CAgDxB"}
1
+ {"version":3,"file":"cascade.d.ts","sourceRoot":"","sources":["../src/cascade.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAoEG;AAGH,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAC;AAK5D,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,eAAe,CAAC;AAKpD,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,mBAAmB,CAAC;AAM1D;;;;;;GAMG;AACH,MAAM,MAAM,cAAc,GAAG,gBAAgB,CAAC;AAsB9C,gFAAgF;AAChF,MAAM,WAAW,gBAAgB;IAC/B,mEAAmE;IACnE,SAAS,EAAE,MAAM,CAAC;IAClB,0CAA0C;IAC1C,SAAS,EAAE,MAAM,CAAC;IAClB,uDAAuD;IACvD,SAAS,EAAE,MAAM,CAAC;IAClB,iDAAiD;IACjD,MAAM,EAAE,MAAM,CAAC;IACf,8EAA8E;IAC9E,MAAM,EAAE,cAAc,CAAC;IACvB,4EAA4E;IAC5E,QAAQ,EAAE,OAAO,CAAC;CACnB;AAED,mEAAmE;AACnE,MAAM,WAAW,2BAA2B;IAC1C,gDAAgD;IAChD,SAAS,EAAE,MAAM,CAAC;IAClB,0CAA0C;IAC1C,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,2EAA2E;AAC3E,MAAM,WAAW,WAAW;IAC1B,4EAA4E;IAC5E,UAAU,EAAE,gBAAgB,EAAE,CAAC;IAC/B,mEAAmE;IACnE,WAAW,EAAE,2BAA2B,EAAE,CAAC;IAC3C,yEAAyE;IACzE,SAAS,EAAE,MAAM,EAAE,CAAC;IACpB,+EAA+E;IAC/E,OAAO,EAAE,OAAO,CAAC;CAClB;AAED;;;;;;GAMG;AACH,MAAM,MAAM,mBAAmB,GAAG,IAAI,CACpC,OAAO,cAAc,EACnB,oBAAoB,GACpB,WAAW,GACX,oBAAoB,GACpB,cAAc,GACd,uBAAuB,GACvB,UAAU,GACV,YAAY,GACZ,gBAAgB,CACnB,CAAC;AAEF;;;;;;GAMG;AACH,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,OAAO,GAAG,cAAc,GAAG,SAAS,CAE5E;AASD;;;;;;;;;;GAUG;AACH,wBAAgB,gBAAgB,CAC9B,QAAQ,EAAE,mBAAmB,EAC7B,SAAS,EAAE,MAAM,GAChB,WAAW,CAgIb;AAkVD,8EAA8E;AAC9E,MAAM,WAAW,aAAa;IAC5B,4EAA4E;IAC5E,cAAc,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;IAC5B;;;;OAIG;IACH,oBAAoB,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CAC3C;AAED;;;;;;GAMG;AACH,wBAAsB,mBAAmB,CACvC,EAAE,EAAE,iBAAiB,EACrB,QAAQ,EAAE,mBAAmB,EAC7B,MAAM,EAAE;IAAE,SAAS,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,EAAE,CAAA;CAAE,GAC9D,OAAO,CAAC,aAAa,CAAC,CAwBxB;AASD;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAsB,gBAAgB,CACpC,EAAE,EAAE,iBAAiB,EACrB,QAAQ,EAAE,mBAAmB,EAC7B,MAAM,EAAE;IACN,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;IAClB,+DAA+D;IAC/D,EAAE,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,CAAC;CAC/B,EACD,UAAU,EAAE,CAAC,EAAE,EAAE,iBAAiB,KAAK,OAAO,CAAC,IAAI,CAAC,GACnD,OAAO,CAAC,aAAa,CAAC,CAgDxB"}
package/dist/cascade.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { classifyDatabaseError } from "./db-errors.js";
2
2
  import { ConfigurationError, DatabaseError } from "./errors.js";
3
+ import { normalizeForeignKeyAction, resolveForeignKeyDeleteAction } from "./schema/foreign-key-policy.js";
3
4
  import { chunkArray } from "./utils/chunk.js";
4
5
  import { toSnakeCase } from "./utils/naming.js";
5
6
  import { createLogger } from "@happyvertical/logger";
@@ -7,8 +8,9 @@ import { createLogger } from "@happyvertical/logger";
7
8
  /**
8
9
  * App-side referential integrity for `SmrtObject.delete()` (#2371).
9
10
  *
10
- * SMRT emits **no** DB-level `FOREIGN KEY` constraints on any engine, so the
11
- * database will never clean up after a deleted row. Before this module,
11
+ * Same-package `@foreignKey` fields now emit database constraints where the
12
+ * engine can enforce the declared policy. Cross-package references and older
13
+ * schemas still rely on this application-side path. Before this module,
12
14
  * `delete()` removed the object's own row and nothing else: junction rows,
13
15
  * polymorphic association rows, `_smrt_embeddings` and `_smrt_contexts` entries
14
16
  * were all left pointing at an id that no longer resolved, and
@@ -45,14 +47,15 @@ import { createLogger } from "@happyvertical/logger";
45
47
  * |---|---|
46
48
  * | Column is part of the referencing class's `conflictColumns`, and is not a `@tenantId()` field | `CASCADE` |
47
49
  * | Polymorphic `(metaType, metaId)` association row | `CASCADE` |
48
- * | Anything else, including every `@tenantId()` field | `NO ACTION` (legacy behaviour the row is left alone) |
50
+ * | Ordinary same-package reference | `NO ACTION` (deletion is refused while references remain) |
51
+ * | Every `@tenantId()` field | Excluded: tenant scope is not an ownership edge |
49
52
  *
50
53
  * The natural-key rule is what makes junction rows work without any
51
54
  * per-package annotation: a junction declares
52
55
  * `@smrt({ conflictColumns: ['content_id', 'asset_id', 'relationship'] })`, so
53
56
  * `content_id` identifies the row and the row cannot outlive the content it
54
57
  * links. An ordinary child (`Order.customerId`) is keyed by `(slug, context)`,
55
- * so it keeps today's behaviour unless it opts in with
58
+ * so it defaults to immediate `NO ACTION` unless it opts in with
56
59
  * `@foreignKey(Customer, { onDelete: 'CASCADE' })`.
57
60
  *
58
61
  * `@tenantId()` fields are excluded from the natural-key rule even though
@@ -88,12 +91,6 @@ var COLLECTION_OWNER_SENTINEL = "__collection__";
88
91
  * is broken by the visited set; this bound catches unbounded *chains*.
89
92
  */
90
93
  var MAX_CASCADE_DEPTH = 10;
91
- var CASCADE_ACTIONS = /* @__PURE__ */ new Set([
92
- "CASCADE",
93
- "SET NULL",
94
- "RESTRICT",
95
- "NO ACTION"
96
- ]);
97
94
  /**
98
95
  * Normalize a declared `onDelete` value.
99
96
  *
@@ -102,9 +99,7 @@ var CASCADE_ACTIONS = /* @__PURE__ */ new Set([
102
99
  * an unset or unrecognized value so the caller can fall back to the default.
103
100
  */
104
101
  function normalizeOnDelete(value) {
105
- if (typeof value !== "string") return void 0;
106
- const normalized = value.trim().toUpperCase().replace(/_/g, " ");
107
- return CASCADE_ACTIONS.has(normalized) ? normalized : void 0;
102
+ return normalizeForeignKeyAction(value);
108
103
  }
109
104
  function isPolymorphicAssociationClass(fields) {
110
105
  return fields.has("metaType") && fields.has("metaId") && fields.has("role");
@@ -142,14 +137,17 @@ function buildCascadePlan(registry, className) {
142
137
  for (const relationship of relationships) {
143
138
  if (relationship.type !== "foreignKey" && relationship.type !== "crossPackageRef") continue;
144
139
  if (!targetNames.has(relationship.targetClass)) continue;
140
+ if (relationship.type === "foreignKey" && relationship.options?.constraint === false) continue;
145
141
  const tableName = registry.getTableName(sourceClass);
146
142
  if (!tableName) continue;
147
143
  const column = toSnakeCase(relationship.fieldName);
148
- const declaredAction = normalizeOnDelete(relationship.options?.onDelete);
149
144
  if (!conflictColumns) conflictColumns = new Set(registry.getConflictColumns(sourceClass));
150
- const isTenantIdField = relationship.options?.__tenancy?.isTenantIdField === true;
151
- const action = declaredAction ?? (!isTenantIdField && conflictColumns.has(column) ? "CASCADE" : "NO ACTION");
152
- if (action === "NO ACTION") continue;
145
+ if (relationship.options?.__tenancy?.isTenantIdField === true) continue;
146
+ const { action, declared } = resolveForeignKeyDeleteAction({
147
+ declared: relationship.options?.onDelete,
148
+ isConflictColumn: conflictColumns.has(column),
149
+ isTenantIdField: false
150
+ });
153
151
  if (action === "SET NULL" && fields.get(relationship.fieldName)?.required) throw ConfigurationError.invalidConfiguration(`${sourceClass}.${relationship.fieldName} onDelete: 'SET NULL'`, relationship.fieldName, "a nullable field (mark it `nullable: true`, or use 'CASCADE' / 'RESTRICT')");
154
152
  references.push({
155
153
  className: sourceClass,
@@ -157,7 +155,7 @@ function buildCascadePlan(registry, className) {
157
155
  fieldName: relationship.fieldName,
158
156
  column,
159
157
  action,
160
- declared: declaredAction !== void 0
158
+ declared
161
159
  });
162
160
  }
163
161
  }
@@ -317,16 +315,16 @@ async function resolveReferences(ctx, className, tableName, ids, depth) {
317
315
  if (depth > MAX_CASCADE_DEPTH) throw DatabaseError.constraintViolation(`cascade delete from ${className} exceeded the maximum depth of ${MAX_CASCADE_DEPTH}; check for a chain of onDelete: 'CASCADE' references that never terminates`, className);
318
316
  const plan = buildCascadePlan(ctx.registry, className);
319
317
  for (const reference of plan.references) {
320
- if (reference.action !== "RESTRICT") continue;
318
+ if (reference.action !== "RESTRICT" && reference.action !== "NO ACTION") continue;
321
319
  let remaining = 0;
322
320
  for (const batch of chunkArray(pending, 900)) {
323
321
  remaining += await tolerateMissingTable(() => ctx.db.count(reference.tableName, idPredicate(reference.column, batch)), 0, {
324
322
  table: reference.tableName,
325
- action: "RESTRICT check"
323
+ action: `${reference.action} check`
326
324
  });
327
325
  if (remaining > 0) break;
328
326
  }
329
- if (remaining > 0) throw DatabaseError.constraintViolation(`${reference.className}.${reference.fieldName} declares onDelete: 'RESTRICT' and ${remaining} row(s) still reference this ${className}`, reference.column);
327
+ if (remaining > 0) throw DatabaseError.constraintViolation(`${reference.className}.${reference.fieldName} ${reference.declared ? "declares" : "resolves to"} onDelete: '${reference.action}' and ${remaining} row(s) still reference this ${className}`, reference.column);
330
328
  }
331
329
  for (const reference of plan.references) {
332
330
  if (reference.action === "SET NULL") {