@happyvertical/smrt-core 0.52.0 → 0.53.1

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 (63) hide show
  1. package/AGENTS.md +1 -0
  2. package/README.md +8 -2
  3. package/agents/model-moves.md +114 -0
  4. package/agents/object-runtime.md +3 -1
  5. package/agents/run-once.md +16 -7
  6. package/dist/cascade.d.ts.map +1 -1
  7. package/dist/cascade.js +4 -2
  8. package/dist/cascade.js.map +1 -1
  9. package/dist/generators/preflight-route.d.ts.map +1 -1
  10. package/dist/generators/preflight-route.js +1 -1
  11. package/dist/generators/preflight-route.js.map +1 -1
  12. package/dist/knowledge.d.ts.map +1 -1
  13. package/dist/knowledge.js +3 -0
  14. package/dist/knowledge.js.map +1 -1
  15. package/dist/manifest/static-manifest.js +1 -1
  16. package/dist/manifest/static-manifest.js.map +1 -1
  17. package/dist/manifest/store.js +1 -1
  18. package/dist/manifest.json +1 -1
  19. package/dist/migrations/index.d.ts +1 -0
  20. package/dist/migrations/index.d.ts.map +1 -1
  21. package/dist/migrations/index.js +2 -1
  22. package/dist/migrations/qualified-name-aliases.d.ts +134 -0
  23. package/dist/migrations/qualified-name-aliases.d.ts.map +1 -0
  24. package/dist/migrations/qualified-name-aliases.js +294 -0
  25. package/dist/migrations/qualified-name-aliases.js.map +1 -0
  26. package/dist/migrations.js +2 -1
  27. package/dist/polymorphic-association.d.ts +18 -0
  28. package/dist/polymorphic-association.d.ts.map +1 -1
  29. package/dist/polymorphic-association.js +27 -2
  30. package/dist/polymorphic-association.js.map +1 -1
  31. package/dist/registry/class-registration.d.ts.map +1 -1
  32. package/dist/registry/class-registration.js +27 -4
  33. package/dist/registry/class-registration.js.map +1 -1
  34. package/dist/registry/name-resolver.d.ts +8 -2
  35. package/dist/registry/name-resolver.d.ts.map +1 -1
  36. package/dist/registry/name-resolver.js +17 -8
  37. package/dist/registry/name-resolver.js.map +1 -1
  38. package/dist/registry/qualified-name-aliases.d.ts +109 -0
  39. package/dist/registry/qualified-name-aliases.d.ts.map +1 -0
  40. package/dist/registry/qualified-name-aliases.js +286 -0
  41. package/dist/registry/qualified-name-aliases.js.map +1 -0
  42. package/dist/registry/relationship-graph.d.ts.map +1 -1
  43. package/dist/registry/relationship-graph.js +1 -1
  44. package/dist/registry/relationship-graph.js.map +1 -1
  45. package/dist/registry/types.d.ts +26 -0
  46. package/dist/registry/types.d.ts.map +1 -1
  47. package/dist/registry.d.ts +59 -2
  48. package/dist/registry.d.ts.map +1 -1
  49. package/dist/registry.js +115 -6
  50. package/dist/registry.js.map +1 -1
  51. package/dist/scanner/manifest-generator.d.ts +11 -0
  52. package/dist/scanner/manifest-generator.d.ts.map +1 -1
  53. package/dist/scanner/manifest-generator.js +36 -1
  54. package/dist/scanner/manifest-generator.js.map +1 -1
  55. package/dist/schema/conflict-target.js +8 -1
  56. package/dist/schema/conflict-target.js.map +1 -1
  57. package/dist/scripts/browser-plain-json-regression.mjs +4 -1
  58. package/dist/smrt-knowledge.json +15 -9
  59. package/dist/utils/qualified-names.d.ts +11 -0
  60. package/dist/utils/qualified-names.d.ts.map +1 -1
  61. package/dist/utils/qualified-names.js +21 -1
  62. package/dist/utils/qualified-names.js.map +1 -1
  63. package/package.json +11 -11
package/AGENTS.md CHANGED
@@ -24,6 +24,7 @@ and repository rules.
24
24
  | `src/object.ts`, `src/collection.ts`, `src/learning/memory.ts` | Context memory and semantic search | [agents/memory.md](agents/memory.md) |
