@happyvertical/smrt-core 0.43.10 → 0.44.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 (81) hide show
  1. package/AGENTS.md +8 -8
  2. package/agents/generators.md +104 -0
  3. package/agents/revision-guard.md +66 -0
  4. package/agents/schema-paths.md +99 -0
  5. package/dist/cascade.d.ts +5 -9
  6. package/dist/cascade.d.ts.map +1 -1
  7. package/dist/cascade.js +65 -30
  8. package/dist/cascade.js.map +1 -1
  9. package/dist/embedded-write-queue.d.ts +8 -0
  10. package/dist/embedded-write-queue.d.ts.map +1 -1
  11. package/dist/embedded-write-queue.js +12 -2
  12. package/dist/embedded-write-queue.js.map +1 -1
  13. package/dist/generators/custom-action.d.ts.map +1 -1
  14. package/dist/generators/custom-action.js +1 -1
  15. package/dist/generators/custom-action.js.map +1 -1
  16. package/dist/generators/index.d.ts +1 -0
  17. package/dist/generators/index.d.ts.map +1 -1
  18. package/dist/generators/index.js +2 -1
  19. package/dist/generators/preflight-route.d.ts +151 -0
  20. package/dist/generators/preflight-route.d.ts.map +1 -0
  21. package/dist/generators/preflight-route.js +194 -0
  22. package/dist/generators/preflight-route.js.map +1 -0
  23. package/dist/generators/rest.d.ts +12 -0
  24. package/dist/generators/rest.d.ts.map +1 -1
  25. package/dist/generators/rest.js +12 -15
  26. package/dist/generators/rest.js.map +1 -1
  27. package/dist/generators.js +2 -1
  28. package/dist/index.d.ts +2 -1
  29. package/dist/index.d.ts.map +1 -1
  30. package/dist/index.js +5 -3
  31. package/dist/knowledge.d.ts +17 -1
  32. package/dist/knowledge.d.ts.map +1 -1
  33. package/dist/knowledge.js +33 -3
  34. package/dist/knowledge.js.map +1 -1
  35. package/dist/manifest/static-manifest.d.ts.map +1 -1
  36. package/dist/manifest/static-manifest.js +6 -2
  37. package/dist/manifest/static-manifest.js.map +1 -1
  38. package/dist/manifest/store.js +1 -1
  39. package/dist/manifest/store.js.map +1 -1
  40. package/dist/manifest.json +8 -2
  41. package/dist/migrations/differ.d.ts +49 -0
  42. package/dist/migrations/differ.d.ts.map +1 -1
  43. package/dist/migrations/differ.js +220 -4
  44. package/dist/migrations/differ.js.map +1 -1
  45. package/dist/migrations/orchestrate.d.ts +23 -0
  46. package/dist/migrations/orchestrate.d.ts.map +1 -1
  47. package/dist/migrations/orchestrate.js +12 -3
  48. package/dist/migrations/orchestrate.js.map +1 -1
  49. package/dist/object.d.ts +31 -1
  50. package/dist/object.d.ts.map +1 -1
  51. package/dist/object.js +57 -9
  52. package/dist/object.js.map +1 -1
  53. package/dist/registry/types.d.ts +8 -3
  54. package/dist/registry/types.d.ts.map +1 -1
  55. package/dist/revision-guard.d.ts +84 -0
  56. package/dist/revision-guard.d.ts.map +1 -0
  57. package/dist/revision-guard.js +120 -0
  58. package/dist/revision-guard.js.map +1 -0
  59. package/dist/schema/foreign-key-ddl.d.ts +7 -0
  60. package/dist/schema/foreign-key-ddl.d.ts.map +1 -1
  61. package/dist/schema/foreign-key-ddl.js +7 -1
  62. package/dist/schema/foreign-key-ddl.js.map +1 -1
  63. package/dist/schema/schema-manager.d.ts +15 -0
  64. package/dist/schema/schema-manager.d.ts.map +1 -1
  65. package/dist/schema/schema-manager.js +52 -9
  66. package/dist/schema/schema-manager.js.map +1 -1
  67. package/dist/schema/types.d.ts +10 -0
  68. package/dist/schema/types.d.ts.map +1 -1
  69. package/dist/schema/uuid-convergence.d.ts +142 -0
  70. package/dist/schema/uuid-convergence.d.ts.map +1 -0
  71. package/dist/schema/uuid-convergence.js +0 -0
  72. package/dist/schema/uuid-convergence.js.map +1 -0
  73. package/dist/smrt-knowledge.json +18 -9
  74. package/dist/utils/scanner-module.d.ts +44 -0
  75. package/dist/utils/scanner-module.d.ts.map +1 -1
  76. package/dist/vite-plugin/index.d.ts +14 -1
  77. package/dist/vite-plugin/index.d.ts.map +1 -1
  78. package/dist/vite-plugin/index.js +54 -2
  79. package/dist/vite-plugin/index.js.map +1 -1
  80. package/dist/vite-plugin.js +3 -2
  81. package/package.json +4 -4
