@voltro/database 0.49.0 → 0.50.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -39,6 +39,64 @@ _Changes staged for the next release accumulate here (rolled up from
39
39
 
40
40
  ---
41
41
 
42
+ ## [0.50.0] — 2026-08-23
43
+
44
+ ### ⚠ BREAKING
45
+
46
+ - **@voltro/cli** — The read-only run-history endpoint moved:
47
+
48
+ GET /_voltro/admin/imports → GET /_voltro/admin/transfers
49
+
50
+ Same secret, same query parameters, same response shape. Only the path changed.
51
+
52
+ It answers for four directions now — import, export, native backup, native restore — and it named one of them. The subcommand and the table were renamed for exactly that reason in 0.49.0 and this path was left behind, which is worse than renaming none of them: a reader who follows a rename tries the matching path, gets a 404, and concludes the instance is too old.
53
+
54
+ `voltro data transfers --target api` uses the new path for you. What needs a hand is anything calling it directly — a monitoring check, an uptime probe, a curl in a runbook, a dashboard datasource. The codemod is `manual` and declares `reach: 'beyond-source'`, so it prints whether or not the path is found in your repository.
55
+ - **@voltro/database, @voltro/voltro** — `InterruptedReplace.tables` is `number | null`, and the interface gains `kind?: 'replace' | 'restore'`.
56
+
57
+ `_voltro_replace_in_progress` records two destructive operations now — an import's `--mode replace` and a native `voltro data restore`. A restore replaces the whole database from an artefact, so "how many tables was this going to empty and refill" has no answer: not known up front, not meaningful after.
58
+
59
+ Writing `0` would have preserved the type and been worse. That number is rendered into the boot refusal, which is read under pressure, and it would have said `began emptying 0 table(s)` — a measurement that was never taken. The framework's own text says "this database" instead; `Number(null)` is `0`, so coercing it reintroduces exactly the sentence being avoided.
60
+
61
+ `DataTransferRun.direction` also widens to name `'backup'` and `'restore'` explicitly. That one is documentary — the union already ended in `| string`, so no assignability changes.
62
+
63
+ **`voltro update` carries you across this** — codemod `0.50.0/03_interrupted-replace-tables-nullable`.
64
+
65
+ ### Added
66
+
67
+ - **@voltro/cli, @voltro/data-transfer, @voltro/database** — `voltro data backup --assets` and `voltro data restore --assets` now move the stored blobs alongside the vendor dump, through the same content-addressed phase the logical `export` / `import` path uses — streamed, deduped by sha256, verified on the way back, resumable per key.
68
+
69
+ `--assets` was accepted by `backup` and silently ignored, with the only signal a field in the closing JSON reading `NOT included`; `restore` did not accept it at all. A rows-only backup restores a database whose rows reference objects nothing puts back, and the reference and the object are checked at different times, so that state is discovered by a user rather than by the restore.
70
+
71
+ Three refusals, each for a belief that is otherwise acted on silently: `backup --assets` with no storage provider is refused rather than swallowed; `restore --assets` on a rows-only artefact is refused; a restore WITHOUT `--assets` over an artefact that has them warns and proceeds, because restoring rows without blobs is legitimate and refusing it would push people at `--force`.
72
+
73
+ The dump itself still has no resume — a vendor artefact is one opaque file with no offset to restart from, and the logical path is what exists for that.
74
+ - **@voltro/cli, @voltro/database, @voltro/data-transfer** — A native `backup` / `restore` now writes to the same `_voltro_data_transfers` record `import` and `export` use, so `voltro data transfers` answers "did last night's backup finish" from the instance that ran it. A native run reports blobs rather than rows — a vendor tool reports no row count we can trust, and printing `0 row(s)` over a dump that worked would be a wrong measurement.
75
+
76
+ `restore` also writes the `_voltro_replace_in_progress` marker before its first destructive statement and clears it after the last write, blobs included, so a killed restore refuses the next boot instead of serving a half-loaded database. `--allow-live` guards from the wrong side — it asks you not to — and this guards from the right one. The marker carries a `kind` so the refusal can say something different for a `replace` (re-import the capture) and a `restore` (finish the restore), and `tables` is nullable because a native restore has no table count to claim.
77
+
78
+ The sqlite / turso restore is atomic now (temp file + rename): its marker lives in the very file being replaced, so a plain in-place copy would leave a truncated database with nothing left to catch it.
79
+
80
+ A target with no `_voltro_data_transfers` table still gets its backup; the closing line says it was not recorded rather than implying it was.
81
+ - **@voltro/cli** — A re-issued migration note for the `voltro data imports` → `voltro data transfers` rename that shipped in 0.49.0.
82
+
83
+ Codemods are selected by `from < version <= to`, so the 0.49.0 note fires once, on the jump that crosses 0.49.0, and cannot be corrected for anyone already past it. Its gate searched `.ts` / `.tsx` while the command it is about lives in shell scripts, CI job definitions and runbooks — so a project whose only occurrence sat in `.gitlab-ci.yml` crossed 0.49.0 and was told there was nothing to apply.
84
+
85
+ The re-issue is filed under 0.50.0, prints unconditionally, and says what to grep for. Redundant for anyone already fixed; the alternative is firing for nobody.
86
+
87
+ ### Fixed
88
+
89
+ - **@voltro/cli** — A `manual` codemod's `appliesTo` can now search every text file the project owns — `.sh`, `.yml`, `.json`, `.md`, `Makefile`, the `.js` scripts — through a new `ctx.text` on the predicate context, and a codemod may declare `reach: 'beyond-source'` to print its note even when nothing matched.
90
+
91
+ Both halves close the same gap, and it was in the codemod most in need of a gate. A manual codemod exists BECAUSE its subject could not be transformed, which usually means it is not source at all — and `appliesTo` was reading the ts-morph project, which holds `.ts` / `.tsx` and nothing else. Measured with one identical CLI invocation in four files: the `.ts` one printed the note, the `.sh`, `.yml` and `.md` ones printed nothing, and the run reported `codemods: nothing to apply for this jump`. That is an acquittal from a check that never looked at the file, and an acquittal gets acted on.
92
+
93
+ `reach: 'beyond-source'` covers what no scan of one repository can reach — an inline script in a CI runner's own UI, a CronJob spec in another repo, a wiki runbook. The note prints either way; only its framing changes, and an uncertain one says plainly that we could not look there.
94
+ - **@voltro/data-transfer** — When `mariadb-dump` is absent and the mysql-family fallback runs Oracle's `mysqldump` against a MariaDB server, the failure now names the way out.
95
+
96
+ The stderr it prints is the child's own words and is the right first thing to show — and on its own it is a dead end: `Unknown table 'COLUMN_STATISTICS' in information_schema (1109)` names a table nobody asked for, in a schema nobody wrote, about a feature nobody enabled. Everything needed to act on it was known where the fallback was DECIDED: which binary we wanted, which one we took, and why the difference matters. That travels with the step now and prints under the stderr, including the counter-move a reader reaches for on their own (`--column-statistics=0` does not exist on `mariadb-dump`, so it fixes the wrong client and breaks the right one).
97
+
98
+ ---
99
+
42
100
  ## [0.49.0] — 2026-08-23
43
101
 
44
102
  ### ⚠ BREAKING
package/dist/index.d.ts CHANGED
@@ -2455,7 +2455,7 @@ export declare interface DataStore {
2455
2455
  /** One transfer, as the inspector reads it back. */
2456
2456
  export declare interface DataTransferRun {
2457
2457
  readonly id: string;
2458
- readonly direction: 'import' | 'export' | string;
2458
+ readonly direction: 'import' | 'export' | 'backup' | 'restore' | string;
2459
2459
  readonly startedAt: Date;
2460
2460
  readonly finishedAt: Date | null;
2461
2461
  readonly mode: string;
@@ -3793,7 +3793,11 @@ export declare const integer: () => ColumnBuilder<number, "integer", boolean>;
3793
3793
  export declare interface InterruptedReplace {
3794
3794
  readonly id: string;
3795
3795
  readonly startedAt: Date;
3796
- readonly tables: number;
3796
+ /** `replace` (an import emptied the target) | `restore` (a native artefact was
3797
+ * being loaded over it). Anything else is read as `replace`. */
3798
+ readonly kind?: string;
3799
+ /** `null` for a `restore` — see the column. */
3800
+ readonly tables: number | null;
3797
3801
  readonly capture: string | null;
3798
3802
  readonly via: string;
3799
3803
  }
package/dist/index.js CHANGED
@@ -2358,14 +2358,17 @@ $voltro_branch$`;
2358
2358
  ], tc = "_voltro_replace_in_progress", nc = T(tc, {
2359
2359
  id: b({ prefix: "repl" }),
2360
2360
  startedAt: S().default("now"),
2361
- tables: C(),
2361
+ kind: C().default("replace"),
2362
+ tables: C().nullable(),
2362
2363
  capture: C().nullable(),
2363
2364
  via: C()
2364
2365
  }), rc = (e) => {
2365
- if (e.length !== 0) return `refusing to start: a destructive import did not finish.
2366
+ if (e.length !== 0) return `refusing to start: a destructive data operation did not finish.
2366
2367
  ${e.map((e) => {
2367
2368
  let t = Number.isFinite(e.startedAt.getTime()) ? `${Math.max(0, Math.round((Date.now() - e.startedAt.getTime()) / 6e4))} minute(s) ago` : "at an unknown time";
2368
- return ` - a \`replace\` over ${e.via} began emptying ${e.tables} table(s) ${t} and never reported finishing.\n` + (e.capture === null ? " NO capture was taken for that run, so the previous rows are not recoverable from here.\n Restore from your own backup." : ` The target's previous rows were captured first:\n ${e.capture}\n Restore them with: voltro data import ${e.capture} --mode replace`);
2369
+ if (e.kind === "restore") return ` - a native \`restore\` over ${e.via} began loading an artefact over this database ${t}\n and never reported finishing.\n` + (e.capture === null ? " The artefact it was loading was not recorded. Re-run the restore from your backup\n directory, or restore from another backup." : ` The artefact it was loading:\n ${e.capture}\n Re-run it to completion with: voltro data restore ${e.capture} --allow-live`);
2370
+ let n = e.tables === null ? "this database" : `${e.tables} table(s)`;
2371
+ return ` - a \`replace\` over ${e.via} began emptying ${n} ${t} and never reported finishing.\n` + (e.capture === null ? " NO capture was taken for that run, so the previous rows are not recoverable from here.\n Restore from your own backup." : ` The target's previous rows were captured first:\n ${e.capture}\n Restore them with: voltro data import ${e.capture} --mode replace`);
2369
2372
  }).join("\n")}\n
