@voltro/database 0.43.1 → 0.43.2

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,37 @@ _Changes staged for the next release accumulate here (rolled up from
39
39
 
40
40
  ---
41
41
 
42
+ ## [0.43.2] — 2026-08-18
43
+
44
+ ### Fixed
45
+
46
+ - **@voltro/sql-mysql, @voltro/database, @voltro/cli** — The mysql-family binlog reader no longer keeps a table excluded after the very migration that fixed it.
47
+
48
+ A UNIQUE on an unbounded text column is a MariaDB hash long-unique, whose hidden `DB_ROW_HASH_n` column the reader can never account for — so the table is held out of binlog capture and the exclusion is reported. `voltro dev` builds its store BEFORE it migrates (kv, cross-replica broadcast and the analytics mirror all need one), so on a boot whose own auto-migration bounds the column, that finding was drawn from a schema that stopped existing about a second later. The exclusion then outlived its cause for the life of the process, and the message — correct when written, and typically the only error line in the boot log — went on describing the pre-migration database.
49
+
50
+ Three changes:
51
+
52
+ - `DataStore.refreshChangeCaptureExclusions()` (optional; implemented by the mysql-family store) re-runs the probe and re-points the LIVE reader, in both directions — schema work that CREATES the condition now excludes the table immediately instead of after three failed writes. `voltro dev` calls it once, after all its schema work. `voltro serve` needs no equivalent: it builds its store after every schema step and never applies DDL itself. - The definitive message now states its own durability — that it is the schema as read at reader attach, and that applying the remedy does not by itself lift the exclusion. - On a boot that will re-check, the finding is reported as a provisional note rather than as a verdict, so a boot that fixes the condition leaves no error line about it. The note escalates to the full verdict on its own if the re-check never runs. - Two reader fixes the re-check depended on: applying a new exclusion set now WAITS for a reconnect already in flight (it is what applies the set, so resolving before it landed meant the caller's next write hit the old filter), and the reconnect loop no longer keeps resuming from an offset it has just jumped away from — that turned one purged offset into a reconnect every watchdog interval, forever, delivering nothing.
53
+ - **@voltro/data-transfer, @voltro/database, @voltro/sql-mysql, @voltro/sql-postgres, @voltro/sql-sqlite, @voltro/sql-mssql, @voltro/cli** — `voltro data import --mode replace` no longer leaves a target in neither state, and now works against schemas that have foreign keys.
54
+
55
+ The delete step ran table by table and stopped at the first refusal, so a run that could not finish left dozens of tables emptied and nothing loaded — and a second attempt destroyed more than the first, because it got further before hitting the same wall. The wall itself was not exotic: MySQL, MariaDB and SQL Server check a foreign key as each ROW is deleted, so a table that references ITSELF cannot be emptied by any ordering of the tables. `createdBy → actors` on the `actors` table is exactly that shape, and it is what an audit mixin on an actor table produces.
56
+
57
+ - `DataStore.emptyTables()` (per dialect) empties the whole set as one unit, in one transaction, with referential integrity suspended for the duration — `FOREIGN_KEY_CHECKS` on the mysql family, a multi-table `TRUNCATE` on postgres, `defer_foreign_keys` on sqlite, per-table `NOCHECK`/`WITH CHECK CHECK` on mssql. All-or-nothing on every engine, including under `--atomic`, where it runs on the transaction the import already holds. - A **pre-flight refusal**: if a table OUTSIDE the bundle holds rows referencing one inside it, the import refuses before deleting anything and names the tables, the columns and the row counts. Those rows cannot be restored from the bundle, so forcing it is not an option. An EMPTY outside table blocks nothing. - Table-level failures carry the driver's own reason and code, the way row-level failures already did. `truncate <table> failed: Failed to execute statement` fits every plausible cause equally; the classification that produced `foreign key <name>: … [ER_NO_REFERENCED_ROW_2/1452]` one level down now applies one level up. The word "truncate" is gone from the message too — the step issues DELETE, and naming a statement it does not run sends whoever reads it to reproduce the wrong thing. - A typed refusal reaching the `--target api` transport keeps its text: the admin import endpoint answers `409` with the reason instead of flattening it to `import failed`, on the one transport where the operator has no other way to see it.
58
+
59
+ The bulk empty emits no change events, where the per-table loop emitted one per row. An import through `--target api` now asks every live subscription to re-read once it lands — the coarse refresh the framework already uses after a broadcast gap — so neither the missing deletes nor a table the bundle carries EMPTY leaves a subscriber holding rows that are gone. Wired where the route is mounted, which is the one place both boot paths share.
60
+
61
+ On postgres the emptying is a `DELETE` per table, not a `TRUNCATE`, and the difference is not performance: postgres refuses `TRUNCATE` on a table with ANY incoming foreign key, rows or not, while the mysql family refuses a DELETE only when rows actually reference the doomed ones. A `TRUNCATE` version made an EMPTY table outside the bundle block a replace on postgres and not on mariadb — one import, refused on one engine and accepted on the other, over a table holding nothing.
62
+
63
+ `--target api` also no longer times out on a full bundle. Both api-target calls went through `fetch`, whose undici default gives up after 300 s — a bound on the caller's database size, on a call whose response arrives only when the import does. They wait as long as the instance needs now, stream the body instead of buffering the whole bundle, and take `--timeout <seconds>` when a deadline is wanted. If one is hit, the message says the instance is probably still importing — and gives different advice for `replace` than for the idempotent modes, since re-running the first while it is mid-flight would empty the target under it.
64
+
65
+ Two more, found by measuring rather than by reading:
66
+
67
+ - **`--atomic` on postgres could not import a bundle that needed the deferred-FK repair at all.** A failed statement aborts the transaction there, and that repair depends on a row whose parent has not loaded yet failing, being held, and being retried — so the first such row poisoned every write after it. Every tolerated write now runs inside a savepoint. Per-row savepoints measured 2.40x the time of none on 5 000 rows, so they are amortised: one savepoint per batch of 200, and a batch that fails rolls back whole and replays row by row. The mysql family and sqlite leave a transaction usable after a failed statement and pay nothing for any of this. - **The replace pre-flight asked the caller's snapshot.** Over `--target api` that is the app's DECLARED schema, which cannot show a table the app stopped declaring but the database still has — and rows in a table nobody declares are exactly the rows nobody is watching. `DataStore.incomingForeignKeys()` reads the live catalog per dialect; the snapshot remains the fallback for stores without one.
68
+
69
+ A bundle bigger than one chunk is now uploaded as a series of short requests, so a proxy body cap or an ingress read timeout has nothing large to choke on, and the switch is automatic — the packer's stream is buffered one chunk ahead, so a small bundle is sent exactly as before and nobody has to know in advance which table is the big one. The import still runs ONCE, at the end, over the whole bundle. Resume is byte-exact (`packBundle` is deterministic over a directory, which this package now asserts), guarded by a bundle key so a different bundle under the same upload id is refused rather than spliced into the partial one, and by a contiguity check so a mis-ordered append cannot produce an archive that only fails later during decode. `--chunk-size <mb>` overrides the 16 MiB default.
70
+
71
+ ---
72
+
42
73
  ## [0.43.1] — 2026-08-18
43
74
 
44
75
  ### Fixed
package/dist/index.d.ts CHANGED
@@ -602,12 +602,39 @@ export declare interface CdcConfig {
602
602
  * proactive anti-stall. Default 10s. See
603
603
  * `BinlogCdcOptions.keepAliveInitialDelayMs`. */
604
604
  readonly keepAliveInitialDelayMs?: number;
605
+ /**
606
+ * The caller promises to call `DataStore.refreshChangeCaptureExclusions()`
607
+ * once its schema work is done.
608
+ *
609
+ * Set by the boot path that migrates in the SAME process as the reader
610
+ * (`voltro dev`). It buys one thing: a table the store cannot decode on the
611
+ * pre-migration shape is reported as PROVISIONAL rather than as a verdict, so
612
+ * a boot whose own migration removes the cause does not leave a fully-argued
613
+ * error in the log about a condition that was gone before anyone read it.
614
+ * `voltro serve` builds its store after all schema work and leaves this
615
+ * unset, because there the reading at attach IS the final one.
616
+ *
617
+ * The promise is not taken on trust: the store escalates the provisional note
618
+ * to the definitive error on its own if no refresh arrives. A flag that turns
619
+ * a loud line quiet has to fail in the loud direction.
620
+ */
621
+ readonly exclusionRefreshFollows?: boolean;
605
622
  }
606
623
 
607
624
  /** Maximum `onChange` listeners a DataStore's change bus accepts before Node
608
625
  * warns. See this file's header for why it is 512 and why it is not `0`. */
609
626
  export declare const CHANGE_LISTENER_CEILING = 512;
610
627
 
628
+ /** What a `refreshChangeCaptureExclusions()` pass changed. */
629
+ export declare interface ChangeCaptureExclusionRefresh {
630
+ /** Excluded before and after — the condition is real and still there. */
631
+ readonly stillExcluded: ReadonlyArray<string>;
632
+ /** Excluded before, decodable now — the schema work removed the cause. */
633
+ readonly readmitted: ReadonlyArray<string>;
634
+ /** Decodable before, excluded now — the schema work INTRODUCED the cause. */
635
+ readonly newlyExcluded: ReadonlyArray<string>;
636
+ }
637
+
611
638
  /** A row was removed from the table. `new` is null, always. */
612
639
  export declare const changeDelete: (table: string, row: Row, meta?: ChangeEventMeta) => ChangeEvent;
613
640
 
@@ -2284,6 +2311,117 @@ export declare interface DataStore {
2284
2311
  * omits it (always reachable). Callers guard with `if (store.ping)`.
2285
2312
  */
2286
2313
  ping?(): Promise<void>;
2314
+ /**
2315
+ * Empty every one of `tables` as ONE unit, with referential integrity
2316
+ * suspended for the duration.
2317
+ *
2318
+ * Written for `voltro data import --mode replace`, whose whole job is to put
2319
+ * a target into the state a bundle describes. Two properties, and both are
2320
+ * the point:
2321
+ *
2322
+ * - **All or nothing.** A run that cannot empty every table must leave the
2323
+ * target exactly as it found it. The loop this replaces deleted table by
2324
+ * table and stopped at the first refusal, so an operator's *second*
2325
+ * attempt destroyed more than the first — the deletes that had already
2326
+ * succeeded stayed gone and nothing was reloaded.
2327
+ * - **Order-free.** A parent cannot be emptied while a child still points
2328
+ * at it, and no ordering fixes the two shapes that occur in every real
2329
+ * schema: a table that references ITSELF (the framework's own actor
2330
+ * pattern — `createdBy` on `actors` points at `actors`), and a genuine
2331
+ * cycle. On engines that check each row as it is deleted, ordering the
2332
+ * TABLES cannot help, because the conflict is between two ROWS of one of
2333
+ * them.
2334
+ *
2335
+ * What it deliberately does NOT do is reach outside `tables`. A row in some
2336
+ * OTHER table pointing into this set is not ours to delete, and suspending
2337
+ * integrity would strand it. The caller checks for that first and refuses;
2338
+ * see `runImport`'s replace pre-flight.
2339
+ *
2340
+ * Emits no change events, and the consequence is worth stating rather than
2341
+ * implying. One event per deleted row is not affordable at bundle scale — a
2342
+ * replace of a grown target fanned tens of thousands of them out to every live
2343
+ * subscriber — but silence has an edge. In practice the LOAD that follows
2344
+ * covers it: the reactive layer re-reads a query when any row of its table
2345
+ * changes, so the first inserted row refreshes subscribers and the emptied
2346
+ * rows leave their view with it. The exception is a table the bundle carries
2347
+ * EMPTY: nothing is loaded, nothing is emitted, and a subscriber keeps rows
2348
+ * that are gone until it re-subscribes.
2349
+ *
2350
+ * OPTIONAL on the interface: SQL stores implement it per dialect (the
2351
+ * suspension mechanism is engine-specific and session-scoped, so it has to
2352
+ * live next to the connection). Callers guard with `if (store.emptyTables)`
2353
+ * and fall back to deleting inside one transaction — which keeps the
2354
+ * all-or-nothing half on any store, and is all the in-memory store needs.
2355
+ */
2356
+ emptyTables?(tables: ReadonlyArray<string>): Promise<void>;
2357
+ /**
2358
+ * Foreign keys pointing INTO `tables` from tables that are NOT in the set,
2359
+ * read from the LIVE catalog.
2360
+ *
2361
+ * The question `--mode replace` has to answer before it deletes anything:
2362
+ * emptying a parent whose children live outside the bundle would strand rows
2363
+ * the bundle cannot restore. It was answered from the caller's SNAPSHOT, which
2364
+ * is right for the CLI (it introspects the target) and wrong for the admin
2365
+ * endpoint, which passes the app's DECLARED schema — that cannot show a table
2366
+ * the app has stopped declaring but the database still has, and those are
2367
+ * exactly the rows nobody is watching.
2368
+ *
2369
+ * The catalog is the only authority here, so the store asks it.
2370
+ *
2371
+ * OPTIONAL: SQL stores implement it; the in-memory store has no catalog and no
2372
+ * foreign keys, so it omits it and callers fall back to the snapshot.
2373
+ */
2374
+ incomingForeignKeys?(tables: ReadonlyArray<string>): Promise<ReadonlyArray<{
2375
+ readonly from: string;
2376
+ readonly column: string;
2377
+ readonly to: string;
2378
+ }>>;
2379
+ /**
2380
+ * Run `fn` inside a nested savepoint, so a failure inside it costs only its
2381
+ * own work instead of the whole transaction.
2382
+ *
2383
+ * Exists for one reason: on postgres (and SQL Server for some errors) a failed
2384
+ * statement ABORTS the enclosing transaction — every statement after it
2385
+ * answers `current transaction is aborted` and nothing can be salvaged. The
2386
+ * importer's deferred-foreign-key repair depends on the opposite: a row whose
2387
+ * parent has not loaded yet is EXPECTED to fail, is held, and is retried once
2388
+ * the rest has streamed. That repair is how a bundle whose row order puts a
2389
+ * child before its parent lands at all — and ids sort that way as often as
2390
+ * not. Without a savepoint the two are incompatible, so `--atomic` simply
2391
+ * could not import such a bundle on those engines.
2392
+ *
2393
+ * Only the TRANSACTIONAL views implement it, and only for the engines that
2394
+ * need it: the mysql family and sqlite leave a transaction usable after a
2395
+ * failed statement, so wrapping every row there would buy nothing and cost a
2396
+ * round trip per row. Callers guard with `if (store.savepoint)` and run `fn`
2397
+ * directly otherwise — which is also correct outside a transaction, where
2398
+ * there is nothing to protect.
2399
+ *
2400
+ * Failures propagate: the savepoint is rolled back and the error rethrown, so
2401
+ * the caller's own error handling is unchanged.
2402
+ */
2403
+ savepoint?<A>(fn: () => Promise<A>): Promise<A>;
2404
+ /**
2405
+ * Re-decide which tables this store's change-capture reader can decode, and
2406
+ * apply the answer to the LIVE reader.
2407
+ *
2408
+ * Exists because that decision is a reading of the SCHEMA, and a boot that
2409
+ * migrates in the same process moves the schema after the reader attached.
2410
+ * The reader is built before the migration runs (it has consumers — kv,
2411
+ * broadcast, the analytics mirror — that need a store first), so a table
2412
+ * excluded on the pre-migration shape stayed excluded for the life of the
2413
+ * process even when the migration was the very thing that fixed it.
2414
+ *
2415
+ * Call it once every boot, AFTER all schema work. Cheap (one catalog query)
2416
+ * and a no-op on a store whose verdict did not move — including every store
2417
+ * with nothing to exclude.
2418
+ *
2419
+ * OPTIONAL on the interface: only the mysql-family binlog reader has a
2420
+ * physical-layout exclusion at all. Postgres NOTIFY, mssql Change Tracking
2421
+ * and the in-memory store decode whatever they are given, so they omit it and
2422
+ * callers guard with `if (store.refreshChangeCaptureExclusions)`.
2423
+ */
2424
+ refreshChangeCaptureExclusions?(): Promise<ChangeCaptureExclusionRefresh>;
2287
2425
  }
2288
2426
 
2289
2427
  /** Calendar date without time. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@voltro/database",
3
- "version": "0.43.1",
3
+ "version": "0.43.2",
4
4
  "description": "Browser-safe schema DSL, query builder, and cross-dialect migration planner for Voltro — one schema, every SQL backend.",
5
5
  "keywords": [
6
6
  "voltro",
@@ -44,7 +44,7 @@
44
44
  },
45
45
  "dependencies": {
46
46
  "@effect/sql": "^0.52.0",
47
- "@voltro/logger": "0.43.1",
47
+ "@voltro/logger": "0.43.2",
48
48
  "typeid-js": "^1.2.0",
49
49
  "ulidx": "^2.4.1"
50
50
  },