@voltro/sql-mysql 0.20.1 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -39,6 +39,177 @@ _Changes staged for the next release accumulate here (rolled up from
39
39
 
40
40
  ---
41
41
 
42
+ ## [0.21.0] — 2026-07-31
43
+
44
+ ### ⚠ BREAKING
45
+
46
+ - **@voltro/database, @voltro/runtime, @voltro/workflow, @voltro/voltro** — `text().maxLength(n)` on an EXISTING column now actually applies. It planned zero operations and reported "schema is up to date" while the live column stayed `longtext` — the documented remedy for MariaDB's hash long-unique was a silent no-op, which is worse than no remedy because you stop looking.
47
+
48
+ **The differ was not comparing lengths wrongly — it could not see them.** `maxLength` was absent from the schema snapshot entirely: it lived on the column definition, was read only when rendering CREATE DDL, and never reached the comparison. A consumer pinned the mechanism with a contrast: a column bounded AT CREATION was `varchar(64)` (DDL path, fine); one bounded afterwards stayed `longtext` (diff path, blind).
49
+
50
+ It is now carried on both sides — declared from the definition, live from introspection — compared as its own dimension, and rendered by the applier from the declared snapshot (a bare `text` tag would emit DDL that applies successfully and changes nothing, the silent no-op the applier's convergence check exists to catch).
51
+
52
+ **Three guards against the real risk, which is not missing a change but re-emitting one forever:**
53
+
54
+ - Only the VARCHAR family contributes a live length. MariaDB reports `character_maximum_length = 4294967295` for `longtext` and 65535 for `text`; postgres reports NULL. Reading a type's theoretical maximum would make a declared `text()` differ from its own live column on every boot. - Only `text()` columns are compared. `id()` renders as `VARCHAR(64)` on mysql/mariadb while its declaration carries no length — measured on live MariaDB while building this, and it would have emitted an ALTER for every id column, forever. - Never on sqlite (no length-enforced type), and never when the caller omitted the dialect — an unknown dialect behaves exactly as before this field existed.
55
+
56
+ Proven by round-trip suites against live MariaDB and live Postgres: the same schema re-plans to NOTHING, a length change produces exactly one operation on the right column, applying it lands the new width, and re-planning after that is clean.
57
+
58
+ Classification follows the nullability precedent: **widening is `safe`** (no data can be lost), **narrowing is `needs-backfill`** and says so, with the count query to run first. Narrowing is deliberately not blocked — blocking it would leave the remedy just as unusable as the silence did.
59
+
60
+ **Also: `_voltro_api_keys.hashedKey` is now bounded at 64**, since the value is `sha256Hex(token)` and narrowing it can never fail. The other four unbounded unique columns in framework tables are deliberately left alone, each with the reason at the column: `_voltro_kv.key` is the caller's own key, `idempotencyKey` comes from a user-supplied function, and the two workflow `executionId`s have no shape the framework guarantees. A narrowing ALTER that fails on existing data during a framework upgrade is a worse outcome than the index-size concern it would fix — and the MariaDB hash long-unique is harmless on those tables anyway, since `_voltro_*` is filtered out of the binlog reader's include list.
61
+
62
+ **Migration** — `voltro update` prints it (`0.21.0/01_maxlength-now-migrates`, `manual`, and it fires only for projects that declare a bound). Your source does not change; every `.maxLength(n)` already written keeps compiling. What changes is that the next `voltro db apply` — or a `voltro dev` / `voltro serve` boot with auto-migrate — emits ALTERs it used to skip. Run `voltro db plan` first: it prints exactly which columns would be altered without touching anything, and an empty plan means this does not affect you. Widening is `safe` and can simply run; narrowing is `needs-backfill` and the plan carries the count query to run before it.
63
+
64
+ *Why this is `BREAKING` and not `Fixed`: the API is compatible — nothing is removed, renamed or narrowed, and the same call compiles. But an upgrade now performs DDL against YOUR tables that the previous version silently skipped, and on MariaDB `longtext → varchar(n)` is a full table rebuild that locks. The DB-changes-need-no-codemod exemption is written for `_voltro_*` tables riding the differ; this reaches user tables, so the operator deserves the warning at `voltro update` time rather than in a changelog section they may never open.*
65
+
66
+ ### Added
67
+
68
+ - **@voltro/cli, @voltro/voltro** — **`voltro schedule run <name>`** fires one scheduled job on demand, against `voltro dev` or `voltro serve`.
69
+
70
+ Asked for by a consumer whose nightly jobs correct business data and whose workaround was: edit the cron expression to a minute out, wait for the reload, put it back. "Run it once now and watch" is a normal thing to want.
71
+
72
+ **Most of it already existed, and that is why it took a measurement to find the gap.** `SchedulerHandle.fireNow` has been there, and so has `POST /_voltro/inspect/schedules/:name/fire`. What was missing was the way in: no CLI verb, and — the part that mattered — **`voltro serve` mounted no inspect surface at all**. Every `/_voltro/inspect/*` route existed only under `voltro dev`, which is the one place a nightly data-correcting job is not running.
73
+
74
+ So production now mounts it. Two things make that safe rather than a new attack surface, and both were checked rather than assumed:
75
+
76
+ - `handleInspectRequest` is **closed by default**: with no `VOLTRO_INSPECT_TOKEN` every request is 401 carrying the remedy, the compare is constant-time, and the token is never minted outside dev. Opening it is an operator's deliberate act. - Only the handlers production can answer TRUTHFULLY are wired. Everything else stays absent and replies "not configured" — an empty array would claim the app has no procedures. `inspectSchedules` is deliberately still absent: dev computes `nextFiringAt` and the EFFECTIVE coordination from its own boot closure, and reporting a guessed coordination mode to a post-deploy gate is worse than reporting nothing.
77
+
78
+ The manifest's `rpc` / `workflows` entries come from a builder both boot paths now share (`inspectEntries.ts`). They are the same facts on both sides, and a second hand-written copy of the descriptor→entry mapping is the shape this repo keeps paying for.
79
+
80
+ **A run id of `null` is reported as its own outcome**, not as success: the run was coordinated away — another replica holds the lock, or `onOverlap: 'skip'` found the previous run still going. Printing "ok" would claim work that never started.
81
+
82
+ **Also fixed, and it affects every command in the inspect family.** `fetchJson` collapsed a non-2xx into the body's `error` field alone, discarding `message`. The surface answers `{ error: <category>, message: <what happened> }`, so firing an unknown schedule printed "fire failed" while the server had said `scheduler.fireNow: unknown schedule "…"`, and a closed surface printed "unauthorized" while the body named the missing env var. It now prefers `message`, then `reason`, then `error` — fixed in the shared fetch rather than per command.
83
+
84
+ Verified end to end against `voltro-starter/apps/v-api-durable`: the fire returns a run id and the job's own output appears in the server log; an unknown name reports the server's reason; `--format json` round-trips.
85
+
86
+ ### Fixed
87
+
88
+ - **@voltro/database, @voltro/sql-postgres, @voltro/sql-mysql, @voltro/sql-sqlite, @voltro/sql-mssql, @voltro/voltro** — <!-- apiSurface: compatible — reasoned, not rubber-stamped. `attributionFields()` gained an OPTIONAL parameter and `withCapturedAttribution` is new. Both are additive, and it was CHECKED rather than assumed, because this repo has already paid once for a narrowing that read as additive: a zero-arg call still compiles, the value is still assignable to the old `() => …` type, and it still passes as a callback typed with the old shape. All three probed under `--strict`. Nothing was removed. -->
89
+
90
+ Write attribution is now CARRIED down the write path instead of re-read from the ambient async-local scope, so a connection-pool handoff can no longer strand a write's `traceId` / `subjectId`.
91
+
92
+ `routeEvent` read the identity with `attributionFields()`, and it runs after `await runPromise(...)`. `AsyncLocalStorage` propagates through continuations the current context CREATES; one scheduled by ANOTHER context — exactly what a pool handoff does when an acquisition queues — resumes with that other context's store. So under contention the write landed with the identity absent. Absent is a LEGAL value there meaning "no request behind this write", so the result does not look like a defect: it looks like a schedule. In a compliance trail that asymmetry is the whole problem, and it is why this is closed structurally rather than left as unlikely.
93
+
94
+ The value is captured ONCE at each public store method, before any await (`withCapturedAttribution`), and threaded explicitly through every `execute*` and into `routeEvent` — in all four dialect stores, including the transactional view, which is the path every framework mutation takes. The ambient scope is kept as a FALLBACK: a site that has not been threaded behaves exactly as before rather than worse, which is what made the change verifiable site by site.
95
+
96
+ **On what is and is not proven.** A pool handoff cannot be reproduced deterministically from a test — the resume context is the driver's choice. Two attempts are worth recording because both produced misleading green: a load-based regression test passed in isolation and failed in the full gate twice (a coin flip that also blocked releases), and a "deterministic" replacement that left the scope before the write finished PASSED against the unthreaded store, because the store re-enters its own scope internally. It proved nothing while looking like proof, so it was deleted.
97
+
98
+ What is proven: the pure semantics (`writeAttributionCapture.test.ts` — explicit wins over the ambient scope, explicit wins over ANOTHER request's scope, "no request" stays "no request", keys omitted rather than `undefined`), and the threading itself (`attributionThreadingParity.test.ts` — every store accepts and uses the carried value, no store still calls a bare `attributionFields()`, every transactional view carries it). The guarantee is structural, and it is stated that way rather than dressed up as a reproduction.
99
+
100
+ Raised by a consumer who could NOT reproduce the loss across 2700 writes at 96-way concurrency with every core saturated, and who asked for the fix anyway on the right grounds: *"impossible beats unlikely when the failure is invisible."*
101
+ - **@voltro/database, @voltro/sql-postgres, @voltro/sql-mysql, @voltro/voltro** — <!-- apiSurface: compatible — `beginLocalWrite`, `endLocalWrite` and `resolveEchoAttribution` are new exports on `@voltro/database`; nothing was removed, renamed or narrowed. Checked rather than assumed, because this repo has already paid once for a narrowing that read as additive. -->
102
+
103
+ Under CDC, a plain `store.insert(...)` could deliver its change with `traceId` and `subjectId` ABSENT — not always, and more often the busier the process.
104
+
105
+ **The registration was racing the echo, and only winning on a margin.** The write path registers the request identity so the transport echo (a postgres NOTIFY, a mysql binlog row) can be re-united with it, and `pendingAttribution.ts` stated the ordering as a guarantee: the registration happens "BEFORE the transport can possibly echo it, because a NOTIFY fires at COMMIT". That is true INSIDE a transaction, where `routeEvent` runs before COMMIT. It is false for a plain write, whose statement commits ITSELF — the trigger fires while the write path is still awaiting the driver, and the registration lands afterwards. The echo has to make a round trip through the LISTEN/binlog connection, and that round trip was the only thing keeping the order right.
106
+
107
+ **Why it stayed hidden.** An unattributed event is a LEGAL event meaning "no request behind this write", so a lost identity is indistinguishable from a background job — there is no error, no warning, and nothing that looks wrong in a trace. It surfaced as one failing assertion in the full test suite, which is the only place this machine is loaded enough to flip the order, and it passed on every isolated re-run.
108
+
109
+ **Closed by construction, not by widening the margin.** A store now brackets each non-transactional write (`beginLocalWrite` / `endLocalWrite`) and delivers echoes through `resolveEchoAttribution`. An echo arriving while a write to that table is mid-registration is HELD until that write has had its chance, then answered. Held echoes keep arrival order — a subscriber seeing an update before the insert it updates would be worse off than one missing a `traceId` — and a write that never registers releases the table after a bound rather than parking its stream.
110
+
111
+ Three narrowings, each deliberate:
112
+
113
+ - **Only under `cdc`.** Nothing is injected in `inline` mode, so there is no echo to order against. - **Only non-transactional writes.** `transactional()` registers before COMMIT already; bracketing it would hold OTHER replicas' echoes for the length of the transaction to fix a race that path does not have. Asserted, so the narrowing is on the record rather than something a later reader "fixes". - **A remote write is still never attributed.** The barrier may delay an answer; it must never invent one. Nothing is registered locally for another replica's write, and that stays true while echoes are held.
114
+
115
+ **Both CDC stores had a hand-written copy of the claim-and-emit tail**, which is the shape of the previous three attribution defects in this package. The decision now lives in one function both call, and `echoBarrierParity.test.ts` fails if either store claims for itself, leaves a public write outside the bracket, or brackets the transactional path. The ordering itself is proven against the primitive (`pendingAttribution.test.ts`) — a live suite cannot force it, because the resume point is the driver's choice, and the four barrier tests were verified to go red against the pre-fix behaviour before being trusted.
116
+
117
+ Also fixed: `mysql`'s `updateMany` / `deleteMany` route their events after their transaction commits, so they carried the same race despite not going through the per-row write path. They are bracketed too.
118
+ - **@voltro/cli, @voltro/voltro** — `voltro build` could fail to build the **start bundle** with `Could not resolve "@voltro/cli/startEntry"`, degrading `voltro start` to the slower per-module boot. The serve bundle carried the identical latent failure.
119
+
120
+ Both bundles generate an entry importing a narrow CLI export and build it with `absWorkingDir: <app root>` — so esbuild resolved that bare specifier from the APP's `node_modules`. Under strict pnpm the app has `@voltro/cli` there only if it DECLARES it, and an app normally depends on `voltro` / `@voltro/web` and gets the CLI transitively. The CLI now resolves its own entry from `import.meta.url` and hands esbuild an absolute alias.
121
+
122
+ Same shape as the tsx bug (`tsxLoader.ts`): a package that is OUR dependency, resolved from the user's directory, invisible under strict pnpm. Same answer — the CLI knows where it lives, so it stops asking the app.
123
+
124
+ **The published export was not the problem.** It is present in the tarball — checked against the real 0.20.1 and 0.20.2 packages, `./startEntry` → `./dist/startEntry.js`, file included. Only resolution failed, which is why re-adding the export would have changed nothing.
125
+
126
+ This monorepo hoists everything, so the bare specifier resolves here and the build passes with or without the alias. The test therefore asserts the alias directly rather than inferring it from a green build — the hoisted layout is exactly what hid the strict-pnpm failure in the first place.
127
+
128
+ **Also pinned, after a wrong turn worth recording.** A consumer reported that the framework provides no Suspense boundary, so any suspend during SSR throws. The obvious repair — a root `<Suspense>` — was implemented, measured, and REVERTED:
129
+
130
+ - With a root boundary, a page that THROWS answered **200** with `<template data-msg="Switched to client rendering">`. React downgrades an errored boundary to client rendering, which silently undid the hard failure shipped moments earlier. - Without one, a suspending page renders fine anyway: `renderToPipeableStream` treats the root as an implicit boundary, so a suspend delays the shell flush rather than failing.
131
+
132
+ So the reported problem does not exist on the streaming path, and the obvious fix for it breaks something that does. Both halves are now fixtures with assertions side by side (`ssr-suspends` must render, `ssr-throws` must 500) — adding a root boundary flips the second, and that pair is what makes it visible instead of shipping it. Docs corrected in both languages, including the two places a suspend genuinely is unsupported (`renderToString` behind static prerender, and the client render), neither of which is the framework's choice.
133
+ - **@voltro/cli, @voltro/voltro** — <!-- apiSurface: compatible — `CliRuntime` keeps its exported signature and its behaviour (NodeContext + logger); only `runCli`'s internal composition changed. `loggerConfig` is module-private. -->
134
+
135
+ Every one-shot `voltro` command printed each log line TWICE. Measured on `voltro agents-md`: 76 lines for 38 events.
136
+
137
+ `runCli` installed two loggers. It provided `CliRuntime` (which contains a `LoggerLayer`) to the program, then provided a second `LoggerLayer` around the `matchCauseEffect` wrapping it — so the program ran inside both. Two `LoggerLayer`s in one fiber do not compose the way the name suggests: `LoggerLayer` is `Logger.replace(Logger.defaultLogger, …)`, which removes the DEFAULT logger and adds its own. The second one finds no default left to remove, the removal is a no-op, and the add still happens. Replace composes as replace only against the default — never against another replace.
138
+
139
+ The duplicates were distinguishable only because the outer layer was built without the command's `defaultScope`, so half the output carried `scope` and half did not. Had both been configured identically the output would have been byte-identical pairs, which is a good deal harder to notice than a stray field.
140
+
141
+ **The failure branch is the half that survives a partial fix**, and it did. Moving the second layer from around `matchCauseEffect` onto the error handler repairs the success path and leaves the failure path doubling, because that branch runs while the program's scope is still open and INHERITS its logger. Verified by measuring three shapes rather than reasoning about scopes: handler-provides → 2 lines, handler-inherits → 1, provide-once-outermost → 1.
142
+
143
+ The last is what shipped. The logger is a FiberRef, not a service the program requires, so it is provided ONCE at the outermost boundary and the program gets only `NodeContext`. That covers both branches by construction, rather than by the handler happening to still be inside a scope that has not closed yet. `CliRuntime` is unchanged and still used by `runCliMain`, which provides it once and never had the problem.
144
+
145
+ Guarded by `cliRuntime.test.ts`, which asserts the COUNT (any second provision doubles it regardless of what it logs) and that every line carries the command scope. It was checked against the reverted fix in both of its shapes before being trusted. Its capture spies on stdout AND stderr, deliberately: diagnostics route to stderr by level, and a stdout-only capture reported zero lines for the failure path — reading as "nothing was logged" when the truth was "logged on the other stream", which had the test accusing the fix it was written to protect.
146
+ - **@voltro/cli, @voltro/voltro** — Three corrections to the `db drift` baseline shipped in 0.20.2, all reported by the consumer who verified the fix — and all of them defects in that fix rather than in older code.
147
+
148
+ **1. The first `db drift` after upgrading CRASHED.** `liveFingerprint` is a new column and does not exist until a `db apply` adds it, so naming it in the ledger read died on `SqlError: Failed to execute statement` instead of reaching the "no baseline yet" branch written for exactly that moment. The documented sequence was `0 → apply → clean`; the real one was `crash → apply → clean`, with the crash landing in the first CI run after an upgrade. Proven by dropping and re-adding the column: absent → exit 1 and a driver error, present → exit 0 and the honest message. The read is now `SELECT *`, which cannot go stale against an older ledger.
149
+
150
+ **2. A no-op `db apply` established no baseline.** The baseline is written per applied plan row, so an already-current schema produced none — and "cannot compare" then persisted indefinitely rather than for one run, for any app whose schema was current when it upgraded. A no-op apply now backfills the latest row's `liveFingerprint` instead of inserting a history entry for a migration that did not happen.
151
+
152
+ **3. "No baseline" gets its own exit code: 3.** Previously it exited 0, so a CI gate could not distinguish "compared and matched" from "did not compare" — and on a stable schema the second could persist forever. The consumer named that as their reason for NOT adding a drift gate: it would pass vacuously, which is the failure this whole thread is about. Now `0` = matched, `3` = no baseline, `4` = diverged.
153
+
154
+ **And the SQL-error reporter added in the same release did not work on this path.** It walked `.cause`, and an `Effect` `FiberFailure` has none — its cause hides behind `Symbol(effect/Runtime/FiberFailure/Cause)`, with only `stack`, `message` and `name` as own keys. So the helper returned `undefined` and not even its "no statement attached" fallback fired. The consumer reproduced that against an empty database and checked field by field; all absent.
155
+
156
+ The reason the tests missed it is worth recording: every fixture was a hand-built object WITH a `.cause` — the shape assumed, not the shape the runtime produces. The suite now builds a real `FiberFailure` through `Effect.runPromise`, and the walk unwraps the symbol and flattens the `Cause` tree (`Fail`/`Die`/`Sequential`).
157
+
158
+ Their note on the irony is fair and is the reason this is one entry rather than two: defect 1 above IS the "next DB-shaped error in your CI" that the reporter existed to make readable, and it arrived as a bare wrapper plus a driver stack. With the statement printed it would have named the missing column immediately.
159
+
160
+ **A FOURTH copy of both defects was found in `voltro dev`'s migrations inspect endpoint, and it was the worst one.** It carried the same postgres-only `::text` casts, wrapped in `orElseSucceed(() => [])` — so on every non-postgres dialect the syntax error became an EMPTY history rather than a failure: the devtools migrations panel showed nothing, and with no history row the drift verdict came out `false`. A silent, permanent "no drift" on every mysql/mariadb/mssql/sqlite app. It also compared the declared hash against a live one, exactly like the CLI did.
161
+
162
+ Both are fixed there too, and `ledgerReadPortability.test.ts` now fails if any query touching `_voltro_migration_plans` grows a `::type` cast again. Three copies were fixed in one change and the fourth was missed in the same change, which is the argument for the test rather than another paragraph in a maintainer note.
163
+ - **@voltro/cli, @voltro/voltro** — `voltro dev` served a client-only shell — with a **200** — whenever a `renderMode: 'ssr'` page failed to render on the server. It now answers 500 with the cause, exactly as `voltro start` does.
164
+
165
+ **Reported as "voltro dev does not SSR". It does**, and has since before 0.20.0 — the middleware, its intent stated in a comment ("mirroring what `voltro start` does in production"), is an ancestor of every 0.20.x tag. What the reporter saw was the masking: their pages suspended during the server render (a lazily-loaded i18n catalog above any Suspense boundary), all 225 degraded to Vite's SPA shell, and an empty `<div id="root">` is indistinguishable from a framework that never server-renders. Their conclusion was the only one the evidence supported.
166
+
167
+ **The 200 is the part that mattered.** The same render is a hard 500 under `voltro start`, so those pages were down in production while dev reported success — the inverse of the usual "works in dev, breaks in prod", and worse, because nothing prompts you to look. Measured before the fix: `HTTP 200`, no `x-voltro-rendered-by` header at all, and the thrown error present in the dev log but nowhere in the response.
168
+
169
+ Three sites did this (both streaming `onShellError` handlers and the outer catch); all three now fail through one helper. The pattern was already in the file — the `isDeferralNotSupported` branch refuses rather than degrades and says why in the same words ("falling through would leave the developer with an unstyled page and a log line, which is exactly the silent degradation the hard error exists to prevent"). One of the removed fallbacks sat directly under a comment stating that falling through would mask the bug.
170
+
171
+ Dev puts the cause and stack in the response body; production keeps its bare `server error`, so a stack never reaches a public response. That is a difference in what the failure says, never in whether it fails.
172
+
173
+ **Also fixed, found while reproducing it: a page added while `voltro dev` runs was never server-rendered.** The middleware matched against a route table built once at boot, so a new page missed matching entirely and returned before its module was ever loaded — Vite's SPA shell, 200, and *no log line at all*, because the middleware never ran. The page tree the middleware reads is now refreshed by the same regeneration that rewrites the route table (`dirs` too, or a layout added after boot would be invisible to the spa-shell decision).
174
+
175
+ Both are covered by `webDevSsrLayoutLoader.test.ts` against a real dev server: `/ssr-throws` must answer 500 + `x-voltro-rendered-by: ssr-dev-failed` + the cause and must never contain the empty shell, and a page written while the server runs must be server-rendered without a restart. Each assertion was verified red against its own reverted fix — separately, because the first failure aborts the test and would have left the second unproven.
176
+
177
+ One thing this does NOT change: the framework still provides a Suspense boundary only for its own deferral (`<Await>`), not a blanket one at the root. Code that suspends outside it needs a boundary you mount yourself. That is now documented next to the failure behaviour, since a hard 500 is how you will meet it.
178
+
179
+ ### Internal (no consumer-facing effect)
180
+
181
+ - **@voltro/cli** — Maintainer notes only — no shipped behaviour changes.
182
+
183
+ `mssqlClusterPatch.ts`'s header said the `@effect/cluster` patch covers "two mssql-only bugs" (it is four: the `deliver_at` INT-overflow, the MERGE…OUTPUT with correlated sub-SELECTs, `FOR UPDATE`, and `USING (SELECT * FROM (VALUES …))`) and implied that a version bump needs nothing but a re-key, because 0.59.0 → 0.60.0 happened to apply unchanged. On 0.60.2 the same patch fails on 3 of its 6 files — upstream refactored `SqlMessageStorage` and moved the context the hunks match on. A bump can require REGENERATING the patch.
184
+
185
+ The regeneration recipe now lives in `packages/sql-mssql/CLAUDE.md`, together with the two measurements that produced confidently wrong answers while working this out: reading an already-patched `node_modules/.pnpm/*patch_hash=*` copy and concluding upstream had fixed it, and un-patching one of the several installed copies and concluding the patch was not load-bearing. Both look like evidence.
186
+
187
+ Also recorded there: verify by BREAKING it. `git apply --check` proves the patch lands, not that it still fixes anything. Un-patched, the mssql cluster suite fails with `Incorrect syntax near ')'`; patched, 5/5 against the live fixture.
188
+
189
+ ---
190
+
191
+ ## [0.20.2] — 2026-07-30
192
+
193
+ ### Fixed
194
+
195
+ - **@voltro/database, @voltro/cli, @voltro/voltro** — `voltro db drift` could never report clean. It compared the LIVE schema's fingerprint against `_voltro_migration_plans.fingerprint` — which is the **declared** snapshot's hash. The two are not comparable: introspection cannot recover everything a declaration carries (generated expressions, `maxLength`, sensitivity markers), so hashing a live snapshot never equals hashing the declaration it came from. The command was therefore RED on a provably clean database, permanently.
196
+
197
+ Fixing the postgres-only cast in the previous release is what made this visible — before that, `db drift` crashed before it could compute a wrong answer.
198
+
199
+ A consumer measured it precisely: three different fingerprints for one database (`apply` recorded `b1078b73…`, `drift` computed `8dcc9c5e…`, `plan` computed `0c376108…`), stable across runs, with `db plan` reporting 0 operations in between.
200
+
201
+ **The fix is a second, comparable baseline.** `_voltro_migration_plans` gains `liveFingerprint` — the post-apply LIVE fingerprint, taken from the convergence re-plan's `fromFingerprint` (that re-plan runs AFTER the apply, so its "pre-state" is our post-state; it is already computed, so this costs nothing). `db drift` compares against that, and hashes the live snapshot WHOLE, exactly as the applier did.
202
+
203
+ It used to strip `_voltro_*` tables before hashing, which sounds reasonable and was half the incomparability. A framework upgrade that adds a `_voltro_*` column will now show as drift until the next `db apply` records a new baseline — honest, since the live schema did change, and it self-heals on the apply an upgrade needs anyway.
204
+
205
+ Rows written before the column exists have no baseline. `db drift` says so and exits 0, instead of comparing a live hash against a declared one and calling the difference drift. `_voltro_*` table changes ride the declarative differ, so no codemod.
206
+
207
+ **The "Probable causes" list is gone.** It named out-of-band DDL and a missing ledger row, and the consumer hit it with neither being true — the row was right there in `db plans`. A diagnosis that asserts a cause it cannot know is the same defect as the `ALTER TABLE FORCE` repair line retracted in the same release, and it costs more here because it is confident: it sends the reader hunting through somebody's shell history. The command now says what it can actually see — that the schema changed, not what or who — and points at `db plan` for the real difference.
208
+
209
+ Worth recording what the pair of defects cost together, in the consumer's framing: `db drift` exists to catch a divergence between declared and live, and the one real divergence they have (`sessions.tokenHash` declared `varchar(64)`, live `longtext`) is invisible to it — while it loudly reported a divergence that did not exist. False negative on the real thing, false positive on nothing. The false negative is the still-open `maxLength`-in-the-snapshot item.
210
+
211
+ ---
212
+
42
213
  ## [0.20.1] — 2026-07-30
43
214
 
44
215
  ### Changed
@@ -9,7 +9,7 @@ Generated from the resolved runtime dependency closure (18 packages).
9
9
 
10
10
  ---
11
11
 
12
- ## @effect/sql@0.51.1
12
+ ## @effect/sql@0.52.0
13
13
 
14
14
  License: MIT
15
15
 
@@ -37,7 +37,7 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
37
37
  SOFTWARE.
38
38
  ```
39
39
 
40
- ## @effect/sql-mysql2@0.52.0
40
+ ## @effect/sql-mysql2@0.53.0
41
41
 
42
42
  License: MIT
43
43
 
@@ -698,7 +698,7 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
698
698
  SOFTWARE.
699
699
  ```
700
700
 
701
- ## mysql2@3.22.4
701
+ ## mysql2@3.22.6
702
702
 
703
703
  License: MIT
704
704
 
@@ -780,7 +780,7 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
780
780
  SOFTWARE.
781
781
  ```
782
782
 
783
- ## sql-escaper@1.3.3
783
+ ## sql-escaper@1.4.0
784
784
 
785
785
  License: MIT
786
786
 
package/dist/index.d.ts CHANGED
@@ -25,6 +25,7 @@ import { SqlDialect } from '@voltro/database';
25
25
  import { SqlError } from '@effect/sql';
26
26
  import { TransactionConnection } from '@effect/sql/SqlClient';
27
27
  import { _voltroCdcOffsetsTable } from '@voltro/database';
28
+ import { WriteAttribution } from '@voltro/database';
28
29
 
29
30
  export declare interface BinlogCdcHandle {
30
31
  readonly stop: () => Promise<void>;
@@ -351,6 +352,19 @@ export declare class MysqlStore implements DataStore {
351
352
  }): Promise<ReadonlyArray<T>>;
352
353
  private runWithEager;
353
354
  /* Excluded from this release type: getInternalRunWithEager */
355
+ /**
356
+ * Bracket a NON-transactional write: capture the request identity before the
357
+ * first await (`withCapturedAttribution`), and hold this table's transport
358
+ * echoes until the write has registered that identity.
359
+ *
360
+ * Both narrowings are deliberate. Under `inline` nothing is ever injected, so
361
+ * there is no echo to order against. Inside `transactional()` the registration
362
+ * already precedes COMMIT, so bracketing it would hold OTHER replicas' echoes
363
+ * for the length of the transaction to close a race that path does not have.
364
+ * The gap being closed is specific: a plain write's statement commits ITSELF,
365
+ * so the transport fires while this is still awaiting the driver.
366
+ */
367
+ private localWrite;
354
368
  insert(t: string, r: Row): Promise<Readonly<Record<string, unknown>>>;
355
369
  insertMany(t: string, rows: ReadonlyArray<Row>): Promise<readonly Readonly<Record<string, unknown>>[]>;
356
370
  patchJson(t: string, pk: string, path: string, value: unknown): Promise<Readonly<Record<string, unknown>> | null>;
package/dist/index.js CHANGED
@@ -3,16 +3,16 @@ import { Config as t, Effect as n, Layer as r, ManagedRuntime as i, Option as a,
3
3
  import { EventEmitter as c } from "node:events";
4
4
  import { createLogger as l } from "@voltro/logger";
5
5
  import { SqlClient as u, TransactionConnection as d } from "@effect/sql/SqlClient";
6
- import { CDC_OFFSETS_TABLE as f, EagerCardinalityError as p, _voltroCdcOffsetsTable as m, attachEagerLoads as h, attributionFields as g, attributionKey as _, claimPendingAttribution as v, compileEagerJson as y, compilePredicate as b, compileRawFragment as x, compileSelect as S, currentWriteAttribution as C, decodeRowsFromSchema as w, encodeRowForSchema as T, hasEagerLoads as E, isTableReactive as D, qualifyTable as ee, raiseChangeListenerCeiling as O, recordsTable as te, registerPendingAttribution as ne, requireTable as re, runWithWriteAttribution as k, runWriteRecorders as A, settleTransactionExit as j, stampGeneratedId as M, stampGeneratedIds as N } from "@voltro/database";
6
+ import { CDC_OFFSETS_TABLE as f, EagerCardinalityError as p, _voltroCdcOffsetsTable as m, attachEagerLoads as h, attributionFields as g, attributionKey as _, beginLocalWrite as v, compileEagerJson as y, compilePredicate as b, compileRawFragment as x, compileSelect as S, currentWriteAttribution as C, decodeRowsFromSchema as w, encodeRowForSchema as T, endLocalWrite as E, hasEagerLoads as D, isTableReactive as O, qualifyTable as ee, raiseChangeListenerCeiling as te, recordsTable as ne, registerPendingAttribution as re, requireTable as k, resolveEchoAttribution as A, runWithWriteAttribution as ie, runWriteRecorders as ae, settleTransactionExit as oe, stampGeneratedId as j, stampGeneratedIds as se, withCapturedAttribution as M } from "@voltro/database";
7
7
  //#region src/sqlLayer.ts
8
- var P = (n) => e.layerConfig({
8
+ var N = (n) => e.layerConfig({
9
9
  host: t.succeed(n.host),
10
10
  port: t.succeed(n.port),
11
11
  username: t.succeed(n.username),
12
12
  password: t.succeed(o.make(n.password)),
13
13
  database: t.succeed(n.database),
14
14
  ...n.maxConnections === void 0 ? {} : { maxConnections: t.succeed(n.maxConnections) }
15
- }), F = (e) => {
15
+ }), P = (e) => {
16
16
  if (e.url) {
17
17
  let t = new URL(e.url);
18
18
  return {
@@ -32,20 +32,20 @@ var P = (n) => e.layerConfig({
32
32
  database: e.database ?? "app",
33
33
  ...e.maxConnections === void 0 ? {} : { maxConnections: e.maxConnections }
34
34
  };
35
- }, I = (e) => P(F(e)), ie = (e, t) => e === null ? !1 : t === null ? !0 : e.filename === t.filename ? e.position > t.position : e.filename > t.filename, ae = (e) => e.msSinceProgress < e.stallThresholdMs ? "healthy" : e.primary !== null && !ie(e.primary, e.reader) ? "idle-caught-up" : "reconnect", L = (e) => (e instanceof Error ? e.message : String(e ?? "")).includes("schema changed between binlog event and metadata fetch"), R = (e) => {
35
+ }, F = (e) => N(P(e)), I = (e, t) => e === null ? !1 : t === null ? !0 : e.filename === t.filename ? e.position > t.position : e.filename > t.filename, L = (e) => e.msSinceProgress < e.stallThresholdMs ? "healthy" : e.primary !== null && !I(e.primary, e.reader) ? "idle-caught-up" : "reconnect", R = (e) => (e instanceof Error ? e.message : String(e ?? "")).includes("schema changed between binlog event and metadata fetch"), z = (e) => {
36
36
  let t = e instanceof Error ? e.message : String(e ?? "");
37
37
  return /Table\s+[^\s.]+\.(\S+)\s+schema changed between binlog event and metadata fetch/.exec(t)?.[1] ?? null;
38
- }, z = "\n SELECT DISTINCT s.TABLE_NAME AS tableName\n FROM information_schema.STATISTICS s\n JOIN information_schema.COLUMNS c\n ON c.TABLE_SCHEMA = s.TABLE_SCHEMA\n AND c.TABLE_NAME = s.TABLE_NAME\n AND c.COLUMN_NAME = s.COLUMN_NAME\n WHERE s.TABLE_SCHEMA = DATABASE()\n AND s.NON_UNIQUE = 0\n AND s.SUB_PART IS NULL\n AND c.DATA_TYPE IN ('text','tinytext','mediumtext','longtext','blob','tinyblob','mediumblob','longblob')\n", B = (e) => `cdc: table '${e}' is EXCLUDED from binlog capture — its row image carries a hidden column the reader cannot account for. Cause: a UNIQUE constraint on an UNBOUNDED text column, which MariaDB backs with a HASH long-unique index; that index adds a hidden DB_ROW_HASH_n column to the row, present in the binlog and absent from information_schema.COLUMNS. Remedy: bound the column — text().maxLength(n) — so the constraint becomes an ordinary B-tree index with no hidden column. ALTER TABLE FORCE does NOT help: the rebuild recreates the index and the hidden column. Until then, cross-instance change events for this table are lost; own-node reactivity is unaffected (writes still emit inline).`, V = 5 * 6e4, H = 3, U = (e, t) => {
39
- let n = [...e.filter((e) => t - e < V), t];
38
+ }, B = "\n SELECT DISTINCT s.TABLE_NAME AS tableName\n FROM information_schema.STATISTICS s\n JOIN information_schema.COLUMNS c\n ON c.TABLE_SCHEMA = s.TABLE_SCHEMA\n AND c.TABLE_NAME = s.TABLE_NAME\n AND c.COLUMN_NAME = s.COLUMN_NAME\n WHERE s.TABLE_SCHEMA = DATABASE()\n AND s.NON_UNIQUE = 0\n AND s.SUB_PART IS NULL\n AND c.DATA_TYPE IN ('text','tinytext','mediumtext','longtext','blob','tinyblob','mediumblob','longblob')\n", V = (e) => `cdc: table '${e}' is EXCLUDED from binlog capture — its row image carries a hidden column the reader cannot account for. Cause: a UNIQUE constraint on an UNBOUNDED text column, which MariaDB backs with a HASH long-unique index; that index adds a hidden DB_ROW_HASH_n column to the row, present in the binlog and absent from information_schema.COLUMNS. Remedy: bound the column — text().maxLength(n) — so the constraint becomes an ordinary B-tree index with no hidden column. ALTER TABLE FORCE does NOT help: the rebuild recreates the index and the hidden column. Until then, cross-instance change events for this table are lost; own-node reactivity is unaffected (writes still emit inline).`, H = 5 * 6e4, U = 3, W = (e, t) => {
39
+ let n = [...e.filter((e) => t - e < H), t];
40
40
  return {
41
- verdict: n.length >= H ? "persistent" : "backlog",
41
+ verdict: n.length >= U ? "persistent" : "backlog",
42
42
  hits: n
43
43
  };
44
- }, W = /* @__PURE__ */ new Set([
44
+ }, G = /* @__PURE__ */ new Set([
45
45
  "writerows",
46
46
  "updaterows",
47
47
  "deleterows"
48
- ]), G = /\b(alter|rename|drop|create)\s+(table|column)?/i, K = (e) => new Promise((t) => setTimeout(t, e)), q = async (e) => {
48
+ ]), ce = /\b(alter|rename|drop|create)\s+(table|column)?/i, K = (e) => new Promise((t) => setTimeout(t, e)), q = async (e) => {
49
49
  let t = l({ scope: `voltro:${e.variant}:cdc` }), n;
50
50
  try {
51
51
  n = (await import("@vlasky/zongji")).default;
@@ -61,14 +61,14 @@ var P = (n) => e.layerConfig({
61
61
  o = n.binlogName;
62
62
  return;
63
63
  }
64
- if (r === "query" && n.query && G.test(n.query)) {
64
+ if (r === "query" && n.query && ce.test(n.query)) {
65
65
  u && (u.tableMap = {});
66
66
  return;
67
67
  }
68
68
  if (n.nextPosition && o && (s = {
69
69
  filename: o,
70
70
  position: n.nextPosition
71
- }, e.onPosition?.(s)), !W.has(r)) return;
71
+ }, e.onPosition?.(s)), !G.has(r)) return;
72
72
  let l = n.tableMap[n.tableId];
73
73
  if (!l || l.parentSchema !== a) return;
74
74
  let d = l.tableName;
@@ -147,10 +147,10 @@ var P = (n) => e.layerConfig({
147
147
  u?.stop();
148
148
  } catch {}
149
149
  if (f++, await K(Math.min(3e4, 500 * 2 ** Math.min(f, 6))), d) return;
150
- let i = s, a = C(n), c = !a && L(n), l = !1;
150
+ let i = s, a = C(n), c = !a && R(n), l = !1;
151
151
  if (c) {
152
- let e = R(n), i = e ?? "<unknown>", { verdict: a, hits: o } = U(h.get(i) ?? [], Date.now());
153
- h.set(i, o), l = a === "persistent", l && !g.has(i) && (g.add(i), e !== null && r.add(e), t.error(B(i)));
152
+ let e = z(n), i = e ?? "<unknown>", { verdict: a, hits: o } = W(h.get(i) ?? [], Date.now());
153
+ h.set(i, o), l = a === "persistent", l && !g.has(i) && (g.add(i), e !== null && r.add(e), t.error(V(i)));
154
154
  }
155
155
  (a || c) && (l || t.warn(c ? "cdc: un-replayable backlog event (schema moved past it) — jumping to current end + self-heal" : "cdc: binlog gap (purged/failover) — jumping to current end + self-heal"), i = e.resolveStartPosition ? await e.resolveStartPosition().catch(() => null) : null, o = i?.filename ?? null, l || e.onResync?.());
156
156
  try {
@@ -181,7 +181,7 @@ var P = (n) => e.layerConfig({
181
181
  if (d || m || Date.now() - p < v) return;
182
182
  let n = null;
183
183
  if (e.resolveStartPosition && (n = await e.resolveStartPosition().catch(() => null)), d || m) return;
184
- let r = ae({
184
+ let r = L({
185
185
  msSinceProgress: Date.now() - p,
186
186
  stallThresholdMs: v,
187
187
  primary: n,
@@ -210,7 +210,7 @@ var P = (n) => e.layerConfig({
210
210
  },
211
211
  currentPosition: () => s
212
212
  };
213
- }, oe = /* @__PURE__ */ new Set(["1213", "1205"]), se = (e) => {
213
+ }, le = /* @__PURE__ */ new Set(["1213", "1205"]), ue = (e) => {
214
214
  let t = e;
215
215
  for (let e = 0; e < 5 && typeof t == "object" && t; e++) {
216
216
  let e = t.errno;
@@ -220,8 +220,8 @@ var P = (n) => e.layerConfig({
220
220
  t = t.cause;
221
221
  }
222
222
  }, J = (e) => {
223
- let t = se(e);
224
- return t !== void 0 && oe.has(t);
223
+ let t = ue(e);
224
+ return t !== void 0 && le.has(t);
225
225
  }, Y = (e) => J(e) ? "retry" : "noRetry", X = (e) => {
226
226
  if (e == null) return "null";
227
227
  let t = typeof e;
@@ -231,19 +231,19 @@ var P = (n) => e.layerConfig({
231
231
  if (Array.isArray(e)) return `[${e.map(X).join(",")}]`;
232
232
  let n = e;
233
233
  return `{${Object.keys(n).sort().map((e) => `${JSON.stringify(e)}:${X(n[e])}`).join(",")}}`;
234
- }, ce = (e) => {
234
+ }, Z = (e) => {
235
235
  let t = X(e), n = 2166136261;
236
236
  for (let e = 0; e < t.length; e++) n ^= t.charCodeAt(e), n = Math.imul(n, 16777619);
237
237
  return (n >>> 0).toString(36);
238
- }, le = (e, t) => {
238
+ }, de = (e, t) => {
239
239
  let n = setTimeout(e, t);
240
240
  typeof n.unref == "function" && n.unref();
241
- }, ue = class {
241
+ }, fe = class {
242
242
  variant;
243
243
  ttlMs;
244
244
  schedule;
245
245
  seen = /* @__PURE__ */ new Map();
246
- constructor(e, t = 6e4, n = le) {
246
+ constructor(e, t = 6e4, n = de) {
247
247
  this.variant = e, this.ttlMs = t, this.schedule = n;
248
248
  }
249
249
  key(e) {
@@ -257,7 +257,7 @@ var P = (n) => e.layerConfig({
257
257
  } catch {
258
258
  r = t;
259
259
  }
260
- return `${e.table} ${e.op} ${String(n)} ${ce(r)}`;
260
+ return `${e.table} ${e.op} ${String(n)} ${Z(r)}`;
261
261
  }
262
262
  admit(e) {
263
263
  let t = this.key(e);
@@ -272,19 +272,19 @@ var P = (n) => e.layerConfig({
272
272
  get pending() {
273
273
  return this.seen.size;
274
274
  }
275
- }, de = /* @__PURE__ */ new Set([
275
+ }, pe = /* @__PURE__ */ new Set([
276
276
  1022,
277
277
  1062,
278
278
  1586
279
- ]), Z = async (e) => {
279
+ ]), Q = async (e) => {
280
280
  let t = e.variant ?? "mysql", n = l({ scope: `voltro:${t}` }), a = e.changeStrategy ?? "inline", o = a;
281
281
  a === "cdc" && !e.cdcConfig && (n.warn("changeStrategy='cdc' requires cdcConfig (serverId + connection); falling back to 'inline'."), o = "inline");
282
- let s = e.tracerLayer ? r.mergeAll(e.sqlLayer, e.tracerLayer) : e.sqlLayer, c = i.make(s), d = new fe(await c.runPromise(u), c, t, o);
282
+ let s = e.tracerLayer ? r.mergeAll(e.sqlLayer, e.tracerLayer) : e.sqlLayer, c = i.make(s), d = new he(await c.runPromise(u), c, t, o);
283
283
  return o === "cdc" && e.cdcConfig && await d.startCdcConsumer(e.cdcConfig), d;
284
- }, Q = () => {
284
+ }, me = () => {
285
285
  let e = C();
286
- return e === void 0 ? (e) => e() : (t) => k(e, t);
287
- }, fe = class e {
286
+ return e === void 0 ? (e) => e() : (t) => ie(e, t);
287
+ }, he = class e {
288
288
  sql;
289
289
  runtime;
290
290
  variant;
@@ -300,7 +300,7 @@ var P = (n) => e.layerConfig({
300
300
  cdcStreamName = "default";
301
301
  cdcGate;
302
302
  constructor(e, t, n, r = "inline", i = null, a, o) {
303
- this.sql = e, this.runtime = t, this.variant = n, this.changeStrategy = r, this.namespace = i, this.log = l({ scope: `voltro:${n}` }), this.emitter = a ?? new c(), O(this.emitter), this.cdcGate = o ?? new ue(n);
303
+ this.sql = e, this.runtime = t, this.variant = n, this.changeStrategy = r, this.namespace = i, this.log = l({ scope: `voltro:${n}` }), this.emitter = a ?? new c(), te(this.emitter), this.cdcGate = o ?? new fe(n);
304
304
  }
305
305
  withNamespace(t) {
306
306
  return t === this.namespace ? this : new e(this.sql, this.runtime, this.variant, this.changeStrategy, t, this.emitter, this.cdcGate);
@@ -317,9 +317,9 @@ var P = (n) => e.layerConfig({
317
317
  variant: this.variant
318
318
  };
319
319
  }
320
- async executeQuery(e, t) {
321
- let r = S(e, this.sql, this.namespace), i = t ? n.provideService(r, d, t) : r;
322
- return w(await this.runtime.runPromise(i), e.table, this.variant);
320
+ async executeQuery(e, t, r) {
321
+ let i = S(e, this.sql, this.namespace), a = t ? n.provideService(i, d, t) : i;
322
+ return w(await this.runtime.runPromise(a), e.table, this.variant);
323
323
  }
324
324
  get supportsInsertReturning() {
325
325
  return this.variant === "mariadb";
@@ -330,39 +330,39 @@ var P = (n) => e.layerConfig({
330
330
  get supportsUpdateReturning() {
331
331
  return !1;
332
332
  }
333
- async executeInsert(e, t, r, i) {
334
- t = M(e, t);
335
- let a = this.sql;
333
+ async executeInsert(e, t, r, i, a) {
334
+ t = j(e, t);
335
+ let o = this.sql;
336
336
  if (this.supportsInsertReturning) {
337
- let o = a`INSERT INTO ${a(this.nsT(e))} ${a.insert(T(t, e))} RETURNING *`, s = r ? n.provideService(o, d, r) : o, c = (await this.runtime.runPromise(s))[0];
338
- if (!c) throw Error(`MysqlStore.insert: no row returned for table '${e}'`);
337
+ let s = o`INSERT INTO ${o(this.nsT(e))} ${o.insert(T(t, e))} RETURNING *`, c = r ? n.provideService(s, d, r) : s, l = (await this.runtime.runPromise(c))[0];
338
+ if (!l) throw Error(`MysqlStore.insert: no row returned for table '${e}'`);
339
339
  return await this.routeEvent({
340
340
  table: e,
341
341
  op: "insert",
342
342
  old: null,
343
- new: c
344
- }, i, r), c;
343
+ new: l
344
+ }, i, r, a), l;
345
345
  }
346
- let o = T(t, e), s = t.id;
347
- if (s === void 0) {
348
- let t = await this.insertRecoverAutoId(e, o, r);
346
+ let s = T(t, e), c = t.id;
347
+ if (c === void 0) {
348
+ let t = await this.insertRecoverAutoId(e, s, r);
349
349
  return await this.routeEvent({
350
350
  table: e,
351
351
  op: "insert",
352
352
  old: null,
353
353
  new: t
354
- }, i, r), t;
354
+ }, i, r, a), t;
355
355
  }
356
- let c = a`INSERT INTO ${a(this.nsT(e))} ${a.insert(o)}`, l = r ? n.provideService(c, d, r) : c;
357
- await this.runtime.runPromise(l);
358
- let u = a`SELECT * FROM ${a(this.nsT(e))} WHERE ${a("id")} = ${s}`, f = r ? n.provideService(u, d, r) : u, p = (await this.runtime.runPromise(f))[0];
359
- if (!p) throw Error(`MysqlStore.insert: row not found post-insert in '${e}'`);
356
+ let l = o`INSERT INTO ${o(this.nsT(e))} ${o.insert(s)}`, u = r ? n.provideService(l, d, r) : l;
357
+ await this.runtime.runPromise(u);
358
+ let f = o`SELECT * FROM ${o(this.nsT(e))} WHERE ${o("id")} = ${c}`, p = r ? n.provideService(f, d, r) : f, m = (await this.runtime.runPromise(p))[0];
359
+ if (!m) throw Error(`MysqlStore.insert: row not found post-insert in '${e}'`);
360
360
  return await this.routeEvent({
361
361
  table: e,
362
362
  op: "insert",
363
363
  old: null,
364
- new: p
365
- }, i, r), p;
364
+ new: m
365
+ }, i, r, a), m;
366
366
  }
367
367
  async insertRecoverAutoId(e, t, r) {
368
368
  let i = this.sql;
@@ -386,41 +386,41 @@ var P = (n) => e.layerConfig({
386
386
  this.inflightTxns--;
387
387
  }
388
388
  }
389
- async executeInsertMany(e, t, r, i) {
390
- if (t = N(e, t), t.length === 0) return [];
391
- let a = this.sql, o = t.map((t) => T(t, e));
389
+ async executeInsertMany(e, t, r, i, a) {
390
+ if (t = se(e, t), t.length === 0) return [];
391
+ let o = this.sql, s = t.map((t) => T(t, e));
392
392
  if (this.supportsInsertReturning) {
393
- let t = a`INSERT INTO ${a(this.nsT(e))} ${a.insert(o)} RETURNING *`, s = r ? n.provideService(t, d, r) : t, c = await this.runtime.runPromise(s);
394
- for (let t of c) await this.routeEvent({
393
+ let t = o`INSERT INTO ${o(this.nsT(e))} ${o.insert(s)} RETURNING *`, c = r ? n.provideService(t, d, r) : t, l = await this.runtime.runPromise(c);
394
+ for (let t of l) await this.routeEvent({
395
395
  table: e,
396
396
  op: "insert",
397
397
  old: null,
398
398
  new: t
399
- }, i, r);
400
- return c;
399
+ }, i, r, a);
400
+ return l;
401
401
  }
402
- let s = t.map((e) => e.id);
403
- if (s.every((e) => e === void 0)) {
404
- let t = await this.insertManyRecoverAutoIds(e, o, r);
402
+ let c = t.map((e) => e.id);
403
+ if (c.every((e) => e === void 0)) {
404
+ let t = await this.insertManyRecoverAutoIds(e, s, r);
405
405
  for (let n of t) await this.routeEvent({
406
406
  table: e,
407
407
  op: "insert",
408
408
  old: null,
409
409
  new: n
410
- }, i, r);
410
+ }, i, r, a);
411
411
  return t;
412
412
  }
413
- if (s.some((e) => e === void 0)) throw Error("MysqlStore.insertMany: rows mix client-supplied and missing 'id's — supply an id for every row or none (AUTO_INCREMENT recovery).");
414
- let c = a`INSERT INTO ${a(this.nsT(e))} ${a.insert(o)}`, l = r ? n.provideService(c, d, r) : c;
415
- await this.runtime.runPromise(l);
416
- let u = a`SELECT * FROM ${a(this.nsT(e))} WHERE ${a("id")} IN ${a.in(s)}`, f = r ? n.provideService(u, d, r) : u, p = await this.runtime.runPromise(f), m = new Map(p.map((e) => [e.id, e])), h = s.map((e) => m.get(e)).filter((e) => e !== void 0);
417
- for (let t of h) await this.routeEvent({
413
+ if (c.some((e) => e === void 0)) throw Error("MysqlStore.insertMany: rows mix client-supplied and missing 'id's — supply an id for every row or none (AUTO_INCREMENT recovery).");
414
+ let l = o`INSERT INTO ${o(this.nsT(e))} ${o.insert(s)}`, u = r ? n.provideService(l, d, r) : l;
415
+ await this.runtime.runPromise(u);
416
+ let f = o`SELECT * FROM ${o(this.nsT(e))} WHERE ${o("id")} IN ${o.in(c)}`, p = r ? n.provideService(f, d, r) : f, m = await this.runtime.runPromise(p), h = new Map(m.map((e) => [e.id, e])), g = c.map((e) => h.get(e)).filter((e) => e !== void 0);
417
+ for (let t of g) await this.routeEvent({
418
418
  table: e,
419
419
  op: "insert",
420
420
  old: null,
421
421
  new: t
422
- }, i, r);
423
- return h;
422
+ }, i, r, a);
423
+ return g;
424
424
  }
425
425
  async insertManyRecoverAutoIds(e, t, r) {
426
426
  let i = this.sql, a = t.length;
@@ -433,58 +433,58 @@ var P = (n) => e.layerConfig({
433
433
  return yield* n.provideService(i`SELECT * FROM ${i(this.nsT(e))} WHERE ${i("id")} BETWEEN ${c} AND ${c + a - 1} ORDER BY ${i("id")} ASC`, d, r);
434
434
  }), "insert");
435
435
  }
436
- async executePatchJson(e, t, r, i, a, o) {
437
- let s = this.sql, c = r.split("."), l = c[0], u = c.slice(1), f = JSON.stringify(i ?? null), p = u.length === 0 ? "$" : `$.${u.join(".")}`, m = s`UPDATE ${s(this.nsT(e))} SET ${s(l)} = JSON_SET(COALESCE(${s(l)}, '{}'), ${p}, CAST(${f} AS JSON)) WHERE ${s("id")} = ${t}`, h = a ? n.provideService(m, d, a) : m, g = await this.runtime.runPromise(h);
438
- if (g && typeof g.affectedRows == "number" && g.affectedRows === 0) return null;
439
- let _ = s`SELECT * FROM ${s(this.nsT(e))} WHERE ${s("id")} = ${t}`, v = a ? n.provideService(_, d, a) : _, y = (await this.runtime.runPromise(v))[0];
440
- return y ? (await this.routeEvent({
436
+ async executePatchJson(e, t, r, i, a, o, s) {
437
+ let c = this.sql, l = r.split("."), u = l[0], f = l.slice(1), p = JSON.stringify(i ?? null), m = f.length === 0 ? "$" : `$.${f.join(".")}`, h = c`UPDATE ${c(this.nsT(e))} SET ${c(u)} = JSON_SET(COALESCE(${c(u)}, '{}'), ${m}, CAST(${p} AS JSON)) WHERE ${c("id")} = ${t}`, g = a ? n.provideService(h, d, a) : h, _ = await this.runtime.runPromise(g);
438
+ if (_ && typeof _.affectedRows == "number" && _.affectedRows === 0) return null;
439
+ let v = c`SELECT * FROM ${c(this.nsT(e))} WHERE ${c("id")} = ${t}`, y = a ? n.provideService(v, d, a) : v, b = (await this.runtime.runPromise(y))[0];
440
+ return b ? (await this.routeEvent({
441
441
  table: e,
442
442
  op: "update",
443
443
  old: null,
444
- new: y
445
- }, o, a), y) : null;
444
+ new: b
445
+ }, o, a, s), b) : null;
446
446
  }
447
- async executeUpdate(e, t, r, i, a) {
448
- let o = this.sql;
447
+ async executeUpdate(e, t, r, i, a, o) {
448
+ let s = this.sql;
449
449
  if (this.supportsUpdateReturning) {
450
- let s = o`UPDATE ${o(this.nsT(e))} SET ${o.update(T(r, e))} WHERE ${o("id")} = ${t} RETURNING *`, c = i ? n.provideService(s, d, i) : s, l = (await this.runtime.runPromise(c))[0];
451
- return l ? (await this.routeEvent({
450
+ let c = s`UPDATE ${s(this.nsT(e))} SET ${s.update(T(r, e))} WHERE ${s("id")} = ${t} RETURNING *`, l = i ? n.provideService(c, d, i) : c, u = (await this.runtime.runPromise(l))[0];
451
+ return u ? (await this.routeEvent({
452
452
  table: e,
453
453
  op: "update",
454
454
  old: null,
455
- new: l
456
- }, a, i), l) : null;
455
+ new: u
456
+ }, a, i, o), u) : null;
457
457
  }
458
- let s = o`UPDATE ${o(this.nsT(e))} SET ${o.update(T(r, e))} WHERE ${o("id")} = ${t}`, c = i ? n.provideService(s, d, i) : s, l = await this.runtime.runPromise(c);
459
- if (l && typeof l.affectedRows == "number" && l.affectedRows === 0) return null;
460
- let u = o`SELECT * FROM ${o(this.nsT(e))} WHERE ${o("id")} = ${t}`, f = i ? n.provideService(u, d, i) : u, p = (await this.runtime.runPromise(f))[0];
461
- return p ? (await this.routeEvent({
458
+ let c = s`UPDATE ${s(this.nsT(e))} SET ${s.update(T(r, e))} WHERE ${s("id")} = ${t}`, l = i ? n.provideService(c, d, i) : c, u = await this.runtime.runPromise(l);
459
+ if (u && typeof u.affectedRows == "number" && u.affectedRows === 0) return null;
460
+ let f = s`SELECT * FROM ${s(this.nsT(e))} WHERE ${s("id")} = ${t}`, p = i ? n.provideService(f, d, i) : f, m = (await this.runtime.runPromise(p))[0];
461
+ return m ? (await this.routeEvent({
462
462
  table: e,
463
463
  op: "update",
464
464
  old: null,
465
- new: p
466
- }, a, i), p) : null;
465
+ new: m
466
+ }, a, i, o), m) : null;
467
467
  }
468
- async executeDelete(e, t, r, i) {
469
- let a = this.sql;
468
+ async executeDelete(e, t, r, i, a) {
469
+ let o = this.sql;
470
470
  if (this.supportsDeleteReturning) {
471
- let o = a`DELETE FROM ${a(this.nsT(e))} WHERE ${a("id")} = ${t} RETURNING *`, s = r ? n.provideService(o, d, r) : o, c = (await this.runtime.runPromise(s))[0];
472
- return c ? (await this.routeEvent({
471
+ let s = o`DELETE FROM ${o(this.nsT(e))} WHERE ${o("id")} = ${t} RETURNING *`, c = r ? n.provideService(s, d, r) : s, l = (await this.runtime.runPromise(c))[0];
472
+ return l ? (await this.routeEvent({
473
473
  table: e,
474
474
  op: "delete",
475
- old: c,
475
+ old: l,
476
476
  new: null
477
- }, i, r), !0) : !1;
477
+ }, i, r, a), !0) : !1;
478
478
  }
479
- let o = a`SELECT * FROM ${a(this.nsT(e))} WHERE ${a("id")} = ${t}`, s = r ? n.provideService(o, d, r) : o, c = (await this.runtime.runPromise(s))[0];
480
- if (!c) return !1;
481
- let l = a`DELETE FROM ${a(this.nsT(e))} WHERE ${a("id")} = ${t}`, u = r ? n.provideService(l, d, r) : l;
482
- return await this.runtime.runPromise(u), await this.routeEvent({
479
+ let s = o`SELECT * FROM ${o(this.nsT(e))} WHERE ${o("id")} = ${t}`, c = r ? n.provideService(s, d, r) : s, l = (await this.runtime.runPromise(c))[0];
480
+ if (!l) return !1;
481
+ let u = o`DELETE FROM ${o(this.nsT(e))} WHERE ${o("id")} = ${t}`, f = r ? n.provideService(u, d, r) : u;
482
+ return await this.runtime.runPromise(f), await this.routeEvent({
483
483
  table: e,
484
484
  op: "delete",
485
- old: c,
485
+ old: l,
486
486
  new: null
487
- }, i, r), !0;
487
+ }, i, r, a), !0;
488
488
  }
489
489
  async appendInTxn(e, t, r) {
490
490
  let i = this.sql, a = i`INSERT INTO ${i(this.nsT(e))} ${i.insert(T(t, e))}`;
@@ -494,11 +494,11 @@ var P = (n) => e.layerConfig({
494
494
  let a = this.sql, o = Object.entries(r).map(([e, t]) => a`${a(e)} = ${t}`), s = a`SELECT MAX(${a(t)}) AS ${a("m")} FROM ${a(this.nsT(e))} WHERE ${a.and(o)}`, c = (await this.runtime.runPromise(i ? n.provideService(s, d, i) : s))[0]?.m;
495
495
  return c == null ? null : Number(c);
496
496
  }
497
- async routeEvent(e, t, n = null) {
497
+ async routeEvent(e, t, n = null, r) {
498
498
  if (e = {
499
- ...g(),
499
+ ...g(r),
500
500
  ...e
501
- }, te(e.table) && await A({
501
+ }, ne(e.table) && await ae({
502
502
  append: (e, t) => this.appendInTxn(e, t, n),
503
503
  maxOf: (e, t, r) => this.maxInTxn(e, t, r, n)
504
504
  }, {
@@ -510,7 +510,7 @@ var P = (n) => e.layerConfig({
510
510
  subjectId: e.subjectId
511
511
  }), this.changeStrategy === "cdc") {
512
512
  let t = (e.op === "delete" ? e.old : e.new)?.id;
513
- t != null && ne(_(e.table, e.op, t), {
513
+ t != null && re(_(e.table, e.op, t), {
514
514
  ...e.traceId === void 0 ? {} : { traceId: e.traceId },
515
515
  ...e.subjectId === void 0 ? {} : { subjectId: e.subjectId }
516
516
  });
@@ -521,61 +521,61 @@ var P = (n) => e.layerConfig({
521
521
  }
522
522
  this.emitChange(e);
523
523
  }
524
- async executeUpsert(e, t, n, r, i) {
524
+ async executeUpsert(e, t, n, r, i, a) {
525
525
  if (this.variant === "mariadb" && typeof n.update != "function") return this.executeMariadbUpsert(e, t, {
526
526
  conflictColumns: n.conflictColumns,
527
527
  ...n.update === void 0 ? {} : { update: n.update }
528
528
  }, r, i);
529
- let a = await this.findByConflict(e, t, n.conflictColumns, r);
530
- if (a) {
531
- let o;
532
- if (typeof n.update == "function") o = n.update(a);
529
+ let o = await this.findByConflict(e, t, n.conflictColumns, r);
530
+ if (o) {
531
+ let a;
532
+ if (typeof n.update == "function") a = n.update(o);
533
533
  else if (Array.isArray(n.update)) {
534
534
  let e = {};
535
535
  for (let r of n.update) r in t && (e[r] = t[r]);
536
- o = e;
536
+ a = e;
537
537
  } else {
538
538
  let e = {};
539
539
  for (let [r, i] of Object.entries(t)) r !== "id" && (n.conflictColumns.includes(r) || (e[r] = i));
540
- o = e;
540
+ a = e;
541
541
  }
542
- return await this.executeUpdate(e, a.id, o, r, i) ?? a;
542
+ return await this.executeUpdate(e, o.id, a, r, i) ?? o;
543
543
  }
544
- return Q()(() => this.executeInsert(e, t, r, i));
544
+ return M((n) => this.executeInsert(e, t, r, i, n));
545
545
  }
546
- async executeMariadbUpsert(e, t, r, i, a) {
547
- let o = this.sql, s = T(t, e), c = Object.keys(s).filter((e) => s[e] !== void 0), l = r.update === void 0 ? c.filter((e) => e !== "id" && !r.conflictColumns.includes(e)) : r.update, u = l.length > 0 ? o.csv(l.map((e) => o`${o(e)} = VALUES(${o(e)})`)) : o`${o(r.conflictColumns[0])} = VALUES(${o(r.conflictColumns[0])})`, f = o`INSERT INTO ${o(this.nsT(e))} ${o.insert(s)} ON DUPLICATE KEY UPDATE ${u} RETURNING *`, p = i ? n.provideService(f, d, i) : f, m = (await this.runtime.runPromise(p))[0];
548
- if (!m) throw Error(`MysqlStore.upsert: no row returned for table '${e}'`);
549
- let h = t.id !== void 0 && t.id === m.id ? "insert" : "update";
546
+ async executeMariadbUpsert(e, t, r, i, a, o) {
547
+ let s = this.sql, c = T(t, e), l = Object.keys(c).filter((e) => c[e] !== void 0), u = r.update === void 0 ? l.filter((e) => e !== "id" && !r.conflictColumns.includes(e)) : r.update, f = u.length > 0 ? s.csv(u.map((e) => s`${s(e)} = VALUES(${s(e)})`)) : s`${s(r.conflictColumns[0])} = VALUES(${s(r.conflictColumns[0])})`, p = s`INSERT INTO ${s(this.nsT(e))} ${s.insert(c)} ON DUPLICATE KEY UPDATE ${f} RETURNING *`, m = i ? n.provideService(p, d, i) : p, h = (await this.runtime.runPromise(m))[0];
548
+ if (!h) throw Error(`MysqlStore.upsert: no row returned for table '${e}'`);
549
+ let g = t.id !== void 0 && t.id === h.id ? "insert" : "update";
550
550
  return await this.routeEvent({
551
551
  table: e,
552
- op: h,
552
+ op: g,
553
553
  old: null,
554
- new: m
555
- }, a, i), m;
554
+ new: h
555
+ }, a, i, o), h;
556
556
  }
557
- async executeInsertIgnore(e, t, r, i, a) {
558
- if (t = M(e, t), this.variant === "mariadb") {
559
- let o = this.sql, s = o`INSERT IGNORE INTO ${o(this.nsT(e))} ${o.insert(T(t, e))} RETURNING *`, c = i ? n.provideService(s, d, i) : s, l = (await this.runtime.runPromise(c))[0];
560
- if (l) return await this.routeEvent({
557
+ async executeInsertIgnore(e, t, r, i, a, o) {
558
+ if (t = j(e, t), this.variant === "mariadb") {
559
+ let s = this.sql, c = s`INSERT IGNORE INTO ${s(this.nsT(e))} ${s.insert(T(t, e))} RETURNING *`, l = i ? n.provideService(c, d, i) : c, u = (await this.runtime.runPromise(l))[0];
560
+ if (u) return await this.routeEvent({
561
561
  table: e,
562
562
  op: "insert",
563
563
  old: null,
564
- new: l
565
- }, a, i), l;
566
- let u = await this.readWarnings(i), f = u.find((e) => !de.has(e.code));
567
- if (f !== void 0) throw Error(`MysqlStore.insertIgnore: the insert into '${e}' was REJECTED, not skipped as a conflict. INSERT IGNORE downgrades every error to a warning, and the warning was: [${f.code}] ${f.message}. Nothing was written and nothing conflicted — fix the cause above.`);
568
- let p = await this.findByConflict(e, t, r.conflictColumns, i);
569
- if (p) return p;
570
- let m = u[0];
571
- throw Error(`MysqlStore.insertIgnore: the insert was skipped as a conflict, but no existing row matches conflictColumns [${r.conflictColumns.join(", ")}] on '${e}'. ` + (m === void 0 ? "The warning could not be read on this connection, so the constraint that fired is unknown — it may be a second unique index, or the primary key under another name. " : `The constraint that actually fired: [${m.code}] ${m.message}. `) + "insertIgnore models ONE conflict target: name the columns of the constraint that actually collides, or handle the violation yourself.");
564
+ new: u
565
+ }, a, i, o), u;
566
+ let f = await this.readWarnings(i), p = f.find((e) => !pe.has(e.code));
567
+ if (p !== void 0) throw Error(`MysqlStore.insertIgnore: the insert into '${e}' was REJECTED, not skipped as a conflict. INSERT IGNORE downgrades every error to a warning, and the warning was: [${p.code}] ${p.message}. Nothing was written and nothing conflicted — fix the cause above.`);
568
+ let m = await this.findByConflict(e, t, r.conflictColumns, i);
569
+ if (m) return m;
570
+ let h = f[0];
571
+ throw Error(`MysqlStore.insertIgnore: the insert was skipped as a conflict, but no existing row matches conflictColumns [${r.conflictColumns.join(", ")}] on '${e}'. ` + (h === void 0 ? "The warning could not be read on this connection, so the constraint that fired is unknown — it may be a second unique index, or the primary key under another name. " : `The constraint that actually fired: [${h.code}] ${h.message}. `) + "insertIgnore models ONE conflict target: name the columns of the constraint that actually collides, or handle the violation yourself.");
572
572
  }
573
- return await this.findByConflict(e, t, r.conflictColumns, i) || Q()(() => this.executeInsert(e, t, i, a));
573
+ return await this.findByConflict(e, t, r.conflictColumns, i) || M((n) => this.executeInsert(e, t, i, a, n));
574
574
  }
575
575
  async findUndecodableCdcTables(e) {
576
576
  if (this.variant !== "mariadb") return [];
577
577
  try {
578
- let t = (await this.runtime.runPromise(this.sql.unsafe(z))).map((e) => String(e.tableName ?? e.TABLE_NAME ?? "")).filter((e) => e !== "");
578
+ let t = (await this.runtime.runPromise(this.sql.unsafe(B))).map((e) => String(e.tableName ?? e.TABLE_NAME ?? "")).filter((e) => e !== "");
579
579
  return e === void 0 ? t : t.filter((t) => e.includes(t));
580
580
  } catch (e) {
581
581
  return this.log.debug(`cdc: could not probe for undecodable tables — ${e?.message ?? String(e)}`), [];
@@ -606,7 +606,7 @@ var P = (n) => e.layerConfig({
606
606
  return this.runtime.runPromise(n);
607
607
  }
608
608
  async runWithEager(e, t) {
609
- if (!E(e)) return this.executeQuery(e, t);
609
+ if (!D(e)) return this.executeQuery(e, t);
610
610
  let r = this.variant === "mariadb" ? "mariadb" : "mysql", i = this.namespace === null ? y(e, this.sql, r) : null;
611
611
  if (i !== null) try {
612
612
  let e = t ? n.provideService(i.fragment, d, t) : i.fragment, r = await this.runtime.runPromise(e);
@@ -615,31 +615,40 @@ var P = (n) => e.layerConfig({
615
615
  if (e instanceof p) throw e;
616
616
  this.log.warn(`${this.variant} JSON-agg eager-load failed; falling back to walker`, { err: e });
617
617
  }
618
- return h(await this.executeQuery(e, t), e.eager, e.sourceTable ?? re(e.table), (e) => this.executeQuery(e, t));
618
+ return h(await this.executeQuery(e, t), e.eager, e.sourceTable ?? k(e.table), (e) => this.executeQuery(e, t));
619
619
  }
620
620
  getInternalRunWithEager() {
621
621
  return this.runWithEager.bind(this);
622
622
  }
623
+ async localWrite(e, t) {
624
+ if (this.changeStrategy !== "cdc") return M(t);
625
+ v(e);
626
+ try {
627
+ return await M(t);
628
+ } finally {
629
+ E(e);
630
+ }
631
+ }
623
632
  insert(e, t) {
624
- return Q()(() => this.executeInsert(e, t, null, null));
633
+ return this.localWrite(e, (n) => this.executeInsert(e, t, null, null, n));
625
634
  }
626
635
  insertMany(e, t) {
627
- return Q()(() => this.executeInsertMany(e, t, null, null));
636
+ return this.localWrite(e, (n) => this.executeInsertMany(e, t, null, null, n));
628
637
  }
629
638
  patchJson(e, t, n, r) {
630
- return Q()(() => this.executePatchJson(e, t, n, r, null, null));
639
+ return this.localWrite(e, (i) => this.executePatchJson(e, t, n, r, null, null, i));
631
640
  }
632
641
  upsert(e, t, n) {
633
- return Q()(() => this.executeUpsert(e, t, n, null, null));
642
+ return this.localWrite(e, (r) => this.executeUpsert(e, t, n, null, null, r));
634
643
  }
635
644
  insertIgnore(e, t, n) {
636
- return Q()(() => this.executeInsertIgnore(e, t, n, null, null));
645
+ return this.localWrite(e, (r) => this.executeInsertIgnore(e, t, n, null, null, r));
637
646
  }
638
647
  update(e, t, n) {
639
- return Q()(() => this.executeUpdate(e, t, n, null, null));
648
+ return this.localWrite(e, (r) => this.executeUpdate(e, t, n, null, null, r));
640
649
  }
641
650
  delete(e, t) {
642
- return Q()(() => this.executeDelete(e, t, null, null));
651
+ return this.localWrite(e, (n) => this.executeDelete(e, t, null, null, n));
643
652
  }
644
653
  async updateMany(e, t, r) {
645
654
  if (this.supportsUpdateReturning) {
@@ -653,7 +662,7 @@ var P = (n) => e.layerConfig({
653
662
  return o.length;
654
663
  }
655
664
  let i = this.sql, o = b(r.where, i, this.namespace);
656
- this.inflightTxns++;
665
+ this.changeStrategy === "cdc" && v(e), this.inflightTxns++;
657
666
  try {
658
667
  let r = n.suspend(() => this.sql.withTransaction(n.flatMap(n.serviceOption(d), (r) => {
659
668
  if (a.isNone(r)) return n.fail(/* @__PURE__ */ Error("MysqlStore.updateMany: TransactionConnection missing."));
@@ -677,7 +686,7 @@ var P = (n) => e.layerConfig({
677
686
  }, null, null);
678
687
  return f.length;
679
688
  } finally {
680
- this.inflightTxns--;
689
+ this.inflightTxns--, this.changeStrategy === "cdc" && E(e);
681
690
  }
682
691
  }
683
692
  async deleteMany(e, t) {
@@ -692,7 +701,7 @@ var P = (n) => e.layerConfig({
692
701
  }, null, null);
693
702
  return n.length;
694
703
  }
695
- this.inflightTxns++;
704
+ this.changeStrategy === "cdc" && v(e), this.inflightTxns++;
696
705
  try {
697
706
  let t = n.suspend(() => this.sql.withTransaction(n.flatMap(n.serviceOption(d), (t) => {
698
707
  if (a.isNone(t)) return n.fail(/* @__PURE__ */ Error("MysqlStore.deleteMany: TransactionConnection missing."));
@@ -712,17 +721,17 @@ var P = (n) => e.layerConfig({
712
721
  }, null, null);
713
722
  return u.length;
714
723
  } finally {
715
- this.inflightTxns--;
724
+ this.inflightTxns--, this.changeStrategy === "cdc" && E(e);
716
725
  }
717
726
  }
718
727
  emitChange(e) {
719
- D(e.table) && (this.changeStrategy === "cdc" && !this.cdcGate.admit(e) || this.emitter.emit("change", e));
728
+ O(e.table) && (this.changeStrategy === "cdc" && !this.cdcGate.admit(e) || this.emitter.emit("change", e));
720
729
  }
721
730
  async startCdcConsumer(e) {
722
731
  if (this.cdcHandle) return;
723
732
  await this.assertBinlogConfig(), this.cdcReplicaId = e.replicaId;
724
733
  let t = await this.readCdcOffset(e.replicaId) ?? await this.resolveBinlogEnd(), n = await this.findUndecodableCdcTables(e.includeTables);
725
- for (let e of n) this.log.error(B(e));
734
+ for (let e of n) this.log.error(V(e));
726
735
  this.cdcHandle = await q({
727
736
  connection: e.connection,
728
737
  serverId: e.serverId,
@@ -840,26 +849,26 @@ var P = (n) => e.layerConfig({
840
849
  }
841
850
  async transactional(e) {
842
851
  this.inflightTxns++;
843
- let t = Q(), r = 0, i = n.suspend(() => {
844
- let i = ++r;
845
- return this.sql.withTransaction(n.flatMap(n.serviceOption(d), (r) => {
846
- if (a.isNone(r)) return n.fail(/* @__PURE__ */ Error("MysqlStore.transactional: TransactionConnection missing."));
847
- let o = new pe(this, r.value);
852
+ let t = C(), r = me(), i = 0, o = n.suspend(() => {
853
+ let o = ++i;
854
+ return this.sql.withTransaction(n.flatMap(n.serviceOption(d), (i) => {
855
+ if (a.isNone(i)) return n.fail(/* @__PURE__ */ Error("MysqlStore.transactional: TransactionConnection missing."));
856
+ let s = new ge(this, i.value, t);
848
857
  return n.tryPromise({
849
- try: () => t(() => e(o)).then((e) => ({
858
+ try: () => r(() => e(s)).then((e) => ({
850
859
  result: e,
851
- view: o,
852
- attempt: i
860
+ view: s,
861
+ attempt: o
853
862
  })),
854
863
  catch: (e) => e
855
864
  });
856
865
  }));
857
- }), o = s.exponential("10 millis").pipe(s.compose(s.recurs(3)), s.whileInput(J)), c = i.pipe(n.retry(o), n.withSpan("store.transactional", { attributes: {
866
+ }), c = s.exponential("10 millis").pipe(s.compose(s.recurs(3)), s.whileInput(J)), l = o.pipe(n.retry(c), n.withSpan("store.transactional", { attributes: {
858
867
  "db.system": this.variant,
859
868
  "db.operation": "transaction"
860
869
  } }));
861
870
  try {
862
- let e = j(await this.runtime.runPromiseExit(c));
871
+ let e = oe(await this.runtime.runPromiseExit(l));
863
872
  return e.view.commitEvents(), e.result;
864
873
  } finally {
865
874
  this.inflightTxns--;
@@ -874,12 +883,14 @@ var P = (n) => e.layerConfig({
874
883
  return this.changeStrategy === "cdc" ? "fleet" : "local";
875
884
  }
876
885
  injectExternalChange(e) {
877
- if (this.changeStrategy === "cdc" && !this.cdcGate.admit(e) || !D(e.table)) return;
878
- let t = (e.op === "delete" ? e.old : e.new)?.id, n = t == null ? void 0 : v(_(e.table, e.op, t));
879
- this.emitter.emit("change", {
880
- ...n,
881
- ...e,
882
- origin: "injected"
886
+ if (this.changeStrategy === "cdc" && !this.cdcGate.admit(e) || !O(e.table)) return;
887
+ let t = (e.op === "delete" ? e.old : e.new)?.id;
888
+ A(e.table, e.op, t, (t) => {
889
+ this.emitter.emit("change", {
890
+ ...t,
891
+ ...e,
892
+ origin: "injected"
893
+ });
883
894
  });
884
895
  }
885
896
  run(e) {
@@ -903,31 +914,32 @@ var P = (n) => e.layerConfig({
903
914
  async ping() {
904
915
  await this.runtime.runPromise(this.sql`SELECT 1`);
905
916
  }
906
- }, pe = class {
917
+ }, ge = class {
907
918
  parent;
908
919
  txn;
920
+ attr;
909
921
  events = [];
910
922
  committed = !1;
911
- constructor(e, t) {
912
- this.parent = e, this.txn = t;
923
+ constructor(e, t, n = void 0) {
924
+ this.parent = e, this.txn = t, this.attr = n;
913
925
  }
914
926
  query(e) {
915
927
  return this.parent.getInternalRunWithEager()(e, this.txn);
916
928
  }
917
929
  insert(e, t) {
918
- return this.parent.getInternalExecuteInsert()(e, t, this.txn, this.events);
930
+ return this.parent.getInternalExecuteInsert()(e, t, this.txn, this.events, this.attr);
919
931
  }
920
932
  insertMany(e, t) {
921
- return this.parent.getInternalExecuteInsertMany()(e, t, this.txn, this.events);
933
+ return this.parent.getInternalExecuteInsertMany()(e, t, this.txn, this.events, this.attr);
922
934
  }
923
935
  patchJson(e, t, n, r) {
924
- return this.parent.getInternalExecutePatchJson()(e, t, n, r, this.txn, this.events);
936
+ return this.parent.getInternalExecutePatchJson()(e, t, n, r, this.txn, this.events, this.attr);
925
937
  }
926
938
  update(e, t, n) {
927
- return this.parent.getInternalExecuteUpdate()(e, t, n, this.txn, this.events);
939
+ return this.parent.getInternalExecuteUpdate()(e, t, n, this.txn, this.events, this.attr);
928
940
  }
929
941
  delete(e, t) {
930
- return this.parent.getInternalExecuteDelete()(e, t, this.txn, this.events);
942
+ return this.parent.getInternalExecuteDelete()(e, t, this.txn, this.events, this.attr);
931
943
  }
932
944
  async updateMany(e, t, n) {
933
945
  let r = await this.query({
@@ -954,10 +966,10 @@ var P = (n) => e.layerConfig({
954
966
  return r;
955
967
  }
956
968
  upsert(e, t, n) {
957
- return this.parent.getInternalExecuteUpsert()(e, t, n, this.txn, this.events);
969
+ return this.parent.getInternalExecuteUpsert()(e, t, n, this.txn, this.events, this.attr);
958
970
  }
959
971
  insertIgnore(e, t, n) {
960
- return this.parent.getInternalExecuteInsertIgnore()(e, t, n, this.txn, this.events);
972
+ return this.parent.getInternalExecuteInsertIgnore()(e, t, n, this.txn, this.events, this.attr);
961
973
  }
962
974
  transactional(e) {
963
975
  return Promise.reject(/* @__PURE__ */ Error("MysqlStore.transactional: nested transactions are not supported."));
@@ -975,7 +987,7 @@ var P = (n) => e.layerConfig({
975
987
  this.events.length = 0;
976
988
  }
977
989
  }
978
- }, $ = (e) => e.__mysqlReplicationFriend ?? null, me = () => ({
990
+ }, $ = (e) => e.__mysqlReplicationFriend ?? null, _e = () => ({
979
991
  async capturePrimaryPosition(e) {
980
992
  let t = $(e);
981
993
  if (t === null) throw Error("mysqlReplicationAdapter: primary is not a MysqlStore (missing __mysqlReplicationFriend).");
@@ -995,19 +1007,19 @@ var P = (n) => e.layerConfig({
995
1007
  compare(e, t) {
996
1008
  return "behind";
997
1009
  }
998
- }), he = {
1010
+ }), ve = {
999
1011
  id: "mysql",
1000
- makeSqlLayer: (e) => I(e),
1001
- makeStore: (e) => Z({
1012
+ makeSqlLayer: (e) => F(e),
1013
+ makeStore: (e) => Q({
1002
1014
  ...e,
1003
1015
  variant: "mysql"
1004
1016
  }),
1005
1017
  compileContains: (e, t, n) => n`${e} LIKE ${`%${t.replace(/[\\%_]/g, (e) => `\\${e}`)}%`} ESCAPE '\\'`,
1006
1018
  retryFilter: Y
1007
- }, ge = {
1019
+ }, ye = {
1008
1020
  id: "mariadb",
1009
- makeSqlLayer: (e) => I(e),
1010
- makeStore: (e) => Z({
1021
+ makeSqlLayer: (e) => F(e),
1022
+ makeStore: (e) => Q({
1011
1023
  ...e,
1012
1024
  variant: "mariadb"
1013
1025
  }),
@@ -1015,4 +1027,4 @@ var P = (n) => e.layerConfig({
1015
1027
  retryFilter: Y
1016
1028
  };
1017
1029
  //#endregion
1018
- export { f as CDC_OFFSETS_TABLE, e as MysqlClient, m as _voltroCdcOffsetsTable, F as connectionFromConfig, P as makeMysqlSqlLayer, I as makeMysqlSqlLayerFromConfig, Z as makeMysqlStore, ge as mariadbDialect, he as mysqlDialect, me as mysqlReplicationAdapter, Y as mysqlRetryFilter, q as startBinlogCdc };
1030
+ export { f as CDC_OFFSETS_TABLE, e as MysqlClient, m as _voltroCdcOffsetsTable, P as connectionFromConfig, N as makeMysqlSqlLayer, F as makeMysqlSqlLayerFromConfig, Q as makeMysqlStore, ye as mariadbDialect, ve as mysqlDialect, _e as mysqlReplicationAdapter, Y as mysqlRetryFilter, q as startBinlogCdc };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@voltro/sql-mysql",
3
- "version": "0.20.1",
3
+ "version": "0.21.0",
4
4
  "description": "MySQL/MariaDB dialect adapter for Voltro's cross-dialect DataStore (mariadb binlog CDC; mysql inline reactivity).",
5
5
  "keywords": [
6
6
  "voltro",
@@ -32,16 +32,16 @@
32
32
  "node": ">=24.0.0"
33
33
  },
34
34
  "dependencies": {
35
- "@effect/sql": "^0.51.1",
36
- "@effect/sql-mysql2": "^0.52.0",
37
- "@voltro/database": "0.20.1",
38
- "@voltro/logger": "0.20.1"
35
+ "@effect/sql": "^0.52.0",
36
+ "@effect/sql-mysql2": "^0.53.0",
37
+ "@voltro/database": "0.21.0",
38
+ "@voltro/logger": "0.21.0"
39
39
  },
40
40
  "optionalDependencies": {
41
41
  "@vlasky/zongji": "^0.9.0"
42
42
  },
43
43
  "peerDependencies": {
44
- "effect": "^3.21.4"
44
+ "effect": "^3.22.0"
45
45
  },
46
46
  "publishConfig": {
47
47
  "access": "public"