@happyvertical/smrt-core 0.42.7 → 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 (100) hide show
  1. package/AGENTS.md +26 -2
  2. package/agents/schema-paths.md +29 -15
  3. package/dist/cascade.d.ts +7 -6
  4. package/dist/cascade.d.ts.map +1 -1
  5. package/dist/cascade.js +19 -21
  6. package/dist/cascade.js.map +1 -1
  7. package/dist/class.d.ts +10 -0
  8. package/dist/class.d.ts.map +1 -1
  9. package/dist/class.js +21 -0
  10. package/dist/class.js.map +1 -1
  11. package/dist/decorators/index.d.ts +33 -15
  12. package/dist/decorators/index.d.ts.map +1 -1
  13. package/dist/decorators/index.js +16 -11
  14. package/dist/decorators/index.js.map +1 -1
  15. package/dist/index.d.ts +1 -0
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/index.js +2 -1
  18. package/dist/manifest/static-manifest.d.ts.map +1 -1
  19. package/dist/manifest/static-manifest.js +17 -1
  20. package/dist/manifest/static-manifest.js.map +1 -1
  21. package/dist/manifest/store.js +1 -1
  22. package/dist/manifest/store.js.map +1 -1
  23. package/dist/manifest.json +20 -1
  24. package/dist/migrations/differ.d.ts +2 -0
  25. package/dist/migrations/differ.d.ts.map +1 -1
  26. package/dist/migrations/differ.js +75 -0
  27. package/dist/migrations/differ.js.map +1 -1
  28. package/dist/migrations/generator.d.ts.map +1 -1
  29. package/dist/migrations/generator.js +19 -2
  30. package/dist/migrations/generator.js.map +1 -1
  31. package/dist/migrations/orchestrate.d.ts.map +1 -1
  32. package/dist/migrations/orchestrate.js +4 -1
  33. package/dist/migrations/orchestrate.js.map +1 -1
  34. package/dist/object.d.ts +4 -4
  35. package/dist/object.js +4 -4
  36. package/dist/object.js.map +1 -1
  37. package/dist/registry/schema-builder.d.ts +6 -0
  38. package/dist/registry/schema-builder.d.ts.map +1 -1
  39. package/dist/registry/schema-builder.js +78 -8
  40. package/dist/registry/schema-builder.js.map +1 -1
  41. package/dist/registry.d.ts +5 -7
  42. package/dist/registry.d.ts.map +1 -1
  43. package/dist/registry.js +6 -8
  44. package/dist/registry.js.map +1 -1
  45. package/dist/scanner/manifest-generator.d.ts.map +1 -1
  46. package/dist/scanner/manifest-generator.js +62 -18
  47. package/dist/scanner/manifest-generator.js.map +1 -1
  48. package/dist/scanner/types.d.ts +5 -1
  49. package/dist/scanner/types.d.ts.map +1 -1
  50. package/dist/scanner/types.js.map +1 -1
  51. package/dist/schema/ddl/base-strategy.d.ts.map +1 -1
  52. package/dist/schema/ddl/base-strategy.js +2 -0
  53. package/dist/schema/ddl/base-strategy.js.map +1 -1
  54. package/dist/schema/ddl/duckdb-strategy.d.ts +1 -0
  55. package/dist/schema/ddl/duckdb-strategy.d.ts.map +1 -1
  56. package/dist/schema/ddl/duckdb-strategy.js +17 -1
  57. package/dist/schema/ddl/duckdb-strategy.js.map +1 -1
  58. package/dist/schema/ddl/postgres-strategy.js +1 -1
  59. package/dist/schema/foreign-key-ddl.d.ts +9 -0
  60. package/dist/schema/foreign-key-ddl.d.ts.map +1 -0
  61. package/dist/schema/foreign-key-ddl.js +53 -0
  62. package/dist/schema/foreign-key-ddl.js.map +1 -0
  63. package/dist/schema/foreign-key-planner.d.ts +18 -0
  64. package/dist/schema/foreign-key-planner.d.ts.map +1 -0
  65. package/dist/schema/foreign-key-planner.js +109 -0
  66. package/dist/schema/foreign-key-planner.js.map +1 -0
  67. package/dist/schema/foreign-key-policy.d.ts +21 -0
  68. package/dist/schema/foreign-key-policy.d.ts.map +1 -0
  69. package/dist/schema/foreign-key-policy.js +41 -0
  70. package/dist/schema/foreign-key-policy.js.map +1 -0
  71. package/dist/schema/generator.d.ts +3 -0
  72. package/dist/schema/generator.d.ts.map +1 -1
  73. package/dist/schema/generator.js +54 -10
  74. package/dist/schema/generator.js.map +1 -1
  75. package/dist/schema/index.d.ts +3 -0
  76. package/dist/schema/index.d.ts.map +1 -1
  77. package/dist/schema/index.js +4 -1
  78. package/dist/schema/manifest-schema.d.ts.map +1 -1
  79. package/dist/schema/manifest-schema.js +20 -3
  80. package/dist/schema/manifest-schema.js.map +1 -1
  81. package/dist/schema/schema-aggregator.d.ts +7 -1
  82. package/dist/schema/schema-aggregator.d.ts.map +1 -1
  83. package/dist/schema/schema-aggregator.js +54 -9
  84. package/dist/schema/schema-aggregator.js.map +1 -1
  85. package/dist/schema/schema-manager.d.ts +1 -6
  86. package/dist/schema/schema-manager.d.ts.map +1 -1
  87. package/dist/schema/schema-manager.js +26 -30
  88. package/dist/schema/schema-manager.js.map +1 -1
  89. package/dist/schema/types.d.ts +8 -5
  90. package/dist/schema/types.d.ts.map +1 -1
  91. package/dist/schema/utils.d.ts.map +1 -1
  92. package/dist/schema/utils.js +14 -1
  93. package/dist/schema/utils.js.map +1 -1
  94. package/dist/schema.js +4 -1
  95. package/dist/smrt-knowledge.json +18 -8
  96. package/dist/testing/database.d.ts +11 -1
  97. package/dist/testing/database.d.ts.map +1 -1
  98. package/dist/testing/database.js +80 -9
  99. package/dist/testing/database.js.map +1 -1
  100. package/package.json +4 -4
package/AGENTS.md CHANGED
@@ -207,12 +207,35 @@ bootstrap prevents missing-table probes from poisoning that transaction.
207
207
  Production DDL comes from the **manifest** paths
208
208
  (`generateSTISchemaFromManifest`/`generateCTISchemaFromManifest`, selected in
209
209
  `src/scanner/manifest-generator.ts` → registered `schema` → `db:migrate`). The
210
- **registry** paths feed `getTestDatabase()` and emit foreign-key indexes
211
- 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.
212
224
 
213
225
  - Change column/index emission on every shipping path, proven by the path-parity
214
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.
215
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.
216
239
  - Numeric types, uuid casts, conflict targets, timestamps, migrations: run the
217
240
  `test:postgres` lane — SQLite affinity accepts what PostgreSQL rejects.
218
241
  - Read `dist/manifest.json`/regenerated schemas for what a decorator produced;
@@ -227,6 +250,7 @@ production never gets: the suite runs on a richer schema than it ships.
227
250
  ## Gotchas
228
251
 
229
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.
230
254
  - **Never override toJSON()** — handles STI discriminator + meta field extraction. Use `transformJSON()`
231
255
  - **Property init order**: TypeScript initializers run first, then `initialize()` applies option values (options win)