package/AGENTS.md CHANGED
@@ -27,14 +27,14 @@ subsystem you are editing. This file keeps what holds across all of them.
27
27
 
28
28
  - `initialize()`: loads field initializers, applies option values (options override initializers), loads from DB if id/slug provided
29
29
  - `save()`: upsert with STI validation, interceptor execution, auto-embeddings. Persisted objects (`isPersisted` — set by DB hydration and successful saves) upsert on `['id']` so natural-key edits (e.g. slug renames) update in place; new objects upsert on the natural-key conflict columns for ingestion-style dedup (#1472)
30
- - Persisted `save()` calls use the object's loaded `updated_at` revision in the
31
- database `UPDATE`; zero affected rows throws `RUNTIME_REVISION_CONFLICT`
32
- without overwriting the newer row. `save({ expectedUpdatedAt })` supplies an
33
- explicit revision when a caller binds the mutation to an earlier preview or
34
- selection snapshot. Embedded adapters serialize every same-process model
35
- save, delete, and complete `SmrtObject.withTransaction()` callback through
36
- one queue; bound saves re-enter that hold. Custom write paths must use those
37
- public APIs rather than bypassing the CAS ordering contract.
30
+ - Persisted `save()` uses loaded `updated_at` in its `UPDATE`; zero rows throws
31
+ `RUNTIME_REVISION_CONFLICT`. Explicit `expectedUpdatedAt` binds a save or
32
+ delete to an earlier snapshot. Remote guarded deletes bind the same predicate
33
+ into the final `DELETE`; embedded adapters compare inside the shared write queue
34
+ before cascading. That queue serializes same-process saves, deletes, and full
35
+ `SmrtObject.withTransaction()` callbacks. Custom writes must preserve this
36
+ public CAS ordering contract. PostgreSQL predicate:
37
+ [agents/revision-guard.md](agents/revision-guard.md).
38
38
  - Native DuckDB UUID columns are hydrated as canonical strings before model
39
39
  initialization, natural-key lookup, and embedded revision claims. Exact
40
40
  natural-key probes retain the interceptor-authorized filter when
@@ -63,6 +63,110 @@ requested path rather than a hardcoded `index.js`.
63
63
  Generated code also has to be valid in an ES module: `arguments` is not a legal
64
64
  binding name there, however convenient it reads.
65
65
 
66
+ ## Browser-plane playbook preflight route (#2590)
67
+
68
+ `GET {basePath}/_preflight?key=<playbook key>` (`src/generators/preflight-route.ts`)
69
+ is an advisory, read-effect, idempotent report of what a caller's playbook would
70
+ be allowed to do — capability *selection*, never authorization. Resolution and
71
+ verdict shaping live in `@happyvertical/smrt-playbooks`, which depends on this
72
+ package, so core takes the evaluator as the `APIConfig.playbookPreflight` seam and
73
+ the dependency stays one-way. Without a provider the route 404s.
74
+
75
+ **`authMiddleware` is never invoked by preflight**, and that is enforced
76
+ structurally rather than by discipline: `PlaybookPreflightRouteOptions` has no
77
+ auth member of any kind, and `rest.ts` passes the boolean `appAuthConfigured`
78
+ instead — so there is no handle in the module to invoke by mistake. A synthetic-
79
+ `Request` dry run is explicitly not an option: the middleware is request-bound,
80
+ returns a `Response` rather than a boolean, and may consult session stores,
81
+ rate-limit, or audit. The app-auth layer therefore reports `unknown`, which is the
82
+ honest answer, and a future `authPredicate` seam can fill it in without changing
83
+ the contract.
84
+
85
+ The static layers preflight predicts against are exported from the same module —
86
+ `isApiActionEnabledForObject`, `isRestActionRoutable`, `isRestRoutePublic`,
87
+ `restFieldReadPermissions`, `restMethodForApiAction`,
88
+ `resolveRegisteredObjectName` — and `APIGenerator`'s own
89
+ `isApiActionEnabled` / `isRoutePublic` now delegate to them, so the route and the
90
+ prediction of the route cannot drift. Exposure and existence are separate
91
+ questions: `include`/`exclude` gate a route, they do not conjure one, so
92
+ `isRestActionRoutable` additionally requires a custom action to be declared in
93
+ `api.routes` — the only map `dispatchCustomCollectionAction` iterates. A custom
94
+ action is predicted against the verb its own route config declares, so a
95
+ `public: 'read'` opt-out neither silently covers a `POST` action nor falsely
96
+ denies a declared `GET` one. Every unresolvable key returns the provider's single uniform
97
+ "unavailable" body with an unconditional 200: unknown and unauthorized keys are
98
+ indistinguishable at the HTTP layer too.
99
+
100
+ ## Emitted agent surface (#2591)
101
+
102
+ Generated model tools have always been build-time artifacts — virtual module,
103
+ manifest, knowledge graph. View intents (#2588) and playbooks (#2589) existed
104
+ only once something mounted, so "what can an agent do in this app" had no answer
105
+ short of enumerating every route. This closes that.
106
+
107
+ The same OXC scan that builds the manifest also runs the scanner's
108
+ agent-surface matcher (`ScanResults.agentSurface`). `smrtPlugin()` captures it
109
+ in `scanWithOxc`, projects it with `toKnowledgeAgentSurface`, and passes it to
110
+ `buildDomainKnowledgeManifest` as `agentSurface`. Note that declaration
111
+ discovery is NOT bound to the plugin's `include` glob — an app that scans
112
+ `src/lib/objects/**` for models still has its `src/lib/agent/*.intents.ts`
113
+ sidecars found (see `packages/scanner/AGENTS.md`). Two more consequences worth
114
+ holding onto:
115
+
116
+ - **It never touches `manifest.json`.** The runtime manifest stays
117
+ runtime-focused; the agent-addressable surface is an agent/developer contract,
118
+ so it lands in `.smrt/smrt-knowledge.json` and `dist/smrt-knowledge.json`
119
+ only, under `agentSurface: { intents, playbooks, diagnostics }`.
120
+ - **It is passed in, not scanned in `knowledge.ts`.** The scanner carries a
121
+ native parser binary and `smrt-core`'s main entry is browser-reachable, so
122
+ core's sync knowledge builder must not import it. The Vite plugin already
123
+ imports the scanner lazily on the Node side and is the only caller that writes
124
+ this artifact.
125
+
126
+ The field is **omitted entirely** when a package declares nothing, which is what
127
+ makes it additive in practice rather than only on paper: every existing
128
+ package's checked-in artifact stays byte-identical.
129
+
130
+ Each declaring module gets a `sourceHashes` entry under the
131
+ `agentSurface:<package-relative path>` prefix (`AGENT_SURFACE_HASH_PREFIX`), so
132
+ EDITING an intent sidecar marks the artifact stale exactly like editing
133
+ `AGENTS.md` does (`stale-domain-knowledge`).
134
+
135
+ Hashes alone cannot see an **added** declaration, though: a brand-new sidecar
136
+ has no recorded hash to mismatch, the runtime manifest never carries intents,
137
+ and `AGENTS.md` is untouched — so every other signal stays green while the
138
+ artifact omits a real operation. `dev:knowledge-check` therefore also re-derives
139
+ the declaration SET from source and compares it to the artifact by identity,
140
+ reporting either direction as `stale-agent-surface`. The scan is bounded like
141
+ the numeric-precision lint: `src` only, behind the scanner's token pre-filter.
142
+
143
+ That re-derivation must model what the EMITTER sees, not merely what is on
144
+ disk, or it reports drift no rebuild can clear. Which files count is decided by
145
+ the scanner's exported `isAgentSurfaceSourcePath` — the same predicate the
146
+ emitter itself uses, never a list copied into the checker — and the per-file
147
+ results run through `mergeAgentSurfaces` before comparing, because the merge is
148
+ where a duplicate identity and a derived tool-name collision are resolved and
149
+ the artifact is the merged result.
150
+ Diagnostics are compared alongside identities: a sidecar containing only a
151
+ computed declaration adds no identity and has no prior hash, so without that,
152
+ "a diagnostic, never silence" would quietly become "a diagnostic, until the
153
+ artifact goes stale". The walk covers `<pkg>/src` while the emitter globs the
154
+ whole project root, so an emitted entry from outside `src` is not reported as
155
+ missing — this check did not look there, and claiming otherwise would be an
156
+ error nothing could clear.
157
+
158
+ Both `stale-*` codes are warnings by default and errors under `--strict`, which
159
+ is what CI runs. Alongside them: `agent-surface-missing-identity`,
160
+ `agent-surface-duplicate-identity`, and `agent-surface-empty-playbook` are
161
+ errors, and `agent-surface-not-static` is a warning. A cross-file duplicate
162
+ arrives as a *diagnostic* rather than two entries — the scanner's merge already
163
+ dropped the loser — so that diagnostic maps to the duplicate error rather than
164
+ the not-static warning; otherwise the error would be unreachable for the case it
165
+ exists to catch.
166
+
167
+ `smrt doctor` prints the whole surface — model tools, intents, playbooks — from
168
+ these artifacts alone, with no application running.
169
+
66
170
  ## Custom-action contract
67
171
 
68
172
  `resolveCustomActionMetadata()` is the common discovery and invocation contract
@@ -0,0 +1,66 @@
1
+ # Revision compare-and-swap guard (`src/revision-guard.ts`)
2
+
3
+ Every persisted `save()` pins its `UPDATE` to the revision the writer loaded;
4
+ `claimRevision()` does the same without running domain hooks, and
5
+ `delete({ expectedUpdatedAt })` binds the same predicate into its final
6
+ `DELETE`. Zero affected rows raises `RUNTIME_REVISION_CONFLICT` rather than
7
+ overwriting or removing a newer row.
8
+
9
+ ## Why the predicate is not an equality (#2620)
10
+
11
+ The guard used to compare `updated_at` to `loadedRevision.toISOString()`. On
12
+ PostgreSQL a JavaScript `Date` is two lossy conversions away from the stored
13
+ value, so that predicate matched no row at all in two common situations — and
14
+ the object then conflicted on *every* later save, permanently, rather than
15
+ losing a race:
16
+
17
+ - **Precision.** `updated_at` is a microsecond column. Any row last written by
18
+ raw SQL — `updated_at = CURRENT_TIMESTAMP` / `now()`, including SMRT's own
19
+ migration backfills — carries a sub-millisecond tail a `Date` cannot hold.
20
+ - **Process timezone.** Schemas created before the `TIMESTAMPTZ` mapping still
21
+ hold `updated_at` as `timestamp WITHOUT time zone`, and `pg` hydrates that
22
+ type in the process zone, so on a non-UTC host `toISOString()` renders a wall
23
+ clock the row never held. The same columns are written under three different
24
+ conventions — `pg` serializes a bound `Date` in the process zone,
25
+ `claimRevision()` writes a UTC ISO string, and raw `CURRENT_TIMESTAMP` writes
26
+ in the *server* zone — so no single rendering can match every row.
27
+
28
+ ## What the predicate does instead
29
+
30
+ `postgresRevisionCondition()` builds
31
+ `date_trunc('milliseconds', updated_at) IN (…)` over both wall-clock renderings
32
+ of the revision, the process-zone one and the UTC one, each tagged `+00` so a
33
+ `timestamptz` comparison honours it and a `timestamp` comparison discards it —
34
+ the predicate therefore does not depend on the *session* TimeZone either. On a
35
+ UTC process the two renderings coincide and the condition is single-valued.
36
+
37
+ Lost-race semantics are preserved: a concurrent writer advances `updated_at` to
38
+ roughly "now", so it must land on the loaded revision — or, on a non-UTC
39
+ process only, on that revision shifted by the whole UTC offset — to the
40
+ millisecond before it could slip past. Collapsing that second rendering so the
41
+ predicate is single-valued on every process is tracked as #2623.
42
+
43
+ ## Rules
44
+
45
+ - Never rebuild this predicate by hand; call `postgresRevisionCondition()`.
46
+ Every guarded write — `save()`, `claimRevision()`, and the guarded `DELETE` —
47
+ goes through `SmrtObject.revisionPredicate()` so no path is left on the exact
48
+ equality.
49
+ - The condition is PostgreSQL-only. Embedded engines take the process-local
50
+ compare/upsert fallback (`usesEmbeddedRevisionFallback`), and remote LibSQL
51
+ stores ISO text whose exact equality round-trips losslessly.
52
+ - Custom write paths must go through `save()`, `save({ expectedUpdatedAt })`,
53
+ or `claimRevision()` rather than bypassing the CAS ordering contract.
54
+ - The driver-layer half — `pg` hydrating and serializing `timestamp` columns in
55
+ the process zone — is tracked as happyvertical/sdk#1223. The guard
56
+ deliberately assumes neither hydration convention, so a UTC-hydration fix
57
+ there cannot break it.
58
+
59
+ ## Coverage
60
+
61
+ `src/__tests__/issue-2620-revision-guard-precision-postgres.optional.test.ts`
62
+ runs the whole battery — guarded save, `save({ expectedUpdatedAt })`,
63
+ `claimRevision()`, guarded delete, and their still-conflicts counterparts —
64
+ against both `updated_at` column shapes in the registered PostgreSQL suite (`pnpm --filter @happyvertical/smrt-core
65
+ test:postgres`). `src/__tests__/revision-guard.test.ts` covers the rendering
66
+ itself in the default suite.
@@ -715,6 +715,105 @@ SQLite requires a deliberate table rebuild; DuckDB reports the unsupported ALTER
715
715
  path. Neither engine treats an unsupported constraint addition as a successful
716
716
  no-op.
717
717
 
718
+ ### Pre-R11 `text` ids converge to `uuid` before any FK statement (#2608)
719
+
720
+ R11 made SMRT identifiers and references native `uuid` on PostgreSQL. A
721
+ database created before that change still stores its `id` columns as `text`
722
+ while every reference column added afterwards materializes as `uuid`.
723
+ PostgreSQL cannot implement a foreign key across two different physical types —
724
+ FK DDL admits no cast — so `ADD CONSTRAINT … NOT VALID` fails with SQLSTATE
725
+ 42804 and aborts every later statement in the same migration batch.
726
+
727
+ Two rails handle it, and both are PostgreSQL-only. SQLite stores UUIDs as text
728
+ by design and DuckDB cannot rewrite a column type in place, so neither engine
729
+ emits anything for this drift.
730
+
731
+ **The runtime guard fails closed.** `SchemaManager.ensurePostgresForeignKey()`
732
+ reads both live column types and refuses to emit `ADD CONSTRAINT` when they
733
+ disagree, naming both columns, both live types, and the repair. It deliberately
734
+ skips the orphan probe in that case: across mismatched types the probe answers
735
+ a question about casted values, not about the constraint being refused, and it
736
+ has to run again after the columns converge anyway.
737
+
738
+ **The differ converges the columns.** `planUuidConvergence()`
739
+ (`src/schema/uuid-convergence.ts`) groups every manifest relationship that
740
+ declares UUID on both sides into connected components and converges a component
741
+ only when the live database already proves the target shape — at least one
742
+ member is native `uuid`. A component that is `text` on *every* side is the
743
+ tolerated pre-R11 deployment and is left alone; its foreign keys are
744
+ type-compatible today, and the R11 uuid/text equivalence in
745
+ `migrations/differ.ts` keeps it out of the column diff.
746
+
747
+ Components, not individual pairs, are the unit of decision: one legacy `text`
748
+ primary key can be referenced by several children, and converting it for one
749
+ of them would break every sibling that is still `text`. A self-referential
750
+ table falls out of the same grouping because both endpoints land in one
751
+ component. Convergence is relationship-driven, so a legacy `text` id that
752
+ nothing references keeps its R11 tolerance.
753
+
754
+ The planner never coerces data. Before emitting anything it probes each column
755
+ it would rewrite for values that are not uuid-shaped (the same `~*` canonical
756
+ pattern the orphan probe uses) and refuses the whole component — with the count
757
+ and a sample value — if any exist, if the probe cannot run, if a member carries
758
+ some third physical type, or if a live foreign key still constrains a column
759
+ that must change. `@happyvertical/sql` introspection does not expose live
760
+ PostgreSQL constraint names, so SMRT cannot drop and re-add those constraints
761
+ for you: drop them deliberately, rerun the migration to converge, and let SMRT
762
+ re-add the manifest constraints.
763
+
764
+ Refusals are reported, not silent. Each one becomes a warning advisory with no
765
+ executable SQL, so it reaches `unactionableChanges` / `hasManualDrift` and
766
+ `db:status` shows **blocked: incompatible column types** instead of *pending*.
767
+ The same check runs per relationship in `compareForeignKeys`, so a foreign key
768
+ whose live types will still disagree after this run's conversions is reported
769
+ blocked rather than emitted as pending DDL that cannot succeed.
770
+
771
+ The planner also inspects tables the manifest no longer declares. A live
772
+ foreign key from an orphan table onto a column that must convert still blocks
773
+ `ALTER COLUMN … TYPE`, so the differ introspects every existing table — not
774
+ only the manifest ones — whenever there is at least one conversion candidate,
775
+ and reports the dependency instead of emitting DDL PostgreSQL would reject. An
776
+ already-converged database has no candidates and pays nothing.
777
+
778
+ Ordering is a contract. Conversions carry `SchemaChange.phase =
779
+ 'pre_foreign_key'`, and the orchestrator emits them **before every CREATE TABLE
780
+ and every foreign-key statement in the batch**. Both halves matter:
781
+ `planForeignKeyCreation()` only defers the constraints inside a mutual cycle,
782
+ so an acyclic new child table keeps its foreign key *inline in `CREATE TABLE`*
783
+ — a brand-new `uuid` child pointing at a legacy `text` parent fails exactly
784
+ like an existing one, before the parent could be converted. Conversions only
785
+ ever rewrite columns that already exist, so leading the batch is always safe. A
786
+ live `DEFAULT` on a converting column is dropped first (PostgreSQL refuses
787
+ `ALTER COLUMN … TYPE` when the default cannot be cast); the ordinary default
788
+ comparison re-establishes the manifest default on the next run.
789
+
790
+ There are **two** batch builders and both order on that marker:
791
+ `collectStatementsFromDiff()` in `migrations/orchestrate.ts` (used by
792
+ `getPendingSchemaStatements` / `migrateSmrtSchemas`) and the tracker batch
793
+ `db:migrate` assembles by hand in `@happyvertical/smrt-cli`
794
+ (`commands/utilities.ts`). `partitionSchemaChanges()` carries
795
+ `SchemaChange.phase` onto `MigrationAction.phase` so the CLI can partition the
796
+ same way, in both the applied batch and the `--dry-run` preview. If you add a
797
+ third consumer, order it the same way.
798
+
799
+ Convergence entries carry the manifest column definition. Every `type_upgrade`
800
+ consumer reads `SchemaChange.column` — `partitionSchemaChanges()` in
801
+ `@happyvertical/smrt-cli` skips an entry without one — so a conversion missing
802
+ it would drop out of the `db:migrate` batch while `compareForeignKeys()` still
803
+ assumed the converged type. Refused convergences carry the same column plus an
804
+ advisory and no SQL, and the CLI routes them to the report-only advisories
805
+ rather than to manual interventions or the tracker.
806
+
807
+ The uuid wording is gated on the manifest. Both the runtime guard and the
808
+ status planner reach their incompatible-type branch for *any* mismatched pair,
809
+ not only uuid/text. A `USING …::uuid` repair is suggested only when the
810
+ manifest declares UUID on both sides **and** a live side is actually `text`;
811
+ otherwise the diagnostic names the two live types and asks the operator to
812
+ align them deliberately.
813
+
814
+ The conversion is one-time and idempotent: once the column is native `uuid`,
815
+ the component is uniformly UUID and the planner emits nothing.
816
+
718
817
  Properties to keep if you touch that module:
719
818
 
720
819
  - **The plan is registry-derived and rebuilt per delete.** Registration is
package/dist/cascade.d.ts CHANGED
@@ -96,15 +96,11 @@ export declare function cascadeReferencesTo(db: DatabaseInterface, registry: Cas
96
96
  /**
97
97
  * Run the cascade and the target row's own deletion atomically.
98
98
  *
99
- * When anything references the class and the adapter exposes `transaction()`,
100
- * the whole sequence runs inside one — including the caller's `deleteSelf`
101
- * statement, so a failure part-way through cannot leave the object deleted with
102
- * its junction rows intact (or vice versa). Adapters without transaction
103
- * support run the same statements sequentially; this is the documented
104
- * degradation, not a silent one.
105
- *
106
- * When nothing references the class there is nothing to keep consistent, and
107
- * the transaction is skipped — see the comment on that branch.
99
+ * When the adapter exposes `transaction()`, the whole sequence runs inside one
100
+ * — including framework-owned row cleanup and the caller's `deleteSelf`
101
+ * statement, so a failure part-way through cannot leave either side orphaned.
102
+ * Adapters without transaction support run the same statements sequentially;
103
+ * this is the documented degradation, not a silent one.
108
104
  *
109
105
  * @param db - Database the object is bound to
110
106
  * @param registry - Registry view used to build cascade plans
@@ -1 +1 @@
1
- {"version":3,"file":"cascade.d.ts","sourceRoot":"","sources":["../src/cascade.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAoEG;AAGH,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAC;AAK5D,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,eAAe,CAAC;AAKpD,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,mBAAmB,CAAC;AAM1D;;;;;;GAMG;AACH,MAAM,MAAM,cAAc,GAAG,gBAAgB,CAAC;AAsB9C,gFAAgF;AAChF,MAAM,WAAW,gBAAgB;IAC/B,mEAAmE;IACnE,SAAS,EAAE,MAAM,CAAC;IAClB,0CAA0C;IAC1C,SAAS,EAAE,MAAM,CAAC;IAClB,uDAAuD;IACvD,SAAS,EAAE,MAAM,CAAC;IAClB,iDAAiD;IACjD,MAAM,EAAE,MAAM,CAAC;IACf,8EAA8E;IAC9E,MAAM,EAAE,cAAc,CAAC;IACvB,4EAA4E;IAC5E,QAAQ,EAAE,OAAO,CAAC;CACnB;AAED,mEAAmE;AACnE,MAAM,WAAW,2BAA2B;IAC1C,gDAAgD;IAChD,SAAS,EAAE,MAAM,CAAC;IAClB,0CAA0C;IAC1C,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,2EAA2E;AAC3E,MAAM,WAAW,WAAW;IAC1B,4EAA4E;IAC5E,UAAU,EAAE,gBAAgB,EAAE,CAAC;IAC/B,mEAAmE;IACnE,WAAW,EAAE,2BAA2B,EAAE,CAAC;IAC3C,yEAAyE;IACzE,SAAS,EAAE,MAAM,EAAE,CAAC;IACpB,+EAA+E;IAC/E,OAAO,EAAE,OAAO,CAAC;CAClB;AAED;;;;;;GAMG;AACH,MAAM,MAAM,mBAAmB,GAAG,IAAI,CACpC,OAAO,cAAc,EACnB,oBAAoB,GACpB,WAAW,GACX,oBAAoB,GACpB,cAAc,GACd,uBAAuB,GACvB,UAAU,GACV,YAAY,GACZ,gBAAgB,CACnB,CAAC;AAEF;;;;;;GAMG;AACH,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,OAAO,GAAG,cAAc,GAAG,SAAS,CAE5E;AASD;;;;;;;;;;GAUG;AACH,wBAAgB,gBAAgB,CAC9B,QAAQ,EAAE,mBAAmB,EAC7B,SAAS,EAAE,MAAM,GAChB,WAAW,CAgIb;AAkVD,8EAA8E;AAC9E,MAAM,WAAW,aAAa;IAC5B,4EAA4E;IAC5E,cAAc,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;IAC5B;;;;OAIG;IACH,oBAAoB,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CAC3C;AAED;;;;;;GAMG;AACH,wBAAsB,mBAAmB,CACvC,EAAE,EAAE,iBAAiB,EACrB,QAAQ,EAAE,mBAAmB,EAC7B,MAAM,EAAE;IAAE,SAAS,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,EAAE,CAAA;CAAE,GAC9D,OAAO,CAAC,aAAa,CAAC,CAwBxB;AASD;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAsB,gBAAgB,CACpC,EAAE,EAAE,iBAAiB,EACrB,QAAQ,EAAE,mBAAmB,EAC7B,MAAM,EAAE;IACN,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;IAClB,+DAA+D;IAC/D,EAAE,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,CAAC;CAC/B,EACD,UAAU,EAAE,CAAC,EAAE,EAAE,iBAAiB,KAAK,OAAO,CAAC,IAAI,CAAC,GACnD,OAAO,CAAC,aAAa,CAAC,CAgDxB"}
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,CAgIb;AAkZD,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
@@ -24,14 +24,11 @@ import { createLogger } from "@happyvertical/logger";
24
24
  * `afterDelete` hooks and interceptors do **not** run, and no change-feed
25
25
  * tombstone is written for them — exactly as `ON DELETE CASCADE` behaves.
26
26
  * Only the object `delete()` was called on runs the full lifecycle.
27
- * - When anything needs cascading, everything runs inside a single
28
- * transaction when the adapter exposes one, so a partial cascade cannot
29
- * survive a failure. A class with no typed references AND no registered
30
- * polymorphic association class anywhere in the process skips the
31
- * transaction — see {@link runCascadeDelete}. A `metaType` column can point
27
+ * - Every delete runs inside a single transaction when the adapter exposes
28
+ * one, including cleanup of framework-owned context and embedding rows, so
29
+ * a partial cascade cannot survive a failure. A `metaType` column can point
32
30
  * at any class at runtime, so a polymorphic association class is always
33
- * plausibly relevant; in an app with even one registered, this fast path is
34
- * rare, not the common case.
31
+ * plausibly relevant.
35
32
  *
36
33
  * ## Which references are followed
37
34
  *
@@ -240,6 +237,43 @@ async function tolerateMissingTable(operation, fallback, context) {
240
237
  throw error;
241
238
  }
242
239
  }
240
+ /**
241
+ * System-table cleanup may ignore an absent table for databases created before
242
+ * that subsystem existed, but never a missing column in a table that does
243
+ * exist. The shared `undefined_object` classification intentionally combines
244
+ * both cases, so this security boundary must retain the narrower driver signal.
245
+ */
246
+ function normalizeMissingTableName(value) {
247
+ const unquoted = value.replace(/["'`[\]]/g, "");
248
+ return (unquoted.split(".").at(-1) ?? unquoted).replace(/[^A-Za-z0-9_$-]/g, "").toLowerCase();
249
+ }
250
+ function missingTableNames(messages) {
251
+ const names = [];
252
+ const patterns = [
253
+ /no such table:\s*([^\s,;]+)/giu,
254
+ /(?:relation|table)\s+((?:"[^"]+"(?:\."[^"]+")*)|(?:[A-Za-z0-9_.$-]+))\s+does not exist/giu,
255
+ /table with name\s+((?:"[^"]+")|(?:[A-Za-z0-9_.$-]+))\s+does not exist/giu
256
+ ];
257
+ for (const message of messages) for (const pattern of patterns) for (const match of message.matchAll(pattern)) if (match[1]) names.push(normalizeMissingTableName(match[1]));
258
+ return names;
259
+ }
260
+ function isMissingTableError(error, expectedTable) {
261
+ const classification = classifyDatabaseError(error);
262
+ if (classification.kind !== "undefined_object") return false;
263
+ if (classification.sqlstate === "42703" || classification.driverCode === "42703" || classification.driverCodes.includes("42703")) return false;
264
+ const names = missingTableNames(classification.driverMessages);
265
+ const expected = normalizeMissingTableName(expectedTable);
266
+ return names.length > 0 && names.every((missingTableName) => missingTableName === expected);
267
+ }
268
+ async function tolerateMissingSystemTable(operation, fallback, context) {
269
+ try {
270
+ return await operation();
271
+ } catch (error) {
272
+ if (!isMissingTableError(error, context.table)) throw error;
273
+ logger.warn(`Cascade delete skipped ${context.action} on '${context.table}': table not found in this database.`, { error: error instanceof Error ? error.message : String(error) });
274
+ return fallback;
275
+ }
276
+ }
243
277
  async function selectIds(db, tableName, column, ids) {
244
278
  const found = [];
245
279
  for (const batch of chunkArray(ids, 900)) {
@@ -275,7 +309,8 @@ async function deleteByIds(db, tableName, ids) {
275
309
  * enough.
276
310
  *
277
311
  * A missing system table is not an error — an application database may predate
278
- * the table, and losing derived rows must never fail an otherwise valid delete.
312
+ * the table. Every other cleanup failure propagates so the surrounding delete
313
+ * transaction rolls back rather than orphaning tenant-sensitive recall data.
279
314
  */
280
315
  async function deleteSystemRows(db, ids, classNames) {
281
316
  const ownerIds = ids.filter((id) => typeof id === "string" && id.length > 0 && id !== COLLECTION_OWNER_SENTINEL);
@@ -288,14 +323,13 @@ async function deleteSystemRows(db, ids, classNames) {
288
323
  EMBEDDINGS_TABLE,
289
324
  "object_id",
290
325
  "object_class"
291
- ]]) for (const batch of chunkArray(ownerIds, 900)) try {
292
- await db.delete(table, {
293
- ...idPredicate(idColumn, batch),
294
- ...idPredicate(classColumn, classNames)
295
- });
296
- } catch (error) {
297
- logger.warn(`Failed to clean ${table} rows during cascade delete: ${error instanceof Error ? error.message : String(error)}`);
298
- }
326
+ ]]) for (const batch of chunkArray(ownerIds, 900)) await tolerateMissingSystemTable(() => db.delete(table, {
327
+ ...idPredicate(idColumn, batch),
328
+ ...idPredicate(classColumn, classNames)
329
+ }), void 0, {
330
+ table,
331
+ action: "framework-owned row cleanup"
332
+ });
299
333
  }
300
334
  /**
301
335
  * Resolve every reference pointing at `ids` of `className`, recursively.
@@ -383,15 +417,11 @@ async function cascadeReferencesTo(db, registry, target) {
383
417
  /**
384
418
  * Run the cascade and the target row's own deletion atomically.
385
419
  *
386
- * When anything references the class and the adapter exposes `transaction()`,
387
- * the whole sequence runs inside one — including the caller's `deleteSelf`
388
- * statement, so a failure part-way through cannot leave the object deleted with
389
- * its junction rows intact (or vice versa). Adapters without transaction
390
- * support run the same statements sequentially; this is the documented
391
- * degradation, not a silent one.
392
- *
393
- * When nothing references the class there is nothing to keep consistent, and
394
- * the transaction is skipped — see the comment on that branch.
420
+ * When the adapter exposes `transaction()`, the whole sequence runs inside one
421
+ * — including framework-owned row cleanup and the caller's `deleteSelf`
422
+ * statement, so a failure part-way through cannot leave either side orphaned.
423
+ * Adapters without transaction support run the same statements sequentially;
424
+ * this is the documented degradation, not a silent one.
395
425
  *
396
426
  * @param db - Database the object is bound to
397
427
  * @param registry - Registry view used to build cascade plans
@@ -402,12 +432,17 @@ async function cascadeReferencesTo(db, registry, target) {
402
432
  async function runCascadeDelete(db, registry, target, deleteSelf) {
403
433
  const ids = target.id ? [target.id] : [];
404
434
  if (buildCascadePlan(registry, target.className).isEmpty) {
405
- await deleteSelf(db);
406
- await deleteSystemRows(db, ids, ownerClassCandidates(registry, target.className));
407
- return {
408
- affectedTables: /* @__PURE__ */ new Set(),
409
- affectedTableClasses: /* @__PURE__ */ new Map()
435
+ const runWithoutReferences = async (bound) => {
436
+ await deleteSystemRows(bound, ids, ownerClassCandidates(registry, target.className));
437
+ await deleteSelf(bound);
438
+ return {
439
+ affectedTables: /* @__PURE__ */ new Set(),
440
+ affectedTableClasses: /* @__PURE__ */ new Map()
441
+ };
410
442
  };
443
+ const transaction = db.transaction;
444
+ if (typeof transaction !== "function") return runWithoutReferences(db);
445
+ return transaction.call(db, runWithoutReferences);
411
446
  }
412
447
  const run = async (bound) => {
413
448
  const result = await cascadeReferencesTo(bound, registry, {