@voltro/sql-mysql 0.19.0 → 0.20.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/CHANGELOG.md CHANGED
@@ -39,6 +39,230 @@ _Changes staged for the next release accumulate here (rolled up from
39
39
 
40
40
  ---
41
41
 
42
+ ## [0.20.1] — 2026-07-30
43
+
44
+ ### Changed
45
+
46
+ - **@voltro/database, @voltro/runtime, @voltro/plugin-versioning, @voltro/plugin-presence, @voltro/voltro** — Five framework-table indexes were holding GENERIC names in a namespace that is shared with your tables. Index names are unique per SCHEMA on every supported dialect, so `_voltro_row_history.index('byTrace')` reserved `byTrace` for the whole database — and `byTrace` is the first thing anyone reaches for when indexing a `traceId`. A consumer added `traceId` to their own audit table, indexed it the obvious way, and collided with ours; the framework's own error message even suggested renaming the framework's index as the fix.
47
+
48
+ Renamed: `_voltro_row_history` `byTrace` → `byRowHistoryTrace`, `bySubject` → `byRowHistorySubject`; `_voltro_api_keys` `byTenant` → `byApiKeyTenant`; `_voltro_presence` `byChannel` → `byPresenceChannel`; `_voltro_connections` `bySubject` → `byConnectionSubject`. These are `_voltro_*` tables, so the rename rides the declarative differ on `voltro db apply` / boot — no codemod. Adopters see a one-time index rebuild.
49
+
50
+ A test now enforces the rule that most framework tables already followed: a framework index name must MENTION its own table. Mechanical, so it cannot rot the way a curated list of "generic" names would, and it does not demand the full `_voltro_<table>_<name>` form — which would force renaming ~20 already-safe indexes for no benefit. It also asserts no two framework tables claim the same index name, since installing two such plugins together would fail at migrate time for a reason neither plugin's author could see.
51
+
52
+ ### Fixed
53
+
54
+ - **@voltro/sql-mysql, @voltro/voltro** — A MariaDB table with a UNIQUE constraint on an UNBOUNDED text column can never be decoded from the binlog. The reader now says so ONCE — with the real cause and a remedy that works — and excludes the table, instead of looping on it forever.
55
+
56
+ **The mechanism.** MariaDB backs an unbounded UNIQUE with a **HASH long-unique index**, which adds a hidden `DB_ROW_HASH_n` column to the InnoDB row. That column IS in the binlog row image and is NOT in `information_schema.COLUMNS`, so the reader compares N+1 against N and throws on every write to that table:
57
+
58
+ ```text
59
+ Table app.sessions schema changed between binlog event and metadata fetch:
60
+ the event has 9 columns, fetched metadata has 8
61
+ ```
62
+
63
+ Nothing is broken; the table is shaped that way, permanently. The previous recovery (skip to the current binlog end) recovered nothing, because the end is exactly where the next failing write appears — a loop a consumer measured at roughly every 9 seconds, re-signalling resync to the whole fleet each pass.
64
+
65
+ **The cause we shipped in the previous entry was WRONG, and this retracts it.** It blamed a `DROP COLUMN` that ran as `ALGORITHM=INSTANT` leaving a phantom column, and told people to run `ALTER TABLE … FORCE`. The same consumer measured that: 9 InnoDB columns before the rebuild, 9 after, hidden column still present — the rebuild recreates the index and therefore recreates the hidden column. The repair line sent readers in a circle. They also disproved the version theory, being on the same MariaDB 11.8 we had tested on and failed to reproduce a phantom column with.
66
+
67
+ **Now:** affected tables are found at CDC start by a privilege-free probe — the direct evidence in `INNODB_SYS_COLUMNS` needs `PROCESS`, which an app DB user does not have, so the constraint SHAPE is inferred from `information_schema.STATISTICS` + `COLUMNS` instead — reported once as an error naming `text().maxLength(n)` as the remedy and `ALTER TABLE FORCE` as explicitly not one, and EXCLUDED from the reader.
68
+
69
+ Excluding is what makes it converge, and that is measured rather than assumed: an excluded table with a hidden hash column produces no reader error at all, while the same table included throws on the first write. Cross-instance change events for such a table are lost until it is bounded; own-node reactivity is unaffected (writes still emit inline).
70
+
71
+ Framework `_voltro_*` tables cannot hit this — they are filtered out of the reader's include list before it reaches the replication client, and exclusion demonstrably shields the metadata fetch.
72
+
73
+ **Caveat worth reading if you are already affected:** on a table that ALREADY exists, adding `.maxLength(n)` currently changes nothing — the schema differ does not diff text length, so it plans 0 operations and reports "up to date". That is a separate defect, reported in the same round and not yet fixed; until it is, the remedy only applies to newly created tables.
74
+ - **@voltro/cli, @voltro/voltro** — `voltro codegen` no longer writes a silently plugin-less `rpcGroup.generated.ts`, and it now reports what it merged.
75
+
76
+ `loadApiConfig` swallows every failure into `null`, and `config?.plugins ?? []` turned that into "this app has no plugins". So an `app.config.ts` that threw while importing produced a generated file with **no plugin error union and no plugin routes** — followed by `voltro codegen: wrote rpcGroup.generated.ts`. The file typechecks, so nothing downstream catches it; the only symptom is a client branching on an error tag that never arrives.
77
+
78
+ A consumer with ~140 declarative `guards:` measured that file 2781 lines shorter after a version bump, with the `ScopeError` import and the whole `__voltroPluginErrors` union gone. For the record, since they were careful to separate measurement from conclusion: the generator did NOT drop the feature — the plugin-codegen path is byte-identical between 0.19.0 and 0.20.0, and the published `@voltro/cli@0.20.0` does contain the identifier they grepped for. Their `grep` came back empty because the bundled chunk contained a literal NUL byte, which makes a file binary to most search tools (fixed separately, and it had been hiding files from our own audits too). What was real is the artefact diff, and this is the path that produces it without a word.
79
+
80
+ Now: a config that EXISTS but fails to load is a refusal with a non-zero exit and the underlying cause, not a quiet downgrade. An app with no `app.config.ts` at all still generates — absence is legitimate, failure is not. And every run prints `(plugins N, error schemas N, plugin routes N)`, because a count that drops from 7 to 0 has to be visible in the success line or the next occurrence is found the same way: by diffing artefacts during a debugging session.
81
+
82
+ `loadApiConfigDiagnosed` is the new seam (`{ config, present, error }`); `loadApiConfig` is unchanged for every existing caller.
83
+ - **@voltro/cli, @voltro/voltro** — `ssr cold-compile` log lines now carry the compile's duration, and `voltro start` emits them at all.
84
+
85
+ The lines had a `start` and an `end` and no timing, which looks readable and is not: cold compiles run concurrently up to `VOLTRO_DEV_SSR_COMPILE_CONCURRENCY`, so the pairs INTERLEAVE. Subtracting adjacent timestamps names the wrong module, and above a limit of two they cannot be paired by eye at all — which is what a user reading a pod log actually hit, with three `start` lines before their `end`s:
86
+
87
+ ```
88
+ …:59.704 ssr cold-compile start id=…/layout.tsx
89
+ …:59.704 ssr cold-compile start id=…/(main)/layout.tsx
90
+ …:03.447 ssr cold-compile end 3743ms id=…/layout.tsx
91
+ ```
92
+
93
+ The gate had the number for free and threw it away. It is measured INSIDE the concurrency permit, so it is the module's own compile cost rather than the time it spent queued behind the limit — those are different numbers and only one of them is a property of the module. A slow first paint is usually one slow module, and this is the line that names it.
94
+
95
+ A failed compile now says `FAILED` instead of `end`. Without that, a 3.7-second line for a module that threw read exactly like a slow but successful compile.
96
+
97
+ `voltro start`'s middleware fallback constructed the same gate with NO callbacks, so an on-demand compile there produced no line whatsoever; it is wired now.
98
+ - **@voltro/cli, @voltro/voltro** — `voltro db plans`, `db drift` and `db restore-snapshot` worked on postgres only. On mysql/mariadb (and mssql and sqlite) all three died with:
99
+
100
+ ```text
101
+ fatal unhandled cli error (FiberFailure) SqlError: Failed to execute statement
102
+ ```
103
+
104
+ The cause is three `${sql('col')}::text AS ${sql('col')}` casts — POSTGRES syntax, in read paths whose helper is still called `buildPgLayer`. `db apply`, which WRITES the same ledger table, has no cast and worked, which is exactly the split a consumer reported: the commands that read were broken, the one that writes was fine.
105
+
106
+ The casts existed to stop a driver handing back a `jsonb` object or a `Date`. Normalising in JS gets the same result and cannot be dialect-specific, since drivers differ in whether a json column arrives parsed and whether a timestamp arrives as a `Date`.
107
+
108
+ Worth naming what it cost: `db drift` is the command whose whole job is "alert if live diverged from declared", and the consumer who found this had live divergence at the time. The specific detector and the general one were blind together.
109
+
110
+ **And the error now names the failing statement.** Their verdict was the actionable part of the report:
111
+
112
+ > *"the error names no statement … the statement text (or even the operation name) > would turn this from a dead end into a bug report. We would have sent you the > failing SQL if the error had contained it."*
113
+
114
+ Right twice over — they could not diagnose it, and neither could we from the report; it took reading our own source. `@effect/sql`'s `SqlError` carries the driver error in `cause`, and every supported driver puts the useful part there (mysql2: `code`, `errno`, `sqlState`, `sqlMessage`, usually `sql`; pg: `code`, `detail`, `hint`, `position`). The CLI's fatal reporter printed only the wrapper. It now walks the cause chain and prints the driver message, the codes and the statement — collapsed to one line, and saying `<not attached by the driver>` when there genuinely is none, because that is information too.
115
+
116
+ Shape-based rather than `instanceof SqlError`, deliberately: the CLI catches errors that have crossed the serve/start bundle boundary, where two copies of `@effect/sql` make `instanceof` silently false — the failure mode this repo has already paid for elsewhere.
117
+ - **@voltro/cli, @voltro/voltro** — `voltro doctor`'s `plaintext-secret` rule no longer flags metadata ABOUT a credential. An audit row denormalising the public facts of an api key — `apiKeyId`, `apiKeyKeyId`, `apiKeyType`, `apiKeyOwnerId`, `apiKeyName` — had three columns already excluded by the `*Id` suffix, while `apiKeyType` and `apiKeyName` fired. Telling a team to encrypt the LABEL of a credential is how a rule earns being ignored.
118
+
119
+ The exclusion now covers final words that cannot BE the credential — `Name`, `Type`, `Kind`, `Label`, `Prefix`, `Suffix`, `Status`, `State`, `Scope(s)`, `Version`, `Count`, `Provider`, `Format`, `Note`/`Description`/`Comment`, plus the existing `Id` and the hash family. Deliberately NOT on the list: `Value`, `Secret`, `Token`, `Key`, `Password` — the words that name the thing itself. A false negative from an over-wide list is silent, so that is the failure mode the list is built against, and a test pins the words that must still fire.
120
+ - **@voltro/database, @voltro/cli, @voltro/voltro** — `voltro doctor` no longer contradicts itself about the `.serverOnly()` wire audit. The same command on the same tree reported:
121
+
122
+ ```
123
+ human: serverOnly: NOT CHECKED | json: {'checked': True, 'leaks': 0}
124
+ ```
125
+
126
+ Two causes, both fixed. `registerRelations` refused a re-registration of the IDENTICAL relation object, so a process that executes a module twice looked like two conflicting declarations — it now mirrors `registerTable`'s `existing === table` tolerance (a DIFFERENT block claiming the same name still throws). And doctor loaded the app three times per run; it now loads once, so every report sees the same outcome instead of the first one succeeding and the next failing.
127
+
128
+ The consequence was worse than the noise: the throw aborted the wire audit, so the check that a token cannot reach a client had not run since the reporting app adopted the marker — and a CI gate written exactly as we documented (`fail on serverOnly.checked === false`) reported green on an app where the audit provably had not run. That is the "reads as coverage without being coverage" failure the `serverOnly` field was added to remove, reappearing in the field added to prevent it.
129
+ - **@voltro/sql-mysql, @voltro/voltro** — `insertIgnore` on MariaDB no longer reports a cause it cannot know, and no longer turns a REJECTED write into a silent "conflict". `INSERT IGNORE` downgrades EVERY error to a warning — foreign key, NOT NULL, CHECK, truncation — so the post-check's premise ("the insert was skipped ⇒ a unique constraint fired") does not hold on this dialect. It asserted a second unique index that did not exist; the real cause was an FK (an auto-stamped `createdBy` with no matching `actors` row), and a consumer spent the diagnosis looking for a phantom index.
130
+
131
+ The message now reads the real error from `SHOW WARNINGS` on the same connection — BEFORE the existing-row lookup, since that lookup is itself a statement and resets the warning list. A non-duplicate warning is reported as a rejection and throws, because returning there is data loss presented as a normal outcome: the row is not written and the caller is told it already was. A genuine duplicate on an unnamed constraint now names the constraint that fired. Outside a transaction the warning cannot be attributed to our own statement (each statement acquires from the pool independently), so the message says the constraint is unknown rather than guessing — framework mutations are auto-transactional, so the common path has the cause.
132
+ - **@voltro/logger, @voltro/cli, @voltro/database, @voltro/voltro** — `voltro doctor --json` and `voltro capabilities --json` now emit exactly one JSON document on stdout. A `log.warn` from module discovery landed there ahead of it, so:
133
+
134
+ ```console
135
+ $ voltro doctor --json 2>/dev/null | python3 -c 'import json,sys; json.load(sys.stdin)'
136
+ JSONDecodeError: Extra data: line 2 column 1
137
+ ```
138
+
139
+ Note the `2>/dev/null` in that repro — stderr was already redirected, so there was no shell-side workaround. And it only happened when a warning fired, so a consumer's CI parsed the document correctly until one file out of 368 tripped one. That is the same failure the `serverOnly.checked` field was added to remove — an automat unable to separate the normal case from the special case — one layer out, in the surface added to fix it.
140
+
141
+ A command that owns stdout for machine output now calls `claimStdoutForJson()` before doing any work that could log, and every record goes to stderr from then on. The stream decision itself moved into ONE place (`@voltro/logger`'s `stream.ts`, exported as `routeDiagnosticsToStderr`): the Effect surface and the direct surface each carried their own copy of `level === 'error' ? stderr : stdout`, and two copies of one rule is how the rule failed to change.
142
+
143
+ **Also fixed, same report:** the warning that started it was itself wrong. A `*.relations.ts` whose `relations(...)` map is EMPTY was reported as *"no relations(...) export found"* — pointing the reader at a missing export that is right there. `isRelationsSpec` rejects an empty map (correctly — there is nothing to register), but the caller could not tell that apart from a module with no export at all. It now says the map is empty and names the export.
144
+ - **@voltro/cli, @voltro/voltro** — The SSR bundle build now externalises a bare specifier it cannot resolve instead of aborting, so an uninstalled OPTIONAL peer no longer makes `voltro build` impossible.
145
+
146
+ The SSR step runs with `ssr: { noExternal: true }` — inlining everything is what lets a production web image ship without a framework dependency tree — and that left no escape for a package that cannot be resolved at all. The commonest such package is an optional native peer reached through a library's Node entry:
147
+
148
+ ```
149
+ Rolldown failed to resolve import "canvas"
150
+ from ".../konva/lib/index-node.js"
151
+ ```
152
+
153
+ `konva`'s `main` is its Node build, which requires the optional native `canvas`; its `browser` field points at one that does not. An app that never renders to a canvas server-side has nothing to install.
154
+
155
+ A consumer measured that there was no way out from their side either, and each measurement is worth keeping: the import was ALREADY dynamic (rolldown must still resolve it to form the chunk), `renderMode: 'spa'` does not help (`.framework/app.tsx` imports every page statically for the router, so the module is in the SSR graph whatever the render mode), and an `ssr.external` passthrough in `app.config.ts` is not read. So `voltro build` — and with it the production image — was unavailable for that app.
156
+
157
+ The api serve bundle and the web start bundle already did exactly this; that plugin is esbuild's and this step is vite/rolldown, so it is the same probe behind a different interface. Framework packages (`@voltro/*`, `@effect/*`, `effect`) are never externalised, so the "needs nothing from node_modules" property still holds.
158
+
159
+ Every externalised specifier is NAMED in the `SSR bundle ready` line. Externalising is right for an uninstalled optional peer and wrong for a genuine missing dependency — it trades a loud build failure for a quiet runtime one — and only the reader can tell which, so it is reported rather than swallowed.
160
+ - **@voltro/sql-mysql, @voltro/sql-sqlite, @voltro/sql-mssql, @voltro/sql-postgres, @voltro/database, @voltro/voltro** — A typed error thrown inside a mutation now reaches the client TYPED, on every dialect. It arrived as an untagged `Die` defect on mysql/mariadb, sqlite and mssql: `transactional()` settled its program with `runPromise`, which rejects with Effect's `FiberFailure` wrapper, and the wrapper copies `message` and a decorated `name` but nothing else — no `_tag`, no payload, no prototype. So the rpc encoder could not match the failure against the mutation descriptor's `error:` union:
161
+
162
+ ```
163
+ └─ ["error"] └─ ["_tag"] └─ is missing
164
+ Expected never, actual (FiberFailure) NotFoundError: …
165
+ ```
166
+
167
+ Framework mutations are auto-transactional, so this was EVERY typed mutation error in an app. Nothing failed — `defineMutation({ error: … })` compiled, the client's type still said `NotFoundError`, and the `error._tag === 'NotFoundError'` branch was simply never taken at runtime. Actions, which are not auto-transactional, marshalled correctly the whole time, which is what made the transaction the discriminator. A hand-rolled error class lost its fields and its `instanceof` too; only `message` survived, which is why a workaround built on `error.message` looked like it worked and hid this.
168
+
169
+ Postgres already had the unwrap, with a comment describing this exact consequence, and the three sibling dialects kept the broken call — so the fix is now one shared `settleTransactionExit` in `@voltro/database` that all four import, plus a parity test that fails if any store's `transactional()` reaches `runtime.runPromise` again. Reported by a consumer on MariaDB who verified it against 0.19.0 too, so it is not a 0.20.0 regression.
170
+
171
+ ### Internal (no consumer-facing effect)
172
+
173
+ - **@voltro/runtime, @voltro/database, @voltro/protocol, @voltro/cli, @voltro/plugin-billing, @voltro/plugin-mail, @voltro/plugin-sso-saml, @voltro/plugin-storage** — Fourteen source files carried a LITERAL NUL byte — the house idiom for a composite map key, written as the raw character instead of an escape. That makes the file BINARY to every text tool: `grep` skips it entirely and reports nothing, which is indistinguishable from a clean file. It was found because a new guard test scanning for framework index names came back clean on `runtime/src/connectionVault.ts` — 1020 lines that every previous grep-based audit in this repo had also silently skipped, including the one looking for exactly the index name that file declares.
174
+
175
+ Replaced with the JavaScript escape for U+0000. Identical runtime value, files are text again. No behaviour change.
176
+
177
+ ---
178
+
179
+ ## [0.20.0] — 2026-07-29
180
+
181
+ ### ⚠ BREAKING
182
+
183
+ - **@voltro/plugin-versioning, @voltro/database, @voltro/voltro, @voltro/sql-postgres, @voltro/sql-mysql, @voltro/sql-sqlite, @voltro/sql-mssql** — `versioningPlugin({ timing: 'in-transaction' })` produced a WRONG trail, not merely a slow one. Reported and reproduced against MariaDB 11 by a team that wired both plugins and measured before migrating a single call site.
184
+
185
+ **It recorded every change twice.** The two timings are alternatives, but the post-commit change tap stayed wired when the in-transaction recorder was registered, so both ran. One `bookmarks.create` → two history rows.
186
+
187
+ **And the trail was mis-ordered, which is worse.** Each path numbered independently: one insert plus one update produced versions `0, 0, 1, 2` across four rows. `selectAsOf`, `sortHistory` and `diffVersionRows` all read `version`, so `rowAsOf` returned the wrong snapshot and `diffVersions` found nothing. A duplicate can be deduped; a wrong order cannot be detected from the data.
188
+
189
+ The recorder wrote a constant `version: 0` on purpose, with a design note arguing that ordering could come from `changedAt` and that a read per covered write was too expensive. Both halves were wrong: `changedAt` is millisecond-resolution, so two writes to one row inside one transaction tie routinely, and the number is what every reader consults.
190
+
191
+ **BREAKING —** a `WriteRecorder` now receives a PORT (`{ append, maxOf }`) rather than a bare `append`. `maxOf` is one aggregate with an equality filter on the connection the write already holds; it is what lets an append-only trail number its own entries. A recorder still cannot UPDATE, DELETE or open a nested transaction, and a throw from either operation still rolls the caller's write back. Apps that merely ENABLE the timing need no change — only a hand-written recorder does, and `tsc` names every site.
192
+
193
+ **Cost, stated rather than avoided:** `'in-transaction'` now takes TWO round-trips per recorded write, roughly doubling this timing's published per-write overhead. Both timings number from 1, so switching `timing` no longer shifts version numbers.
194
+
195
+ ### Fixed
196
+
197
+ - **@voltro/database, @voltro/sql-postgres, @voltro/sql-mysql, @voltro/sql-sqlite, @voltro/sql-mssql** — The correlation bridge did not survive a transaction, and did not survive CDC. Both are fixed, and both were found by measuring against live databases after a consumer isolated the symptom in a scratch app.
198
+
199
+ **Every write a framework mutation makes was unattributed.** `transactional()` is entered from the request's async-local scope, but its callback runs from inside the Effect the store builds — and measured against live postgres AND live mariadb, the scope is active at the call site and EMPTY inside the callback. Framework mutations are auto-transactional, so this was every handler write. Same class as the `bindMutation` defect fixed alongside it: a scope covering the construction of an Effect and not its execution. The caller's attribution is now captured at `transactional()` entry and re-established around the callback, in all four dialect stores.
200
+
201
+ **And the CDC transports could not carry it at all.** Under `changeStrategy: 'cdc'` — the DEFAULT — the event a subscriber receives is rebuilt from a postgres NOTIFY payload or a mysql binlog row image, neither of which can hold a request context. `registerPendingAttribution` / `claimPendingAttribution` (`@voltro/database`) let the write path hand its identity to the echo, keyed by `(table, op, id)` and claimed once. A write made on ANOTHER replica has nothing pending and stays unattributed, which is the correct answer rather than a gap.
202
+
203
+ **Plus one nobody had reported, found on the way:** on postgres under CDC the write path skipped `routeEvent` entirely, and `runWriteRecorders` lives inside it — so `versioningPlugin({ timing: 'in-transaction' })` with the default `CDC=1` recorded NOTHING. The mode whose entire promise is "if the change committed, the entry is there" wrote an empty trail, silently. `routeEvent` now runs in both modes; only the DELIVERY decision is strategy-dependent.
204
+
205
+ New live-dialect suites (`cdcAttribution.integration.test.ts` in `sql-postgres` and `sql-mysql`) pin all of it, and were verified red against the previous code.
206
+ - **@voltro/plugin-versioning, @voltro/runtime, @voltro/cli** — `_voltro_row_history.traceId` and `.subjectId` were NULL on every write. Three independent causes, all found from one consumer report whose evidence pinned the diagnosis before we looked: `subjectId` was NULL while `changedBy` on the SAME row carried the acting user — so the identity was known and was not travelling.
207
+
208
+ - **The adapter dropped them.** `dataStoreHistoryStore.append` hand-wrote its insert object and listed `changedBy` but not `traceId` / `subjectId`. This is the second time that shape has bitten in this file — the READ side (`rowToVersion`) had drifted identically. A row built by hand in one place and read by hand in another disagree exactly when a field is ADDED, because nothing fails. Both now spread the row. - **An Effect-returning handler was unattributed.** `bindMutation` established the scope around the CALL, which covers an async executor for its whole run — but an Effect-returning one is only CONSTRUCTED there and runs later. It is now forked inside the scope, with interruption and typed failures preserved (both pinned by tests). Verified by measurement, not assumption: an Effect forked inside an ALS scope keeps seeing it across `sleep`, `yieldNow` and a `setTimeout` promise, while the same effect merely constructed inside sees nothing. - **The devtools `/invoke` path never entered the scope at all.** It bypasses the rpc stack by design, and that also bypassed everything `bindMutation` sets up. The audit plugin recorded a traceId (it reads `requestContext.traceId`, which this path does build) while every write underneath carried none — three consumers of one call disagreeing about its trace. It also synthesised `inspect-<8 random chars>`, which no trace consumer can parse; the reporter's framing is the rule worth keeping — *a synthesised id produces a column that looks joinable and is not; NULL at least fails honestly.* It is a real 32-hex id now, and it reaches all three sinks.
209
+
210
+ `actingUserId` is imported at the new call site rather than re-derived — one answer to "who is writing", shared with what `audit()` stamps.
211
+ - **@voltro/cli** — Three ways a check reported nothing while checking nothing, all found by a consumer verifying the silence instead of trusting it.
212
+
213
+ - **`unexercised-row-filter` never fired on a typed registration.** The match was `/\bsetRowFilter\s*\(/`, which demands the paren directly after the name, so `setRowFilter<Ctx>({…})` — the spelling our own generic signature invites — broke it. The rule was blind for exactly the teams that had wired `load`/`predicate` carefully. It counts CALLS now. - **…and its test-side condition was satisfiable by a COMMENT.** It matched `rowFilter:` in raw text. Comments and string literals are stripped, and the suite must both call `makeTestContext` and bind `rowFilter` in real code. - **The same paren-adjacent shape sat in two shipped codemod gates.** `0.7.0/01` (row filter) and `0.7.0/02` (`invoke`) both gate on a generic export, so a typed call made `voltro update` print nothing at all: the upgrade reads as clean and the behaviour change lands unread. Both now use the shared `callPattern`, which allows type arguments including nested ones.
214
+
215
+ Two false-positive fixes in the hand-roll detector, from the same report:
216
+
217
+ - **A file that WIRES a plugin is no longer told to adopt it.** The `presence` rule reported `app.config.ts` (which calls `presencePlugin()`) to an app that had just deleted its hand-rolled table. Rules that recommend a package now declare it, and a file referencing that package is skipped. - **Generated files and `.d.ts` are out of the scan.** A recommendation aimed at a file the next boot overwrites is never actionable.
218
+
219
+ And one more of the first kind, found while checking why a withdrawn report's probe had stayed silent: `raw-fetch` counted only a BARE `fetch(…)` callee, so `globalThis.fetch(url)` / `self.fetch(url)` in a server file read as clean.
220
+ - **@voltro/cli** — `voltro doctor`'s `serverOnly: NOT CHECKED` line now names the failure, and the field exists in `--json`.
221
+
222
+ The refusal to claim a pass was right. What shipped with it was nothing to act on: the `catch` discarded the error entirely, so there was no reason, no failing module, and — because the field was absent from `--json` — no way for CI to assert "still unchecked" rather than reading silence as a pass.
223
+
224
+ A consumer's verdict, which is the useful part: *"The message is honest and that is the problem."* They had already verified that every descriptor, `app.config.ts` and the generated rpc group imported cleanly under `tsx` on their own, so the difference had to be in what `loadDiscovered` does BEYOND importing — and none of that was visible from outside. It matters more than its size because `.serverOnly()` is what guards their `sessions.tokenHash` and `apiKeys.keyHash`, markers they added after finding a query whose output schema shipped a hash over the wire.
225
+
226
+ `--json` now carries `serverOnly: { checked, reason?, leaks? }`. Gate CI on `checked === false`.
227
+ - **@voltro/plugin-versioning** — A version snapshot no longer copies `.serverOnly()` columns into `_voltro_row_history`. `.encrypted()` columns are KEPT, and that distinction is the whole finding.
228
+
229
+ Reported by a team choosing which tables to version: `sessions` holds `.encrypted()` PATs and a `tokenHash`, `apiKeys` holds a `keyHash`, and they could not determine from outside what the snapshot would contain. They excluded both tables — then went and measured it, which corrected their own report:
230
+
231
+ ```
232
+ probeItems.secret enc:v1:a56iziEV9THLhzmJ:Vk0ux+0bECleTLBJkCa0Rg==:3AtMwP…
233
+ _voltro_row_history {"secret":"enc:v1:a56iziEV9THLhzmJ:Vk0ux+0bECleTLBJkCa0Rg==:…"}
234
+ ```
235
+
236
+ **`.encrypted()` lands as ciphertext, byte-identical to the source column**, so versioning such a table widens nothing — the history is exactly as readable as the row it came from. Withholding it would have cost real audit data to prevent an exposure that does not exist.
237
+
238
+ **`.serverOnly()` is withheld**, and the reason is not "a second copy under different retention" — that argument is weak on its own, since the hash already sits in the source table. The decisive one: `crud.*` STRIPS `.serverOnly()` columns from every row it returns, and a snapshot would smuggle the same value back past that stripping inside a `json()` blob, where no column-level rule applies.
239
+
240
+ Withheld names are listed under `data._omitted`, so a reader can tell "this column was withheld" from "this column did not exist then". Both timings apply the same policy. `.sensitive()` is not involved: it is an export-masking marker for values that are legitimately readable in the app.
241
+ - **@voltro/cli** — A page that RE-EXPORTS its component (`export { default, renderMode } from '../page'`) no longer fails the codegen gate with "exports no default". The check required the literal words `as default`, so the one spelling that lets two routes share a screen without copying it was the one spelling it refused — and it refused in `voltro build`, while dev and tests stayed green because nothing prerenders there. `export { default as Screen }` is still correctly rejected: it renames the default away.
242
+
243
+ Two follow-ons from the same shape:
244
+
245
+ - The refusal message said the file "ends in `.page.tsx`" and offered "drop the `.page` suffix" as a fix. That is the 0.15.0 convention, replaced by directory routing in 0.17.0 — it named a convention that no longer exists and a fix that could not work. It now names `page.tsx` and both real fixes. - `scanRenderProfile` read a forwarded `renderMode` as absent and fell back to `'static'`, so `staticSafe` and the deploy-target classification could call an app CDN-deployable with an `ssr` route in it. The forward is now followed (relative specifiers, depth-capped); an unresolvable one still falls back rather than failing the scan.
246
+ - **@voltro/database** — `VOLTRO_SOFT_DROP=1` could never converge. The applier renames the object to `<name>__dropped_<ts>` instead of dropping it, which leaves it undeclared — and the differ read that as one more forgotten table, planning the drop again. The re-plan inside `applyPlan` then found an operation still outstanding and aborted with "the DDL for these operations is a no-op — this is a framework bug", which was a wrong diagnosis of a real defect: the DDL had worked. No fingerprint was recorded, so the migration counted as unapplied and every later `db apply` / boot hit the same wall. The only exit was a hard drop of the snapshot — exactly the recoverability the flag is chosen for.
247
+
248
+ The planner now treats `<name>__dropped_<YYYYMMDDHHMMSS>` as framework-managed, alongside `_voltro_*` / `cluster_*`. Deliberately not retention-aware: a planner whose output depends on the clock would produce different plans before and after midnight, and `db gc-snapshots` already owns expiry.
249
+
250
+ Reported against tables; the same defect existed one level down for soft-dropped COLUMNS, where it was worse — a re-planned `drop-column` carries no `dropped()` marker and so refuses to plan at all. Both are fixed.
251
+
252
+ The convergence message itself no longer asserts a cause it cannot know. It said "the DDL for these operations is a no-op", which was flatly wrong here and sent the reporter looking for dead DDL. It now names both causes — no-op DDL, and a planner that cannot see what the DDL did — and says which one an operation naming a just-renamed object usually is.
253
+
254
+ ### Internal (no consumer-facing effect)
255
+
256
+ - **The `0.20.0/01_write-recorder-port` codemod gains the gate test its two predecessors have.**
257
+
258
+ `codemodRegistry.test.ts` asserts that every `*.codemod.ts` on disk is registered and that ids are unique — registration, not behaviour. What it cannot see is the one way a `manual` codemod fails in practice: an `appliesTo` that is too broad, so the note prints for projects that have nothing to do. That is not a cosmetic problem. A note which fires on every app is how readers learn to skip notes, and the next one carries a boot refusal.
259
+
260
+ This codemod is the case where the silent direction matters most. The break is a TYPE error, so `tsc` already names every affected site; the note exists only to explain `maxOf`, which the compiler cannot. Apps that merely ENABLE `timing: 'in-transaction'` need to do nothing — `plugin-versioning` ships the recorder and it is already updated — and they are the large majority.
261
+
262
+ Four cases, covering both directions: a project registering its own recorder (note prints, and names `{ append }`, `maxOf`, and the `null`-is-not-zero distinction that a hand-written sequence gets wrong), an app that only enables the timing (silent), the identifier in a comment or a string (silent), and the generic call form `registerWriteRecorder<Row>(…)`, which `callPattern` admits and a naive match would miss.
263
+
264
+ ---
265
+
42
266
  ## [0.19.0] — 2026-07-29
43
267
 
44
268
  ### ⚠ BREAKING
package/dist/index.d.ts CHANGED
@@ -59,6 +59,18 @@ export declare interface BinlogCdcOptions {
59
59
  * per-subscribe re-query already does this on the next change.
60
60
  */
61
61
  readonly onResync?: () => void;
62
+ /**
63
+ * Names of tables whose binlog row image carries MORE columns than
64
+ * `information_schema.COLUMNS` reports, so the reader can NEVER decode them.
65
+ *
66
+ * Supplied by the store from a privilege-free probe (see
67
+ * `hashLongUniqueTablesQuery`). The reader subtracts them from `includeSchema`,
68
+ * which is what makes the condition converge: MEASURED against live MariaDB
69
+ * 11.8, an EXCLUDED table with a hidden hash column produces no error at all,
70
+ * while the same table included throws
71
+ * `the event has 3 columns, fetched metadata has 2` on the first write.
72
+ */
73
+ readonly undecodableTables?: ReadonlyArray<string>;
62
74
  /**
63
75
  * Watchdog liveness cadence + stall threshold (ms). The reader tracks its
64
76
  * last progress (any binlog event or a fresh attach); when nothing has
@@ -282,6 +294,12 @@ export declare class MysqlStore implements DataStore {
282
294
  * re-enter `routeEvent` and emit a change event for the trail's own table.
283
295
  */
284
296
  private appendInTxn;
297
+ /**
298
+ * The other half of the recorder port: ONE aggregate on the caller's
299
+ * connection, so an append-only trail can number its own entries. Equality
300
+ * filter only — see `writeRecorder.ts` for why it is this narrow.
301
+ */
302
+ private maxInTxn;
285
303
  private routeEvent;
286
304
  private executeUpsert;
287
305
  /**
@@ -294,6 +312,38 @@ export declare class MysqlStore implements DataStore {
294
312
  */
295
313
  private executeMariadbUpsert;
296
314
  private executeInsertIgnore;
315
+ /**
316
+ * The warnings MariaDB raised for the statement that just ran on `txn`.
317
+ *
318
+ * Only meaningful inside a transaction, and that is why the parameter is not
319
+ * optional. `SHOW WARNINGS` reports the last statement on the CONNECTION, and
320
+ * outside a transaction each statement acquires from the pool independently —
321
+ * so a warning read there could belong to an unrelated statement on another
322
+ * caller's connection. Reporting that as this insert's cause would be a worse
323
+ * version of the bug this exists to fix, so `insertIgnore` passes `null` and
324
+ * says "unknown" rather than reading something it cannot attribute.
325
+ *
326
+ * Diagnostic-only: a failure to read warnings must never replace the caller's
327
+ * real problem with a problem about reading warnings.
328
+ */
329
+ /**
330
+ * Tables among `includeTables` whose binlog row image carries a HIDDEN column,
331
+ * so the reader could never decode them.
332
+ *
333
+ * Privilege-free by construction: the direct evidence lives in
334
+ * `INNODB_SYS_COLUMNS`, which needs `PROCESS` — a grant an app DB user does not
335
+ * have (verified: `Access denied; you need … the PROCESS privilege(s)`). This
336
+ * infers the same thing from the CONSTRAINT shape, which any user can read: a
337
+ * UNIQUE index with no prefix length over a text/blob column can only be a
338
+ * MariaDB hash long-unique, and that is what adds the hidden column.
339
+ *
340
+ * Diagnostic-only — a probe that fails must never stop CDC from starting. An
341
+ * undiscovered table still gets caught by the reconnect path's repetition check;
342
+ * this just moves the diagnosis to boot, where it is one message instead of a
343
+ * loop.
344
+ */
345
+ private findUndecodableCdcTables;
346
+ private readWarnings;
297
347
  private findByConflict;
298
348
  query(d: QueryDescriptor): Promise<readonly Readonly<Record<string, unknown>>[]>;
299
349
  raw<T extends object = Row>(fragment: RawSqlFragment, _opts?: {
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, compileEagerJson as _, compilePredicate as v, compileRawFragment as y, compileSelect as b, decodeRowsFromSchema as x, encodeRowForSchema as S, hasEagerLoads as C, isTableReactive as w, qualifyTable as T, raiseChangeListenerCeiling as E, recordsTable as D, requireTable as O, runWriteRecorders as k, stampGeneratedId as A, stampGeneratedIds as j } from "@voltro/database";
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";
7
7
  //#region src/sqlLayer.ts
8
- var M = (n) => e.layerConfig({
8
+ var P = (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
- }), N = (e) => {
15
+ }), F = (e) => {
16
16
  if (e.url) {
17
17
  let t = new URL(e.url);
18
18
  return {
@@ -32,63 +32,72 @@ var M = (n) => e.layerConfig({
32
32
  database: e.database ?? "app",
33
33
  ...e.maxConnections === void 0 ? {} : { maxConnections: e.maxConnections }
34
34
  };
35
- }, P = (e) => M(N(e)), F = (e, t) => e === null ? !1 : t === null ? !0 : e.filename === t.filename ? e.position > t.position : e.filename > t.filename, I = (e) => e.msSinceProgress < e.stallThresholdMs ? "healthy" : e.primary !== null && !F(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 = /* @__PURE__ */ new Set([
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) => {
36
+ let t = e instanceof Error ? e.message : String(e ?? "");
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];
40
+ return {
41
+ verdict: n.length >= H ? "persistent" : "backlog",
42
+ hits: n
43
+ };
44
+ }, W = /* @__PURE__ */ new Set([
36
45
  "writerows",
37
46
  "updaterows",
38
47
  "deleterows"
39
- ]), z = /\b(alter|rename|drop|create)\s+(table|column)?/i, B = (e) => new Promise((t) => setTimeout(t, e)), V = async (e) => {
48
+ ]), G = /\b(alter|rename|drop|create)\s+(table|column)?/i, K = (e) => new Promise((t) => setTimeout(t, e)), q = async (e) => {
40
49
  let t = l({ scope: `voltro:${e.variant}:cdc` }), n;
41
50
  try {
42
51
  n = (await import("@vlasky/zongji")).default;
43
52
  } catch (t) {
44
53
  throw Error(`changeStrategy='cdc' on ${e.variant} requires the '@vlasky/zongji' optional dependency. Install it (it ships as an optionalDependency of @voltro/sql-mysql) to enable binlog CDC.`, { cause: t });
45
54
  }
46
- let r = e.includeTables ? new Set(e.includeTables) : null, i = e.connection.database, a = e.startPosition?.filename ?? null, o = e.startPosition ?? null, s = (t, n) => x([n], t, e.variant)[0], c = null, u = !1, d = 0, f = Date.now(), p = !1, m = null, h = e.stallThresholdMs ?? 25e3, g = e.watchdogIntervalMs ?? 15e3, _ = () => {
47
- f = Date.now();
48
- }, v = (n) => {
49
- _();
50
- let l = n.getEventName();
51
- if (l === "rotate" && n.binlogName) {
52
- a = n.binlogName;
55
+ let r = new Set(e.undecodableTables ?? []), i = e.includeTables ? new Set(e.includeTables.filter((e) => !r.has(e))) : null, a = e.connection.database, o = e.startPosition?.filename ?? null, s = e.startPosition ?? null, c = (t, n) => w([n], t, e.variant)[0], u = null, d = !1, f = 0, p = Date.now(), m = !1, h = /* @__PURE__ */ new Map(), g = /* @__PURE__ */ new Set(), _ = null, v = e.stallThresholdMs ?? 25e3, y = e.watchdogIntervalMs ?? 15e3, b = () => {
56
+ p = Date.now();
57
+ }, x = (n) => {
58
+ b();
59
+ let r = n.getEventName();
60
+ if (r === "rotate" && n.binlogName) {
61
+ o = n.binlogName;
53
62
  return;
54
63
  }
55
- if (l === "query" && n.query && z.test(n.query)) {
56
- c && (c.tableMap = {});
64
+ if (r === "query" && n.query && G.test(n.query)) {
65
+ u && (u.tableMap = {});
57
66
  return;
58
67
  }
59
- if (n.nextPosition && a && (o = {
60
- filename: a,
68
+ if (n.nextPosition && o && (s = {
69
+ filename: o,
61
70
  position: n.nextPosition
62
- }, e.onPosition?.(o)), !R.has(l)) return;
63
- let u = n.tableMap[n.tableId];
64
- if (!u || u.parentSchema !== i) return;
65
- let d = u.tableName;
66
- if (!d.startsWith("_voltro_") && !(r && !r.has(d))) try {
67
- if (l === "writerows") for (let t of n.rows) e.onChange({
71
+ }, e.onPosition?.(s)), !W.has(r)) return;
72
+ let l = n.tableMap[n.tableId];
73
+ if (!l || l.parentSchema !== a) return;
74
+ let d = l.tableName;
75
+ if (!d.startsWith("_voltro_") && !(i && !i.has(d))) try {
76
+ if (r === "writerows") for (let t of n.rows) e.onChange({
68
77
  table: d,
69
78
  op: "insert",
70
79
  old: null,
71
- new: s(d, t)
80
+ new: c(d, t)
72
81
  });
73
- else if (l === "deleterows") for (let t of n.rows) e.onChange({
82
+ else if (r === "deleterows") for (let t of n.rows) e.onChange({
74
83
  table: d,
75
84
  op: "delete",
76
- old: s(d, t),
85
+ old: c(d, t),
77
86
  new: null
78
87
  });
79
88
  else for (let t of n.rows) e.onChange({
80
89
  table: d,
81
90
  op: "update",
82
- old: s(d, t.before),
83
- new: s(d, t.after)
91
+ old: c(d, t.before),
92
+ new: c(d, t.after)
84
93
  });
85
94
  } catch (n) {
86
95
  t.warn("cdc: failed to map a row event", {
87
96
  table: d,
88
- name: l
97
+ name: r
89
98
  }, n), e.onError?.(n);
90
99
  }
91
- }, y = (t) => {
100
+ }, S = (t) => {
92
101
  let n = {
93
102
  serverId: e.serverId,
94
103
  includeEvents: [
@@ -101,11 +110,11 @@ var M = (n) => e.layerConfig({
101
110
  "query"
102
111
  ]
103
112
  };
104
- return e.includeTables && e.includeTables.length > 0 && (n.includeSchema = { [i]: [...e.includeTables] }), t ? (n.filename = t.filename, n.position = t.position) : n.startAtEnd = !0, n;
105
- }, b = (e) => {
113
+ return e.includeTables && e.includeTables.length > 0 && (n.includeSchema = { [a]: e.includeTables.filter((e) => !r.has(e)) }), t ? (n.filename = t.filename, n.position = t.position) : n.startAtEnd = !0, n;
114
+ }, C = (e) => {
106
115
  let t = e;
107
116
  return t?.errno === 1236 || String(t?.code ?? "").includes("ER_MASTER_FATAL_ERROR_READING_BINLOG");
108
- }, S = (r) => new Promise((i, a) => {
117
+ }, T = (r) => new Promise((i, a) => {
109
118
  let o = new n({
110
119
  host: e.connection.host,
111
120
  port: e.connection.port,
@@ -114,10 +123,10 @@ var M = (n) => e.layerConfig({
114
123
  enableKeepAlive: !0,
115
124
  keepAliveInitialDelay: e.keepAliveInitialDelayMs ?? 1e4
116
125
  });
117
- c = o;
126
+ u = o;
118
127
  let s = !1;
119
- o.on("binlog", v), o.on("ready", () => {
120
- _(), !s && (s = !0, d = 0, t.info("cdc: binlog reader attached", {
128
+ o.on("binlog", x), o.on("ready", () => {
129
+ b(), !s && (s = !0, f = 0, t.info("cdc: binlog reader attached", {
121
130
  serverId: e.serverId,
122
131
  from: r ?? "current-end"
123
132
  }), i());
@@ -126,78 +135,82 @@ var M = (n) => e.layerConfig({
126
135
  s = !0, a(e instanceof Error ? e : Error(String(e)));
127
136
  return;
128
137
  }
129
- C("binlog reader error", e);
130
- }), o.start(y(r));
131
- }), C = async (n, r) => {
132
- if (!(u || p)) {
133
- p = !0, t.warn(`cdc: reconnecting — ${n}`, {}, r instanceof Error ? r : void 0), e.onError?.(r);
138
+ E("binlog reader error", e);
139
+ }), o.start(S(r));
140
+ }), E = async (n, i) => {
141
+ if (!(d || m)) {
142
+ m = !0, t.warn(`cdc: reconnecting — ${n}`, {}, i instanceof Error ? i : void 0), e.onError?.(i);
134
143
  try {
135
- let n = r;
136
- for (; !u;) {
144
+ let n = i;
145
+ for (; !d;) {
137
146
  try {
138
- c?.stop();
147
+ u?.stop();
139
148
  } catch {}
140
- if (d++, await B(Math.min(3e4, 500 * 2 ** Math.min(d, 6))), u) return;
141
- let r = o, i = b(n), s = !i && L(n);
142
- (i || s) && (t.warn(s ? "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"), r = e.resolveStartPosition ? await e.resolveStartPosition().catch(() => null) : null, a = r?.filename ?? null, e.onResync?.());
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;
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)));
154
+ }
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?.());
143
156
  try {
144
- await S(r), d = 0, _();
157
+ await T(i), f = 0, b();
145
158
  return;
146
159
  } catch (e) {
147
160
  n = e;
148
161
  }
149
162
  }
150
163
  } finally {
151
- p = !1;
164
+ m = !1;
152
165
  }
153
166
  }
154
167
  };
155
168
  try {
156
- await S(e.startPosition ?? null);
169
+ await T(e.startPosition ?? null);
157
170
  } catch (n) {
158
- if (b(n) && e.startPosition) {
171
+ if (C(n) && e.startPosition) {
159
172
  t.warn("cdc: persisted offset purged — starting at current end + self-heal");
160
173
  try {
161
- c?.stop();
174
+ u?.stop();
162
175
  } catch {}
163
176
  let n = e.resolveStartPosition ? await e.resolveStartPosition().catch(() => null) : null;
164
- a = n?.filename ?? null, e.onResync?.(), await B(500), await S(n);
177
+ o = n?.filename ?? null, e.onResync?.(), await K(500), await T(n);
165
178
  } else throw n;
166
179
  }
167
- let w = async () => {
168
- if (u || p || Date.now() - f < h) return;
180
+ let D = async () => {
181
+ if (d || m || Date.now() - p < v) return;
169
182
  let n = null;
170
- if (e.resolveStartPosition && (n = await e.resolveStartPosition().catch(() => null)), u || p) return;
171
- let r = I({
172
- msSinceProgress: Date.now() - f,
173
- stallThresholdMs: h,
183
+ if (e.resolveStartPosition && (n = await e.resolveStartPosition().catch(() => null)), d || m) return;
184
+ let r = ae({
185
+ msSinceProgress: Date.now() - p,
186
+ stallThresholdMs: v,
174
187
  primary: n,
175
- reader: o
188
+ reader: s
176
189
  });
177
190
  if (r !== "reconnect") {
178
- r === "idle-caught-up" && _();
191
+ r === "idle-caught-up" && b();
179
192
  return;
180
193
  }
181
194
  t.warn("cdc: watchdog detected a stalled reader — forcing reconnect", {
182
- reader: o,
195
+ reader: s,
183
196
  primary: n,
184
- stalledForMs: Date.now() - f
185
- }), C("watchdog: stream stalled (no events while primary advanced)", /* @__PURE__ */ Error("cdc watchdog stall"));
197
+ stalledForMs: Date.now() - p
198
+ }), E("watchdog: stream stalled (no events while primary advanced)", /* @__PURE__ */ Error("cdc watchdog stall"));
186
199
  };
187
- return m = setInterval(() => {
188
- w();
189
- }, g), m.unref && m.unref(), {
200
+ return _ = setInterval(() => {
201
+ D();
202
+ }, y), _.unref && _.unref(), {
190
203
  stop: async () => {
191
- u = !0, m &&= (clearInterval(m), null);
204
+ d = !0, _ &&= (clearInterval(_), null);
192
205
  try {
193
- c?.stop();
206
+ u?.stop();
194
207
  } catch (e) {
195
208
  t.warn("cdc: error stopping binlog reader", {}, e);
196
209
  }
197
210
  },
198
- currentPosition: () => o
211
+ currentPosition: () => s
199
212
  };
200
- }, H = /* @__PURE__ */ new Set(["1213", "1205"]), U = (e) => {
213
+ }, oe = /* @__PURE__ */ new Set(["1213", "1205"]), se = (e) => {
201
214
  let t = e;
202
215
  for (let e = 0; e < 5 && typeof t == "object" && t; e++) {
203
216
  let e = t.errno;
@@ -206,31 +219,31 @@ var M = (n) => e.layerConfig({
206
219
  if (typeof n == "string") return n;
207
220
  t = t.cause;
208
221
  }
209
- }, W = (e) => {
210
- let t = U(e);
211
- return t !== void 0 && H.has(t);
212
- }, G = (e) => W(e) ? "retry" : "noRetry", K = (e) => {
222
+ }, J = (e) => {
223
+ let t = se(e);
224
+ return t !== void 0 && oe.has(t);
225
+ }, Y = (e) => J(e) ? "retry" : "noRetry", X = (e) => {
213
226
  if (e == null) return "null";
214
227
  let t = typeof e;
215
228
  if (t === "bigint") return `${e}n`;
216
229
  if (t !== "object") return JSON.stringify(e);
217
230
  if (e instanceof Date) return `"${e.toISOString()}"`;
218
- if (Array.isArray(e)) return `[${e.map(K).join(",")}]`;
231
+ if (Array.isArray(e)) return `[${e.map(X).join(",")}]`;
219
232
  let n = e;
220
- return `{${Object.keys(n).sort().map((e) => `${JSON.stringify(e)}:${K(n[e])}`).join(",")}}`;
221
- }, q = (e) => {
222
- let t = K(e), n = 2166136261;
233
+ return `{${Object.keys(n).sort().map((e) => `${JSON.stringify(e)}:${X(n[e])}`).join(",")}}`;
234
+ }, ce = (e) => {
235
+ let t = X(e), n = 2166136261;
223
236
  for (let e = 0; e < t.length; e++) n ^= t.charCodeAt(e), n = Math.imul(n, 16777619);
224
237
  return (n >>> 0).toString(36);
225
- }, J = (e, t) => {
238
+ }, le = (e, t) => {
226
239
  let n = setTimeout(e, t);
227
240
  typeof n.unref == "function" && n.unref();
228
- }, Y = class {
241
+ }, ue = class {
229
242
  variant;
230
243
  ttlMs;
231
244
  schedule;
232
245
  seen = /* @__PURE__ */ new Map();
233
- constructor(e, t = 6e4, n = J) {
246
+ constructor(e, t = 6e4, n = le) {
234
247
  this.variant = e, this.ttlMs = t, this.schedule = n;
235
248
  }
236
249
  key(e) {
@@ -240,11 +253,11 @@ var M = (n) => e.layerConfig({
240
253
  if (n == null) return null;
241
254
  let r = t;
242
255
  try {
243
- r = x([t], e.table, this.variant)[0] ?? t;
256
+ r = w([t], e.table, this.variant)[0] ?? t;
244
257
  } catch {
245
258
  r = t;
246
259
  }
247
- return `${e.table} ${e.op} ${String(n)} ${q(r)}`;
260
+ return `${e.table} ${e.op} ${String(n)} ${ce(r)}`;
248
261
  }
249
262
  admit(e) {
250
263
  let t = this.key(e);
@@ -259,12 +272,19 @@ var M = (n) => e.layerConfig({
259
272
  get pending() {
260
273
  return this.seen.size;
261
274
  }
262
- }, X = async (e) => {
275
+ }, de = /* @__PURE__ */ new Set([
276
+ 1022,
277
+ 1062,
278
+ 1586
279
+ ]), Z = async (e) => {
263
280
  let t = e.variant ?? "mysql", n = l({ scope: `voltro:${t}` }), a = e.changeStrategy ?? "inline", o = a;
264
281
  a === "cdc" && !e.cdcConfig && (n.warn("changeStrategy='cdc' requires cdcConfig (serverId + connection); falling back to 'inline'."), o = "inline");
265
- let s = e.tracerLayer ? r.mergeAll(e.sqlLayer, e.tracerLayer) : e.sqlLayer, c = i.make(s), d = new Z(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 fe(await c.runPromise(u), c, t, o);
266
283
  return o === "cdc" && e.cdcConfig && await d.startCdcConsumer(e.cdcConfig), d;
267
- }, Z = class e {
284
+ }, Q = () => {
285
+ let e = C();
286
+ return e === void 0 ? (e) => e() : (t) => k(e, t);
287
+ }, fe = class e {
268
288
  sql;
269
289
  runtime;
270
290
  variant;
@@ -280,13 +300,13 @@ var M = (n) => e.layerConfig({
280
300
  cdcStreamName = "default";
281
301
  cdcGate;
282
302
  constructor(e, t, n, r = "inline", i = null, a, o) {
283
- 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(), E(this.emitter), this.cdcGate = o ?? new Y(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(), O(this.emitter), this.cdcGate = o ?? new ue(n);
284
304
  }
285
305
  withNamespace(t) {
286
306
  return t === this.namespace ? this : new e(this.sql, this.runtime, this.variant, this.changeStrategy, t, this.emitter, this.cdcGate);
287
307
  }
288
308
  nsT(e) {
289
- return T(this.namespace, e);
309
+ return ee(this.namespace, e);
290
310
  }
291
311
  get dialectId() {
292
312
  return this.variant;
@@ -298,8 +318,8 @@ var M = (n) => e.layerConfig({
298
318
  };
299
319
  }
300
320
  async executeQuery(e, t) {
301
- let r = b(e, this.sql, this.namespace), i = t ? n.provideService(r, d, t) : r;
302
- return x(await this.runtime.runPromise(i), e.table, this.variant);
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);
303
323
  }
304
324
  get supportsInsertReturning() {
305
325
  return this.variant === "mariadb";
@@ -311,10 +331,10 @@ var M = (n) => e.layerConfig({
311
331
  return !1;
312
332
  }
313
333
  async executeInsert(e, t, r, i) {
314
- t = A(e, t);
334
+ t = M(e, t);
315
335
  let a = this.sql;
316
336
  if (this.supportsInsertReturning) {
317
- let o = a`INSERT INTO ${a(this.nsT(e))} ${a.insert(S(t, e))} RETURNING *`, s = r ? n.provideService(o, d, r) : o, c = (await this.runtime.runPromise(s))[0];
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];
318
338
  if (!c) throw Error(`MysqlStore.insert: no row returned for table '${e}'`);
319
339
  return await this.routeEvent({
320
340
  table: e,
@@ -323,7 +343,7 @@ var M = (n) => e.layerConfig({
323
343
  new: c
324
344
  }, i, r), c;
325
345
  }
326
- let o = S(t, e), s = t.id;
346
+ let o = T(t, e), s = t.id;
327
347
  if (s === void 0) {
328
348
  let t = await this.insertRecoverAutoId(e, o, r);
329
349
  return await this.routeEvent({
@@ -357,7 +377,7 @@ var M = (n) => e.layerConfig({
357
377
  if (e) return this.runtime.runPromise(t(e));
358
378
  this.inflightTxns++;
359
379
  try {
360
- let e = n.suspend(() => this.sql.withTransaction(n.flatMap(n.serviceOption(d), (e) => a.isNone(e) ? n.fail(/* @__PURE__ */ Error("MysqlStore.insert: TransactionConnection missing.")) : t(e.value)))), i = s.exponential("10 millis").pipe(s.compose(s.recurs(3)), s.whileInput(W)), o = e.pipe(n.retry(i), n.withSpan("store.insert", { attributes: {
380
+ let e = n.suspend(() => this.sql.withTransaction(n.flatMap(n.serviceOption(d), (e) => a.isNone(e) ? n.fail(/* @__PURE__ */ Error("MysqlStore.insert: TransactionConnection missing.")) : t(e.value)))), i = s.exponential("10 millis").pipe(s.compose(s.recurs(3)), s.whileInput(J)), o = e.pipe(n.retry(i), n.withSpan("store.insert", { attributes: {
361
381
  "db.system": this.variant,
362
382
  "db.operation": r
363
383
  } }));
@@ -367,8 +387,8 @@ var M = (n) => e.layerConfig({
367
387
  }
368
388
  }
369
389
  async executeInsertMany(e, t, r, i) {
370
- if (t = j(e, t), t.length === 0) return [];
371
- let a = this.sql, o = t.map((t) => S(t, e));
390
+ if (t = N(e, t), t.length === 0) return [];
391
+ let a = this.sql, o = t.map((t) => T(t, e));
372
392
  if (this.supportsInsertReturning) {
373
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);
374
394
  for (let t of c) await this.routeEvent({
@@ -427,7 +447,7 @@ var M = (n) => e.layerConfig({
427
447
  async executeUpdate(e, t, r, i, a) {
428
448
  let o = this.sql;
429
449
  if (this.supportsUpdateReturning) {
430
- let s = o`UPDATE ${o(this.nsT(e))} SET ${o.update(S(r, e))} WHERE ${o("id")} = ${t} RETURNING *`, c = i ? n.provideService(s, d, i) : s, l = (await this.runtime.runPromise(c))[0];
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];
431
451
  return l ? (await this.routeEvent({
432
452
  table: e,
433
453
  op: "update",
@@ -435,7 +455,7 @@ var M = (n) => e.layerConfig({
435
455
  new: l
436
456
  }, a, i), l) : null;
437
457
  }
438
- let s = o`UPDATE ${o(this.nsT(e))} SET ${o.update(S(r, e))} WHERE ${o("id")} = ${t}`, c = i ? n.provideService(s, d, i) : s, l = await this.runtime.runPromise(c);
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);
439
459
  if (l && typeof l.affectedRows == "number" && l.affectedRows === 0) return null;
440
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];
441
461
  return p ? (await this.routeEvent({
@@ -467,21 +487,35 @@ var M = (n) => e.layerConfig({
467
487
  }, i, r), !0;
468
488
  }
469
489
  async appendInTxn(e, t, r) {
470
- let i = this.sql, a = i`INSERT INTO ${i(this.nsT(e))} ${i.insert(S(t, e))}`;
490
+ let i = this.sql, a = i`INSERT INTO ${i(this.nsT(e))} ${i.insert(T(t, e))}`;
471
491
  await this.runtime.runPromise(r ? n.provideService(a, d, r) : a);
472
492
  }
493
+ async maxInTxn(e, t, r, i) {
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
+ return c == null ? null : Number(c);
496
+ }
473
497
  async routeEvent(e, t, n = null) {
474
498
  if (e = {
475
499
  ...g(),
476
500
  ...e
477
- }, D(e.table) && await k((e, t) => this.appendInTxn(e, t, n), {
501
+ }, te(e.table) && await A({
502
+ append: (e, t) => this.appendInTxn(e, t, n),
503
+ maxOf: (e, t, r) => this.maxInTxn(e, t, r, n)
504
+ }, {
478
505
  table: e.table,
479
506
  op: e.op,
480
507
  next: e.new,
481
508
  prev: e.old,
482
509
  traceId: e.traceId,
483
510
  subjectId: e.subjectId
484
- }), t !== null) {
511
+ }), this.changeStrategy === "cdc") {
512
+ let t = (e.op === "delete" ? e.old : e.new)?.id;
513
+ t != null && ne(_(e.table, e.op, t), {
514
+ ...e.traceId === void 0 ? {} : { traceId: e.traceId },
515
+ ...e.subjectId === void 0 ? {} : { subjectId: e.subjectId }
516
+ });
517
+ }
518
+ if (t !== null) {
485
519
  t.push(e);
486
520
  return;
487
521
  }
@@ -507,10 +541,10 @@ var M = (n) => e.layerConfig({
507
541
  }
508
542
  return await this.executeUpdate(e, a.id, o, r, i) ?? a;
509
543
  }
510
- return this.executeInsert(e, t, r, i);
544
+ return Q()(() => this.executeInsert(e, t, r, i));
511
545
  }
512
546
  async executeMariadbUpsert(e, t, r, i, a) {
513
- let o = this.sql, s = S(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];
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];
514
548
  if (!m) throw Error(`MysqlStore.upsert: no row returned for table '${e}'`);
515
549
  let h = t.id !== void 0 && t.id === m.id ? "insert" : "update";
516
550
  return await this.routeEvent({
@@ -521,19 +555,43 @@ var M = (n) => e.layerConfig({
521
555
  }, a, i), m;
522
556
  }
523
557
  async executeInsertIgnore(e, t, r, i, a) {
524
- if (t = A(e, t), this.variant === "mariadb") {
525
- let o = this.sql, s = o`INSERT IGNORE INTO ${o(this.nsT(e))} ${o.insert(S(t, e))} RETURNING *`, c = i ? n.provideService(s, d, i) : s, l = (await this.runtime.runPromise(c))[0];
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];
526
560
  if (l) return await this.routeEvent({
527
561
  table: e,
528
562
  op: "insert",
529
563
  old: null,
530
564
  new: l
531
565
  }, a, i), l;
532
- let u = await this.findByConflict(e, t, r.conflictColumns, i);
533
- if (!u) throw Error(`MysqlStore.insertIgnore: the insert was skipped as a conflict, but no existing row matches conflictColumns [${r.conflictColumns.join(", ")}] on '${e}'. A DIFFERENT unique constraint fired a second unique index, or the primary key when you named something else. insertIgnore models ONE conflict target: name the columns of the constraint that actually collides, or handle the unique violation yourself.`);
534
- return u;
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.");
572
+ }
573
+ return await this.findByConflict(e, t, r.conflictColumns, i) || Q()(() => this.executeInsert(e, t, i, a));
574
+ }
575
+ async findUndecodableCdcTables(e) {
576
+ if (this.variant !== "mariadb") return [];
577
+ try {
578
+ let t = (await this.runtime.runPromise(this.sql.unsafe(z))).map((e) => String(e.tableName ?? e.TABLE_NAME ?? "")).filter((e) => e !== "");
579
+ return e === void 0 ? t : t.filter((t) => e.includes(t));
580
+ } catch (e) {
581
+ return this.log.debug(`cdc: could not probe for undecodable tables — ${e?.message ?? String(e)}`), [];
582
+ }
583
+ }
584
+ async readWarnings(e) {
585
+ if (e === null) return [];
586
+ try {
587
+ let t = this.sql`SHOW WARNINGS`;
588
+ return (await this.runtime.runPromise(n.provideService(t, d, e))).map((e) => ({
589
+ code: Number(e.Code ?? e.code ?? 0),
590
+ message: String(e.Message ?? e.message ?? "")
591
+ }));
592
+ } catch {
593
+ return [];
535
594
  }
536
- return await this.findByConflict(e, t, r.conflictColumns, i) || this.executeInsert(e, t, i, a);
537
595
  }
538
596
  async findByConflict(e, t, r, i) {
539
597
  if (r.length === 0) return;
@@ -544,12 +602,12 @@ var M = (n) => e.layerConfig({
544
602
  return this.runWithEager(e, null);
545
603
  }
546
604
  raw(e, t) {
547
- let n = y(e, this.sql);
605
+ let n = x(e, this.sql);
548
606
  return this.runtime.runPromise(n);
549
607
  }
550
608
  async runWithEager(e, t) {
551
- if (!C(e)) return this.executeQuery(e, t);
552
- let r = this.variant === "mariadb" ? "mariadb" : "mysql", i = this.namespace === null ? _(e, this.sql, r) : null;
609
+ if (!E(e)) return this.executeQuery(e, t);
610
+ let r = this.variant === "mariadb" ? "mariadb" : "mysql", i = this.namespace === null ? y(e, this.sql, r) : null;
553
611
  if (i !== null) try {
554
612
  let e = t ? n.provideService(i.fragment, d, t) : i.fragment, r = await this.runtime.runPromise(e);
555
613
  return i.decode(r);
@@ -557,35 +615,35 @@ var M = (n) => e.layerConfig({
557
615
  if (e instanceof p) throw e;
558
616
  this.log.warn(`${this.variant} JSON-agg eager-load failed; falling back to walker`, { err: e });
559
617
  }
560
- return h(await this.executeQuery(e, t), e.eager, e.sourceTable ?? O(e.table), (e) => this.executeQuery(e, t));
618
+ return h(await this.executeQuery(e, t), e.eager, e.sourceTable ?? re(e.table), (e) => this.executeQuery(e, t));
561
619
  }
562
620
  getInternalRunWithEager() {
563
621
  return this.runWithEager.bind(this);
564
622
  }
565
623
  insert(e, t) {
566
- return this.executeInsert(e, t, null, null);
624
+ return Q()(() => this.executeInsert(e, t, null, null));
567
625
  }
568
626
  insertMany(e, t) {
569
- return this.executeInsertMany(e, t, null, null);
627
+ return Q()(() => this.executeInsertMany(e, t, null, null));
570
628
  }
571
629
  patchJson(e, t, n, r) {
572
- return this.executePatchJson(e, t, n, r, null, null);
630
+ return Q()(() => this.executePatchJson(e, t, n, r, null, null));
573
631
  }
574
632
  upsert(e, t, n) {
575
- return this.executeUpsert(e, t, n, null, null);
633
+ return Q()(() => this.executeUpsert(e, t, n, null, null));
576
634
  }
577
635
  insertIgnore(e, t, n) {
578
- return this.executeInsertIgnore(e, t, n, null, null);
636
+ return Q()(() => this.executeInsertIgnore(e, t, n, null, null));
579
637
  }
580
638
  update(e, t, n) {
581
- return this.executeUpdate(e, t, n, null, null);
639
+ return Q()(() => this.executeUpdate(e, t, n, null, null));
582
640
  }
583
641
  delete(e, t) {
584
- return this.executeDelete(e, t, null, null);
642
+ return Q()(() => this.executeDelete(e, t, null, null));
585
643
  }
586
644
  async updateMany(e, t, r) {
587
645
  if (this.supportsUpdateReturning) {
588
- let n = this.sql, i = v(r.where, n, this.namespace), a = n`UPDATE ${n(this.nsT(e))} SET ${n.update(S(t, e))} WHERE ${i} RETURNING *`, o = await this.runtime.runPromise(a);
646
+ let n = this.sql, i = b(r.where, n, this.namespace), a = n`UPDATE ${n(this.nsT(e))} SET ${n.update(T(t, e))} WHERE ${i} RETURNING *`, o = await this.runtime.runPromise(a);
589
647
  for (let t of o) await this.routeEvent({
590
648
  table: e,
591
649
  op: "update",
@@ -594,23 +652,23 @@ var M = (n) => e.layerConfig({
594
652
  }, null, null);
595
653
  return o.length;
596
654
  }
597
- let i = this.sql, o = v(r.where, i, this.namespace);
655
+ let i = this.sql, o = b(r.where, i, this.namespace);
598
656
  this.inflightTxns++;
599
657
  try {
600
658
  let r = n.suspend(() => this.sql.withTransaction(n.flatMap(n.serviceOption(d), (r) => {
601
659
  if (a.isNone(r)) return n.fail(/* @__PURE__ */ Error("MysqlStore.updateMany: TransactionConnection missing."));
602
- let s = r.value, c = i`SELECT id FROM ${i(this.nsT(e))} WHERE ${o} FOR UPDATE`, l = i`UPDATE ${i(this.nsT(e))} SET ${i.update(S(t, e))} WHERE ${o}`;
660
+ let s = r.value, c = i`SELECT id FROM ${i(this.nsT(e))} WHERE ${o} FOR UPDATE`, l = i`UPDATE ${i(this.nsT(e))} SET ${i.update(T(t, e))} WHERE ${o}`;
603
661
  return n.flatMap(n.provideService(c, d, s), (t) => {
604
662
  if (t.length === 0) return n.succeed([]);
605
663
  let r = t.map((e) => e.id), a = i`SELECT * FROM ${i(this.nsT(e))} WHERE ${i("id")} IN ${i.in(r)}`;
606
664
  return n.flatMap(n.provideService(l, d, s), () => n.provideService(a, d, s));
607
665
  });
608
- }))), c = s.exponential("10 millis").pipe(s.compose(s.recurs(3)), s.whileInput(W)), l = r.pipe(n.retry(c), n.withSpan("store.updateMany", { attributes: {
666
+ }))), c = s.exponential("10 millis").pipe(s.compose(s.recurs(3)), s.whileInput(J)), l = r.pipe(n.retry(c), n.withSpan("store.updateMany", { attributes: {
609
667
  "db.system": this.variant,
610
668
  "db.operation": "update"
611
669
  } })), u = await this.runtime.runPromise(l);
612
670
  if (u.length === 0) return 0;
613
- let f = x(u, e, this.variant);
671
+ let f = w(u, e, this.variant);
614
672
  for (let t of f) await this.routeEvent({
615
673
  table: e,
616
674
  op: "update",
@@ -623,9 +681,9 @@ var M = (n) => e.layerConfig({
623
681
  }
624
682
  }
625
683
  async deleteMany(e, t) {
626
- let r = this.sql, i = v(t.where, r, this.namespace);
684
+ let r = this.sql, i = b(t.where, r, this.namespace);
627
685
  if (this.supportsDeleteReturning) {
628
- let t = r`DELETE FROM ${r(this.nsT(e))} WHERE ${i} RETURNING *`, n = x(await this.runtime.runPromise(t), e, this.variant);
686
+ let t = r`DELETE FROM ${r(this.nsT(e))} WHERE ${i} RETURNING *`, n = w(await this.runtime.runPromise(t), e, this.variant);
629
687
  for (let t of n) await this.routeEvent({
630
688
  table: e,
631
689
  op: "delete",
@@ -640,12 +698,12 @@ var M = (n) => e.layerConfig({
640
698
  if (a.isNone(t)) return n.fail(/* @__PURE__ */ Error("MysqlStore.deleteMany: TransactionConnection missing."));
641
699
  let o = t.value, s = r`SELECT * FROM ${r(this.nsT(e))} WHERE ${i} FOR UPDATE`, c = r`DELETE FROM ${r(this.nsT(e))} WHERE ${i}`;
642
700
  return n.flatMap(n.provideService(s, d, o), (e) => e.length === 0 ? n.succeed(e) : n.as(n.provideService(c, d, o), e));
643
- }))), o = s.exponential("10 millis").pipe(s.compose(s.recurs(3)), s.whileInput(W)), c = t.pipe(n.retry(o), n.withSpan("store.deleteMany", { attributes: {
701
+ }))), o = s.exponential("10 millis").pipe(s.compose(s.recurs(3)), s.whileInput(J)), c = t.pipe(n.retry(o), n.withSpan("store.deleteMany", { attributes: {
644
702
  "db.system": this.variant,
645
703
  "db.operation": "delete"
646
704
  } })), l = await this.runtime.runPromise(c);
647
705
  if (l.length === 0) return 0;
648
- let u = x(l, e, this.variant);
706
+ let u = w(l, e, this.variant);
649
707
  for (let t of u) await this.routeEvent({
650
708
  table: e,
651
709
  op: "delete",
@@ -658,17 +716,19 @@ var M = (n) => e.layerConfig({
658
716
  }
659
717
  }
660
718
  emitChange(e) {
661
- w(e.table) && (this.changeStrategy === "cdc" && !this.cdcGate.admit(e) || this.emitter.emit("change", e));
719
+ D(e.table) && (this.changeStrategy === "cdc" && !this.cdcGate.admit(e) || this.emitter.emit("change", e));
662
720
  }
663
721
  async startCdcConsumer(e) {
664
722
  if (this.cdcHandle) return;
665
723
  await this.assertBinlogConfig(), this.cdcReplicaId = e.replicaId;
666
- let t = await this.readCdcOffset(e.replicaId) ?? await this.resolveBinlogEnd();
667
- this.cdcHandle = await V({
724
+ 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));
726
+ this.cdcHandle = await q({
668
727
  connection: e.connection,
669
728
  serverId: e.serverId,
670
729
  variant: this.variant,
671
730
  ...e.includeTables ? { includeTables: e.includeTables } : {},
731
+ ...n.length > 0 ? { undecodableTables: n } : {},
672
732
  ...e.watchdogIntervalMs === void 0 ? {} : { watchdogIntervalMs: e.watchdogIntervalMs },
673
733
  ...e.stallThresholdMs === void 0 ? {} : { stallThresholdMs: e.stallThresholdMs },
674
734
  ...e.keepAliveInitialDelayMs === void 0 ? {} : { keepAliveInitialDelayMs: e.keepAliveInitialDelayMs },
@@ -780,26 +840,26 @@ var M = (n) => e.layerConfig({
780
840
  }
781
841
  async transactional(e) {
782
842
  this.inflightTxns++;
783
- let t = 0, r = n.suspend(() => {
784
- let r = ++t;
785
- return this.sql.withTransaction(n.flatMap(n.serviceOption(d), (t) => {
786
- if (a.isNone(t)) return n.fail(/* @__PURE__ */ Error("MysqlStore.transactional: TransactionConnection missing."));
787
- let i = new Q(this, t.value);
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);
788
848
  return n.tryPromise({
789
- try: () => e(i).then((e) => ({
849
+ try: () => t(() => e(o)).then((e) => ({
790
850
  result: e,
791
- view: i,
792
- attempt: r
851
+ view: o,
852
+ attempt: i
793
853
  })),
794
854
  catch: (e) => e
795
855
  });
796
856
  }));
797
- }), i = s.exponential("10 millis").pipe(s.compose(s.recurs(3)), s.whileInput(W)), o = r.pipe(n.retry(i), n.withSpan("store.transactional", { attributes: {
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: {
798
858
  "db.system": this.variant,
799
859
  "db.operation": "transaction"
800
860
  } }));
801
861
  try {
802
- let e = await this.runtime.runPromise(o);
862
+ let e = j(await this.runtime.runPromiseExit(c));
803
863
  return e.view.commitEvents(), e.result;
804
864
  } finally {
805
865
  this.inflightTxns--;
@@ -814,7 +874,10 @@ var M = (n) => e.layerConfig({
814
874
  return this.changeStrategy === "cdc" ? "fleet" : "local";
815
875
  }
816
876
  injectExternalChange(e) {
817
- this.changeStrategy === "cdc" && !this.cdcGate.admit(e) || w(e.table) && this.emitter.emit("change", {
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,
818
881
  ...e,
819
882
  origin: "injected"
820
883
  });
@@ -840,7 +903,7 @@ var M = (n) => e.layerConfig({
840
903
  async ping() {
841
904
  await this.runtime.runPromise(this.sql`SELECT 1`);
842
905
  }
843
- }, Q = class {
906
+ }, pe = class {
844
907
  parent;
845
908
  txn;
846
909
  events = [];
@@ -912,7 +975,7 @@ var M = (n) => e.layerConfig({
912
975
  this.events.length = 0;
913
976
  }
914
977
  }
915
- }, $ = (e) => e.__mysqlReplicationFriend ?? null, ee = () => ({
978
+ }, $ = (e) => e.__mysqlReplicationFriend ?? null, me = () => ({
916
979
  async capturePrimaryPosition(e) {
917
980
  let t = $(e);
918
981
  if (t === null) throw Error("mysqlReplicationAdapter: primary is not a MysqlStore (missing __mysqlReplicationFriend).");
@@ -932,24 +995,24 @@ var M = (n) => e.layerConfig({
932
995
  compare(e, t) {
933
996
  return "behind";
934
997
  }
935
- }), te = {
998
+ }), he = {
936
999
  id: "mysql",
937
- makeSqlLayer: (e) => P(e),
938
- makeStore: (e) => X({
1000
+ makeSqlLayer: (e) => I(e),
1001
+ makeStore: (e) => Z({
939
1002
  ...e,
940
1003
  variant: "mysql"
941
1004
  }),
942
1005
  compileContains: (e, t, n) => n`${e} LIKE ${`%${t.replace(/[\\%_]/g, (e) => `\\${e}`)}%`} ESCAPE '\\'`,
943
- retryFilter: G
944
- }, ne = {
1006
+ retryFilter: Y
1007
+ }, ge = {
945
1008
  id: "mariadb",
946
- makeSqlLayer: (e) => P(e),
947
- makeStore: (e) => X({
1009
+ makeSqlLayer: (e) => I(e),
1010
+ makeStore: (e) => Z({
948
1011
  ...e,
949
1012
  variant: "mariadb"
950
1013
  }),
951
1014
  compileContains: (e, t, n) => n`${e} LIKE ${`%${t.replace(/[\\%_]/g, (e) => `\\${e}`)}%`} ESCAPE '\\'`,
952
- retryFilter: G
1015
+ retryFilter: Y
953
1016
  };
954
1017
  //#endregion
955
- export { f as CDC_OFFSETS_TABLE, e as MysqlClient, m as _voltroCdcOffsetsTable, N as connectionFromConfig, M as makeMysqlSqlLayer, P as makeMysqlSqlLayerFromConfig, X as makeMysqlStore, ne as mariadbDialect, te as mysqlDialect, ee as mysqlReplicationAdapter, G as mysqlRetryFilter, V as startBinlogCdc };
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 };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@voltro/sql-mysql",
3
- "version": "0.19.0",
3
+ "version": "0.20.1",
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",
@@ -34,8 +34,8 @@
34
34
  "dependencies": {
35
35
  "@effect/sql": "^0.51.1",
36
36
  "@effect/sql-mysql2": "^0.52.0",
37
- "@voltro/database": "0.19.0",
38
- "@voltro/logger": "0.19.0"
37
+ "@voltro/database": "0.20.1",
38
+ "@voltro/logger": "0.20.1"
39
39
  },
40
40
  "optionalDependencies": {
41
41
  "@vlasky/zongji": "^0.9.0"