@voltro/ui-shadcn 0.72.0 → 0.74.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.
Files changed (2) hide show
  1. package/CHANGELOG.md +215 -0
  2. package/package.json +2 -2
package/CHANGELOG.md CHANGED
@@ -39,6 +39,221 @@ _Changes staged for the next release accumulate here (rolled up from
39
39
 
40
40
  ---
41
41
 
42
+ ## [0.74.0] — 2026-09-18
43
+
44
+ ### Added
45
+
46
+ - **An app can assert it still agrees with the framework** — `@voltro/cli`
47
+
48
+ `@voltro/cli/contract` publishes the three things an app needs to check itself against the framework's own implementation rather than against a copy of it:
49
+
50
+ - `assertBrowserSafeRpcGroup(path)` — the generated rpc group is loaded value-level by the browser, so a descriptor that transitively imports a server-only module drags the schema graph into the client bundle. `voltro dev` runs this at boot; until now an app could not ask the same question from its own suite, before a deploy. - `observeInspectResponse(path, response, fleetSize)` — the envelope every inspect answer is wrapped in. An app whose output schema decodes an inspect payload is making a claim about this function's shape, and a test that rebuilds the envelope by hand asserts against its own reconstruction instead. - `buildClusterInspect(deps)` — the cluster snapshot as the framework assembles it, for the same reason.
51
+
52
+ ```ts
53
+ import { assertBrowserSafeRpcGroup } from '@voltro/cli/contract'
54
+
55
+ it('keeps the generated rpc descriptor graph browser-safe', async () => {
56
+ await expect(assertBrowserSafeRpcGroup(rpcGroupPath)).resolves.toBeUndefined()
57
+ }, 30_000)
58
+ ```
59
+
60
+ One consequence is worth stating rather than leaving to be discovered: `observeInspectResponse` is default-deny over its route-scope table — a path with no declared scope is answered with a 500 naming the fix, not wrapped bare — and publishing it makes that part of the public promise.
61
+
62
+ The surface is for asserting, not for building on: nothing in it mounts a route, serves a request or writes a file.
63
+
64
+ ### Fixed
65
+
66
+ - **A file migration records the body that ran, so a later edit to it is visible** — `@voltro/database`, `@voltro/cli`
67
+
68
+ An applied `migrations/<timestamp>_<slug>.ts` file was looked up by its id and nothing else:
69
+
70
+ ```sql
71
+ SELECT id FROM _voltro_migration_plans WHERE source = 'file'
72
+ ```
73
+
74
+ So its body could be rewritten after it had been applied and nothing would say so. The file then reads like a description of what ran against the database, and is not one — which is the state every other migration tool checks for, and which the framework's own `*.migration.ts` runner has recorded a hash for and warned about since it shipped. The file runner wrote an empty string into the same column.
75
+
76
+ It records the body hash now, and reports a body that no longer matches:
77
+
78
+ ```
79
+ file migration: the body on disk is not the one that was applied —
80
+ the file no longer describes this database id=20260915_054800_vacation_days
81
+ ```
82
+
83
+ Two things about it are deliberate:
84
+
85
+ - **A row written before this shipped carries an empty hash, and that reads as "cannot compare", never as "changed".** The other way round, the first run after upgrading would report drift for every migration a database has ever applied — the loudest possible way to make a true warning ignorable. - **It warns; it does not fail.** Nothing can be re-run to fix it: the change is in the database, and the edit is usually deliberate. What was missing is that anyone could tell.
86
+
87
+ The hash is one function shared by both runners rather than spelled twice — a hash is only comparable to one computed identically, and two definitions of "the hash of this file" become two answers the moment either is touched.
88
+ - **A new unique column arrives with its unique, and a part-applied schema says so** — `@voltro/database`
89
+
90
+ Adding a NOT NULL column that declares `.unique()` produced a migration that executed without error and then failed its own convergence proof:
91
+
92
+ ```
93
+ applyPlan: the migration did not converge. 14 operation(s) were executed without
94
+ error, but re-planning against the live schema still finds 1:
95
+ - add-unique _voltro_ai_usage.receiptKey
96
+ ```
97
+
98
+ The second run succeeded with exactly that one operation, because by then the column existed.
99
+
100
+ A column-level `.unique()` is a column flag, not a snapshot index, so a unique appearing on an EXISTING column is caught by the column-change comparison. A column the same plan CREATES has no live side to compare against, which leaves the add-column emitter as the only thing that can carry the flag — and it read the flag on one of its three branches. A nullable add rendered `UNIQUE` inline; the NOT NULL branches (with a default, or with a backfill) dropped it. So the column was created, filled and enforced, the unique was never created, and the re-plan proposed it.
101
+
102
+ The three-step add cannot render `UNIQUE` inline — the column is NULL for the length of the backfill — so the emitter now reports the unique as still owed and the applier creates it after the NOT NULL enforcement, through the same per-dialect statements `add-unique` already uses. It is reachable without `.backfill()`: a NOT NULL column with a default and a unique lost it the same way.
103
+
104
+ The failure message changed too, and independently. It ended with *"the schema is unchanged and safe"* in every case, while the applier already knew — twenty lines earlier, on its other failure path — whether the dialect applies a plan atomically. On postgres and mssql the sentence was true; on mysql and mariadb it was not, and a deployment that read it went looking for an untouched schema and found thirteen of fourteen operations committed. It now states which of the two situations it is, and on a non-atomic dialect lists the operations that took effect.
105
+ - **Change-trigger drift is measured against the name postgres stores** — `@voltro/database`
106
+
107
+ On postgres, a table whose name carries an uppercase letter — or is long enough to reach the identifier limit — was reported as having no change trigger on every boot, while its trigger was present and firing. `voltro db apply` then re-installed every one of them, every run, and the count never went down:
108
+
109
+ ```text
110
+ db apply: installing change triggers on 71 table(s)
111
+ db apply: change triggers converged (214 statement(s))
112
+ ```
113
+
114
+ `CREATE TRIGGER` is emitted with the trigger identifier unquoted — the table name beside it IS quoted, which is what made the asymmetry easy to miss — so postgres stores the name the way it stores any unquoted identifier: downcased, then clipped to 63 bytes. Measured on postgres 17.10:
115
+
116
+ ```text
117
+ activityEvents -> framework_changes_activityevents
118
+ aVeryLongTableNameOverTheLimit… -> framework_changes_averylongtablenameoverthelimit… (63)
119
+ ```
120
+
121
+ The drift detector compared `pg_trigger` against the name the framework DERIVES, so the two could never match for an affected table. It reads the stored form now. The emission is deliberately unchanged: `CREATE TRIGGER` and its `DROP TRIGGER IF EXISTS` fold identically and therefore already agree with each other, so quoting only the create would have left every existing database's trigger undroppable and added a second one beside it.
122
+
123
+ The truncation half is reachable with no uppercase letter at all, so folding alone would have fixed the reported case and left the other.
124
+
125
+ The comparison also asks which RELATION carries a trigger, rather than matching on the name alone. Trigger names are unique per table in postgres, not per schema, so two tables whose names differ only in case legitimately carry the same trigger name — and keyed by name alone, one table's trigger answered for the other's. Folding without that would have traded a table wrongly reported as untriggered for a table wrongly reported as triggered, which is the same defect in the more dangerous direction.
126
+ - **voltro db status reports both migration conventions** — `@voltro/cli`
127
+
128
+ Run against the same project, seconds apart:
129
+
130
+ ```text
131
+ voltro db status . -> no migrations found
132
+ voltro db files . -> no pending file-based migrations (4 already applied)
133
+ ```
134
+
135
+ Neither answer was about the database's state. The two commands walk for different files: `db status` (with `db migrate` and `db rollback`) walks `*.migration.ts` anywhere under the project root, while `db files` — and `db apply`, and the boot path — walks `migrations/<timestamp>_<slug>.ts`. Both conventions are supported; only one of them was ever reported.
136
+
137
+ `no migrations found` is the worst available answer for the other one. It is not a warning that invites a second look, it is a statement that there is nothing to see, and it was being given to a project with four applied migrations sitting in the directory the same CLI had just listed.
138
+
139
+ `db status` now reports both, marks a file migration as `applied` or `pending`, and flags one whose body no longer matches what was recorded when it ran. It says `no migrations found` only when BOTH conventions are empty — and a database it cannot reach is not reported as "no migrations" either, which would be the same false statement one layer along.
140
+
141
+ The count in `db files` was right, for the record: `4 already applied` counts the migrations found in the ledger, not the ones a `skipUnless` precondition skipped. Those are recorded as applied with `skipped: <reason>` in their notes, which is a different thing that the same word describes.
142
+ - **A migration's truncate tolerates a missing table and emits no per-row event** — `@voltro/database`
143
+
144
+ `ctx.schema.truncate(table)` was the one schema operation that did not look at the live schema first. Its neighbours — `dropTable`, `dropIndex`, `dropColumn`, `dropForeignKey`, `renameColumn` — all answer `already-absent` when there is nothing to do, and the documentation states that as a property of every schema operation. So a migration run against a database that predates the table died on the truncate while the `dropForeignKey` on the line above it, naming the same table, had answered `already-absent`:
145
+
146
+ ```text
147
+ ctx.schema.dropForeignKey('timers', 'roadmapId') -> already-absent
148
+ ctx.schema.truncate('timers') -> SqlError
149
+ Table 'app.timers' doesn't exist (ER_NO_SUCH_TABLE / 1146 / 42S02)
150
+ ```
151
+
152
+ It returns `'applied' | 'already-absent'` now, like the rest.
153
+
154
+ The statement it runs changed too, and that half is the more expensive one. The truncate went through the store's set-based delete, which is `DELETE … WHERE … RETURNING *` **by design**: the rows it returns are the old-images the store fans out as one change event per row. Correct for an application write, wrong for a truncate — emptying a large table pulled every row back to the client and notified every subscriber once per deleted row. It now issues a plain `DELETE FROM <table>` through the migration's own client: no rows returned, no per-row event.
155
+
156
+ `DELETE FROM` rather than `TRUNCATE` is unchanged and deliberate — it is a data statement on every dialect, so it stays inside a transactional migration, where `TRUNCATE` (DDL on the MySQL family and SQL Server) would commit it.
157
+
158
+ ### Internal (no consumer-facing effect)
159
+
160
+ - **A test's fixture probe cannot outlive its test** — `@voltro/cli`
161
+
162
+ `frameworkSourceTypo.integration.test.ts` writes a query descriptor into the shared `memory-api` fixture for a few seconds and removes it again. Another task generates from that same fixture — `web-form-post`'s typecheck runs `voltro codegen ../memory-api` — and codegen emits one import per query file it finds. A codegen landing inside that window bakes the probe into `rpcGroup.generated.ts`; the probe is then deleted, and the generated file is left importing a module that no longer exists.
163
+
164
+ Measured rather than reasoned: plant a probe and run `voltro codegen .` and seven references appear; delete the probe and all seven remain. What the next reader gets is `TS2307: Cannot find module './queries/<probe>.query'` in a generated file, with nothing pointing at where it came from.
165
+
166
+ The artefact is gitignored, which is what makes it silent — `git status` reports a clean tree over a poisoned one, so the usual check answers the wrong question.
167
+
168
+ Two changes, for the two ways the residue survives. The `finally` now restores the fixture's generated files from a snapshot taken before the probe was written, so a concurrent codegen is undone along with the probe. And the suite sweeps leftover probe files at start, because a killed process never runs a `finally` — this file already records a saturated machine having the OS kill its child.
169
+
170
+ ---
171
+
172
+ ## [0.73.0] — 2026-09-17
173
+
174
+ ### Added
175
+
176
+ - **Typed writes on the Effect channel — the row check and StoreError compose** — `@voltro/runtime`
177
+
178
+ `insertRow`, `insertManyRows`, `upsertRow`, `upsertRowOutcome` and `insertIgnoreRowOutcome` check their payload against the table — a required column the row omits is a compile error at the call site — and return a `Promise`. `EffectStore` carries every one of those operations on the typed error channel and takes `(table, row)` with an unchecked `Row`.
179
+
180
+ So the two properties excluded each other. A handler that wanted the row checked gave up `StoreError`; one that wanted the channel passed an unchecked row. There was no third option: wrapping a typed helper in `Effect.promise` runs the write outside the Effect and discards exactly the channel at issue.
181
+
182
+ Five helpers close it, with the same signatures over a store whose methods return Effects:
183
+
184
+ ```ts
185
+ const store = yield* EffectStore
186
+ const saved = yield* insertRowEffect(store, players, { tenantId, name })
187
+ const { row, outcome } = yield* upsertRowOutcomeEffect(store, players, player, {
188
+ conflictColumns: ['tenantId', 'externalId'],
189
+ update: ['name', 'score'],
190
+ })
191
+ ```
192
+
193
+ `EffectStore` satisfies each helper's store parameter structurally, so it is the argument at every call site. The row type, the conflict-key constraint and the return type are spelled as the Promise forms spell them, so moving between the two families means reading one signature rather than two.
194
+
195
+ One deliberate difference: `upsertRowOutcome` throws when a hand-written `DataStore` lacks `upsertWithOutcome`. `EffectStore` always provides it, so the Effect form does not carry that failure — an error case for an unreachable condition is one every caller must handle and none can trigger.
196
+
197
+ ### Fixed
198
+
199
+ - **A module that re-exports the generated route builder is not a hand-roll** — `@voltro/cli`
200
+
201
+ `voltro doctor`'s `hand-route-module` rule read the importing file and asked whether it mentioned `.framework/routes.generated`. An app that keeps exactly one module importing the generated builder and re-exports it — so that a single file owns the dependency — therefore had every consumer of that alias reported, while using the typed builders the rule recommends.
202
+
203
+ The rule now resolves the imported module and stays silent when it re-exports the generated builder. A genuinely hand-maintained route or url module is reported as before.
204
+
205
+ An alias onto a generated artefact is the adoption of this rule, and a finding that fires on the adoption is an argument for deleting the seam that made it clean.
206
+ - **The migration codemod no longer re-indents the body it rewrites** — `@voltro/cli`
207
+
208
+ `0.72.0/01_migration-context-raw` rewrites two lines per migration and produced diffs of 85 to 133 changed lines. Two causes, both removed.
209
+
210
+ A migration whose body is an expression — `up: (ctx) => Effect.gen(function* () { … })`, the common shape — was hoisted into a block so the `raw(reason)` binding had somewhere to live, which indented the entire body one level. The binding is placed inside the generator instead, so the body keeps its position and the change is the two lines it actually is.
211
+
212
+ Separately, every node any codemod inserted was printed with four-space indentation while this framework's own templates use two. That is fixed for all codemods, not just this one.
213
+
214
+ Neither changes what the codemod means. It changes whether the diff can be read — which matters here more than most, because every `raw(reason)` it writes is a placeholder its author has to fill in before committing.
215
+ - **voltro serve refuses a serve bundle built by a different framework version** — `@voltro/cli`
216
+
217
+ `voltro serve` loads the precompiled serve bundle, and a bundle built before an upgrade imports perfectly — it simply runs the previous version's code. Nothing said so. Updating `@voltro/*` without re-running `voltro build` therefore left a process serving the old framework while the installed one had moved on, and the only way to notice was to recognise a log line's shape as belonging to the older release.
218
+
219
+ The bundle already carries the version that built it. `voltro serve` now reads it before importing and refuses when it disagrees with the installed CLI, naming both versions and the command that fixes it:
220
+
221
+ ```
222
+ FATAL: the serve bundle was built by @voltro/cli 0.71.1, but 0.72.0 is installed.
223
+ Serving it would run 0.71.1's code against 0.72.0's dependencies.
224
+ Rebuild it: voltro build .
225
+ ```
226
+
227
+ A mismatch refuses rather than falling back to the slower boot path: it is a build error of the deployment, and quietly running a different version is the failure being fixed. A bundle carrying no version marker is left alone — those predate the marker and are not evidence of anything.
228
+ - **A workflow step can reach the store under the test runner** — `@voltro/testing`
229
+
230
+ A workflow step may `yield* EffectStore`, and in production it gets one: the runtime provides the layer from the context's store. `makeWorkflowRunner` provided the step recorder and the workflow's own layer and nothing else, so the step died under test with
231
+
232
+ ```
233
+ Service not found: @voltro/EffectStore
234
+ ```
235
+
236
+ while the identical code ran in production. The only supported way to drive a workflow in a test could not run the form the framework documents, which left `Effect.promise` as the shape people shipped instead.
237
+
238
+ The runner now provides the same layer from the same place production does, from the context it already receives. A context without a store stays runnable, so a test that never touches the store does not acquire a requirement.
239
+ - **The AI usage receipt key no longer refuses to migrate an existing ledger** — `@voltro/ai`, `@voltro/cli`
240
+
241
+ `_voltro_ai_usage.receiptKey` arrived NOT NULL with neither a default nor a backfill, so adding it to a database that already holds usage rows was refused:
242
+
243
+ ```
244
+ auto-migrate: REFUSED — 1 blocked operation(s):
245
+ - add-column [_voltro_ai_usage]: NOT NULL column on a table whose row count is unknown
246
+ auto-migrate failed — aborting boot
247
+ ```
248
+
249
+ The refusal was correct and its advice was not reachable: it asks whoever owns the declaration to add `.backfill()` or `.default()`, and this table is the framework's, not the app's. `VOLTRO_AUTO_MIGRATE=0` left the app on the previous schema and `voltro db apply` refused the same way before a deploy, so an upgrade could not be rolled out at all.
250
+
251
+ The column now derives a per-row value from the row's own id. A literal default could not do this job — the column is UNIQUE, so one value for every existing row collides on the second — and the derivation is computed per row rather than in SQL because string concatenation has no dialect-neutral spelling and this table ships on five.
252
+
253
+ A guard now plans every framework table against a populated database and fails on any column added since the last release that an app could not migrate onto. Two earlier shapes of that check were built and discarded, each disproved by its own measurement: both asked every framework column to be addable, and a column created together with its table never is.
254
+
255
+ ---
256
+
42
257
  ## [0.72.0] — 2026-09-16
43
258
 
44
259
  ### ⚠ BREAKING
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@voltro/ui-shadcn",
3
- "version": "0.72.0",
3
+ "version": "0.74.0",
4
4
  "description": "Voltro's first-party shadcn/ui kit: Tailwind v4 design tokens (light + dark), 30+ primitives, layout compositions, styled widgets for the @voltro/ui seam, and the canonical theme/language preference-cookie helpers.",
5
5
  "keywords": [
6
6
  "voltro",
@@ -52,7 +52,7 @@
52
52
  "@radix-ui/react-toggle-group": "^1.1.19",
53
53
  "@shikijs/langs": "^4.4.3",
54
54
  "@shikijs/themes": "^4.4.3",
55
- "@voltro/ui": "0.72.0",
55
+ "@voltro/ui": "0.74.0",
56
56
  "class-variance-authority": "^0.7.1",
57
57
  "clsx": "^2.1.1",
58
58
  "shiki": "^4.4.3",