@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.
- package/AGENTS.md +8 -8
- package/agents/generators.md +104 -0
- package/agents/revision-guard.md +66 -0
- package/agents/schema-paths.md +99 -0
- package/dist/cascade.d.ts +5 -9
- package/dist/cascade.d.ts.map +1 -1
- package/dist/cascade.js +65 -30
- package/dist/cascade.js.map +1 -1
- package/dist/embedded-write-queue.d.ts +8 -0
- package/dist/embedded-write-queue.d.ts.map +1 -1
- package/dist/embedded-write-queue.js +12 -2
- package/dist/embedded-write-queue.js.map +1 -1
- package/dist/generators/custom-action.d.ts.map +1 -1
- package/dist/generators/custom-action.js +1 -1
- package/dist/generators/custom-action.js.map +1 -1
- package/dist/generators/index.d.ts +1 -0
- package/dist/generators/index.d.ts.map +1 -1
- package/dist/generators/index.js +2 -1
- package/dist/generators/preflight-route.d.ts +151 -0
- package/dist/generators/preflight-route.d.ts.map +1 -0
- package/dist/generators/preflight-route.js +194 -0
- package/dist/generators/preflight-route.js.map +1 -0
- package/dist/generators/rest.d.ts +12 -0
- package/dist/generators/rest.d.ts.map +1 -1
- package/dist/generators/rest.js +12 -15
- package/dist/generators/rest.js.map +1 -1
- package/dist/generators.js +2 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -3
- package/dist/knowledge.d.ts +17 -1
- package/dist/knowledge.d.ts.map +1 -1
- package/dist/knowledge.js +33 -3
- package/dist/knowledge.js.map +1 -1
- package/dist/manifest/static-manifest.d.ts.map +1 -1
- package/dist/manifest/static-manifest.js +6 -2
- package/dist/manifest/static-manifest.js.map +1 -1
- package/dist/manifest/store.js +1 -1
- package/dist/manifest/store.js.map +1 -1
- package/dist/manifest.json +8 -2
- package/dist/migrations/differ.d.ts +49 -0
- package/dist/migrations/differ.d.ts.map +1 -1
- package/dist/migrations/differ.js +220 -4
- package/dist/migrations/differ.js.map +1 -1
- package/dist/migrations/orchestrate.d.ts +23 -0
- package/dist/migrations/orchestrate.d.ts.map +1 -1
- package/dist/migrations/orchestrate.js +12 -3
- package/dist/migrations/orchestrate.js.map +1 -1
- package/dist/object.d.ts +31 -1
- package/dist/object.d.ts.map +1 -1
- package/dist/object.js +57 -9
- package/dist/object.js.map +1 -1
- package/dist/registry/types.d.ts +8 -3
- package/dist/registry/types.d.ts.map +1 -1
- package/dist/revision-guard.d.ts +84 -0
- package/dist/revision-guard.d.ts.map +1 -0
- package/dist/revision-guard.js +120 -0
- package/dist/revision-guard.js.map +1 -0
- package/dist/schema/foreign-key-ddl.d.ts +7 -0
- package/dist/schema/foreign-key-ddl.d.ts.map +1 -1
- package/dist/schema/foreign-key-ddl.js +7 -1
- package/dist/schema/foreign-key-ddl.js.map +1 -1
- package/dist/schema/schema-manager.d.ts +15 -0
- package/dist/schema/schema-manager.d.ts.map +1 -1
- package/dist/schema/schema-manager.js +52 -9
- package/dist/schema/schema-manager.js.map +1 -1
- package/dist/schema/types.d.ts +10 -0
- package/dist/schema/types.d.ts.map +1 -1
- package/dist/schema/uuid-convergence.d.ts +142 -0
- package/dist/schema/uuid-convergence.d.ts.map +1 -0
- package/dist/schema/uuid-convergence.js +0 -0
- package/dist/schema/uuid-convergence.js.map +1 -0
- package/dist/smrt-knowledge.json +18 -9
- package/dist/utils/scanner-module.d.ts +44 -0
- package/dist/utils/scanner-module.d.ts.map +1 -1
- package/dist/vite-plugin/index.d.ts +14 -1
- package/dist/vite-plugin/index.d.ts.map +1 -1
- package/dist/vite-plugin/index.js +54 -2
- package/dist/vite-plugin/index.js.map +1 -1
- package/dist/vite-plugin.js +3 -2
- 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()`
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
package/agents/generators.md
CHANGED
|
@@ -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.
|
package/agents/schema-paths.md
CHANGED
|
@@ -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
|
|
100
|
-
*
|
|
101
|
-
* statement, so a failure part-way through cannot leave
|
|
102
|
-
*
|
|
103
|
-
*
|
|
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
|
package/dist/cascade.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"cascade.d.ts","sourceRoot":"","sources":["../src/cascade.ts"],"names":[],"mappings":"AAAA
|
|
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
|
-
* -
|
|
28
|
-
*
|
|
29
|
-
* survive a failure. A
|
|
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
|
|
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
|
|
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))
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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
|
|
387
|
-
*
|
|
388
|
-
* statement, so a failure part-way through cannot leave
|
|
389
|
-
*
|
|
390
|
-
*
|
|
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
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
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, {
|