25
25
  | `src/system/diagnostics.ts` | SELECT-only `_smrt_*` diagnostics reader behind smrt-dev-mcp runtime tools (#1824) | [agents/system-diagnostics.md](agents/system-diagnostics.md) |
26
26
  | `src/system/registry-snapshot.ts` | Sanitized plain-JSON projection of the booted `ObjectRegistry` for the smrt-dev-mcp runtime dev-plane (#1831); never constructors, validators, values, or absolute paths | [agents/registry-snapshot.md](agents/registry-snapshot.md) |
27
+ | `src/registry/qualified-name-aliases.ts`, `src/migrations/qualified-name-aliases.ts` | Model moves: `previousQualifiedNames` aliases, deprecation, doctor counts, opt-in backfill (#3338) | [agents/model-moves.md](agents/model-moves.md) |
27
28
  | `src/run-once.ts` | Shared idempotency seam: insert-only claim + caller work in one transaction, replay on retry, typed in-flight/unknown-outcome answers (#3080) | [agents/run-once.md](agents/run-once.md) |
28
29
 
29
30
  ## Cross-module invariants
package/README.md CHANGED
@@ -14,7 +14,7 @@ pnpm add @happyvertical/smrt-core
14
14
  pnpm add -D @happyvertical/smrt-cli @happyvertical/smrt-vitest
15
15
  ```
16
16
 
17
- Requires Node.js 24.18.0 or newer. s-m-r-t projects use the Vite plugin to generate
17
+ Requires Node.js 26.0.0 or newer. s-m-r-t projects use the Vite plugin to generate
18
18
  manifests and the CLI to apply schema migrations before runtime.
19
19
 
20
20
  ## Usage
@@ -111,7 +111,13 @@ const purchaseOrderId = await runOnce(
111
111
  too — a failed attempt never strands a claim, and a retry with the same key
112
112
  runs again.
113
113
 
114
- See [`agents/run-once.md`](agents/run-once.md) for the full contract.
114
+ See [`agents/run-once.md`](agents/run-once.md) for the full contract. The
115
+ browser half — minting and keeping the submission key per tab and form,
116
+ refusing a concurrent submit, keeping typed values across a failed submit, and
117
+ opt-in restore after a reload — is `createFormRetry()` in
118
+ `@happyvertical/smrt-ui/form-retry`; the
119
+ [form retry guide](../../docs/content/form-retry.md) shows both halves
120
+ together.
115
121
 
116
122
  ### Bounded multi-collection reads
117
123
 
@@ -0,0 +1,114 @@
1
+ # Moving a model between packages (#3338)
2
+
3
+ A class's registry identity is its qualified name, `@package/name:ClassName`.
4
+ Moving a class to another package (or renaming it) changes that name, and every
5
+ reference that stored or declared the old one stops resolving silently:
6
+ polymorphic `metaType` rows (including smrt-assets `AssetAssociation`),
7
+ `ObjectRegistry.getClassByQualifiedName()`, `@crossPackageRef` and relationship
8
+ targets, and playbook step `model:` names.
9
+
10
+ ## Declare the old name
11
+
12
+ ```ts
13
+ @smrt({
14
+ tableName: 'service_time_entries', // keep the table: a move is not a rename
15
+ previousQualifiedNames: ['@happyvertical/smrt-projects:ServiceTimeEntry'],
16
+ })
17
+ export class ServiceTimeEntry extends SmrtObject {}
18
+ ```
19
+
20
+ `previousQualifiedNames` (`registry/types.ts`) is a list of deprecated
21
+ qualified names. Keep `tableName` explicit so the moved class keeps its table.
22
+
23
+ ## What the alias does
24
+
25
+ - **Resolution.** `registry/qualified-name-aliases.ts` derives an alias index
26
+ from registered classes once per registry generation. `findClass()`,
27
+ `findClassStrict()`, `getClassByQualifiedName()`, `getClassInPackage()` and
28
+ `resolveType()` consult it after a direct miss, for qualified input only;
29
+ simple-name lookups are unchanged. `resolveType(old)` returns the current
30
+ name.
31
+ - **Lazy path.** `tryLoadFromExternalPackage(old)` falls back to the alias
32
+ index the NEW owner's manifest carries: the scanner keeps
33
+ `decoratorConfig.previousQualifiedNames` verbatim, so the old identity
34
+ resolves even when the old package no longer lists the class or is not
35
+ installed. Loaded and discoverable manifests are all searched; two distinct
36
+ claimants of one old name throw `CONFIG_QUALIFIED_NAME_ALIAS_COLLISION`.
37
+ `ensureManifestLoaded(old)` reconciles under the current name.
38
+ - **Covered call sites.** `SmrtPolymorphicAssociation.hydrate()`, relationship
39
+ targets (`relationship-graph.ts` resolves `@crossPackageRef('<old>')` to the
40
+ current name, so loads, inverses and delete planning key on it), delete
41
+ cascades (`cascade.ts` also removes association rows stored under an old
42
+ name), and playbook preflight (`resolveRegisteredObjectName`).
43
+ - **Deprecation warning.** The first resolution of each old name per process
44
+ logs a warning naming the old name, the current name, and the resolution
45
+ site, and records a `QUALIFIED_NAME_ALIAS_DEPRECATED` registry diagnostic.
46
+ `ObjectRegistry.clear()` resets it.
47
+ - **New writes store the current name.** `SmrtPolymorphicAssociation.save()`
48
+ rewrites an aliased `metaType`; a legacy row that is loaded and saved again
49
+ is upgraded. Readers that filter on stored names use
50
+ `ObjectRegistry.getEquivalentQualifiedNamesAsync(name)`, which returns the
51
+ current name followed by the aliases, as `AssetAssociationCollection`'s
52
+ `byLeft()`, `detach()` and `setLinks()` do.
53
+ - **Writes and filters cover unregistered classes.** Both of those call sites
54
+ use the async helpers. When a qualified name misses, they lazily load the
55
+ class from its manifest before normalizing or filtering. The sync variants
56
+ only see registered classes, so a write or filter could otherwise miss a
57
+ class known only from its installed manifest. A genuinely unknown name is
58
+ stored or matched as given.
59
+ - **Public helpers.** `ObjectRegistry.getQualifiedNameAliases()` returns the
60
+ alias-to-current map. `resolveQualifiedName(name)` and
61
+ `resolveQualifiedNameAsync(name)` return the current name.
62
+
63
+ ## An alias is never a second class
64
+
65
+ Alias keys never enter the `classes` map. `getAllClasses()`, schema/DDL,
66
+ MCP/CLI/REST generation, knowledge surfaces and every other iteration see one
67
+ entry per class. Registration refuses ambiguity with
68
+ `CONFIG_QUALIFIED_NAME_ALIAS_COLLISION` when an alias equals a live class's
69
+ qualified name (in either registration order) or when two classes claim one old
70
+ name. That includes any loaded manifest: registration refuses an alias that
71
+ another installed package also declares, or that an installed manifest still
72
+ defines as a class, for example a stale old package version. A re-exported
73
+ constructor is not a definition. A lazy load of a
74
+ manifest that declares aliases first loads every discoverable manifest. That
75
+ inventory is cached per registry generation, so the check applies whether the
76
+ class is requested by its current name or by an old one. It refuses `CONFIG_QUALIFIED_NAME_ALIAS_INVALID` when an entry is not
77
+ a scoped `@scope/package:ClassName` with exactly one colon (unscoped names never resolve as qualified), repeats, or names the class itself. Manifest generation
78
+ (`ManifestGenerator.assertQualifiedNameAliases`) fails closed on the same
79
+ mistakes. The knowledge artifact lists the names as
80
+ `objects[].previousQualifiedNames`, sorted and omitted when empty.
81
+
82
+ ## Stored references: doctor and backfill
83
+
84
+ `migrations/qualified-name-aliases.ts`, exported from
85
+ `@happyvertical/smrt-core/migrations`, covers two kinds of stored reference:
86
+ `meta_type` on every registered polymorphic-association table, and `_meta_type`
87
+ on the STI table of an aliased STI class.
88
+
89
+ - `countLegacyQualifiedNameReferences(db, { tenantId? })` is read-only and
90
+ returns counts by table, column and old name. `smrt doctor --db` prints them
91
+ under "Deprecated Qualified Names" and warns while the count is non-zero.
92
+ - `backfillLegacyQualifiedNames(db, { dryRun?, tenantId?, force? })`, run as
93
+ `smrt db:migrate-qualified-names`, is opt-in and never automatic. It rewrites
94
+ only exact old-name values, in one transaction where available, and never
95
+ touches ids or tenant columns. A `tenantId` run only touches that tenant's
96
+ rows in tenant-owned tables. It skips and reports any row whose rewrite would
97
+ duplicate a current-name row on the table's conflict columns. Once nothing in
98
+ scope remains, it records a `_smrt_backfills` marker named
99
+ `@happyvertical/smrt-core:qualified-name-aliases:v1:<digest>[:tenant:<id>]`.
100
+ The digest covers the alias set, so a later move runs again.
101
+
102
+ Known gap: STI child-collection reads filter `_meta_type` by the current name
103
+ only. Run the backfill before relying on subclass collections of a moved STI
104
+ class.
105
+
106
+ ## Lifecycle
107
+
108
+ 1. Move the class, keep its table, and declare `previousQualifiedNames`. This
109
+ needs no data migration.
110
+ 2. Consumers upgrade. Old names keep resolving and warn once per process.
111
+ 3. Optionally run `smrt db:migrate-qualified-names` to clean up early.
112
+ 4. Remove the alias in a later **breaking** release, only once
113
+ `smrt doctor --db` reports zero stored references for every consumer and no
114
+ consumer source still declares the old name.
@@ -60,7 +60,9 @@ warmed, interleaved per-row timings without a flaky timing assertion.
60
60
  `src/plain-json.ts` helper and executes it in Chromium with `process` and
61
61
  `JSON.isRawJSON` unavailable. It is an opt-in local/browser release gate rather
62
62
  than part of the default core suite: it requires the repository's
63
- `playwright-core` dependency and an installed Chromium binary. The normal test
63
+ `playwright-core` dependency and an installed Chromium binary. Set
64
+ `PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/path/to/chromium` to use an installed
65
+ executable; otherwise Playwright's managed Chromium is used. The normal test
64
66
  suite covers the same helper through `toPlainObject()` on Node.
65
67
 
66
68
  ## SmrtCollection Query
@@ -58,13 +58,22 @@ beside the other write primitives (`object.ts`, `collection.ts`) because:
58
58
  - The claim table must exist for every `smrt-core` consumer, not only ones
59
59
  that also depend on `smrt-svelte`.
60
60
 
61
- No SvelteKit form-token helper shipped alongside it. The token contract
62
- (mint a random, unguessable value once per rendered form; carry it as a
63
- hidden field or equivalent; pass it straight through to `runOnce()`) is a few
64
- lines any consumer already owns as part of rendering its own form, and there
65
- was no existing svelte-facing package this fit into without adding one. If a
66
- second consumer needs the exact same minting/reading helper, add it to
67
- `smrt-svelte`'s forms module then — do not add a package for one caller.
61
+ The browser half did not ship with it; it now lives in
62
+ `@happyvertical/smrt-ui/form-retry` (`createFormRetry()`, #3291): a per-tab,
63
+ per-form submission key in `sessionStorage` with an in-memory fallback,
64
+ rotated only on a confirmed write that cleared the form, refused while a
65
+ submit is in flight, kept with the typed values across a transport error, and
66
+ optional restore-after-reload so the retry carries byte-identical content. It
67
+ is in `smrt-ui` rather than `smrt-svelte` because smrt-ui is the dependency
68
+ leaf that both `smrt-svelte` and plain SvelteKit apps can use, and its `Form`
69
+ takes `enhance` and the retry attachment directly (smrt#3265: attach
70
+ `fromAction(enhance, () => retry.enhance())` and `retry.attach`). It is typed
71
+ structurally against SvelteKit's `SubmitFunction`, so smrt-ui takes no
72
+ `@sveltejs/kit` dependency. Its `digestSubmissionContent()` is a Web Crypto port of
73
+ `digestRunOnceContent()`; `src/__tests__/issue-3291-digest-vectors.test.ts`
74
+ pins vectors shared with the port, so change both together (and remember a
75
+ digest change re-keys every stored claim). Guide:
76
+ [form retry](../../../docs/content/form-retry.md).
68
77
 
69
78
  ## Schema: a hand-DDL system table, not an `@smrt()` model
70
79
 
@@ -1 +1 @@
1
- {"version":3,"file":"cascade.d.ts","sourceRoot":"","sources":["../src/cascade.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiEG;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,CA0Ib;AAqZD,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;;;;;;;;;;;;;;GAcG;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,CAuDxB"}
1
+ {"version":3,"file":"cascade.d.ts","sourceRoot":"","sources":["../src/cascade.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiEG;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,CAkJb;AAqZD,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;;;;;;;;;;;;;;GAcG;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,CAuDxB"}
package/dist/cascade.js CHANGED
@@ -161,9 +161,11 @@ function buildCascadePlan(registry, className) {
161
161
  }
162
162
  }
163
163
  const metaTypes = [];
164
- const qualified = registry.getClass(className)?.qualifiedName;
164
+ const registered = registry.getClass(className);
165
+ const qualified = registered?.qualifiedName;
165
166
  if (qualified) metaTypes.push(qualified);
166
- const simple = registry.getClass(className)?.name ?? className;
167
+ for (const alias of registered?.config?.previousQualifiedNames ?? []) if (typeof alias === "string" && !metaTypes.includes(alias)) metaTypes.push(alias);
168
+ const simple = registered?.name ?? className;
167
169
  if (!metaTypes.includes(simple)) metaTypes.push(simple);
168
170
  return {
169
171
  references,
@@ -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 * 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 * - Every delete runs inside a single transaction when the adapter exposes\n * one, including cleanup of framework-owned context and embedding rows, so\n * a partial cascade cannot survive a failure. A `metaType` column can point\n * at any class at runtime, so a polymorphic association class is always\n * plausibly relevant.\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 // Keep canonical self/ancestor identities from the registry. Never resolve\n // their display aliases again: a shared simple name can select another\n // package. Legacy relationship views without canonical metadata still use\n // the simple aliases already included by getSelfReferableNames().\n const targetNames = new Set(registry.getSelfReferableNames(className));\n\n const references: CascadeReference[] = [];\n const polymorphic: CascadePolymorphicReference[] = [];\n const visitedBuckets = new Set<unknown>();\n\n const relationshipMap = registry.getRelationshipMap();\n for (const [sourceClass, relationships] of relationshipMap) {\n const simpleSource = relationships[0]?.sourceClass;\n if (\n simpleSource &&\n sourceClass !== simpleSource &&\n relationshipMap.get(simpleSource) === relationships\n ) {\n continue;\n }\n if (visitedBuckets.has(relationships)) continue;\n visitedBuckets.add(relationships);\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 const target =\n relationship.targetQualifiedClass === undefined\n ? relationship.targetClass\n : relationship.targetQualifiedClass;\n if (target === null || !targetNames.has(target)) 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 columnName: column,\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\n/**\n * System-table cleanup may ignore an absent table for databases created before\n * that subsystem existed, but never a missing column in a table that does\n * exist. The shared `undefined_object` classification intentionally combines\n * both cases, so this security boundary must retain the narrower driver signal.\n */\nfunction normalizeMissingTableName(value: string): string {\n const unquoted = value.replace(/[\"'`[\\]]/g, '');\n return (unquoted.split('.').at(-1) ?? unquoted)\n .replace(/[^A-Za-z0-9_$-]/g, '')\n .toLowerCase();\n}\n\nfunction missingTableNames(messages: readonly string[]): string[] {\n const names: string[] = [];\n const patterns = [\n /no such table:\\s*([^\\s,;]+)/giu,\n /(?:relation|table)\\s+((?:\"[^\"]+\"(?:\\.\"[^\"]+\")*)|(?:[A-Za-z0-9_.$-]+))\\s+does not exist/giu,\n /table with name\\s+((?:\"[^\"]+\")|(?:[A-Za-z0-9_.$-]+))\\s+does not exist/giu,\n ];\n for (const message of messages) {\n for (const pattern of patterns) {\n for (const match of message.matchAll(pattern)) {\n if (match[1]) names.push(normalizeMissingTableName(match[1]));\n }\n }\n }\n return names;\n}\n\nfunction isMissingTableError(error: unknown, expectedTable: string): boolean {\n const classification = classifyDatabaseError(error);\n if (classification.kind !== 'undefined_object') return false;\n if (\n classification.sqlstate === '42703' ||\n classification.driverCode === '42703' ||\n classification.driverCodes.includes('42703')\n ) {\n return false;\n }\n const names = missingTableNames(classification.driverMessages);\n const expected = normalizeMissingTableName(expectedTable);\n return (\n names.length > 0 &&\n names.every((missingTableName) => missingTableName === expected)\n );\n}\n\nasync function tolerateMissingSystemTable<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 (!isMissingTableError(error, context.table)) throw error;\n logger.warn(\n `Cascade delete skipped ${context.action} on '${context.table}': ` +\n 'table not found in this database.',\n { error: error instanceof Error ? error.message : String(error) },\n );\n return fallback;\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. Every other cleanup failure propagates so the surrounding delete\n * transaction rolls back rather than orphaning tenant-sensitive recall data.\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 await tolerateMissingSystemTable(\n () =>\n db.delete(table, {\n ...idPredicate(idColumn, batch),\n ...idPredicate(classColumn, classNames),\n }),\n undefined,\n { table, action: 'framework-owned row cleanup' },\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 // A caught missing-table DELETE still aborts PostgreSQL's transaction.\n // Optional/abstract association tables must be checked before mutation.\n if (!(await ctx.db.tableExists(association.tableName))) continue;\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 the adapter exposes `transaction()`, the whole sequence runs inside one\n * — including framework-owned row cleanup and the caller's `deleteSelf`\n * statement, so a failure part-way through cannot leave either side orphaned.\n * Adapters without transaction support run the same statements sequentially;\n * this is the documented degradation, not a silent one.\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 // Framework-owned context and embedding rows still need atomic cleanup even\n // when the registry has no typed or polymorphic references for this class.\n if (buildCascadePlan(registry, target.className).isEmpty) {\n const runWithoutReferences = async (\n bound: DatabaseInterface,\n ): Promise<CascadeResult> => {\n await deleteSystemRows(\n bound,\n ids,\n ownerClassCandidates(registry, target.className),\n );\n await deleteSelf(bound);\n return { affectedTables: new Set(), affectedTableClasses: new Map() };\n };\n const transaction = (db as TransactionCapable).transaction;\n if (typeof transaction !== 'function') return runWithoutReferences(db);\n return transaction.call<\n DatabaseInterface,\n [(tx: DatabaseInterface) => Promise<CascadeResult>],\n Promise<CascadeResult>\n >(db, runWithoutReferences);\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 * - Every delete runs inside a single transaction when the adapter exposes\n * one, including cleanup of framework-owned context and embedding rows, so\n * a partial cascade cannot survive a failure. A `metaType` column can point\n * at any class at runtime, so a polymorphic association class is always\n * plausibly relevant.\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 // Keep canonical self/ancestor identities from the registry. Never resolve\n // their display aliases again: a shared simple name can select another\n // package. Legacy relationship views without canonical metadata still use\n // the simple aliases already included by getSelfReferableNames().\n const targetNames = new Set(registry.getSelfReferableNames(className));\n\n const references: CascadeReference[] = [];\n const polymorphic: CascadePolymorphicReference[] = [];\n const visitedBuckets = new Set<unknown>();\n\n const relationshipMap = registry.getRelationshipMap();\n for (const [sourceClass, relationships] of relationshipMap) {\n const simpleSource = relationships[0]?.sourceClass;\n if (\n simpleSource &&\n sourceClass !== simpleSource &&\n relationshipMap.get(simpleSource) === relationships\n ) {\n continue;\n }\n if (visitedBuckets.has(relationships)) continue;\n visitedBuckets.add(relationships);\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 const target =\n relationship.targetQualifiedClass === undefined\n ? relationship.targetClass\n : relationship.targetQualifiedClass;\n if (target === null || !targetNames.has(target)) 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 columnName: column,\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 // A class's deprecated qualified names (#3338) still identify it in rows\n // written before it moved package, so deleting it removes those too.\n const metaTypes: string[] = [];\n const registered = registry.getClass(className);\n const qualified = registered?.qualifiedName;\n if (qualified) metaTypes.push(qualified);\n for (const alias of registered?.config?.previousQualifiedNames ?? []) {\n if (typeof alias === 'string' && !metaTypes.includes(alias)) {\n metaTypes.push(alias);\n }\n }\n const simple = registered?.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\n/**\n * System-table cleanup may ignore an absent table for databases created before\n * that subsystem existed, but never a missing column in a table that does\n * exist. The shared `undefined_object` classification intentionally combines\n * both cases, so this security boundary must retain the narrower driver signal.\n */\nfunction normalizeMissingTableName(value: string): string {\n const unquoted = value.replace(/[\"'`[\\]]/g, '');\n return (unquoted.split('.').at(-1) ?? unquoted)\n .replace(/[^A-Za-z0-9_$-]/g, '')\n .toLowerCase();\n}\n\nfunction missingTableNames(messages: readonly string[]): string[] {\n const names: string[] = [];\n const patterns = [\n /no such table:\\s*([^\\s,;]+)/giu,\n /(?:relation|table)\\s+((?:\"[^\"]+\"(?:\\.\"[^\"]+\")*)|(?:[A-Za-z0-9_.$-]+))\\s+does not exist/giu,\n /table with name\\s+((?:\"[^\"]+\")|(?:[A-Za-z0-9_.$-]+))\\s+does not exist/giu,\n ];\n for (const message of messages) {\n for (const pattern of patterns) {\n for (const match of message.matchAll(pattern)) {\n if (match[1]) names.push(normalizeMissingTableName(match[1]));\n }\n }\n }\n return names;\n}\n\nfunction isMissingTableError(error: unknown, expectedTable: string): boolean {\n const classification = classifyDatabaseError(error);\n if (classification.kind !== 'undefined_object') return false;\n if (\n classification.sqlstate === '42703' ||\n classification.driverCode === '42703' ||\n classification.driverCodes.includes('42703')\n ) {\n return false;\n }\n const names = missingTableNames(classification.driverMessages);\n const expected = normalizeMissingTableName(expectedTable);\n return (\n names.length > 0 &&\n names.every((missingTableName) => missingTableName === expected)\n );\n}\n\nasync function tolerateMissingSystemTable<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 (!isMissingTableError(error, context.table)) throw error;\n logger.warn(\n `Cascade delete skipped ${context.action} on '${context.table}': ` +\n 'table not found in this database.',\n { error: error instanceof Error ? error.message : String(error) },\n );\n return fallback;\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. Every other cleanup failure propagates so the surrounding delete\n * transaction rolls back rather than orphaning tenant-sensitive recall data.\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 await tolerateMissingSystemTable(\n () =>\n db.delete(table, {\n ...idPredicate(idColumn, batch),\n ...idPredicate(classColumn, classNames),\n }),\n undefined,\n { table, action: 'framework-owned row cleanup' },\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 // A caught missing-table DELETE still aborts PostgreSQL's transaction.\n // Optional/abstract association tables must be checked before mutation.\n if (!(await ctx.db.tableExists(association.tableName))) continue;\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 the adapter exposes `transaction()`, the whole sequence runs inside one\n * — including framework-owned row cleanup and the caller's `deleteSelf`\n * statement, so a failure part-way through cannot leave either side orphaned.\n * Adapters without transaction support run the same statements sequentially;\n * this is the documented degradation, not a silent one.\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 // Framework-owned context and embedding rows still need atomic cleanup even\n // when the registry has no typed or polymorphic references for this class.\n if (buildCascadePlan(registry, target.className).isEmpty) {\n const runWithoutReferences = async (\n bound: DatabaseInterface,\n ): Promise<CascadeResult> => {\n await deleteSystemRows(\n bound,\n ids,\n ownerClassCandidates(registry, target.className),\n );\n await deleteSelf(bound);\n return { affectedTables: new Set(), affectedTableClasses: new Map() };\n };\n const transaction = (db as TransactionCapable).transaction;\n if (typeof transaction !== 'function') return runWithoutReferences(db);\n return transaction.call<\n DatabaseInterface,\n [(tx: DatabaseInterface) => Promise<CascadeResult>],\n Promise<CascadeResult>\n >(db, runWithoutReferences);\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 +1 @@
1
- {"version":3,"file":"preflight-route.d.ts","sourceRoot":"","sources":["../../src/generators/preflight-route.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;AAWH,8EAA8E;AAC9E,eAAO,MAAM,gCAAgC,eAAe,CAAC;AAE7D;;;;;GAKG;AACH,eAAO,MAAM,6BAA6B;;;;EAI/B,CAAC;AAEZ;;;;GAIG;AACH,MAAM,WAAW,6BAA6B;IAC5C,kCAAkC;IAClC,GAAG,EAAE,MAAM,CAAC;IACZ,0CAA0C;IAC1C,KAAK,EAAE,SAAS,CAAC;IACjB,8EAA8E;IAC9E,WAAW,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,CAAC;IAC/B,8EAA8E;IAC9E,iBAAiB,EAAE,OAAO,CAAC;IAC3B,uCAAuC;IACvC,EAAE,CAAC,EAAE,OAAO,CAAC;CACd;AAED;;;GAGG;AACH,MAAM,MAAM,yBAAyB,GAAG,CACtC,OAAO,EAAE,6BAA6B,KACnC,OAAO,CAAC,OAAO,CAAC,CAAC;AAEtB;;;;GAIG;AACH,MAAM,WAAW,6BAA6B;IAC5C,QAAQ,CAAC,EAAE,yBAAyB,CAAC;IACrC,WAAW,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,CAAC;IAC/B,iBAAiB,EAAE,OAAO,CAAC;IAC3B,EAAE,CAAC,EAAE,OAAO,CAAC;CACd;AAYD;;;;;;;GAOG;AACH,wBAAsB,4BAA4B,CAChD,GAAG,EAAE,OAAO,EACZ,OAAO,EAAE,6BAA6B,GACrC,OAAO,CAAC,QAAQ,CAAC,CAwBnB;AAED;;;;;;GAMG;AACH,wBAAgB,2BAA2B,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAK7E;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,sBAAsB,CACpC,MAAM,EAAE,MAAM,EACd,UAAU,CAAC,EAAE,MAAM,GAClB,MAAM,CAgCR;AAgBD;;;;;;;;;;;;GAYG;AACH,wBAAgB,2BAA2B,CACzC,UAAU,EAAE,MAAM,GAAG,SAAS,EAC9B,MAAM,EAAE,MAAM,GACb,OAAO,CAsBT;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,oBAAoB,CAClC,UAAU,EAAE,MAAM,GAAG,SAAS,EAC9B,MAAM,EAAE,MAAM,GACb,OAAO,CAuCT;AAeD;;;;GAIG;AACH,wBAAgB,iBAAiB,CAC/B,UAAU,EAAE,MAAM,GAAG,SAAS,EAC9B,MAAM,EAAE,MAAM,GACb,OAAO,CAQT;AAED;;;;;;;GAOG;AACH,wBAAgB,wBAAwB,CACtC,UAAU,EAAE,MAAM,GAAG,SAAS,GAC7B,MAAM,EAAE,CAiBV"}
1
+ {"version":3,"file":"preflight-route.d.ts","sourceRoot":"","sources":["../../src/generators/preflight-route.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;AAWH,8EAA8E;AAC9E,eAAO,MAAM,gCAAgC,eAAe,CAAC;AAE7D;;;;;GAKG;AACH,eAAO,MAAM,6BAA6B;;;;EAI/B,CAAC;AAEZ;;;;GAIG;AACH,MAAM,WAAW,6BAA6B;IAC5C,kCAAkC;IAClC,GAAG,EAAE,MAAM,CAAC;IACZ,0CAA0C;IAC1C,KAAK,EAAE,SAAS,CAAC;IACjB,8EAA8E;IAC9E,WAAW,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,CAAC;IAC/B,8EAA8E;IAC9E,iBAAiB,EAAE,OAAO,CAAC;IAC3B,uCAAuC;IACvC,EAAE,CAAC,EAAE,OAAO,CAAC;CACd;AAED;;;GAGG;AACH,MAAM,MAAM,yBAAyB,GAAG,CACtC,OAAO,EAAE,6BAA6B,KACnC,OAAO,CAAC,OAAO,CAAC,CAAC;AAEtB;;;;GAIG;AACH,MAAM,WAAW,6BAA6B;IAC5C,QAAQ,CAAC,EAAE,yBAAyB,CAAC;IACrC,WAAW,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,CAAC;IAC/B,iBAAiB,EAAE,OAAO,CAAC;IAC3B,EAAE,CAAC,EAAE,OAAO,CAAC;CACd;AAYD;;;;;;;GAOG;AACH,wBAAsB,4BAA4B,CAChD,GAAG,EAAE,OAAO,EACZ,OAAO,EAAE,6BAA6B,GACrC,OAAO,CAAC,QAAQ,CAAC,CAwBnB;AAED;;;;;;GAMG;AACH,wBAAgB,2BAA2B,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAO7E;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,sBAAsB,CACpC,MAAM,EAAE,MAAM,EACd,UAAU,CAAC,EAAE,MAAM,GAClB,MAAM,CAgCR;AAgBD;;;;;;;;;;;;GAYG;AACH,wBAAgB,2BAA2B,CACzC,UAAU,EAAE,MAAM,GAAG,SAAS,EAC9B,MAAM,EAAE,MAAM,GACb,OAAO,CAsBT;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,oBAAoB,CAClC,UAAU,EAAE,MAAM,GAAG,SAAS,EAC9B,MAAM,EAAE,MAAM,GACb,OAAO,CAuCT;AAeD;;;;GAIG;AACH,wBAAgB,iBAAiB,CAC/B,UAAU,EAAE,MAAM,GAAG,SAAS,EAC9B,MAAM,EAAE,MAAM,GACb,OAAO,CAQT;AAED;;;;;;;GAOG;AACH,wBAAgB,wBAAwB,CACtC,UAAU,EAAE,MAAM,GAAG,SAAS,GAC7B,MAAM,EAAE,CAiBV"}
@@ -86,7 +86,7 @@ async function handlePlaybookPreflightRoute(req, options) {
86
86
  * fails closed rather than reporting on a model that has no route.
87
87
  */
88
88
  function resolveRegisteredObjectName(model) {
89
- return (model.includes(":") ? ObjectRegistry.getClassByQualifiedName(model) : ObjectRegistry.getClass(model))?.name;
89
+ return (model.includes(":") ? ObjectRegistry.getClassByQualifiedName(model, { source: "playbook step model (REST preflight)" }) : ObjectRegistry.getClass(model))?.name;
90
90
  }
91
91
  /**
92
92
  * The HTTP method a REST action is served by.
@@ -1 +1 @@
1
- {"version":3,"file":"preflight-route.js","names":[],"sources":["../../src/generators/preflight-route.ts"],"sourcesContent":["/**\n * Generated `_preflight` HTTP route for browser-plane playbook preflight\n * (issue #2590).\n *\n * Handles `GET {basePath}/_preflight?key=<playbook key>` in the REST generator:\n * an advisory, read-effect, idempotent report of what a caller's playbook would\n * be allowed to do — *predicted*, never granted. Every step is authorized again\n * where it executes; this route is capability **selection**, never\n * authorization (epic #2585 invariant 2).\n *\n * ## `authMiddleware` is never invoked here\n *\n * Generated REST authorization is\n * `authMiddleware?: (objectName, action) => (req) => Promise<Request | Response>`\n * — request-bound, `Response`-returning rather than boolean, and free to consult\n * session stores, rate-limit, or audit. It is not a dry-run predicate, and a\n * synthetic-`Request` dry run of it would be a side effect the caller never\n * asked for.\n *\n * That is enforced **structurally**, not by discipline:\n * {@link PlaybookPreflightRouteOptions} has no `authMiddleware` member and no\n * function-valued auth member of any kind. `rest.ts` passes the boolean\n * `appAuthConfigured` and nothing else, so there is no handle in this module to\n * invoke even by mistake. The app-auth layer is consequently reported as\n * `unknown`, which is the honest answer.\n *\n * ## Why the provider is injected\n *\n * Resolution and verdict shaping live in `@happyvertical/smrt-playbooks`, which\n * depends on this package. Core therefore owns the route and the static-layer\n * facts (`ObjectRegistry` is core's), and takes the evaluator as a seam — the\n * dependency stays one-way.\n */\n\nimport { ObjectRegistry } from '../registry.js';\nimport type { MethodDefinition } from '../scanner/types.js';\nimport { PRIVATE_READ_CACHE_CONTROL } from './conditional-get.js';\nimport {\n declaresRuntimeRestRoute,\n readMethodDecoratorConfig,\n resolveEffectiveActionMetadata,\n} from './custom-action.js';\n\n/** Path segment the preflight route is served at, under the API base path. */\nexport const PLAYBOOK_PREFLIGHT_ROUTE_SEGMENT = '_preflight';\n\n/**\n * Capability classification of the preflight route: a read, safely repeatable,\n * and closed-world. It is admitted by a default read-only browser exposure\n * policy without an opt-in, because it reports on the system rather than\n * changing it.\n */\nexport const PLAYBOOK_PREFLIGHT_CAPABILITY = Object.freeze({\n effect: 'read',\n idempotent: true,\n openWorld: false,\n} as const);\n\n/**\n * The request handed to a preflight provider. Deliberately carries no request,\n * no headers, and no auth handle — only the caller-scoped facts the static\n * layers need.\n */\nexport interface PlaybookPreflightRouteRequest {\n /** The requested playbook key. */\n key: string;\n /** Always `'browser'` from this route. */\n plane: 'browser';\n /** The caller's published permission slugs, when the API context has them. */\n permissions?: Iterable<string>;\n /** Whether an app auth middleware is wired. A boolean; never the function. */\n appAuthConfigured: boolean;\n /** The generator's `APIContext.db`. */\n db?: unknown;\n}\n\n/**\n * Host-supplied browser-plane preflight evaluator, wired from\n * `@happyvertical/smrt-playbooks`. Returns a JSON-serializable report.\n */\nexport type PlaybookPreflightProvider = (\n request: PlaybookPreflightRouteRequest,\n) => Promise<unknown>;\n\n/**\n * Options for {@link handlePlaybookPreflightRoute}.\n *\n * There is intentionally no `authMiddleware` member — see the module docs.\n */\nexport interface PlaybookPreflightRouteOptions {\n provider?: PlaybookPreflightProvider;\n permissions?: Iterable<string>;\n appAuthConfigured: boolean;\n db?: unknown;\n}\n\nfunction jsonResponse(body: unknown, status: number): Response {\n return new Response(JSON.stringify(body), {\n status,\n headers: {\n 'Content-Type': 'application/json',\n 'Cache-Control': PRIVATE_READ_CACHE_CONTROL,\n },\n });\n}\n\n/**\n * Serves `GET {basePath}/_preflight?key=<key>`.\n *\n * Unresolvable keys are the provider's concern: it returns the same uniform\n * \"unavailable\" body for an unknown key as for an unauthorized one, and this\n * route serves whatever it returns with an unconditional 200, so the HTTP layer\n * adds no way to tell them apart either.\n */\nexport async function handlePlaybookPreflightRoute(\n req: Request,\n options: PlaybookPreflightRouteOptions,\n): Promise<Response> {\n if (!options.provider) {\n // Route not wired for this deployment. Says nothing about any key.\n return jsonResponse({ error: 'Not found' }, 404);\n }\n\n if (req.method !== 'GET') {\n return jsonResponse({ error: 'Method not allowed' }, 405);\n }\n\n const key = new URL(req.url).searchParams.get('key');\n if (!key) {\n return jsonResponse({ error: \"Query parameter 'key' is required\" }, 400);\n }\n\n const report = await options.provider({\n key,\n plane: 'browser',\n ...(options.permissions ? { permissions: options.permissions } : {}),\n appAuthConfigured: options.appAuthConfigured,\n db: options.db,\n });\n\n return jsonResponse(report, 200);\n}\n\n/**\n * Resolves a qualified model reference (`@happyvertical/smrt-commerce:Order`)\n * to the name the registry and the REST generator key configuration by.\n *\n * Returns `undefined` for anything this build does not register, so the caller\n * fails closed rather than reporting on a model that has no route.\n */\nexport function resolveRegisteredObjectName(model: string): string | undefined {\n const registered = model.includes(':')\n ? ObjectRegistry.getClassByQualifiedName(model)\n : ObjectRegistry.getClass(model);\n return registered?.name;\n}\n\n/**\n * The HTTP method a REST action is served by.\n *\n * CRUD actions map to their fixed verbs. A custom action is served under the\n * method its own declaration supplies — `@method({ httpMethod })` first, then\n * `api.routes[action].method`, defaulting to `POST` exactly as\n * `dispatchCustomCollectionAction` does — because guessing `POST` for a\n * declared `GET` action would make preflight report a false `deny` on a\n * `public: 'read'` model, hiding a playbook the caller can actually run, which\n * is the tool-listing case this exists to serve. Without an `objectName` to\n * read the declaration from, the fail-closed `POST` remains.\n */\nexport function restMethodForApiAction(\n action: string,\n objectName?: string,\n): string {\n switch (action) {\n case 'list':\n case 'get':\n return 'GET';\n case 'create':\n return 'POST';\n case 'update':\n return 'PUT';\n case 'delete':\n return 'DELETE';\n default:\n break;\n }\n\n if (!objectName) {\n return 'POST';\n }\n\n const apiConfig = ObjectRegistry.getConfig(objectName)?.api;\n if (!apiConfig || typeof apiConfig !== 'object') {\n return 'POST';\n }\n\n const effective = resolveEffectiveActionMetadata({\n actionName: action,\n ...(readRegisteredMethod(objectName, action)\n ? { method: readRegisteredMethod(objectName, action) }\n : {}),\n apiConfig,\n });\n return (effective.httpMethod ?? 'POST').toUpperCase();\n}\n\n/**\n * The method view backing `action`: the manifest entry when the registry has\n * one, with its `@method()` config backfilled from the live decorator store\n * when it does not — the unscanned-runtime posture, where reading only the\n * manifest would silently ignore `@method({ expose: false })` (#2686). Mirrors\n * `APIGenerator.runtimeMethod`, which decides the dispatch this predicts.\n */\nfunction readRegisteredMethod(\n objectName: string,\n action: string,\n): MethodDefinition | undefined {\n return ObjectRegistry.resolveRuntimeMethod(objectName, action).method;\n}\n\n/**\n * Whether `@smrt({ api })` exposes `action` on `objectName`.\n *\n * The generator's own action gate, lifted to a module function so preflight and\n * the live route read the same rule from one place. Accepts any action name so\n * a custom action is gated by the same `include`/`exclude` lists.\n *\n * @remarks An absent `objectName` returns `true` — fail-**open**, because the\n * live route reaches this only after it has already resolved a model, and an\n * unnamed object there means \"not gated by this rule\". A caller *predicting*\n * rather than serving has no such guarantee and must reject an unresolvable\n * model before asking (see `createRestPreflightLayerSource`).\n */\nexport function isApiActionEnabledForObject(\n objectName: string | undefined,\n action: string,\n): boolean {\n if (!objectName) {\n return true;\n }\n\n const apiConfig = ObjectRegistry.getConfig(objectName).api;\n\n if (apiConfig === false) {\n return false;\n }\n\n if (apiConfig && typeof apiConfig === 'object') {\n if (apiConfig.include && !apiConfig.include.includes(action)) {\n return false;\n }\n\n if (apiConfig.exclude?.includes(action)) {\n return false;\n }\n }\n\n return true;\n}\n\n/**\n * Whether `action` names a route the generated REST surface can actually\n * dispatch on `objectName`.\n *\n * CRUD actions always have a route. A custom action exists only when it is\n * DECLARED — historically an `api.routes` entry, and since #2686 also a\n * `@method()` supplying route-shaping metadata. `dispatchCustomCollectionAction`\n * iterates exactly that union, so an action outside it can never execute no\n * matter what `include`/`exclude` say. Exposure alone would report a typo'd or\n * removed custom action as `allow`, and an agent would then start the earlier\n * steps of a non-atomic playbook before dying on it — the failure preflight\n * exists to prevent. This prediction and that dispatch must stay one rule.\n */\nexport function isRestActionRoutable(\n objectName: string | undefined,\n action: string,\n): boolean {\n if (!objectName) return false;\n if (\n action === 'list' ||\n action === 'get' ||\n action === 'create' ||\n action === 'update' ||\n action === 'delete'\n ) {\n return true;\n }\n\n const apiConfig = ObjectRegistry.getConfig(objectName)?.api;\n if (!apiConfig || typeof apiConfig !== 'object') {\n return false;\n }\n\n const registered = readRegisteredMethod(objectName, action);\n\n // `@method({ expose: false })` outranks a legacy `api.routes` entry for the\n // same method, and dispatch honors that. Checking it BEFORE the routes map is\n // what keeps the two one rule: otherwise a withheld action that still carried\n // a route declaration predicted `allow` for an operation the transport\n // refuses (#2686).\n if (readMethodDecoratorConfig(registered)?.expose === false) return false;\n\n if (\n apiConfig.routes &&\n Object.hasOwn(apiConfig.routes as Record<string, unknown>, action)\n ) {\n return isHostableByRuntimeRest(objectName, action);\n }\n\n // Shared with `APIGenerator.declaredCollectionActions`, which decides the\n // dispatch this predicate exists to predict.\n return (\n declaresRuntimeRestRoute(registered) &&\n isHostableByRuntimeRest(objectName, action)\n );\n}\n\n/**\n * Whether this transport can HOST the action at all.\n *\n * It serves only collection-scoped custom actions, so an item-scoped\n * declaration — the `@method()` decorator's most common shape, on a model\n * instance method — answers 404 in dispatch. Predicting `allow` for it is the\n * false-`allow` this module exists to prevent, so routability is the\n * conjunction of \"declared\" and \"hostable\" (#2686).\n */\nfunction isHostableByRuntimeRest(objectName: string, action: string): boolean {\n return ObjectRegistry.isCollectionHostedMethod(objectName, action);\n}\n\n/**\n * Fail-closed authorization posture (#1540): true only when the object opts out\n * of auth via `@smrt({ api: { public } })` — `true` for every method, `'read'`\n * for safe (GET) methods only.\n */\nexport function isRestRoutePublic(\n objectName: string | undefined,\n method: string,\n): boolean {\n if (!objectName) return false;\n const apiConfig = ObjectRegistry.getConfig(objectName)?.api;\n if (!apiConfig || typeof apiConfig !== 'object') return false;\n const publicAccess = (apiConfig as { public?: boolean | 'read' }).public;\n if (publicAccess === true) return true;\n if (publicAccess === 'read') return method.toUpperCase() === 'GET';\n return false;\n}\n\n/**\n * Field-level read-permission slugs declared by `objectName`.\n *\n * Reads the model's own and inherited fields only — deliberately **not** the\n * STI base-plus-descendants union used for cache policy. That union answers\n * \"does any variant carry a read-permission field?\"; used here it would report\n * a sibling variant's slugs as required for a step naming this concrete model.\n */\nexport function restFieldReadPermissions(\n objectName: string | undefined,\n): string[] {\n if (!objectName) return [];\n const registered = ObjectRegistry.getClass(objectName);\n const className = registered?.qualifiedName ?? registered?.name ?? objectName;\n const fields =\n registered?.inheritedFields ?? ObjectRegistry.getFields(className);\n const slugs = new Set<string>();\n for (const [, def] of fields) {\n const slug =\n typeof def?.readPermission === 'string'\n ? def.readPermission\n : typeof def?._meta?.readPermission === 'string'\n ? (def._meta.readPermission as string)\n : undefined;\n if (slug) slugs.add(slug);\n }\n return [...slugs].sort();\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"preflight-route.js","names":[],"sources":["../../src/generators/preflight-route.ts"],"sourcesContent":["/**\n * Generated `_preflight` HTTP route for browser-plane playbook preflight\n * (issue #2590).\n *\n * Handles `GET {basePath}/_preflight?key=<playbook key>` in the REST generator:\n * an advisory, read-effect, idempotent report of what a caller's playbook would\n * be allowed to do — *predicted*, never granted. Every step is authorized again\n * where it executes; this route is capability **selection**, never\n * authorization (epic #2585 invariant 2).\n *\n * ## `authMiddleware` is never invoked here\n *\n * Generated REST authorization is\n * `authMiddleware?: (objectName, action) => (req) => Promise<Request | Response>`\n * — request-bound, `Response`-returning rather than boolean, and free to consult\n * session stores, rate-limit, or audit. It is not a dry-run predicate, and a\n * synthetic-`Request` dry run of it would be a side effect the caller never\n * asked for.\n *\n * That is enforced **structurally**, not by discipline:\n * {@link PlaybookPreflightRouteOptions} has no `authMiddleware` member and no\n * function-valued auth member of any kind. `rest.ts` passes the boolean\n * `appAuthConfigured` and nothing else, so there is no handle in this module to\n * invoke even by mistake. The app-auth layer is consequently reported as\n * `unknown`, which is the honest answer.\n *\n * ## Why the provider is injected\n *\n * Resolution and verdict shaping live in `@happyvertical/smrt-playbooks`, which\n * depends on this package. Core therefore owns the route and the static-layer\n * facts (`ObjectRegistry` is core's), and takes the evaluator as a seam — the\n * dependency stays one-way.\n */\n\nimport { ObjectRegistry } from '../registry.js';\nimport type { MethodDefinition } from '../scanner/types.js';\nimport { PRIVATE_READ_CACHE_CONTROL } from './conditional-get.js';\nimport {\n declaresRuntimeRestRoute,\n readMethodDecoratorConfig,\n resolveEffectiveActionMetadata,\n} from './custom-action.js';\n\n/** Path segment the preflight route is served at, under the API base path. */\nexport const PLAYBOOK_PREFLIGHT_ROUTE_SEGMENT = '_preflight';\n\n/**\n * Capability classification of the preflight route: a read, safely repeatable,\n * and closed-world. It is admitted by a default read-only browser exposure\n * policy without an opt-in, because it reports on the system rather than\n * changing it.\n */\nexport const PLAYBOOK_PREFLIGHT_CAPABILITY = Object.freeze({\n effect: 'read',\n idempotent: true,\n openWorld: false,\n} as const);\n\n/**\n * The request handed to a preflight provider. Deliberately carries no request,\n * no headers, and no auth handle — only the caller-scoped facts the static\n * layers need.\n */\nexport interface PlaybookPreflightRouteRequest {\n /** The requested playbook key. */\n key: string;\n /** Always `'browser'` from this route. */\n plane: 'browser';\n /** The caller's published permission slugs, when the API context has them. */\n permissions?: Iterable<string>;\n /** Whether an app auth middleware is wired. A boolean; never the function. */\n appAuthConfigured: boolean;\n /** The generator's `APIContext.db`. */\n db?: unknown;\n}\n\n/**\n * Host-supplied browser-plane preflight evaluator, wired from\n * `@happyvertical/smrt-playbooks`. Returns a JSON-serializable report.\n */\nexport type PlaybookPreflightProvider = (\n request: PlaybookPreflightRouteRequest,\n) => Promise<unknown>;\n\n/**\n * Options for {@link handlePlaybookPreflightRoute}.\n *\n * There is intentionally no `authMiddleware` member — see the module docs.\n */\nexport interface PlaybookPreflightRouteOptions {\n provider?: PlaybookPreflightProvider;\n permissions?: Iterable<string>;\n appAuthConfigured: boolean;\n db?: unknown;\n}\n\nfunction jsonResponse(body: unknown, status: number): Response {\n return new Response(JSON.stringify(body), {\n status,\n headers: {\n 'Content-Type': 'application/json',\n 'Cache-Control': PRIVATE_READ_CACHE_CONTROL,\n },\n });\n}\n\n/**\n * Serves `GET {basePath}/_preflight?key=<key>`.\n *\n * Unresolvable keys are the provider's concern: it returns the same uniform\n * \"unavailable\" body for an unknown key as for an unauthorized one, and this\n * route serves whatever it returns with an unconditional 200, so the HTTP layer\n * adds no way to tell them apart either.\n */\nexport async function handlePlaybookPreflightRoute(\n req: Request,\n options: PlaybookPreflightRouteOptions,\n): Promise<Response> {\n if (!options.provider) {\n // Route not wired for this deployment. Says nothing about any key.\n return jsonResponse({ error: 'Not found' }, 404);\n }\n\n if (req.method !== 'GET') {\n return jsonResponse({ error: 'Method not allowed' }, 405);\n }\n\n const key = new URL(req.url).searchParams.get('key');\n if (!key) {\n return jsonResponse({ error: \"Query parameter 'key' is required\" }, 400);\n }\n\n const report = await options.provider({\n key,\n plane: 'browser',\n ...(options.permissions ? { permissions: options.permissions } : {}),\n appAuthConfigured: options.appAuthConfigured,\n db: options.db,\n });\n\n return jsonResponse(report, 200);\n}\n\n/**\n * Resolves a qualified model reference (`@happyvertical/smrt-commerce:Order`)\n * to the name the registry and the REST generator key configuration by.\n *\n * Returns `undefined` for anything this build does not register, so the caller\n * fails closed rather than reporting on a model that has no route.\n */\nexport function resolveRegisteredObjectName(model: string): string | undefined {\n const registered = model.includes(':')\n ? ObjectRegistry.getClassByQualifiedName(model, {\n source: 'playbook step model (REST preflight)',\n })\n : ObjectRegistry.getClass(model);\n return registered?.name;\n}\n\n/**\n * The HTTP method a REST action is served by.\n *\n * CRUD actions map to their fixed verbs. A custom action is served under the\n * method its own declaration supplies — `@method({ httpMethod })` first, then\n * `api.routes[action].method`, defaulting to `POST` exactly as\n * `dispatchCustomCollectionAction` does — because guessing `POST` for a\n * declared `GET` action would make preflight report a false `deny` on a\n * `public: 'read'` model, hiding a playbook the caller can actually run, which\n * is the tool-listing case this exists to serve. Without an `objectName` to\n * read the declaration from, the fail-closed `POST` remains.\n */\nexport function restMethodForApiAction(\n action: string,\n objectName?: string,\n): string {\n switch (action) {\n case 'list':\n case 'get':\n return 'GET';\n case 'create':\n return 'POST';\n case 'update':\n return 'PUT';\n case 'delete':\n return 'DELETE';\n default:\n break;\n }\n\n if (!objectName) {\n return 'POST';\n }\n\n const apiConfig = ObjectRegistry.getConfig(objectName)?.api;\n if (!apiConfig || typeof apiConfig !== 'object') {\n return 'POST';\n }\n\n const effective = resolveEffectiveActionMetadata({\n actionName: action,\n ...(readRegisteredMethod(objectName, action)\n ? { method: readRegisteredMethod(objectName, action) }\n : {}),\n apiConfig,\n });\n return (effective.httpMethod ?? 'POST').toUpperCase();\n}\n\n/**\n * The method view backing `action`: the manifest entry when the registry has\n * one, with its `@method()` config backfilled from the live decorator store\n * when it does not — the unscanned-runtime posture, where reading only the\n * manifest would silently ignore `@method({ expose: false })` (#2686). Mirrors\n * `APIGenerator.runtimeMethod`, which decides the dispatch this predicts.\n */\nfunction readRegisteredMethod(\n objectName: string,\n action: string,\n): MethodDefinition | undefined {\n return ObjectRegistry.resolveRuntimeMethod(objectName, action).method;\n}\n\n/**\n * Whether `@smrt({ api })` exposes `action` on `objectName`.\n *\n * The generator's own action gate, lifted to a module function so preflight and\n * the live route read the same rule from one place. Accepts any action name so\n * a custom action is gated by the same `include`/`exclude` lists.\n *\n * @remarks An absent `objectName` returns `true` — fail-**open**, because the\n * live route reaches this only after it has already resolved a model, and an\n * unnamed object there means \"not gated by this rule\". A caller *predicting*\n * rather than serving has no such guarantee and must reject an unresolvable\n * model before asking (see `createRestPreflightLayerSource`).\n */\nexport function isApiActionEnabledForObject(\n objectName: string | undefined,\n action: string,\n): boolean {\n if (!objectName) {\n return true;\n }\n\n const apiConfig = ObjectRegistry.getConfig(objectName).api;\n\n if (apiConfig === false) {\n return false;\n }\n\n if (apiConfig && typeof apiConfig === 'object') {\n if (apiConfig.include && !apiConfig.include.includes(action)) {\n return false;\n }\n\n if (apiConfig.exclude?.includes(action)) {\n return false;\n }\n }\n\n return true;\n}\n\n/**\n * Whether `action` names a route the generated REST surface can actually\n * dispatch on `objectName`.\n *\n * CRUD actions always have a route. A custom action exists only when it is\n * DECLARED — historically an `api.routes` entry, and since #2686 also a\n * `@method()` supplying route-shaping metadata. `dispatchCustomCollectionAction`\n * iterates exactly that union, so an action outside it can never execute no\n * matter what `include`/`exclude` say. Exposure alone would report a typo'd or\n * removed custom action as `allow`, and an agent would then start the earlier\n * steps of a non-atomic playbook before dying on it — the failure preflight\n * exists to prevent. This prediction and that dispatch must stay one rule.\n */\nexport function isRestActionRoutable(\n objectName: string | undefined,\n action: string,\n): boolean {\n if (!objectName) return false;\n if (\n action === 'list' ||\n action === 'get' ||\n action === 'create' ||\n action === 'update' ||\n action === 'delete'\n ) {\n return true;\n }\n\n const apiConfig = ObjectRegistry.getConfig(objectName)?.api;\n if (!apiConfig || typeof apiConfig !== 'object') {\n return false;\n }\n\n const registered = readRegisteredMethod(objectName, action);\n\n // `@method({ expose: false })` outranks a legacy `api.routes` entry for the\n // same method, and dispatch honors that. Checking it BEFORE the routes map is\n // what keeps the two one rule: otherwise a withheld action that still carried\n // a route declaration predicted `allow` for an operation the transport\n // refuses (#2686).\n if (readMethodDecoratorConfig(registered)?.expose === false) return false;\n\n if (\n apiConfig.routes &&\n Object.hasOwn(apiConfig.routes as Record<string, unknown>, action)\n ) {\n return isHostableByRuntimeRest(objectName, action);\n }\n\n // Shared with `APIGenerator.declaredCollectionActions`, which decides the\n // dispatch this predicate exists to predict.\n return (\n declaresRuntimeRestRoute(registered) &&\n isHostableByRuntimeRest(objectName, action)\n );\n}\n\n/**\n * Whether this transport can HOST the action at all.\n *\n * It serves only collection-scoped custom actions, so an item-scoped\n * declaration — the `@method()` decorator's most common shape, on a model\n * instance method — answers 404 in dispatch. Predicting `allow` for it is the\n * false-`allow` this module exists to prevent, so routability is the\n * conjunction of \"declared\" and \"hostable\" (#2686).\n */\nfunction isHostableByRuntimeRest(objectName: string, action: string): boolean {\n return ObjectRegistry.isCollectionHostedMethod(objectName, action);\n}\n\n/**\n * Fail-closed authorization posture (#1540): true only when the object opts out\n * of auth via `@smrt({ api: { public } })` — `true` for every method, `'read'`\n * for safe (GET) methods only.\n */\nexport function isRestRoutePublic(\n objectName: string | undefined,\n method: string,\n): boolean {\n if (!objectName) return false;\n const apiConfig = ObjectRegistry.getConfig(objectName)?.api;\n if (!apiConfig || typeof apiConfig !== 'object') return false;\n const publicAccess = (apiConfig as { public?: boolean | 'read' }).public;\n if (publicAccess === true) return true;\n if (publicAccess === 'read') return method.toUpperCase() === 'GET';\n return false;\n}\n\n/**\n * Field-level read-permission slugs declared by `objectName`.\n *\n * Reads the model's own and inherited fields only — deliberately **not** the\n * STI base-plus-descendants union used for cache policy. That union answers\n * \"does any variant carry a read-permission field?\"; used here it would report\n * a sibling variant's slugs as required for a step naming this concrete model.\n */\nexport function restFieldReadPermissions(\n objectName: string | undefined,\n): string[] {\n if (!objectName) return [];\n const registered = ObjectRegistry.getClass(objectName);\n const className = registered?.qualifiedName ?? registered?.name ?? objectName;\n const fields =\n registered?.inheritedFields ?? ObjectRegistry.getFields(className);\n const slugs = new Set<string>();\n for (const [, def] of fields) {\n const slug =\n typeof def?.readPermission === 'string'\n ? def.readPermission\n : typeof def?._meta?.readPermission === 'string'\n ? (def._meta.readPermission as string)\n : undefined;\n if (slug) slugs.add(slug);\n }\n return [...slugs].sort();\n}\n"],"mappings":"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"}
@@ -1 +1 @@
1
- {"version":3,"file":"knowledge.d.ts","sourceRoot":"","sources":["../src/knowledge.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,yBAAyB,EACzB,sBAAsB,EACtB,mBAAmB,EACnB,0BAA0B,EAC3B,MAAM,uCAAuC,CAAC;AAE/C,OAAO,EACL,oBAAoB,EACpB,qBAAqB,GACtB,MAAM,2CAA2C,CAAC;AACnD,OAAO,EAAE,0BAA0B,EAAE,MAAM,uBAAuB,CAAC;AACnE,OAAO,EACL,gCAAgC,EAChC,mBAAmB,EACnB,KAAK,sBAAsB,GAC5B,MAAM,0BAA0B,CAAC;AAClC,OAAO,EACL,mBAAmB,EACnB,4BAA4B,EAC5B,8BAA8B,EAC9B,KAAK,kBAAkB,EACvB,KAAK,sBAAsB,EAC3B,KAAK,4BAA4B,EACjC,KAAK,mBAAmB,EACxB,KAAK,wBAAwB,EAC7B,KAAK,yBAAyB,EAC9B,KAAK,kBAAkB,EACvB,eAAe,GAChB,MAAM,sBAAsB,CAAC;AAC9B,OAAO,EACL,yBAAyB,EACzB,sBAAsB,EACtB,mBAAmB,EACnB,0BAA0B,GAC3B,CAAC;AAKF,OAAO,KAAK,EACV,2BAA2B,EAC3B,qBAAqB,EAGrB,uBAAuB,EAMxB,MAAM,2BAA2B,CAAC;AAanC,OAAO,KAAK,EAEV,mBAAmB,EACpB,MAAM,oBAAoB,CAAC;AAG5B;;;;;GAKG;AACH,MAAM,WAAW,eAAe;IAC9B,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;CACxB;AAED,MAAM,WAAW,2BAA2B;IAC1C,QAAQ,EAAE,mBAAmB,CAAC;IAC9B,OAAO,EAAE,MAAM,CAAC;IAChB,WAAW,CAAC,EAAE,eAAe,CAAC;IAC9B,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,MAAM,CAAC,EAAE,qBAAqB,CAAC;IAC/B;;;;;;;;;OASG;IACH,YAAY,CAAC,EAAE,2BAA2B,CAAC;CAC5C;AA0CD,wBAAgB,4BAA4B,CAC1C,OAAO,EAAE,2BAA2B,GACnC,uBAAuB,CA0FzB"}
1
+ {"version":3,"file":"knowledge.d.ts","sourceRoot":"","sources":["../src/knowledge.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,yBAAyB,EACzB,sBAAsB,EACtB,mBAAmB,EACnB,0BAA0B,EAC3B,MAAM,uCAAuC,CAAC;AAE/C,OAAO,EACL,oBAAoB,EACpB,qBAAqB,GACtB,MAAM,2CAA2C,CAAC;AACnD,OAAO,EAAE,0BAA0B,EAAE,MAAM,uBAAuB,CAAC;AACnE,OAAO,EACL,gCAAgC,EAChC,mBAAmB,EACnB,KAAK,sBAAsB,GAC5B,MAAM,0BAA0B,CAAC;AAClC,OAAO,EACL,mBAAmB,EACnB,4BAA4B,EAC5B,8BAA8B,EAC9B,KAAK,kBAAkB,EACvB,KAAK,sBAAsB,EAC3B,KAAK,4BAA4B,EACjC,KAAK,mBAAmB,EACxB,KAAK,wBAAwB,EAC7B,KAAK,yBAAyB,EAC9B,KAAK,kBAAkB,EACvB,eAAe,GAChB,MAAM,sBAAsB,CAAC;AAC9B,OAAO,EACL,yBAAyB,EACzB,sBAAsB,EACtB,mBAAmB,EACnB,0BAA0B,GAC3B,CAAC;AAKF,OAAO,KAAK,EACV,2BAA2B,EAC3B,qBAAqB,EAGrB,uBAAuB,EAMxB,MAAM,2BAA2B,CAAC;AAanC,OAAO,KAAK,EAEV,mBAAmB,EACpB,MAAM,oBAAoB,CAAC;AAI5B;;;;;GAKG;AACH,MAAM,WAAW,eAAe;IAC9B,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;CACxB;AAED,MAAM,WAAW,2BAA2B;IAC1C,QAAQ,EAAE,mBAAmB,CAAC;IAC9B,OAAO,EAAE,MAAM,CAAC;IAChB,WAAW,CAAC,EAAE,eAAe,CAAC;IAC9B,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,MAAM,CAAC,EAAE,qBAAqB,CAAC;IAC/B;;;;;;;;;OASG;IACH,YAAY,CAAC,EAAE,2BAA2B,CAAC;CAC5C;AA0CD,wBAAgB,4BAA4B,CAC1C,OAAO,EAAE,2BAA2B,GACnC,uBAAuB,CA0FzB"}
package/dist/knowledge.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { toSnakeCase } from "./utils/naming.js";
2
+ import { readPreviousQualifiedNames } from "./utils/qualified-names.js";
2
3
  import { publishArtifactFiles, publishAtomicArtifact } from "./consumer-plugin/artifact-publication.js";
3
4
  import { CRUD_OPERATIONS, createManifestClassNamePredicate, isCrudOperation, isCrudToolAction, isFrameworkLifecycleMethod, resolveApiMethodExposure, resolveCustomActionMetadata, resolveEffectiveActionMetadata } from "./generators/custom-action.js";
4
5
  import { isFrameworkBaseClass } from "./registry/framework-base-classes.js";
@@ -151,9 +152,11 @@ function buildKnowledgeObject(object, manifest) {
151
152
  related: field.related,
152
153
  columnType: field.columnType
153
154
  }));
155
+ const previousQualifiedNames = readPreviousQualifiedNames(object.decoratorConfig).sort();
154
156
  return {
155
157
  name: object.className,
156
158
  qualifiedName: object.qualifiedName,
159
+ ...previousQualifiedNames.length > 0 ? { previousQualifiedNames } : {},
157
160
  collection: object.collection,
158
161
  tableName: object.schema?.tableName,
159
162
  packageName: object.packageName,