@voltro/database 0.77.0 → 0.78.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,54 @@ _Changes staged for the next release accumulate here (rolled up from
39
39
 
40
40
  ---
41
41
 
42
+ ## [0.78.0] — 2026-09-22
43
+
44
+ ### ⚠ BREAKING
45
+
46
+ - **A file migration declares whether it runs before or after the schema apply** — `@voltro/database`, `@voltro/cli`
47
+
48
+ `migration({ … })` now requires `phase: 'before-schema' | 'after-schema'`. `'before-schema'` is the order every file migration has always run in — first, before the planner diffs declared against live — and is unchanged. The new `'after-schema'` phase runs LAST in the same job, once the plan has been applied and the plugin steps and change triggers have converged, which is where a body that needs the new shape belongs: a back-fill into a table the same deploy creates could previously only meet its own `skipUnless` guard, be recorded, and never run again. Both phases run under the same failure gate in `voltro migrate`, `voltro db apply`, `voltro db files` and the `voltro dev` auto-migrate; `voltro db apply --plan` refuses a pending `before-schema` migration as before and applies a pending `after-schema` one. Migration: run `voltro update` (the codemod adds `phase: 'before-schema'` to every existing migration), or add the field by hand.
49
+
50
+ ### Added
51
+
52
+ - **A migrate run names a skip the same run makes permanent** — `@voltro/database`, `@voltro/cli`
53
+
54
+ A `before-schema` file migration that stops at `ctx.skipUnless` because a schema object is absent is recorded as applied and never offered again — so when the plan computed immediately afterwards CREATES that object, the skip could never have been anything else. `voltro db apply`, `voltro migrate` and the `voltro dev` auto-migrate now say so, naming the objects the body found missing that the plan goes on to create and the phase that would have run it. The objects are measured from the `ctx.schema` existence questions the body actually asked, not read out of the skip reason, so a body that skips for a reason unrelated to the schema is not reported. Nothing is refused: by the time both facts exist the migration is already recorded.
55
+
56
+ ### Fixed
57
+
58
+ - **`latest` waits for the whole release, the dashboard included** — `@voltro/cli`
59
+
60
+ Every `@voltro/*` package of a release carries the same version, and `@voltro/dashboard` is published from its own repository once the rest are on the registry — so for the length of that run the release existed for every package but one, while `latest` already named it. Measured across the three releases before this change: the dashboard landed 8m43s, 9m00s and 8m17s after `@voltro/cli`. An installation started in that window against the newest published version bumps every pin and then fails with `ERR_PNPM_NO_MATCHING_VERSION` on the one package that does not have it yet. The release now holds the `latest` tag until every package of the lockstep set is downloadable — the dashboard is waited for exactly like a tarball that has not reached the CDN — so the newest published version is one that can be installed whole. Until it is complete, `latest` stays on the previous release rather than naming a partial one. An update that names an explicit version can still start mid-release, because a version resolves the moment its own package uploads; `voltro update --wait` remains the way to cover that.
61
+ - **A migration body guarding on `schema.tableExists` runs in a database-free test** — `@voltro/database`, `@voltro/runtime`
62
+
63
+ A file migration whose first statement is the guard the docs recommend — `ctx.skipUnless(ctx.schema.tableExists('…'), '…')` — could not be driven over the in-memory store: `skipUnless` worked without a SQL client, but its predicate was a `ctx.schema` call and every one of those refused, so the only way to test such a body was to split the guard out of `up`. A migration context built without a client now answers the two read-only probes the store can answer honestly about itself — `schema.tableExists` from the store's own table map, `schema.columnExists` from the keys the held rows carry — through the new optional `DataStore.storedTables()` capability, which `InMemoryDataStore` and its transaction view implement. A table that is present and empty exists; `columnExists` on an empty table refuses rather than answering `false`, because it holds no evidence either way. `schema.snapshot`, `schema.indexExists`, `schema.foreignKeyExists` and every schema change still refuse — a store has no catalog, and a fabricated answer is one a migration would branch on — and each refusal now names what to do instead.
64
+
65
+ Two more fixes on the same surface, both found by running a PROMISE-form body through the guard rather than only an Effect one. `skipUnless` reached the runner wrapped in a `FiberFailure`, so the migration FAILED instead of being recorded as skipped. And a `ctx.schema` call from a Promise body inside a transactional migration asked the pool for a connection instead of using the transaction's, which on a single-connection dialect is the connection the transaction is holding — the migration hung forever. Every `ctx.schema` member now executes on the store's own connection, so both body shapes reach the same outcome on every dialect.
66
+ - **A storage change no longer reads as `text → text` in a migration plan** — `@voltro/cli`, `@voltro/database`
67
+
68
+ A change to a column's storage PARAMETERS — a text length, a decimal precision — keeps the column's type, so the planned operation carries the same type on both sides. Two readers rendered that as the type change it is not: the plan row showed `tier text → text`, which is indistinguishable from a no-op, and the rolling-deploy advisory said "the new text type can reject their writes", which names nothing to act on. Both now say the storage is what moves, and the advisory's remedy is the one that applies — widen now, narrow in a later deploy once every writer produces values that fit. The numbers were never lost: the operation's reason carries them (`text length 65535 → 16`, with the count query for a narrowing) and still does. Reachable for an ordinary edit: adding `.oneOf([...])` to an existing unbounded `text()` column renders a bounded column on every dialect that can express a width, measured on MySQL and MariaDB.
69
+ - **TypeScript 7 removed thirteen tsconfig options, not the three we named** — `@voltro/cli`
70
+
71
+ The TypeScript 7 upgrade note named three removed tsconfig options. A workspace that followed it hit a fourth — `"esModuleInterop": false`, in twelve of its configs, where the ones that set `true` typechecked cleanly so only part of the workspace failed. Adding a fourth bullet would have left nine more waiting, so the set was measured instead: every boolean option TypeScript 6.0.3 accepts, written into a tsconfig in both spellings, plus every value of every enum option, typechecked against TypeScript 7.0.2. Thirteen are refused, in two shapes — an option whose value alone is gone (`esModuleInterop=false`, `allowSyntheticDefaultImports=false`, `alwaysStrict=false`, `target=ES5`, `module=AMD`/`System`/`UMD`, `moduleResolution=node10`/`Classic`) and one removed outright (`downlevelIteration`, `outFile`, `baseUrl`). A new note carries all of them with their replacements and three `git grep` lines that find every occurrence at once, and it fires only for a config that actually carries one — the accepted spelling stays quiet. It also says the thing the codes cannot: TS5108 covers several unrelated removals, so what identifies a case is the option and value in the message, not the code. The framework's own templates carry none of the thirteen; checked, not assumed.
72
+
73
+ The gate matches a PATTERN rather than a literal, which is new for a codemod that reads config: JSON permits whitespace wherever a token boundary allows, so `"esModuleInterop" : false` and a literal list of spellings disagree — and a spelling the list forgets is silence from the gate, which reads as nothing to do.
74
+ - **Workflow submission rows carry one identity shape again** — `@voltro/cli`, `@voltro/workflow`
75
+
76
+ Starts of a workflow with a declared concurrency limit wrote their internal submission row with an identity prefix one character shorter than the one every other start path writes, so `_voltro_workflow_submissions` ended up holding an identity shape nothing declared. That is not cosmetic: pool release recovery pages this table by id under the database's own collation, and the two shapes sort in one order there and the opposite order in the serving process — measured on MariaDB with `utf8mb3_general_ci`, where a census over a mixed page stopped at the row that crosses the shapes and left every pooled permit held. All start paths now mint a new identity through one function. The one other shape this table holds is deliberate — a retry request reuses its own content-addressed identity so a retried reservation finds its own row — and recovery tolerates it. Rows written by earlier versions keep their identity and are read normally; nothing needs to be migrated.
77
+
78
+ ---
79
+
80
+ ## [0.77.1] — 2026-09-22
81
+
82
+ ### Fixed
83
+
84
+ - **A built page no longer claims it was generated by `voltro dev`** — `@voltro/cli`
85
+
86
+ `voltro dev`, `voltro build` and `voltro start` share one shell generator, and it headed every generated file with ``GENERATED BY `voltro dev` — do not edit; will be overwritten on next boot.`` whoever called it. `voltro build` runs Vite with the generated shell as its template, so that line travelled into `dist/` and was readable in the page source of every deployed site — wrong about the command, and wrong about the promise, because nothing overwrites a deployed tree on a boot. The writing command is now passed in and the banner names it: a built page says `voltro build` and `will be overwritten on next build`. The parameter is required, so a future caller has to decide rather than silently inheriting the development wording.
87
+
88
+ ---
89
+
42
90
  ## [0.77.0] — 2026-09-22
43
91
 
44
92
  ### ⚠ BREAKING
package/dist/index.d.ts CHANGED
@@ -2961,6 +2961,42 @@ export declare interface DataStore {
2961
2961
  * a way to hand a store somebody else's connection.
2962
2962
  */
2963
2963
  run?<A, E>(effect: Effect.Effect<A, E, SqlClient.SqlClient>): Promise<A>;
2964
+ /**
2965
+ * What this store can say about its OWN shape without a catalog — the
2966
+ * tables it holds and the columns their rows carry.
2967
+ *
2968
+ * The inverse capability of {@link DataStore.run}. A SQL store answers a
2969
+ * schema question by asking the server's catalog, which is authoritative
2970
+ * about far more than this (types, indexes, constraints) and is therefore
2971
+ * the right source whenever there is a connection. A store that IS its data
2972
+ * has no catalog — and is not thereby ignorant: it knows exactly which
2973
+ * tables it holds, because holding them is all a table is there. That much
2974
+ * is answerable honestly, and nothing beyond it is.
2975
+ *
2976
+ * So the contract is deliberately narrow, and the narrowness is the point:
2977
+ *
2978
+ * - `table` is unqualified by whatever namespace the view is bound to, so
2979
+ * a tenant view reports the tenant's tables under their bare names.
2980
+ * - `columns` is the UNION of the keys the held rows carry. An empty
2981
+ * table therefore reports NO columns, and that is not a claim that it
2982
+ * has none — a caller must read `rowCount` before treating an absent
2983
+ * column as absent.
2984
+ * - `rowCount` is how many rows are held, so "the table is empty and says
2985
+ * nothing about its columns" is distinguishable from "the table has
2986
+ * rows and none of them carries that column".
2987
+ *
2988
+ * OPTIONAL on the interface: the in-memory store implements it (it is the
2989
+ * only store whose data IS its schema); SQL stores omit it, because the
2990
+ * catalog answers the same questions better and a second, weaker source of
2991
+ * truth beside it is how the two drift. Callers guard with
2992
+ * `if (store.storedTables)`.
2993
+ *
2994
+ * Read by the file-migration context built WITHOUT a `sql` client
2995
+ * (`makeFileMigrationContext`), so a migration body that opens with
2996
+ * `ctx.skipUnless(ctx.schema.tableExists('…'), …)` — the guard the file
2997
+ * migration docs ask for — runs unchanged in a database-free test.
2998
+ */
2999
+ storedTables?(): ReadonlyArray<StoredTableShape>;
2964
3000
  }
2965
3001
 
2966
3002
  /** One transfer, as the inspector reads it back. */
@@ -3966,6 +4002,31 @@ export declare interface FileMigration {
3966
4002
  * schema change through `ctx.schema` in a migration of its own).
3967
4003
  */
3968
4004
  readonly transactional?: boolean;
4005
+ /**
4006
+ * WHERE in a migrate run this body belongs, relative to the declarative
4007
+ * schema apply. Required, and there is no default, because the answer cannot
4008
+ * be inferred from the body and getting it wrong is silent.
4009
+ *
4010
+ * - **`'before-schema'`** — runs FIRST, before the planner diffs declared
4011
+ * against live. This is the phase for everything a state diff cannot infer
4012
+ * and must not be computed against: a table split, a merge, a foreign key
4013
+ * dropped so the planner does not trip over it, a type change with a USING
4014
+ * expression. The plan is computed AFTER this body, against the shape it
4015
+ * left behind.
4016
+ * - **`'after-schema'`** — runs LAST, after the plan has been applied and the
4017
+ * plugin steps and change triggers have converged. This is the phase for a
4018
+ * body that NEEDS the new shape: a back-fill into a table the same deploy
4019
+ * creates, a column filled from its neighbours. It runs in the same job,
4020
+ * under the same failure gate, and — on a dialect that holds a deploy in
4021
+ * one transaction — inside the same unit as the schema it depends on.
4022
+ *
4023
+ * A back-fill declared `'before-schema'` is the mistake this field exists to
4024
+ * make unavailable: on the deploy that creates its table the body can only
4025
+ * take its own `skipUnless` guard, and the skip is recorded, so it never gets
4026
+ * a second chance. The runner names that case when it can see it, but a
4027
+ * warning after the fact is the weaker half — the phase is the answer.
4028
+ */
4029
+ readonly phase: MigrationPhase;
3969
4030
  /** The forward body. */
3970
4031
  readonly up: (ctx: FileMigrationContext) => Promise<void> | Effect.Effect<void, unknown, never>;
3971
4032
  /**
@@ -5128,6 +5189,7 @@ export declare type MergeMixinFields<Mixins extends ReadonlyArray<AnyMixin>> = M
5128
5189
  * export default migration({
5129
5190
  * id: '20260415_120000_split_address_out',
5130
5191
  * description: 'Move users.address* into a separate addresses table.',
5192
+ * phase: 'before-schema',
5131
5193
  * up: ({ sql, store, log }) => Effect.gen(function* () {
5132
5194
  * yield* sql.unsafe(`CREATE TABLE addresses ( ... )`)
5133
5195
  * yield* sql.unsafe(`INSERT INTO addresses (...) SELECT (...) FROM users`)
@@ -5139,6 +5201,9 @@ export declare type MergeMixinFields<Mixins extends ReadonlyArray<AnyMixin>> = M
5139
5201
  */
5140
5202
  export declare const migration: (spec: FileMigration) => FileMigration;
5141
5203
 
5204
+ /** Both phases, in the order a run executes them. The one definition; readers derive. */
5205
+ export declare const MIGRATION_PHASES: readonly [MigrationPhase, MigrationPhase];
5206
+
5142
5207
  export declare interface MigrationDefinition {
5143
5208
  readonly __migration: true;
5144
5209
  readonly id: string;
@@ -5182,10 +5247,13 @@ export declare class MigrationExpectationFailed extends Error {
5182
5247
  /**
5183
5248
  * A migration context was built without a SQL client — over the in-memory
5184
5249
  * store, the shape a unit test uses — and the body reached a surface that
5185
- * needs a live connection: `raw()`, every `ctx.schema` operation, or an
5186
- * Effect body under `ctx.transactional` (which runs ON the connection).
5187
- * `each`, `rewrite`, `expect`, `skipUnless`, `store` and `log` work without
5188
- * one. Also raised at construction when what was handed in as `sql` is not
5250
+ * needs a live connection: `raw()`, an Effect body under `ctx.transactional`
5251
+ * (which runs ON the connection), or a `ctx.schema` member that can only be
5252
+ * answered from a CATALOG — `snapshot`, `indexExists`, `foreignKeyExists` and
5253
+ * every schema CHANGE. `each`, `rewrite`, `expect`, `skipUnless`, `store`,
5254
+ * `log` and the two probes the store can answer about its own shape
5255
+ * (`tableExists`, `columnExists`) work without one; each refusal names what to
5256
+ * do instead. Also raised at construction when what was handed in as `sql` is not
5189
5257
  * an `@effect/sql` client, or when a SQL-backed store arrives without the
5190
5258
  * client it executes on — both are refused before a body can run rather
5191
5259
  * than surfacing as a TypeError from inside the first helper that touches
@@ -5198,6 +5266,18 @@ export declare class MigrationNeedsSqlClient extends Error {
5198
5266
  constructor(migrationId: string, operation: string, detail: string);
5199
5267
  }
5200
5268
 
5269
+ /**
5270
+ * The two points in a migrate run at which a file-based body can execute.
5271
+ *
5272
+ * The ORDER of `'before-schema'` is what every file migration has always had
5273
+ * and is not negotiable: a body that drops a constraint the planner would trip
5274
+ * over has to run before the plan is computed, and `voltro db apply --plan`
5275
+ * refuses a pending one for exactly that reason. `'after-schema'` is the point
5276
+ * that did not exist — everything the declarative apply produces is in place,
5277
+ * so a body may depend on it.
5278
+ */
5279
+ export declare type MigrationPhase = 'before-schema' | 'after-schema';
5280
+
5201
5281
  /**
5202
5282
  * Schema questions and schema operations a migration body can ask of the
5203
5283
  * database it is running against, spelled once for every dialect.
@@ -7943,6 +8023,16 @@ export declare const stampGeneratedIds: <T extends Record<string, unknown>>(tabl
7943
8023
  */
7944
8024
  export declare const startsWith: <RowOf = Record<string, unknown>, K extends keyof RowOf & string = keyof RowOf & string>(column: K, prefix: string) => PredicateLeaf;
7945
8025
 
8026
+ /** One table as a store that has no catalog can describe it. See {@link DataStore.storedTables}. */
8027
+ export declare interface StoredTableShape {
8028
+ /** The table's name, unqualified by any namespace the reporting view is bound to. */
8029
+ readonly table: string;
8030
+ /** The union of the column names the held rows carry. Empty for a table with no rows. */
8031
+ readonly columns: ReadonlyArray<string>;
8032
+ /** How many rows the store holds for this table. `0` means `columns` carries no evidence. */
8033
+ readonly rowCount: number;
8034
+ }
8035
+
7946
8036
  /** The facts a framework refusal carries into `ConstraintViolation`. */
7947
8037
  export declare interface StoreRefusal {
7948
8038
  /** Which integrity rule the caller ran into. Mirrors