232
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
@@ -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/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") {
@@ -1 +1 @@
1
- {"version":3,"file":"cascade.js","names":[],"sources":["../src/cascade.ts"],"sourcesContent":["/**\n * App-side referential integrity for `SmrtObject.delete()` (#2371).\n *\n * SMRT emits **no** DB-level `FOREIGN KEY` constraints on any engine, so the\n * database will never clean up after a deleted row. Before this module,\n * `delete()` removed the object's own row and nothing else: junction rows,\n * polymorphic association rows, `_smrt_embeddings` and `_smrt_contexts` entries\n * were all left pointing at an id that no longer resolved, and\n * `@foreignKey(..., { onDelete })` was metadata nobody read.\n *\n * This module implements those semantics in the application layer. It mirrors\n * what a DB-level constraint would do, deliberately including the parts that\n * make it *unlike* a model-layer delete:\n *\n * - Cascaded rows are removed with set-based statements. Their `beforeDelete` /\n * `afterDelete` hooks and interceptors do **not** run, and no change-feed\n * tombstone is written for them — exactly as `ON DELETE CASCADE` behaves.\n * Only the object `delete()` was called on runs the full lifecycle.\n * - When anything needs cascading, everything runs inside a single\n * transaction when the adapter exposes one, so a partial cascade cannot\n * survive a failure. A class with no typed references AND no registered\n * polymorphic association class anywhere in the process skips the\n * transaction — see {@link runCascadeDelete}. A `metaType` column can point\n * at any class at runtime, so a polymorphic association class is always\n * plausibly relevant; in an app with even one registered, this fast path is\n * rare, not the common case.\n *\n * ## Which references are followed\n *\n * A reference is any `@foreignKey` / `@crossPackageRef` field on a registered\n * class whose target resolves to the deleted object's class (or one of its\n * registered STI ancestors), plus the polymorphic `(metaType, metaId)` pair on\n * {@link SmrtPolymorphicAssociation} subclasses.\n *\n * The action comes from the field's declared `onDelete`. When it is not\n * declared, the default is derived from the referencing class's natural key:\n *\n * | Reference | Default when `onDelete` is absent |\n * |---|---|\n * | Column is part of the referencing class's `conflictColumns`, and is not a `@tenantId()` field | `CASCADE` |\n * | Polymorphic `(metaType, metaId)` association row | `CASCADE` |\n * | Anything else, including every `@tenantId()` field | `NO ACTION` (legacy behaviour — the row is left alone) |\n *\n * The natural-key rule is what makes junction rows work without any\n * per-package annotation: a junction declares\n * `@smrt({ conflictColumns: ['content_id', 'asset_id', 'relationship'] })`, so\n * `content_id` identifies the row and the row cannot outlive the content it\n * links. An ordinary child (`Order.customerId`) is keyed by `(slug, context)`,\n * so it keeps today's behaviour unless it opts in with\n * `@foreignKey(Customer, { onDelete: 'CASCADE' })`.\n *\n * `@tenantId()` fields are excluded from the natural-key rule even though\n * `@happyvertical/smrt-tenancy` leads a tenant-scoped class's *default*\n * `conflictColumns` with the tenant column (#2360): the tenant column scopes\n * ownership, it does not identify the row the way a junction's foreign key\n * does, and it targets a class (`Tenant`) that is virtually always\n * referenced. Without this exclusion, deleting one `Tenant` row would\n * recursively CASCADE through every tenant-scoped table that has not\n * declared its own `conflictColumns` — the overwhelming majority. The field\n * is detected via the `__tenancy.isTenantIdField` marker `@tenantId()`\n * attaches to its own registration (`FieldMeta.__tenancy`, read structurally\n * so `smrt-core` never depends on `smrt-tenancy`). `@tenantId()` exposes no\n * `onDelete` option today, so this cannot currently be overridden per field.\n *\n * @see https://github.com/happyvertical/smrt/issues/2371\n * @module\n */\n\nimport { createLogger } from '@happyvertical/logger';\nimport type { DatabaseInterface } from '@happyvertical/sql';\nimport { classifyDatabaseError } from './db-errors.js';\nimport { ConfigurationError, DatabaseError } from './errors.js';\n// Type-only: erased at runtime, so it cannot re-enter the\n// `registry → object → cascade` import cycle.\nimport type { ObjectRegistry } from './registry.js';\nimport { chunkArray, IN_LIST_CHUNK_SIZE } from './utils/chunk.js';\nimport { toSnakeCase } from './utils/naming.js';\n\nconst logger = createLogger({ level: 'info' });\n\n/**\n * Referential action applied to rows pointing at a deleted object.\n *\n * Same vocabulary as SQL's `ON DELETE`, enforced by the framework instead of\n * the engine. `NO ACTION` means \"leave the rows alone\" — SMRT emits no\n * constraint, so nothing raises.\n */\nexport type OnDeleteAction = 'CASCADE' | 'SET NULL' | 'RESTRICT' | 'NO ACTION';\n\n/** Table holding framework-managed per-object memory entries. */\nconst CONTEXTS_TABLE = '_smrt_contexts';\n\n/** Table holding framework-managed per-object embedding vectors. */\nconst EMBEDDINGS_TABLE = '_smrt_embeddings';\n\n/**\n * Reserved `_smrt_contexts.owner_id` used by `SmrtCollection.remember()` for\n * collection-scoped memory. A real object id is a UUID and can never equal it,\n * but the guard keeps a malformed id from wiping every sibling's defaults.\n */\nconst COLLECTION_OWNER_SENTINEL = '__collection__';\n\n/**\n * Maximum number of cascade levels followed from the object `delete()` was\n * called on. A cycle between two classes that both declare `onDelete: 'CASCADE'`\n * is broken by the visited set; this bound catches unbounded *chains*.\n */\nconst MAX_CASCADE_DEPTH = 10;\n\n/** One reference that must be resolved before the target row can be removed. */\nexport interface CascadeReference {\n /** Registered name of the class holding the referencing column. */\n className: string;\n /** Table holding the referencing rows. */\n tableName: string;\n /** Field name (camelCase) on the referencing class. */\n fieldName: string;\n /** Column (snake_case) holding the reference. */\n column: string;\n /** Resolved action, never `NO ACTION` (those are dropped from the plan). */\n action: Exclude<OnDeleteAction, 'NO ACTION'>;\n /** `true` when the action was declared rather than derived from the key. */\n declared: boolean;\n}\n\n/** A polymorphic association table that can point at any class. */\nexport interface CascadePolymorphicReference {\n /** Registered name of the association class. */\n className: string;\n /** Table holding the association rows. */\n tableName: string;\n}\n\n/** Everything that must happen before rows of one class can be deleted. */\nexport interface CascadePlan {\n /** Typed `@foreignKey` / `@crossPackageRef` references, action-resolved. */\n references: CascadeReference[];\n /** Polymorphic association tables that may point at this class. */\n polymorphic: CascadePolymorphicReference[];\n /** `meta_type` values that identify this class in an association row. */\n metaTypes: string[];\n /** `true` when nothing references this class and nothing has to be visited. */\n isEmpty: boolean;\n}\n\n/**\n * Read-only slice of `ObjectRegistry` this module needs.\n *\n * Taking it as a parameter — rather than importing the registry as a value —\n * keeps `cascade.ts` out of the `registry → object → cascade` import cycle,\n * and lets tests drive the planner from a hand-built registry.\n */\nexport type CascadeRegistryView = Pick<\n typeof ObjectRegistry,\n | 'getRelationshipMap'\n | 'getFields'\n | 'getConflictColumns'\n | 'getTableName'\n | 'getSelfReferableNames'\n | 'getClass'\n | 'getSTIBase'\n | 'getDescendants'\n>;\n\nconst CASCADE_ACTIONS = new Set<OnDeleteAction>([\n 'CASCADE',\n 'SET NULL',\n 'RESTRICT',\n 'NO ACTION',\n]);\n\n/**\n * Normalize a declared `onDelete` value.\n *\n * Accepts any casing and both `SET NULL` and `SET_NULL`, matching what the\n * schema generator has always carried in the manifest. Returns `undefined` for\n * an unset or unrecognized value so the caller can fall back to the default.\n */\nexport function normalizeOnDelete(value: unknown): OnDeleteAction | undefined {\n if (typeof value !== 'string') return undefined;\n const normalized = value.trim().toUpperCase().replace(/_/g, ' ');\n return CASCADE_ACTIONS.has(normalized as OnDeleteAction)\n ? (normalized as OnDeleteAction)\n : undefined;\n}\n\nfunction isPolymorphicAssociationClass(fields: Map<string, unknown>): boolean {\n // The three columns `SmrtPolymorphicAssociation` contributes. Requiring all\n // of them keeps an unrelated class that happens to carry a `metaType` from\n // being treated as an association table.\n return fields.has('metaType') && fields.has('metaId') && fields.has('role');\n}\n\n/**\n * Build the cascade plan for one class.\n *\n * The plan is derived entirely from registry metadata, so it is valid for any\n * database the class is used against. It is rebuilt per delete rather than\n * cached: registration is incremental (manifests load lazily, tests register\n * classes between cases) and a stale plan would silently skip a table.\n *\n * @param registry - Registry view (`ObjectRegistry` satisfies it)\n * @param className - Class whose incoming references should be resolved\n */\nexport function buildCascadePlan(\n registry: CascadeRegistryView,\n className: string,\n): CascadePlan {\n // `className` is normally already qualified (delete() passes\n // getResolvedQualifiedName()), but a common same-package `@foreignKey('X')`\n // still stores its target as the bare simple name `X`. That still matches:\n // getSelfReferableNames() walks the full ancestor chain (self included) and\n // adds each ancestor's *simple* name via `getClass(ancestor)?.name`, so the\n // simple form is already in the seed set below, not just the loop's\n // qualified-variant augmentation.\n const targetNames = new Set(registry.getSelfReferableNames(className));\n for (const name of [...targetNames]) {\n const qualified = registry.getClass(name)?.qualifiedName;\n if (qualified) targetNames.add(qualified);\n }\n\n const references: CascadeReference[] = [];\n const polymorphic: CascadePolymorphicReference[] = [];\n\n for (const [sourceClass, relationships] of registry.getRelationshipMap()) {\n const fields = registry.getFields(sourceClass);\n\n if (isPolymorphicAssociationClass(fields)) {\n const tableName = registry.getTableName(sourceClass);\n if (tableName) {\n polymorphic.push({ className: sourceClass, tableName });\n }\n }\n\n if (relationships.length === 0) continue;\n\n let conflictColumns: Set<string> | undefined;\n\n for (const relationship of relationships) {\n if (\n relationship.type !== 'foreignKey' &&\n relationship.type !== 'crossPackageRef'\n ) {\n continue;\n }\n if (!targetNames.has(relationship.targetClass)) continue;\n\n const tableName = registry.getTableName(sourceClass);\n if (!tableName) continue;\n\n const column = toSnakeCase(relationship.fieldName);\n const declaredAction = normalizeOnDelete(relationship.options?.onDelete);\n\n if (!conflictColumns) {\n conflictColumns = new Set(registry.getConflictColumns(sourceClass));\n }\n\n // A `@tenantId()` field is a structural scoping marker, not a\n // junction/ownership key — it lands in `conflictColumns` only because\n // #2360 leads every tenant-scoped class's *default* natural key with\n // the tenant column, not because the referencing row is *identified*\n // by its tenant the way a junction row is identified by its parent.\n // Without this guard, deleting a `Tenant` would silently CASCADE\n // through the tenant column of every tenant-scoped class in the\n // schema that has not declared its own `conflictColumns` — the\n // overwhelming majority. `@tenantId()` exposes no `onDelete` today, so\n // an explicit declaration can never widen this back to CASCADE; that\n // is deliberate until tenant-delete cascade is an explicit decision.\n const isTenantIdField =\n relationship.options?.__tenancy?.isTenantIdField === true;\n\n const action =\n declaredAction ??\n (!isTenantIdField && conflictColumns.has(column)\n ? 'CASCADE'\n : 'NO ACTION');\n\n if (action === 'NO ACTION') continue;\n\n if (\n action === 'SET NULL' &&\n fields.get(relationship.fieldName)?.required\n ) {\n // Fail on the declaration, not later on a NOT NULL violation from the\n // engine — the message there names a column, not the decorator.\n throw ConfigurationError.invalidConfiguration(\n `${sourceClass}.${relationship.fieldName} onDelete: 'SET NULL'`,\n relationship.fieldName,\n \"a nullable field (mark it `nullable: true`, or use 'CASCADE' / 'RESTRICT')\",\n );\n }\n\n references.push({\n className: sourceClass,\n tableName,\n fieldName: relationship.fieldName,\n column,\n action,\n declared: declaredAction !== undefined,\n });\n }\n }\n\n // Both forms are matched deliberately: an association row written before a\n // class was package-qualified still carries the simple `meta_type`. The\n // qualified form alone is unambiguous; the simple-name fallback can still\n // match a same-simple-name sibling class's row if it also shares a\n // `meta_id` — a real, not just theoretical, risk for a class declaring\n // `idType: 'text'` (non-UUID, not guaranteed globally unique), the same gap\n // `deleteSystemRows()` closed for `_smrt_contexts` / `_smrt_embeddings`\n // (review fix) by adding a class-name filter. Closing it here needs the\n // same STI-and-legacy-name-aware filter this module already builds for\n // `deleteSystemRows()` narrowed further to registry-unambiguous simple\n // names; tracked under #2419 alongside the other same-simple-name\n // collision gaps rather than reworked here.\n const metaTypes: string[] = [];\n const qualified = registry.getClass(className)?.qualifiedName;\n if (qualified) metaTypes.push(qualified);\n const simple = registry.getClass(className)?.name ?? className;\n if (!metaTypes.includes(simple)) metaTypes.push(simple);\n\n return {\n references,\n polymorphic,\n metaTypes,\n isEmpty: references.length === 0 && polymorphic.length === 0,\n };\n}\n\n/** Per-delete state shared by every level of the cascade. */\ninterface CascadeContext {\n db: DatabaseInterface;\n registry: CascadeRegistryView;\n /** `table:id` pairs already expanded, so a reference cycle terminates. */\n visited: Set<string>;\n /** Tables touched by the cascade, for read-cache invalidation. */\n affectedTables: Set<string>;\n /**\n * Qualified name of the class that owns each affected table, so the\n * caller can resolve *that* class's own `@smrt({ cache })` config —\n * cross-process cache invalidation is a per-class opt-in, and a table\n * cascaded into belongs to a different class than the one `delete()` was\n * called on.\n */\n affectedTableClasses: Map<string, string>;\n}\n\n/** Resolve a registry-recorded class name to its qualified form, when known. */\nfunction toQualifiedClassName(\n registry: CascadeRegistryView,\n className: string,\n): string {\n return registry.getClass(className)?.qualifiedName ?? className;\n}\n\n/**\n * Every value `_smrt_contexts.owner_class` / `_smrt_embeddings.object_class`\n * could plausibly hold for a row belonging to an id of `className` (review\n * fix): both name forms (qualified + simple — older rows predate package\n * qualification) of `className` itself, plus every other member of its STI\n * hierarchy sharing its table. A CASCADE-collected id can belong to any\n * concrete STI subclass of the declaring reference, which stamps its own\n * (more specific) runtime class name at write time, not the reference's.\n *\n * Narrowing by this set — instead of matching by id alone — closes a\n * cross-class collision: two unrelated classes using `idType: 'text'`\n * (non-UUID, not guaranteed globally unique) could otherwise share an id\n * value and have one's `remember()`/embeddings rows deleted by the other's\n * cascade.\n */\nfunction ownerClassCandidates(\n registry: CascadeRegistryView,\n className: string,\n): string[] {\n const stiBase = registry.getSTIBase(className) ?? className;\n const members = new Set<string>([\n className,\n stiBase,\n ...registry.getDescendants(stiBase),\n ]);\n\n const names = new Set<string>();\n for (const member of members) {\n names.add(member);\n const registered = registry.getClass(member);\n if (registered?.qualifiedName) names.add(registered.qualifiedName);\n if (registered?.name) names.add(registered.name);\n }\n return [...names];\n}\n\n/**\n * Build the `where` clause matching `column` against one or more ids.\n *\n * A single id uses equality so the planner can use a plain index; multiple ids\n * use the adapter's `in` operator.\n */\nfunction idPredicate(column: string, ids: string[]): Record<string, unknown> {\n return ids.length === 1 ? { [column]: ids[0] } : { [`${column} in`]: ids };\n}\n\n/**\n * Run a cascade statement against a *referencing* table, tolerating the\n * table (or an expected column) not existing in this database.\n *\n * The cascade plan is built from the in-process registry, which can carry a\n * class from any imported package — including one whose table this specific\n * database was never migrated to include (a partially adopted feature, a\n * package pulled in for its types, or, in a test process, a fixture some\n * other test file registered). A missing referencing table trivially has no\n * rows to act on, so the correct behaviour is identical to the table\n * existing and being empty — this must never abort an otherwise valid\n * delete. Any other failure (a real constraint violation, a lock timeout, a\n * genuine SQL error unrelated to the table's existence) still propagates.\n */\nasync function tolerateMissingTable<T>(\n operation: () => Promise<T>,\n fallback: T,\n context: { table: string; action: string },\n): Promise<T> {\n try {\n return await operation();\n } catch (error) {\n if (classifyDatabaseError(error).kind === 'undefined_object') {\n logger.warn(\n `Cascade delete skipped ${context.action} on '${context.table}': ` +\n 'table or column not found in this database.',\n { error: error instanceof Error ? error.message : String(error) },\n );\n return fallback;\n }\n throw error;\n }\n}\n\nasync function selectIds(\n db: DatabaseInterface,\n tableName: string,\n column: string,\n ids: string[],\n): Promise<string[]> {\n const found: string[] = [];\n for (const batch of chunkArray(ids, IN_LIST_CHUNK_SIZE)) {\n const rows = await tolerateMissingTable(\n () => db.list(tableName, idPredicate(column, batch)),\n [] as Record<string, unknown>[],\n { table: tableName, action: 'CASCADE select' },\n );\n for (const row of rows) {\n const id = row?.id;\n if (typeof id === 'string' && id.length > 0) found.push(id);\n }\n }\n return found;\n}\n\nasync function deleteByIds(\n db: DatabaseInterface,\n tableName: string,\n ids: string[],\n): Promise<void> {\n for (const batch of chunkArray(ids, IN_LIST_CHUNK_SIZE)) {\n await tolerateMissingTable(\n () => db.delete(tableName, idPredicate('id', batch)),\n undefined,\n { table: tableName, action: 'CASCADE delete' },\n );\n }\n}\n\n/**\n * Remove the framework-managed side rows owned by the given object ids.\n *\n * `_smrt_contexts` and `_smrt_embeddings` are keyed by `(owner_class, owner_id)`\n * and `(object_class, object_id)` respectively. Matched by id *and* class\n * (review fix): id alone would let two unrelated classes using\n * `idType: 'text'` (non-UUID, not guaranteed globally unique) collide on a\n * shared id value and delete each other's memory/embeddings rows. `classNames`\n * is the STI-hierarchy-expanded candidate set from\n * {@link ownerClassCandidates} — the class column stores the *runtime*\n * constructor name, which for an STI hierarchy is a concrete subclass rather\n * than the class the cascade was planned from, so a single exact name is not\n * enough.\n *\n * A missing system table is not an error — an application database may predate\n * the table, and losing derived rows must never fail an otherwise valid delete.\n */\nasync function deleteSystemRows(\n db: DatabaseInterface,\n ids: string[],\n classNames: string[],\n): Promise<void> {\n const ownerIds = ids.filter(\n (id) =>\n typeof id === 'string' &&\n id.length > 0 &&\n id !== COLLECTION_OWNER_SENTINEL,\n );\n if (ownerIds.length === 0 || classNames.length === 0) return;\n\n for (const [table, idColumn, classColumn] of [\n [CONTEXTS_TABLE, 'owner_id', 'owner_class'],\n [EMBEDDINGS_TABLE, 'object_id', 'object_class'],\n ] as const) {\n for (const batch of chunkArray(ownerIds, IN_LIST_CHUNK_SIZE)) {\n try {\n await db.delete(table, {\n ...idPredicate(idColumn, batch),\n ...idPredicate(classColumn, classNames),\n });\n } catch (error) {\n logger.warn(\n `Failed to clean ${table} rows during cascade delete: ${\n error instanceof Error ? error.message : String(error)\n }`,\n );\n }\n }\n }\n}\n\n/**\n * Resolve every reference pointing at `ids` of `className`, recursively.\n *\n * Does **not** delete the rows identified by `ids` — the caller owns that, so\n * `SmrtObject.delete()` keeps issuing its own final statement and its own\n * lifecycle hooks.\n */\nasync function resolveReferences(\n ctx: CascadeContext,\n className: string,\n tableName: string,\n ids: string[],\n depth: number,\n): Promise<void> {\n const pending = ids.filter((id) => {\n const key = `${tableName}:${id}`;\n if (ctx.visited.has(key)) return false;\n ctx.visited.add(key);\n return true;\n });\n if (pending.length === 0) return;\n\n if (depth > MAX_CASCADE_DEPTH) {\n throw DatabaseError.constraintViolation(\n `cascade delete from ${className} exceeded the maximum depth of ` +\n `${MAX_CASCADE_DEPTH}; check for a chain of onDelete: 'CASCADE' ` +\n 'references that never terminates',\n className,\n );\n }\n\n const plan = buildCascadePlan(ctx.registry, className);\n\n // RESTRICT first: refuse before anything has been mutated.\n for (const reference of plan.references) {\n if (reference.action !== 'RESTRICT') continue;\n let remaining = 0;\n for (const batch of chunkArray(pending, IN_LIST_CHUNK_SIZE)) {\n remaining += await tolerateMissingTable(\n () =>\n ctx.db.count(\n reference.tableName,\n idPredicate(reference.column, batch),\n ),\n 0,\n { table: reference.tableName, action: 'RESTRICT check' },\n );\n if (remaining > 0) break;\n }\n if (remaining > 0) {\n throw DatabaseError.constraintViolation(\n `${reference.className}.${reference.fieldName} declares ` +\n `onDelete: 'RESTRICT' and ${remaining} row(s) still reference this ` +\n `${className}`,\n reference.column,\n );\n }\n }\n\n for (const reference of plan.references) {\n if (reference.action === 'SET NULL') {\n for (const batch of chunkArray(pending, IN_LIST_CHUNK_SIZE)) {\n await tolerateMissingTable(\n () =>\n ctx.db.update(\n reference.tableName,\n idPredicate(reference.column, batch),\n { [reference.column]: null },\n ),\n undefined,\n { table: reference.tableName, action: 'SET NULL' },\n );\n }\n ctx.affectedTables.add(reference.tableName);\n ctx.affectedTableClasses.set(\n reference.tableName,\n toQualifiedClassName(ctx.registry, reference.className),\n );\n continue;\n }\n\n if (reference.action !== 'CASCADE') continue;\n\n const childIds = await selectIds(\n ctx.db,\n reference.tableName,\n reference.column,\n pending,\n );\n if (childIds.length === 0) continue;\n\n await resolveReferences(\n ctx,\n reference.className,\n reference.tableName,\n childIds,\n depth + 1,\n );\n await deleteSystemRows(\n ctx.db,\n childIds,\n ownerClassCandidates(ctx.registry, reference.className),\n );\n await deleteByIds(ctx.db, reference.tableName, childIds);\n ctx.affectedTables.add(reference.tableName);\n ctx.affectedTableClasses.set(\n reference.tableName,\n toQualifiedClassName(ctx.registry, reference.className),\n );\n }\n\n for (const association of plan.polymorphic) {\n for (const batch of chunkArray(pending, IN_LIST_CHUNK_SIZE)) {\n const where: Record<string, unknown> = {\n ...idPredicate('meta_id', batch),\n };\n if (plan.metaTypes.length === 1) {\n where.meta_type = plan.metaTypes[0];\n } else {\n where['meta_type in'] = plan.metaTypes;\n }\n const result = await tolerateMissingTable(\n () => ctx.db.delete(association.tableName, where),\n undefined,\n {\n table: association.tableName,\n action: 'polymorphic association cleanup',\n },\n );\n if ((result?.affected ?? 0) > 0) {\n ctx.affectedTables.add(association.tableName);\n ctx.affectedTableClasses.set(\n association.tableName,\n toQualifiedClassName(ctx.registry, association.className),\n );\n }\n }\n }\n}\n\n/** Outcome of a cascade run, returned so the caller can invalidate caches. */\nexport interface CascadeResult {\n /** Tables whose rows were removed or nulled, excluding the target's own. */\n affectedTables: Set<string>;\n /**\n * Qualified class name that owns each entry in {@link affectedTables},\n * where resolvable — lets the caller check *that* class's own\n * cross-process cache config rather than only its own.\n */\n affectedTableClasses: Map<string, string>;\n}\n\n/**\n * Resolve every reference to `ids` of `className` and clean their framework\n * side rows, then hand control back so the caller can delete the rows.\n *\n * Callers are expected to run this on a transaction-bound `db` — see\n * {@link runCascadeDelete}, which owns that decision.\n */\nexport async function cascadeReferencesTo(\n db: DatabaseInterface,\n registry: CascadeRegistryView,\n target: { className: string; tableName: string; ids: string[] },\n): Promise<CascadeResult> {\n const ctx: CascadeContext = {\n db,\n registry,\n visited: new Set(),\n affectedTables: new Set(),\n affectedTableClasses: new Map(),\n };\n await resolveReferences(\n ctx,\n target.className,\n target.tableName,\n target.ids,\n 0,\n );\n await deleteSystemRows(\n db,\n target.ids,\n ownerClassCandidates(registry, target.className),\n );\n return {\n affectedTables: ctx.affectedTables,\n affectedTableClasses: ctx.affectedTableClasses,\n };\n}\n\ntype TransactionCapable = DatabaseInterface & {\n transaction?: <T>(\n this: DatabaseInterface,\n callback: (tx: DatabaseInterface) => Promise<T>,\n ) => Promise<T>;\n};\n\n/**\n * Run the cascade and the target row's own deletion atomically.\n *\n * When anything references the class and the adapter exposes `transaction()`,\n * the whole sequence runs inside one — including the caller's `deleteSelf`\n * statement, so a failure part-way through cannot leave the object deleted with\n * its junction rows intact (or vice versa). Adapters without transaction\n * support run the same statements sequentially; this is the documented\n * degradation, not a silent one.\n *\n * When nothing references the class there is nothing to keep consistent, and\n * the transaction is skipped — see the comment on that branch.\n *\n * @param db - Database the object is bound to\n * @param registry - Registry view used to build cascade plans\n * @param target - Class, table and id of the object being deleted\n * @param deleteSelf - Issues the target row's own `DELETE`, on the tx-bound db\n * @returns Tables affected by the cascade, for read-cache invalidation\n */\nexport async function runCascadeDelete(\n db: DatabaseInterface,\n registry: CascadeRegistryView,\n target: {\n className: string;\n tableName: string;\n /** An unsaved object has none; the cascade is then a no-op. */\n id: string | null | undefined;\n },\n deleteSelf: (db: DatabaseInterface) => Promise<void>,\n): Promise<CascadeResult> {\n const ids = target.id ? [target.id] : [];\n\n // Nothing references this class AND no polymorphic association class is\n // registered anywhere in the process. `plan.polymorphic` is unconditionally\n // every registered `SmrtPolymorphicAssociation` subclass — a `metaType`\n // column can point at any class at runtime, so there is no static metadata\n // to scope it by, unlike a typed `@foreignKey`/`@crossPackageRef`. This\n // branch is therefore common for a class with no incoming references in an\n // app with no polymorphic associations at all, but rare — not \"the\n // overwhelmingly common case\" — once even one polymorphic class exists\n // anywhere in the process, since every delete's plan then carries it.\n // Opening a transaction to wrap statements that cannot disagree with each\n // other would cost two extra round trips, and on single-connection adapters\n // it would serialize concurrent deletes behind the transaction queue. Order\n // the two statements instead: the row goes first, so a failure leaves\n // everything as it was, and the derived side rows follow under the\n // best-effort contract they already carry.\n if (buildCascadePlan(registry, target.className).isEmpty) {\n await deleteSelf(db);\n await deleteSystemRows(\n db,\n ids,\n ownerClassCandidates(registry, target.className),\n );\n return { affectedTables: new Set(), affectedTableClasses: new Map() };\n }\n\n const run = async (bound: DatabaseInterface): Promise<CascadeResult> => {\n const result = await cascadeReferencesTo(bound, registry, {\n className: target.className,\n tableName: target.tableName,\n ids,\n });\n await deleteSelf(bound);\n return result;\n };\n\n const transaction = (db as TransactionCapable).transaction;\n if (typeof transaction !== 'function') {\n return run(db);\n }\n\n return transaction.call<\n DatabaseInterface,\n [(tx: DatabaseInterface) => Promise<CascadeResult>],\n Promise<CascadeResult>\n >(db, run);\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"cascade.js","names":[],"sources":["../src/cascade.ts"],"sourcesContent":["/**\n * App-side referential integrity for `SmrtObject.delete()` (#2371).\n *\n * Same-package `@foreignKey` fields now emit database constraints where the\n * engine can enforce the declared policy. Cross-package references and older\n * schemas still rely on this application-side path. Before this module,\n * `delete()` removed the object's own row and nothing else: junction rows,\n * polymorphic association rows, `_smrt_embeddings` and `_smrt_contexts` entries\n * were all left pointing at an id that no longer resolved, and\n * `@foreignKey(..., { onDelete })` was metadata nobody read.\n *\n * This module implements those semantics in the application layer. It mirrors\n * what a DB-level constraint would do, deliberately including the parts that\n * make it *unlike* a model-layer delete:\n *\n * - Cascaded rows are removed with set-based statements. Their `beforeDelete` /\n * `afterDelete` hooks and interceptors do **not** run, and no change-feed\n * tombstone is written for them — exactly as `ON DELETE CASCADE` behaves.\n * Only the object `delete()` was called on runs the full lifecycle.\n * - When anything needs cascading, everything runs inside a single\n * transaction when the adapter exposes one, so a partial cascade cannot\n * survive a failure. A class with no typed references AND no registered\n * polymorphic association class anywhere in the process skips the\n * transaction — see {@link runCascadeDelete}. A `metaType` column can point\n * at any class at runtime, so a polymorphic association class is always\n * plausibly relevant; in an app with even one registered, this fast path is\n * rare, not the common case.\n *\n * ## Which references are followed\n *\n * A reference is any `@foreignKey` / `@crossPackageRef` field on a registered\n * class whose target resolves to the deleted object's class (or one of its\n * registered STI ancestors), plus the polymorphic `(metaType, metaId)` pair on\n * {@link SmrtPolymorphicAssociation} subclasses.\n *\n * The action comes from the field's declared `onDelete`. When it is not\n * declared, the default is derived from the referencing class's natural key:\n *\n * | Reference | Default when `onDelete` is absent |\n * |---|---|\n * | Column is part of the referencing class's `conflictColumns`, and is not a `@tenantId()` field | `CASCADE` |\n * | Polymorphic `(metaType, metaId)` association row | `CASCADE` |\n * | Ordinary same-package reference | `NO ACTION` (deletion is refused while references remain) |\n * | Every `@tenantId()` field | Excluded: tenant scope is not an ownership edge |\n *\n * The natural-key rule is what makes junction rows work without any\n * per-package annotation: a junction declares\n * `@smrt({ conflictColumns: ['content_id', 'asset_id', 'relationship'] })`, so\n * `content_id` identifies the row and the row cannot outlive the content it\n * links. An ordinary child (`Order.customerId`) is keyed by `(slug, context)`,\n * so it defaults to immediate `NO ACTION` unless it opts in with\n * `@foreignKey(Customer, { onDelete: 'CASCADE' })`.\n *\n * `@tenantId()` fields are excluded from the natural-key rule even though\n * `@happyvertical/smrt-tenancy` leads a tenant-scoped class's *default*\n * `conflictColumns` with the tenant column (#2360): the tenant column scopes\n * ownership, it does not identify the row the way a junction's foreign key\n * does, and it targets a class (`Tenant`) that is virtually always\n * referenced. Without this exclusion, deleting one `Tenant` row would\n * recursively CASCADE through every tenant-scoped table that has not\n * declared its own `conflictColumns` — the overwhelming majority. The field\n * is detected via the `__tenancy.isTenantIdField` marker `@tenantId()`\n * attaches to its own registration (`FieldMeta.__tenancy`, read structurally\n * so `smrt-core` never depends on `smrt-tenancy`). `@tenantId()` exposes no\n * `onDelete` option today, so this cannot currently be overridden per field.\n *\n * @see https://github.com/happyvertical/smrt/issues/2371\n * @module\n */\n\nimport { createLogger } from '@happyvertical/logger';\nimport type { DatabaseInterface } from '@happyvertical/sql';\nimport { classifyDatabaseError } from './db-errors.js';\nimport { ConfigurationError, DatabaseError } from './errors.js';\n// Type-only: erased at runtime, so it cannot re-enter the\n// `registry → object → cascade` import cycle.\nimport type { ObjectRegistry } from './registry.js';\nimport {\n normalizeForeignKeyAction,\n resolveForeignKeyDeleteAction,\n} from './schema/foreign-key-policy.js';\nimport type { ForeignKeyAction } from './schema/types.js';\nimport { chunkArray, IN_LIST_CHUNK_SIZE } from './utils/chunk.js';\nimport { toSnakeCase } from './utils/naming.js';\n\nconst logger = createLogger({ level: 'info' });\n\n/**\n * Referential action applied to rows pointing at a deleted object.\n *\n * Same vocabulary as SQL's `ON DELETE`, enforced by the framework before the\n * engine. `NO ACTION` is preflighted like an immediate restrictive action so\n * application-side and database-side enforcement agree.\n */\nexport type OnDeleteAction = ForeignKeyAction;\n\n/** Table holding framework-managed per-object memory entries. */\nconst CONTEXTS_TABLE = '_smrt_contexts';\n\n/** Table holding framework-managed per-object embedding vectors. */\nconst EMBEDDINGS_TABLE = '_smrt_embeddings';\n\n/**\n * Reserved `_smrt_contexts.owner_id` used by `SmrtCollection.remember()` for\n * collection-scoped memory. A real object id is a UUID and can never equal it,\n * but the guard keeps a malformed id from wiping every sibling's defaults.\n */\nconst COLLECTION_OWNER_SENTINEL = '__collection__';\n\n/**\n * Maximum number of cascade levels followed from the object `delete()` was\n * called on. A cycle between two classes that both declare `onDelete: 'CASCADE'`\n * is broken by the visited set; this bound catches unbounded *chains*.\n */\nconst MAX_CASCADE_DEPTH = 10;\n\n/** One reference that must be resolved before the target row can be removed. */\nexport interface CascadeReference {\n /** Registered name of the class holding the referencing column. */\n className: string;\n /** Table holding the referencing rows. */\n tableName: string;\n /** Field name (camelCase) on the referencing class. */\n fieldName: string;\n /** Column (snake_case) holding the reference. */\n column: string;\n /** Resolved action. `NO ACTION` is preflighted like an immediate RESTRICT. */\n action: OnDeleteAction;\n /** `true` when the action was declared rather than derived from the key. */\n declared: boolean;\n}\n\n/** A polymorphic association table that can point at any class. */\nexport interface CascadePolymorphicReference {\n /** Registered name of the association class. */\n className: string;\n /** Table holding the association rows. */\n tableName: string;\n}\n\n/** Everything that must happen before rows of one class can be deleted. */\nexport interface CascadePlan {\n /** Typed `@foreignKey` / `@crossPackageRef` references, action-resolved. */\n references: CascadeReference[];\n /** Polymorphic association tables that may point at this class. */\n polymorphic: CascadePolymorphicReference[];\n /** `meta_type` values that identify this class in an association row. */\n metaTypes: string[];\n /** `true` when nothing references this class and nothing has to be visited. */\n isEmpty: boolean;\n}\n\n/**\n * Read-only slice of `ObjectRegistry` this module needs.\n *\n * Taking it as a parameter — rather than importing the registry as a value —\n * keeps `cascade.ts` out of the `registry → object → cascade` import cycle,\n * and lets tests drive the planner from a hand-built registry.\n */\nexport type CascadeRegistryView = Pick<\n typeof ObjectRegistry,\n | 'getRelationshipMap'\n | 'getFields'\n | 'getConflictColumns'\n | 'getTableName'\n | 'getSelfReferableNames'\n | 'getClass'\n | 'getSTIBase'\n | 'getDescendants'\n>;\n\n/**\n * Normalize a declared `onDelete` value.\n *\n * Accepts any casing and both `SET NULL` and `SET_NULL`, matching what the\n * schema generator has always carried in the manifest. Returns `undefined` for\n * an unset or unrecognized value so the caller can fall back to the default.\n */\nexport function normalizeOnDelete(value: unknown): OnDeleteAction | undefined {\n return normalizeForeignKeyAction(value);\n}\n\nfunction isPolymorphicAssociationClass(fields: Map<string, unknown>): boolean {\n // The three columns `SmrtPolymorphicAssociation` contributes. Requiring all\n // of them keeps an unrelated class that happens to carry a `metaType` from\n // being treated as an association table.\n return fields.has('metaType') && fields.has('metaId') && fields.has('role');\n}\n\n/**\n * Build the cascade plan for one class.\n *\n * The plan is derived entirely from registry metadata, so it is valid for any\n * database the class is used against. It is rebuilt per delete rather than\n * cached: registration is incremental (manifests load lazily, tests register\n * classes between cases) and a stale plan would silently skip a table.\n *\n * @param registry - Registry view (`ObjectRegistry` satisfies it)\n * @param className - Class whose incoming references should be resolved\n */\nexport function buildCascadePlan(\n registry: CascadeRegistryView,\n className: string,\n): CascadePlan {\n // `className` is normally already qualified (delete() passes\n // getResolvedQualifiedName()), but a common same-package `@foreignKey('X')`\n // still stores its target as the bare simple name `X`. That still matches:\n // getSelfReferableNames() walks the full ancestor chain (self included) and\n // adds each ancestor's *simple* name via `getClass(ancestor)?.name`, so the\n // simple form is already in the seed set below, not just the loop's\n // qualified-variant augmentation.\n const targetNames = new Set(registry.getSelfReferableNames(className));\n for (const name of [...targetNames]) {\n const qualified = registry.getClass(name)?.qualifiedName;\n if (qualified) targetNames.add(qualified);\n }\n\n const references: CascadeReference[] = [];\n const polymorphic: CascadePolymorphicReference[] = [];\n\n for (const [sourceClass, relationships] of registry.getRelationshipMap()) {\n const fields = registry.getFields(sourceClass);\n\n if (isPolymorphicAssociationClass(fields)) {\n const tableName = registry.getTableName(sourceClass);\n if (tableName) {\n polymorphic.push({ className: sourceClass, tableName });\n }\n }\n\n if (relationships.length === 0) continue;\n\n let conflictColumns: Set<string> | undefined;\n\n for (const relationship of relationships) {\n if (\n relationship.type !== 'foreignKey' &&\n relationship.type !== 'crossPackageRef'\n ) {\n continue;\n }\n if (!targetNames.has(relationship.targetClass)) continue;\n\n // An explicit app-side-only relationship is archival metadata, not a\n // referential-integrity rule. Its identifier is deliberately allowed to\n // outlive the parent, so delete planning must not block, null, or remove\n // the retained row (#2413).\n if (\n relationship.type === 'foreignKey' &&\n relationship.options?.constraint === false\n ) {\n continue;\n }\n\n const tableName = registry.getTableName(sourceClass);\n if (!tableName) continue;\n\n const column = toSnakeCase(relationship.fieldName);\n if (!conflictColumns) {\n conflictColumns = new Set(registry.getConflictColumns(sourceClass));\n }\n\n // A `@tenantId()` field is a structural scoping marker, not a\n // junction/ownership key — it lands in `conflictColumns` only because\n // #2360 leads every tenant-scoped class's *default* natural key with\n // the tenant column, not because the referencing row is *identified*\n // by its tenant the way a junction row is identified by its parent.\n // Without this guard, deleting a `Tenant` would silently CASCADE\n // through the tenant column of every tenant-scoped class in the\n // schema that has not declared its own `conflictColumns` — the\n // overwhelming majority. `@tenantId()` exposes no `onDelete` today, so\n // an explicit declaration can never widen this back to CASCADE; that\n // is deliberate until tenant-delete cascade is an explicit decision.\n const isTenantIdField =\n relationship.options?.__tenancy?.isTenantIdField === true;\n if (isTenantIdField) continue;\n\n const { action, declared } = resolveForeignKeyDeleteAction({\n declared: relationship.options?.onDelete,\n isConflictColumn: conflictColumns.has(column),\n isTenantIdField: false,\n });\n\n if (\n action === 'SET NULL' &&\n fields.get(relationship.fieldName)?.required\n ) {\n // Fail on the declaration, not later on a NOT NULL violation from the\n // engine — the message there names a column, not the decorator.\n throw ConfigurationError.invalidConfiguration(\n `${sourceClass}.${relationship.fieldName} onDelete: 'SET NULL'`,\n relationship.fieldName,\n \"a nullable field (mark it `nullable: true`, or use 'CASCADE' / 'RESTRICT')\",\n );\n }\n\n references.push({\n className: sourceClass,\n tableName,\n fieldName: relationship.fieldName,\n column,\n action,\n declared,\n });\n }\n }\n\n // Both forms are matched deliberately: an association row written before a\n // class was package-qualified still carries the simple `meta_type`. The\n // qualified form alone is unambiguous; the simple-name fallback can still\n // match a same-simple-name sibling class's row if it also shares a\n // `meta_id` — a real, not just theoretical, risk for a class declaring\n // `idType: 'text'` (non-UUID, not guaranteed globally unique), the same gap\n // `deleteSystemRows()` closed for `_smrt_contexts` / `_smrt_embeddings`\n // (review fix) by adding a class-name filter. Closing it here needs the\n // same STI-and-legacy-name-aware filter this module already builds for\n // `deleteSystemRows()` narrowed further to registry-unambiguous simple\n // names; tracked under #2419 alongside the other same-simple-name\n // collision gaps rather than reworked here.\n const metaTypes: string[] = [];\n const qualified = registry.getClass(className)?.qualifiedName;\n if (qualified) metaTypes.push(qualified);\n const simple = registry.getClass(className)?.name ?? className;\n if (!metaTypes.includes(simple)) metaTypes.push(simple);\n\n return {\n references,\n polymorphic,\n metaTypes,\n isEmpty: references.length === 0 && polymorphic.length === 0,\n };\n}\n\n/** Per-delete state shared by every level of the cascade. */\ninterface CascadeContext {\n db: DatabaseInterface;\n registry: CascadeRegistryView;\n /** `table:id` pairs already expanded, so a reference cycle terminates. */\n visited: Set<string>;\n /** Tables touched by the cascade, for read-cache invalidation. */\n affectedTables: Set<string>;\n /**\n * Qualified name of the class that owns each affected table, so the\n * caller can resolve *that* class's own `@smrt({ cache })` config —\n * cross-process cache invalidation is a per-class opt-in, and a table\n * cascaded into belongs to a different class than the one `delete()` was\n * called on.\n */\n affectedTableClasses: Map<string, string>;\n}\n\n/** Resolve a registry-recorded class name to its qualified form, when known. */\nfunction toQualifiedClassName(\n registry: CascadeRegistryView,\n className: string,\n): string {\n return registry.getClass(className)?.qualifiedName ?? className;\n}\n\n/**\n * Every value `_smrt_contexts.owner_class` / `_smrt_embeddings.object_class`\n * could plausibly hold for a row belonging to an id of `className` (review\n * fix): both name forms (qualified + simple — older rows predate package\n * qualification) of `className` itself, plus every other member of its STI\n * hierarchy sharing its table. A CASCADE-collected id can belong to any\n * concrete STI subclass of the declaring reference, which stamps its own\n * (more specific) runtime class name at write time, not the reference's.\n *\n * Narrowing by this set — instead of matching by id alone — closes a\n * cross-class collision: two unrelated classes using `idType: 'text'`\n * (non-UUID, not guaranteed globally unique) could otherwise share an id\n * value and have one's `remember()`/embeddings rows deleted by the other's\n * cascade.\n */\nfunction ownerClassCandidates(\n registry: CascadeRegistryView,\n className: string,\n): string[] {\n const stiBase = registry.getSTIBase(className) ?? className;\n const members = new Set<string>([\n className,\n stiBase,\n ...registry.getDescendants(stiBase),\n ]);\n\n const names = new Set<string>();\n for (const member of members) {\n names.add(member);\n const registered = registry.getClass(member);\n if (registered?.qualifiedName) names.add(registered.qualifiedName);\n if (registered?.name) names.add(registered.name);\n }\n return [...names];\n}\n\n/**\n * Build the `where` clause matching `column` against one or more ids.\n *\n * A single id uses equality so the planner can use a plain index; multiple ids\n * use the adapter's `in` operator.\n */\nfunction idPredicate(column: string, ids: string[]): Record<string, unknown> {\n return ids.length === 1 ? { [column]: ids[0] } : { [`${column} in`]: ids };\n}\n\n/**\n * Run a cascade statement against a *referencing* table, tolerating the\n * table (or an expected column) not existing in this database.\n *\n * The cascade plan is built from the in-process registry, which can carry a\n * class from any imported package — including one whose table this specific\n * database was never migrated to include (a partially adopted feature, a\n * package pulled in for its types, or, in a test process, a fixture some\n * other test file registered). A missing referencing table trivially has no\n * rows to act on, so the correct behaviour is identical to the table\n * existing and being empty — this must never abort an otherwise valid\n * delete. Any other failure (a real constraint violation, a lock timeout, a\n * genuine SQL error unrelated to the table's existence) still propagates.\n */\nasync function tolerateMissingTable<T>(\n operation: () => Promise<T>,\n fallback: T,\n context: { table: string; action: string },\n): Promise<T> {\n try {\n return await operation();\n } catch (error) {\n if (classifyDatabaseError(error).kind === 'undefined_object') {\n logger.warn(\n `Cascade delete skipped ${context.action} on '${context.table}': ` +\n 'table or column not found in this database.',\n { error: error instanceof Error ? error.message : String(error) },\n );\n return fallback;\n }\n throw error;\n }\n}\n\nasync function selectIds(\n db: DatabaseInterface,\n tableName: string,\n column: string,\n ids: string[],\n): Promise<string[]> {\n const found: string[] = [];\n for (const batch of chunkArray(ids, IN_LIST_CHUNK_SIZE)) {\n const rows = await tolerateMissingTable(\n () => db.list(tableName, idPredicate(column, batch)),\n [] as Record<string, unknown>[],\n { table: tableName, action: 'CASCADE select' },\n );\n for (const row of rows) {\n const id = row?.id;\n if (typeof id === 'string' && id.length > 0) found.push(id);\n }\n }\n return found;\n}\n\nasync function deleteByIds(\n db: DatabaseInterface,\n tableName: string,\n ids: string[],\n): Promise<void> {\n for (const batch of chunkArray(ids, IN_LIST_CHUNK_SIZE)) {\n await tolerateMissingTable(\n () => db.delete(tableName, idPredicate('id', batch)),\n undefined,\n { table: tableName, action: 'CASCADE delete' },\n );\n }\n}\n\n/**\n * Remove the framework-managed side rows owned by the given object ids.\n *\n * `_smrt_contexts` and `_smrt_embeddings` are keyed by `(owner_class, owner_id)`\n * and `(object_class, object_id)` respectively. Matched by id *and* class\n * (review fix): id alone would let two unrelated classes using\n * `idType: 'text'` (non-UUID, not guaranteed globally unique) collide on a\n * shared id value and delete each other's memory/embeddings rows. `classNames`\n * is the STI-hierarchy-expanded candidate set from\n * {@link ownerClassCandidates} — the class column stores the *runtime*\n * constructor name, which for an STI hierarchy is a concrete subclass rather\n * than the class the cascade was planned from, so a single exact name is not\n * enough.\n *\n * A missing system table is not an error — an application database may predate\n * the table, and losing derived rows must never fail an otherwise valid delete.\n */\nasync function deleteSystemRows(\n db: DatabaseInterface,\n ids: string[],\n classNames: string[],\n): Promise<void> {\n const ownerIds = ids.filter(\n (id) =>\n typeof id === 'string' &&\n id.length > 0 &&\n id !== COLLECTION_OWNER_SENTINEL,\n );\n if (ownerIds.length === 0 || classNames.length === 0) return;\n\n for (const [table, idColumn, classColumn] of [\n [CONTEXTS_TABLE, 'owner_id', 'owner_class'],\n [EMBEDDINGS_TABLE, 'object_id', 'object_class'],\n ] as const) {\n for (const batch of chunkArray(ownerIds, IN_LIST_CHUNK_SIZE)) {\n try {\n await db.delete(table, {\n ...idPredicate(idColumn, batch),\n ...idPredicate(classColumn, classNames),\n });\n } catch (error) {\n logger.warn(\n `Failed to clean ${table} rows during cascade delete: ${\n error instanceof Error ? error.message : String(error)\n }`,\n );\n }\n }\n }\n}\n\n/**\n * Resolve every reference pointing at `ids` of `className`, recursively.\n *\n * Does **not** delete the rows identified by `ids` — the caller owns that, so\n * `SmrtObject.delete()` keeps issuing its own final statement and its own\n * lifecycle hooks.\n */\nasync function resolveReferences(\n ctx: CascadeContext,\n className: string,\n tableName: string,\n ids: string[],\n depth: number,\n): Promise<void> {\n const pending = ids.filter((id) => {\n const key = `${tableName}:${id}`;\n if (ctx.visited.has(key)) return false;\n ctx.visited.add(key);\n return true;\n });\n if (pending.length === 0) return;\n\n if (depth > MAX_CASCADE_DEPTH) {\n throw DatabaseError.constraintViolation(\n `cascade delete from ${className} exceeded the maximum depth of ` +\n `${MAX_CASCADE_DEPTH}; check for a chain of onDelete: 'CASCADE' ` +\n 'references that never terminates',\n className,\n );\n }\n\n const plan = buildCascadePlan(ctx.registry, className);\n\n // SQL NO ACTION is immediate on every supported SMRT migration path, so the\n // app-side belt preflights it exactly like RESTRICT before any mutation.\n for (const reference of plan.references) {\n if (reference.action !== 'RESTRICT' && reference.action !== 'NO ACTION') {\n continue;\n }\n let remaining = 0;\n for (const batch of chunkArray(pending, IN_LIST_CHUNK_SIZE)) {\n remaining += await tolerateMissingTable(\n () =>\n ctx.db.count(\n reference.tableName,\n idPredicate(reference.column, batch),\n ),\n 0,\n { table: reference.tableName, action: `${reference.action} check` },\n );\n if (remaining > 0) break;\n }\n if (remaining > 0) {\n throw DatabaseError.constraintViolation(\n `${reference.className}.${reference.fieldName} ` +\n `${reference.declared ? 'declares' : 'resolves to'} onDelete: ` +\n `'${reference.action}' and ${remaining} row(s) still reference this ` +\n `${className}`,\n reference.column,\n );\n }\n }\n\n for (const reference of plan.references) {\n if (reference.action === 'SET NULL') {\n for (const batch of chunkArray(pending, IN_LIST_CHUNK_SIZE)) {\n await tolerateMissingTable(\n () =>\n ctx.db.update(\n reference.tableName,\n idPredicate(reference.column, batch),\n { [reference.column]: null },\n ),\n undefined,\n { table: reference.tableName, action: 'SET NULL' },\n );\n }\n ctx.affectedTables.add(reference.tableName);\n ctx.affectedTableClasses.set(\n reference.tableName,\n toQualifiedClassName(ctx.registry, reference.className),\n );\n continue;\n }\n\n if (reference.action !== 'CASCADE') continue;\n\n const childIds = await selectIds(\n ctx.db,\n reference.tableName,\n reference.column,\n pending,\n );\n if (childIds.length === 0) continue;\n\n await resolveReferences(\n ctx,\n reference.className,\n reference.tableName,\n childIds,\n depth + 1,\n );\n await deleteSystemRows(\n ctx.db,\n childIds,\n ownerClassCandidates(ctx.registry, reference.className),\n );\n await deleteByIds(ctx.db, reference.tableName, childIds);\n ctx.affectedTables.add(reference.tableName);\n ctx.affectedTableClasses.set(\n reference.tableName,\n toQualifiedClassName(ctx.registry, reference.className),\n );\n }\n\n for (const association of plan.polymorphic) {\n for (const batch of chunkArray(pending, IN_LIST_CHUNK_SIZE)) {\n const where: Record<string, unknown> = {\n ...idPredicate('meta_id', batch),\n };\n if (plan.metaTypes.length === 1) {\n where.meta_type = plan.metaTypes[0];\n } else {\n where['meta_type in'] = plan.metaTypes;\n }\n const result = await tolerateMissingTable(\n () => ctx.db.delete(association.tableName, where),\n undefined,\n {\n table: association.tableName,\n action: 'polymorphic association cleanup',\n },\n );\n if ((result?.affected ?? 0) > 0) {\n ctx.affectedTables.add(association.tableName);\n ctx.affectedTableClasses.set(\n association.tableName,\n toQualifiedClassName(ctx.registry, association.className),\n );\n }\n }\n }\n}\n\n/** Outcome of a cascade run, returned so the caller can invalidate caches. */\nexport interface CascadeResult {\n /** Tables whose rows were removed or nulled, excluding the target's own. */\n affectedTables: Set<string>;\n /**\n * Qualified class name that owns each entry in {@link affectedTables},\n * where resolvable — lets the caller check *that* class's own\n * cross-process cache config rather than only its own.\n */\n affectedTableClasses: Map<string, string>;\n}\n\n/**\n * Resolve every reference to `ids` of `className` and clean their framework\n * side rows, then hand control back so the caller can delete the rows.\n *\n * Callers are expected to run this on a transaction-bound `db` — see\n * {@link runCascadeDelete}, which owns that decision.\n */\nexport async function cascadeReferencesTo(\n db: DatabaseInterface,\n registry: CascadeRegistryView,\n target: { className: string; tableName: string; ids: string[] },\n): Promise<CascadeResult> {\n const ctx: CascadeContext = {\n db,\n registry,\n visited: new Set(),\n affectedTables: new Set(),\n affectedTableClasses: new Map(),\n };\n await resolveReferences(\n ctx,\n target.className,\n target.tableName,\n target.ids,\n 0,\n );\n await deleteSystemRows(\n db,\n target.ids,\n ownerClassCandidates(registry, target.className),\n );\n return {\n affectedTables: ctx.affectedTables,\n affectedTableClasses: ctx.affectedTableClasses,\n };\n}\n\ntype TransactionCapable = DatabaseInterface & {\n transaction?: <T>(\n this: DatabaseInterface,\n callback: (tx: DatabaseInterface) => Promise<T>,\n ) => Promise<T>;\n};\n\n/**\n * Run the cascade and the target row's own deletion atomically.\n *\n * When anything references the class and the adapter exposes `transaction()`,\n * the whole sequence runs inside one — including the caller's `deleteSelf`\n * statement, so a failure part-way through cannot leave the object deleted with\n * its junction rows intact (or vice versa). Adapters without transaction\n * support run the same statements sequentially; this is the documented\n * degradation, not a silent one.\n *\n * When nothing references the class there is nothing to keep consistent, and\n * the transaction is skipped — see the comment on that branch.\n *\n * @param db - Database the object is bound to\n * @param registry - Registry view used to build cascade plans\n * @param target - Class, table and id of the object being deleted\n * @param deleteSelf - Issues the target row's own `DELETE`, on the tx-bound db\n * @returns Tables affected by the cascade, for read-cache invalidation\n */\nexport async function runCascadeDelete(\n db: DatabaseInterface,\n registry: CascadeRegistryView,\n target: {\n className: string;\n tableName: string;\n /** An unsaved object has none; the cascade is then a no-op. */\n id: string | null | undefined;\n },\n deleteSelf: (db: DatabaseInterface) => Promise<void>,\n): Promise<CascadeResult> {\n const ids = target.id ? [target.id] : [];\n\n // Nothing references this class AND no polymorphic association class is\n // registered anywhere in the process. `plan.polymorphic` is unconditionally\n // every registered `SmrtPolymorphicAssociation` subclass — a `metaType`\n // column can point at any class at runtime, so there is no static metadata\n // to scope it by, unlike a typed `@foreignKey`/`@crossPackageRef`. This\n // branch is therefore common for a class with no incoming references in an\n // app with no polymorphic associations at all, but rare — not \"the\n // overwhelmingly common case\" — once even one polymorphic class exists\n // anywhere in the process, since every delete's plan then carries it.\n // Opening a transaction to wrap statements that cannot disagree with each\n // other would cost two extra round trips, and on single-connection adapters\n // it would serialize concurrent deletes behind the transaction queue. Order\n // the two statements instead: the row goes first, so a failure leaves\n // everything as it was, and the derived side rows follow under the\n // best-effort contract they already carry.\n if (buildCascadePlan(registry, target.className).isEmpty) {\n await deleteSelf(db);\n await deleteSystemRows(\n db,\n ids,\n ownerClassCandidates(registry, target.className),\n );\n return { affectedTables: new Set(), affectedTableClasses: new Map() };\n }\n\n const run = async (bound: DatabaseInterface): Promise<CascadeResult> => {\n const result = await cascadeReferencesTo(bound, registry, {\n className: target.className,\n tableName: target.tableName,\n ids,\n });\n await deleteSelf(bound);\n return result;\n };\n\n const transaction = (db as TransactionCapable).transaction;\n if (typeof transaction !== 'function') {\n return run(db);\n }\n\n return transaction.call<\n DatabaseInterface,\n [(tx: DatabaseInterface) => Promise<CascadeResult>],\n Promise<CascadeResult>\n >(db, run);\n}\n"],"mappings":"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"}
package/dist/class.d.ts CHANGED
@@ -300,6 +300,16 @@ export declare class SmrtClass {
300
300
  * Gets the database interface instance
301
301
  */
302
302
  get db(): DatabaseInterface;
303
+ /**
304
+ * Temporarily bind this initialized instance to another database interface.
305
+ *
306
+ * This is intended for transaction-scoped work where a caller must ensure
307
+ * that every nested SMRT read and write uses the transaction database passed
308
+ * to its callback. The original database is restored whether the callback
309
+ * succeeds or throws. Do not use the same instance concurrently while it is
310
+ * temporarily bound.
311
+ */
312
+ withDatabase<T>(db: DatabaseInterface, operation: (bound: this) => Promise<T>): Promise<T>;
303
313
  /**
304
314
  * Gets the AI client instance
305
315
  */
@@ -1 +1 @@
1
- {"version":3,"file":"class.d.ts","sourceRoot":"","sources":["../src/class.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"class.d.ts","sourceRoot":"","sources":["../src/class.ts"],"names":[],"mappings":"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"}
package/dist/class.js CHANGED
@@ -581,6 +581,27 @@ var SmrtClass = class SmrtClass {
581
581
  return this._db;
582
582
  }
583
583
  /**
584
+ * Temporarily bind this initialized instance to another database interface.
585
+ *
586
+ * This is intended for transaction-scoped work where a caller must ensure
587
+ * that every nested SMRT read and write uses the transaction database passed
588
+ * to its callback. The original database is restored whether the callback
589
+ * succeeds or throws. Do not use the same instance concurrently while it is
590
+ * temporarily bound.
591
+ */
592
+ async withDatabase(db, operation) {
593
+ const previousDb = this.db;
594
+ const previousOptionDb = this.options.db;
595
+ this._db = db;
596
+ this.options.db = db;
597
+ try {
598
+ return await operation(this);
599
+ } finally {
600
+ this._db = previousDb;
601
+ this.options.db = previousOptionDb;
602
+ }
603
+ }
604
+ /**
584
605
  * Gets the AI client instance
585
606
  */
586
607
  get ai() {