2370
2373
  A half-replaced database is indistinguishable from an empty one from the inside — every table
2371
2374
  exists, every constraint holds, and every query returns nothing without erroring. That is why
@@ -2539,7 +2542,7 @@ ${e.map((e) => {
2539
2542
  failure: C().nullable(),
2540
2543
  staged: ue().nullable()
2541
2544
  }).renamedFrom("_voltro_data_imports").nonReactive(), zc = (e) => {
2542
- let t = Number.isFinite(e.startedAt.getTime()) ? e.startedAt.toISOString() : "an unknown time", n = e.direction === "export" ? `export ${e.mode}` : e.mode, r = e.failure === null ? e.finishedAt === null ? `${n} — started and never reported finishing` : `${n} — ${e.rowsWritten} row(s) across ${e.tablesWritten} table(s)` : `FAILED ${n} — ${e.failure}`, i = e.staged === null ? "" : e.staged ? " · staged (target untouched until the swap)" : " · NOT staged (target emptied first)";
2545
+ let t = Number.isFinite(e.startedAt.getTime()) ? e.startedAt.toISOString() : "an unknown time", n = e.direction === "import" ? e.mode : `${e.direction} ${e.mode}`, r = e.failure === null ? e.finishedAt === null ? `${n} — started and never reported finishing` : e.direction === "backup" || e.direction === "restore" ? `${n} — finished${e.rowsWritten > 0 ? ` (${e.rowsWritten} blob(s))` : ""}` : `${n} — ${e.rowsWritten} row(s) across ${e.tablesWritten} table(s)` : `FAILED ${n} — ${e.failure}`, i = e.staged === null ? "" : e.staged ? " · staged (target untouched until the swap)" : " · NOT staged (target emptied first)";
2543
2546
  return `${t} · ${r} · via ${e.via}${i}` + (e.bundle === null ? "" : ` · from ${e.bundle}`) + (e.sourceFingerprint === null ? "" : ` · source schema ${e.sourceFingerprint}`);
2544
2547
  }, Bc = "postgres";
2545
2548
  //#endregion
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@voltro/database",
3
- "version": "0.49.0",
3
+ "version": "0.50.0",
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.49.0",
47
+ "@voltro/logger": "0.50.0",
48
48
  "typeid-js": "^1.2.0",
49
49
  "ulidx": "^2.4.1"
50
50
  },