@voltro/cli 0.12.0 → 0.13.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 (85) hide show
  1. package/CHANGELOG.md +93 -0
  2. package/dist/{apiBuild-GefjNI5d.js → apiBuild-B6_ehbem.js} +3 -3
  3. package/dist/apiBuild-Bzpb383i.js +2 -0
  4. package/dist/bin.js +3 -3
  5. package/dist/{commands-BmNi1RgZ.js → commands-DYOuGlEC.js} +1668 -1371
  6. package/dist/dbCommand-ADD4mrTI.js +2 -0
  7. package/dist/{dbCommand-DNrkb58g.js → dbCommand-DIOJQwsq.js} +71 -57
  8. package/dist/{dev-d0LjACxE.js → dev-Fb9BmavX.js} +1483 -1414
  9. package/dist/dev-xdijqojc.js +3 -0
  10. package/dist/{seedRunner-DZmhoqNt.js → frameworkTableAssembly-CYlrafoK.js} +110 -331
  11. package/dist/frameworkTableAssembly-XvKO7wQt.js +2 -0
  12. package/dist/index.js +1 -1
  13. package/dist/{inspectMetrics-uEfIS1vp.js → inspectMetrics-DOiiII34.js} +302 -293
  14. package/dist/seedRunner-D6eu-u5U.js +229 -0
  15. package/dist/{serveCommand-B2-YV9TU.js → serveCommand-CqYxzBZR.js} +368 -367
  16. package/dist/serveEntry.js +3 -3
  17. package/dist/{start-DKcQd52a.js → start-CjG_0xhR.js} +1 -1
  18. package/dist/startEntry.js +2 -2
  19. package/package.json +17 -17
  20. package/templates/AGENTS.md +1 -1
  21. package/templates/agent-docs/_index.md +1 -1
  22. package/templates/agent-docs/authentication.md +36 -0
  23. package/templates/agent-docs/cli.md +78 -4
  24. package/templates/agent-docs/configuration.md +48 -0
  25. package/templates/agent-docs/database/migrations.md +32 -0
  26. package/templates/agent-docs/database/transactions.md +31 -0
  27. package/templates/agent-docs/testing.md +24 -0
  28. package/templates/agent-docs/whats-new.md +41 -137
  29. package/templates/apps/api-ai/package.json +7 -7
  30. package/templates/apps/api-auth/package.json +8 -8
  31. package/templates/apps/api-backend/package.json +7 -7
  32. package/templates/apps/api-backend-deactivation/package.json +7 -7
  33. package/templates/apps/api-backend-deactivation/tests/users.deactivate.test.ts +1 -1
  34. package/templates/apps/api-backend-mail/package.json +8 -8
  35. package/templates/apps/api-backend-mariadb/package.json +9 -9
  36. package/templates/apps/api-backend-storage/package.json +8 -8
  37. package/templates/apps/api-data-advanced/package.json +8 -8
  38. package/templates/apps/api-data-advanced/tests/queries.test.ts +6 -1
  39. package/templates/apps/api-durable/package.json +8 -8
  40. package/templates/apps/api-feature-flags/package.json +9 -9
  41. package/templates/apps/api-governance/app.config.ts +0 -1
  42. package/templates/apps/api-governance/package.json +8 -8
  43. package/templates/apps/api-kv/package.json +8 -8
  44. package/templates/apps/api-kv/tests/sync.test.ts +2 -2
  45. package/templates/apps/api-moderation/package.json +8 -8
  46. package/templates/apps/api-observability/package.json +8 -8
  47. package/templates/apps/api-ratelimit/package.json +8 -8
  48. package/templates/apps/api-rbac/README.md +53 -14
  49. package/templates/apps/api-rbac/app.config.ts +24 -26
  50. package/templates/apps/api-rbac/authz.ts +76 -0
  51. package/templates/apps/api-rbac/database/schema.ts +23 -6
  52. package/templates/apps/api-rbac/mutations/notes.create.mutation.server.ts +7 -5
  53. package/templates/apps/api-rbac/mutations/notes.create.mutation.ts +15 -8
  54. package/templates/apps/api-rbac/mutations/notes.delete.mutation.server.ts +54 -0
  55. package/templates/apps/api-rbac/mutations/notes.delete.mutation.ts +43 -0
  56. package/templates/apps/api-rbac/mutations/teams.rename.mutation.server.ts +19 -0
  57. package/templates/apps/api-rbac/mutations/teams.rename.mutation.ts +37 -0
  58. package/templates/apps/api-rbac/package.json +8 -8
  59. package/templates/apps/api-rbac/queries/notes.list.query.server.ts +12 -0
  60. package/templates/apps/api-rbac/queries/notes.list.query.ts +26 -0
  61. package/templates/apps/api-rbac/template.json +2 -2
  62. package/templates/apps/api-rbac/tests/authz.test.ts +233 -0
  63. package/templates/apps/api-rest/package.json +7 -7
  64. package/templates/apps/api-saas/package.json +11 -11
  65. package/templates/apps/api-search/package.json +8 -8
  66. package/templates/apps/api-versioning/package.json +8 -8
  67. package/templates/apps/api-webhooks/package.json +9 -8
  68. package/templates/apps/changelog/package.json +6 -6
  69. package/templates/apps/edge-functions/package.json +2 -2
  70. package/templates/apps/frontend-admin/package.json +8 -8
  71. package/templates/apps/frontend-app/package.json +8 -8
  72. package/templates/apps/frontend-blank/package.json +7 -7
  73. package/templates/apps/frontend-contact/package.json +7 -7
  74. package/templates/apps/frontend-dashboard/package.json +7 -7
  75. package/templates/apps/frontend-docs/package.json +7 -7
  76. package/templates/apps/frontend-i18n/package.json +6 -6
  77. package/templates/apps/frontend-landing/package.json +7 -7
  78. package/templates/apps/frontend-spa/package.json +7 -7
  79. package/templates/apps/frontend-ssr/package.json +7 -7
  80. package/templates/apps/frontend-ssr-api/package.json +8 -8
  81. package/templates/apps/frontend-static-blog/package.json +6 -6
  82. package/dist/apiBuild-s7swuEU5.js +0 -2
  83. package/dist/dbCommand-Bt6BCG7g.js +0 -2
  84. package/dist/dev-YHbHmeXt.js +0 -3
  85. package/templates/apps/api-rbac/tests/notes.create.test.ts +0 -80
@@ -1,5 +1,5 @@
1
- import { X as e } from "./inspectMetrics-uEfIS1vp.js";
2
- import { K as t } from "./seedRunner-DZmhoqNt.js";
1
+ import { X as e } from "./inspectMetrics-DOiiII34.js";
2
+ import { c as t } from "./seedRunner-D6eu-u5U.js";
3
3
  import { r as n } from "./appModuleLoader-C9r9mxZt.js";
4
- import { t as r } from "./serveCommand-B2-YV9TU.js";
4
+ import { t as r } from "./serveCommand-CqYxzBZR.js";
5
5
  export { e as loadDotEnv, n as registerAppModules, t as registerDriver, r as runServe };
@@ -1,4 +1,4 @@
1
- import { $ as e, A as t, C as n, E as r, G as i, H as a, I as o, J as s, N as c, O as l, Q as u, R as ee, U as d, V as f, W as p, _ as m, a as h, at as g, b as _, c as v, ct as y, et as b, f as te, g as x, h as S, i as C, j as w, k as ne, m as T, nt as re, o as ie, p as E, q as D, r as O, s as k, t as A, tt as j, v as M, w as ae, y as N } from "./inspectMetrics-uEfIS1vp.js";
1
+ import { $ as e, A as t, C as n, E as r, G as i, H as a, I as o, J as s, N as c, O as l, Q as u, R as ee, U as d, V as f, W as p, _ as m, a as h, at as g, b as _, c as v, ct as y, et as b, f as te, g as x, h as S, i as C, j as w, k as ne, m as T, nt as re, o as ie, p as E, q as D, r as O, s as k, t as A, tt as j, v as M, w as ae, y as N } from "./inspectMetrics-DOiiII34.js";
2
2
  import { D as oe, E as se, T as ce, a as P, p as le, w as ue } from "./inspect-Dwx0_tUj.js";
3
3
  import { t as de } from "./bootTiming-BdyP9nYw.js";
4
4
  import { dirname as fe, extname as F, join as I, resolve as L } from "node:path";
@@ -1,3 +1,3 @@
1
- import { X as e } from "./inspectMetrics-uEfIS1vp.js";
2
- import { t } from "./start-DKcQd52a.js";
1
+ import { X as e } from "./inspectMetrics-DOiiII34.js";
2
+ import { t } from "./start-CjG_0xhR.js";
3
3
  export { e as loadDotEnv, t as runStartCommand };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@voltro/cli",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
4
4
  "description": "The `voltro` CLI — dev server, codegen, migrations, project scaffolding, agent-docs seeding, and production serve.",
5
5
  "keywords": [
6
6
  "voltro",
@@ -62,22 +62,22 @@
62
62
  "@effect/platform-node": "^0.107.0",
63
63
  "@effect/sql": "^0.51.1",
64
64
  "@effect/workflow": "^0.18.2",
65
- "@voltro/ai": "0.12.0",
66
- "@voltro/cache": "0.12.0",
67
- "@voltro/data-transfer": "0.12.0",
68
- "@voltro/database": "0.12.0",
69
- "@voltro/env": "0.12.0",
70
- "@voltro/kv": "0.12.0",
71
- "@voltro/logger": "0.12.0",
72
- "@voltro/plugin-auth": "0.12.0",
73
- "@voltro/plugin-broadcast": "0.12.0",
74
- "@voltro/plugin-mail": "0.12.0",
75
- "@voltro/plugin-storage": "0.12.0",
76
- "@voltro/plugin-webhooks": "0.12.0",
77
- "@voltro/protocol": "0.12.0",
78
- "@voltro/runtime": "0.12.0",
79
- "@voltro/serverless": "0.12.0",
80
- "@voltro/workflow": "0.12.0",
65
+ "@voltro/ai": "0.13.0",
66
+ "@voltro/cache": "0.13.0",
67
+ "@voltro/data-transfer": "0.13.0",
68
+ "@voltro/database": "0.13.0",
69
+ "@voltro/env": "0.13.0",
70
+ "@voltro/kv": "0.13.0",
71
+ "@voltro/logger": "0.13.0",
72
+ "@voltro/plugin-auth": "0.13.0",
73
+ "@voltro/plugin-broadcast": "0.13.0",
74
+ "@voltro/plugin-mail": "0.13.0",
75
+ "@voltro/plugin-storage": "0.13.0",
76
+ "@voltro/plugin-webhooks": "0.13.0",
77
+ "@voltro/protocol": "0.13.0",
78
+ "@voltro/runtime": "0.13.0",
79
+ "@voltro/serverless": "0.13.0",
80
+ "@voltro/workflow": "0.13.0",
81
81
  "chokidar": "^5.0.0",
82
82
  "ioredis": "^5.11.1",
83
83
  "tinyglobby": "^0.2.17",
@@ -452,7 +452,7 @@ each plugin's own README.
452
452
 
453
453
  | Topic | Open | Summary |
454
454
  |---|---|---|
455
- | **What's new in 0.11.4** | `node_modules/@voltro/cli/templates/agent-docs/whats-new.md` | Everything that changed in this version. Read it before hand-rolling something the framework may now ship. |
455
+ | **What's new in 0.13.0** | `node_modules/@voltro/cli/templates/agent-docs/whats-new.md` | Everything that changed in this version. Read it before hand-rolling something the framework may now ship. |
456
456
  | AI | `node_modules/@voltro/cli/templates/agent-docs/ai.md` | How Voltro treats AI — agents, tools, streaming, RAG — all primitives over the same WebSocket as the rest of the framework. |
457
457
  | Authentication | `node_modules/@voltro/cli/templates/agent-docs/authentication.md` | How @voltro/plugin-auth wires password + session-cookie auth across api + web, plus the pluggable identity-strategy protocol. |
458
458
  | Caching | `node_modules/@voltro/cli/templates/agent-docs/caching.md` | Voltro's caching layer (@voltro/cache) — an always-on memory default, swappable Redis-compatible backends, a low-level wrap primitive, and automatic query-result invalidation. |
@@ -9,7 +9,7 @@ each plugin's own README.
9
9
 
10
10
  | Topic | Open | Summary |
11
11
  |---|---|---|
12
- | **What's new in 0.11.4** | `node_modules/@voltro/cli/templates/agent-docs/whats-new.md` | Everything that changed in this version. Read it before hand-rolling something the framework may now ship. |
12
+ | **What's new in 0.13.0** | `node_modules/@voltro/cli/templates/agent-docs/whats-new.md` | Everything that changed in this version. Read it before hand-rolling something the framework may now ship. |
13
13
  | AI | `node_modules/@voltro/cli/templates/agent-docs/ai.md` | How Voltro treats AI — agents, tools, streaming, RAG — all primitives over the same WebSocket as the rest of the framework. |
14
14
  | Authentication | `node_modules/@voltro/cli/templates/agent-docs/authentication.md` | How @voltro/plugin-auth wires password + session-cookie auth across api + web, plus the pluggable identity-strategy protocol. |
15
15
  | Caching | `node_modules/@voltro/cli/templates/agent-docs/caching.md` | Voltro's caching layer (@voltro/cache) — an always-on memory default, swappable Redis-compatible backends, a low-level wrap primitive, and automatic query-result invalidation. |
@@ -563,6 +563,42 @@ const resolve = composeAuthStrategies(
563
563
 
564
564
  Order matters: put the cheapest / most-common strategy first. When no strategy matches, the resolver returns an [anonymous Subject](/docs/authentication/subject) scoped to the `x-tenant` header (or a custom `fallback` you supply).
565
565
 
566
+ ## Roles from your database — `auth.resolveScopes`
567
+
568
+ If your authorization is a database ROLE rather than a scope on the token, the framework cannot see it. `voltro check`'s `rbac/unguarded-mutation` reports every such write as unguarded — correctly, because nothing about the decision is declared — and the declarative alternative is unusable for you: subjects that come from an external IdP carry no scopes, so `requireScope('employee:admin')` would lock out every real user. One app measured 1566 findings it had no way to act on.
569
+
570
+ `resolveScopes` closes that. It runs after a strategy matches and adds scopes to the resolved Subject from whatever source you like:
571
+
572
+ ```ts
573
+ // app.config.ts
574
+ export default defineApiConfig({
575
+ auth: {
576
+ resolveScopes: async (subject) => {
577
+ const role = await roleCache.get(subject.id) // cache it — see below
578
+ return role === 'admin' ? ['employee:admin', 'employee:read'] : ['employee:read']
579
+ },
580
+ },
581
+ })
582
+ ```
583
+
584
+ The same authorization is now declarable on the descriptor:
585
+
586
+ ```ts
587
+ export const payrollList = defineQuery({
588
+ name: 'payroll.list',
589
+ guards: [requireScope('employee:admin')], // visible in the manifest, checkable in CI
590
+
591
+ })
592
+ ```
593
+
594
+ **Scopes only — never a Subject.** The hook cannot change `id` or `tenantId`: identity belongs to the auth strategy, and a hook that could rewrite it would be a forgery surface. The returned scopes are UNIONED with whatever the strategy already set, so a resolver can grant authority but never revoke it.
595
+
596
+ **It does not run for anonymous callers** — there is no identity to look a role up for.
597
+
598
+ **Cache it yourself.** It is on the request path. A `Map` keyed by subject id with a short TTL is usually enough. The framework deliberately does not cache for you, because only you know how quickly a role change has to take effect.
599
+
600
+ Wired identically under `voltro dev` and `voltro serve`.
601
+
566
602
  ## Wiring it into the app
567
603
 
568
604
  The composed resolver becomes the runtime's `AuthMiddleware` — the per-request middleware that populates `SubjectService` so every handler can `yield* SubjectService` (or read `ctx.subject`). On a single-strategy password app you never touch this; the plugin wires `voltroPasswordStrategy` for you. You only assemble the chain explicitly when you add a second strategy:
@@ -163,7 +163,7 @@ nothing type-checks — the mutation succeeds, the write lands, and the wrong li
163
163
  fails to update.
164
164
 
165
165
  `voltro dev` records what each procedure ACTUALLY touched, into
166
- `app.graph.observed.json` (gitignored automatically). When that file is present,
166
+ `app.graph.observed.generated.json` (gitignored automatically). When that file is present,
167
167
  `check` diffs it against the declarations:
168
168
 
169
169
  ```
@@ -447,6 +447,25 @@ _voltro dev, codegen, agents-md — what runs during local development and the e
447
447
 
448
448
  `voltro dev` is the day-to-day command. It runs different machinery for api vs web apps but the contract is the same: edit a file, the right thing happens.
449
449
 
450
+ ### Running `voltro dev` in a container
451
+
452
+ If your dev pod runs as root with the host workspace bind-mounted, everything
453
+ the framework generates would otherwise land `root:root` inside your own tree —
454
+ and on the host `voltro build` then fails on its own output:
455
+
456
+ ```
457
+ EACCES: permission denied, open '…/apps/display/.framework/index.html'
458
+ ```
459
+
460
+ `voltro dev` and `voltro build` hand their generated output (`.framework`,
461
+ `.env.local`, every `*.generated.*`) to whoever owns the app root, and warn
462
+ loudly when they cannot. Only generated state — the framework never takes
463
+ ownership of a file you wrote.
464
+
465
+ The cleaner fix is on your side and worth doing anyway: start the container as
466
+ the workspace owner, `docker run --user $(id -u):$(id -g)`. Then nothing needs
467
+ handing over at all.
468
+
450
469
  ## `voltro dev <appDir>`
451
470
 
452
471
  ```bash
@@ -885,9 +904,31 @@ exactly the apps that already adopted it, and misses the ones that never did.
885
904
  Those are usually the same apps that secured least elsewhere, which is why the
886
905
  absence is worth naming out loud rather than assuming the default did its job.
887
906
 
888
- Server files only (`*.server.ts`, `*.cron.ts`, `*.subscribe.ts`, …): `fetch` is
889
- unremarkable in a browser component, and flagging it there would make the rule
890
- noise that gets scrolled past taking the real findings with it.
907
+ The rule follows the IMPORT GRAPH, not the filename. Server-convention files
908
+ (`*.server.ts`, `*.cron.ts`, `*.subscribe.ts`, …) are the starting points, and
909
+ any file reachable from them and from **nothing else** counts as server code
910
+ too. That matters: keyed on filenames alone the rule caught 9 of 39 outbound
911
+ calls on the app that reported it — the other 30 sat in `lib/*.ts` helpers
912
+ (payments, an AI provider, TTS) imported only from server executors. A
913
+ `lib/payments-mollie.ts` is not client code, and no file extension can say so.
914
+
915
+ A helper a page ALSO imports stays unflagged, and that is the property keeping
916
+ this rule useful: `fetch` is unremarkable in a browser component, and flagging it
917
+ there would make the rule noise that gets scrolled past — taking the real
918
+ findings with it. Relative imports and your tsconfig `paths` aliases are both
919
+ followed.
920
+
921
+ The detector also flags an **executor that never names its own descriptor**.
922
+ Pairing is by FILENAME, which is right — and it means a `*.server.ts` can be a
923
+ complete, correct executor with no reference at all to the contract it
924
+ implements. Those are exactly the files where a hand-written input drifts from
925
+ the wire: in one reported codebase, six executors declared `boardPurpose: string`
926
+ where their own descriptor said `Schema.Literal(...)`, discarding the contract at
927
+ the executor boundary. Fix by importing the descriptor and typing the input as
928
+ `ExecutorInput<typeof descriptor>`. Only a SIBLING import clears the finding —
929
+ an executor importing nothing but `@voltro/*` and `node:*` has still not named
930
+ its contract.
931
+
891
932
 
892
933
  ### The `workflows.start` audit
893
934
 
@@ -1014,6 +1055,39 @@ matching rule lives inside the CLI, so you cannot re-derive the list with your
1014
1055
  own grep. `--json` prints the complete scan, nothing elided, with no preflight
1015
1056
  output mixed in:
1016
1057
 
1058
+ ### Duplicate package instances
1059
+
1060
+ `voltro doctor` also reports any identity-sensitive package resolved at more than
1061
+ one version — `effect`, `@effect/*`, `@voltro/*`, react/react-dom:
1062
+
1063
+ ```
1064
+ • 1 package(s) resolved at more than one version:
1065
+ effect — 3.18.4, 3.21.0
1066
+ node_modules/effect
1067
+ ../../node_modules/effect
1068
+ ```
1069
+
1070
+ This is worth its own check because of how it PRESENTS. Effect's types are
1071
+ nominal, so a `Schema` built by one copy is not the type the other expects, and
1072
+ the errors land in the GENERATED `rpcGroup.generated.ts` — a file you cannot edit
1073
+ and did not write:
1074
+
1075
+ ```
1076
+ Property '[TypeId]' is missing in type … Schema<any, any, unknown>
1077
+ Type 'typeof Never' is not assignable to type 'All'
1078
+ Argument of type 'Rpc<…, Stream<…>, …>' is not assignable to 'Any'
1079
+ ```
1080
+
1081
+ Read cold, that says "the framework emits bad types". It says nothing about the
1082
+ dependency tree, which is where the problem is. And the RUNTIME usually stays
1083
+ green — two instances only diverge where identity matters — so the app boots,
1084
+ serves and passes its tests while `tsc` is red.
1085
+
1086
+ Fix it in the install, not the code: align the version across the workspace (a
1087
+ root `pnpm.overrides` / `resolutions` entry for `effect` is the blunt
1088
+ instrument), then reinstall. Do NOT add `@ts-nocheck` to the generated file — it
1089
+ is exactly where a genuine mistake in your own descriptors surfaces.
1090
+
1017
1091
  ```bash
1018
1092
  voltro doctor . --json # the complete scan: every file path, machine-readable
1019
1093
  ```
@@ -326,6 +326,54 @@ const issued = await svc.issue({ tenantId, name: 'mobile app', scopes: ['read']
326
326
  // show issued.token ONCE; later: svc.rotate(id), svc.revoke(id), svc.list(tenantId)
327
327
  ```
328
328
 
329
+ ## The second ownership axis — `metadata`
330
+
331
+ `tenantId` and `onBehalfOf` are the two relationships the framework models. If
332
+ your keys also belong to something else — a team, a project, an environment —
333
+ and that binding is what authorizes them, store it in `metadata`:
334
+
335
+ ```ts
336
+ const key = await keys.issue({
337
+ tenantId: ctx.request.subject.tenantId,
338
+ name: 'CI deploy',
339
+ createdBy: ctx.request.subject.id, // who minted it
340
+ onBehalfOf: null, // an ORG key: acts as no person
341
+ metadata: { teamId: 'team_7' }, // your axis
342
+ })
343
+ ```
344
+
345
+ It comes straight back on resolve, so a guard needs no second query:
346
+
347
+ ```ts
348
+ const resolved = await keys.verify(token)
349
+ resolved?.metadata // { teamId: 'team_7' }
350
+ ```
351
+
352
+ It survives `rotate` — a rotated key is the same credential with a new secret,
353
+ so dropping it would silently de-authorize every rotated key. And it reaches the
354
+ Subject as `metadata`, alongside the framework's own claims.
355
+
356
+ **It is app data, never identity.** The strategy merges your bag UNDER its own
357
+ claims: `provider`, and the acting `userId`, are written afterwards from
358
+ `onBehalfOf` and always win — including when the answer is "none". A bag that
359
+ could set `userId` would let whoever minted a key choose who the request is.
360
+
361
+ Before this slot existed, an app with a team axis could authenticate through the
362
+ built-in strategy and still not authorize, so `apiKeys: true` was unusable for
363
+ it. The alternatives people reached for were a second table joined on every auth
364
+ check, or `team:<id>` smuggled into `scopes` — where `hasScope` then sees a scope
365
+ that is not a scope.
366
+
367
+ ## Two strategies, one prefix
368
+
369
+ If your app already runs its own key strategy on a prefix and you then enable
370
+ `apiKeys: true`, both claim the same shape. The chain is first-match-wins, so the
371
+ first one decides the Subject — and if they resolve to different authority,
372
+ *which strategy answered* decides whether authorization works.
373
+
374
+ `voltro dev` / `voltro serve` warn at boot when this happens. Give them distinct
375
+ prefixes (`apiKeys: { prefix: 'vk_' }`) or drop one.
376
+
329
377
  ## Security model
330
378
 
331
379
  - **Hash-only storage.** A DB dump never exposes a usable key — only `sha256(token)`. Lose a token → rotate it (`rotate` revokes the old + issues a fresh one with the same scopes).
@@ -2600,6 +2600,38 @@ export const users = table('users', {
2600
2600
 
2601
2601
  The planner folds the diff into one `ALTER TABLE users RENAME COLUMN firstName TO givenName`, classified `safe`. After the rename is applied in every env, the marker can be removed (covered in [rename-and-drop.md](./rename-and-drop.md)).
2602
2602
 
2603
+ ## "the migration did not converge" (apply refuses to record a fingerprint)
2604
+
2605
+ ```
2606
+ applyPlan: the migration did not converge. 31 operation(s) were executed without
2607
+ error, but re-planning against the live schema still finds 31:
2608
+ - alter-column-default todos.attachments
2609
+
2610
+ No fingerprint was recorded — recording one would make the next boot report
2611
+ "schema up to date" for a schema that was never applied.
2612
+ ```
2613
+
2614
+ Every statement ran and the database accepted every one of them, and none of them
2615
+ changed anything. That is possible because DDL that changes nothing succeeds
2616
+ exactly as quietly as DDL that works — `ALTER COLUMN x TYPE text` on a column
2617
+ that is already `text` is a valid, successful no-op.
2618
+
2619
+ This message exists because the alternative is worse. Before the convergence
2620
+ check, such a plan reported `applied 31 op(s)`, recorded a fingerprint, and every
2621
+ later boot short-circuited on "schema up to date" — for a schema that had never
2622
+ been applied. One app ran that way for two releases. The apply now proves it
2623
+ worked before it records anything: the same planner, run against the database as
2624
+ it now is, must have nothing left to do.
2625
+
2626
+ **It is a framework bug, not a mistake in your schema.** The named operations
2627
+ emit DDL that does not take effect. Report the operation kinds plus the column
2628
+ types involved. In the meantime the schema is unchanged and safe — nothing was
2629
+ half-applied, and no fingerprint was written, so `voltro db plan` still shows you
2630
+ the truth.
2631
+
2632
+ If you need to move forward before a fix lands, apply the equivalent DDL by hand
2633
+ and re-run `voltro db plan` to confirm it converges.
2634
+
2603
2635
  ## "Schema fingerprint mismatch" (prod refuse)
2604
2636
 
2605
2637
  Full error:
@@ -235,6 +235,37 @@ The `where` predicate is a regular [Predicate](/docs/database/query-builder#pred
235
235
  AST — same shape `.where()` uses. Sub-queries via `inSubquery` /
236
236
  `exists` are supported.
237
237
 
238
+ ### Typed: `updateManyRow`
239
+
240
+ `updateMany` takes a string table name and an untyped patch, so a misspelled
241
+ column or a wrongly-typed value is only found by the database — or not at all,
242
+ if the column happens to exist. `updateManyRow` takes the TABLE OBJECT instead
243
+ and checks the patch against the row type:
244
+
245
+ ```ts
246
+ import { updateManyRow, eq } from '@voltro/database'
247
+
248
+ await updateManyRow(ctx.store, posts, { hidden: true }, {
249
+ where: eq('userId', bannedId),
250
+ })
251
+
252
+ await updateManyRow(ctx.store, posts, { hiddne: true }, { where: … })
253
+ // ^^^^^^^ compile error: not a column
254
+ ```
255
+
256
+ It is worth using rather than the string form, and the evidence is concrete: one
257
+ app migrating 29 `store.upsert` call sites to the typed `upsertRow` got 15 `tsc`
258
+ errors across 8 distinct defects that no test had caught — including seven
259
+ per-user mutations with no authentication check at all, which wrote
260
+ `ctx.request.subject.id` (typed `string | null`) into a NOT NULL column, so an
261
+ anonymous caller reached the database and got a raw statement failure instead of
262
+ a typed refusal.
263
+
264
+ The subtlest one is the most persuasive: a value spread from a plain object
265
+ literal widens to `string`, and a column's `.oneOf()` union rejects it even
266
+ though the value IS one of the members. Neither a reviewer nor a test would
267
+ plausibly find that; only the row type asks the question. (`as const` fixes it.)
268
+
238
269
  ### Reactivity
239
270
 
240
271
  `updateMany` emits ONE ChangeEvent per affected row, so reactive
@@ -66,6 +66,30 @@ voltro e2e # boot api + web siblings, run tests, tear down
66
66
 
67
67
  _makeTestContext — the in-memory request ctx for handler and tool tests. The real mixin-wrapped store, mockStore seeding, subject/tenant re-scoping, and the deterministic clock / email / LLM mocks._
68
68
 
69
+ ## tsconfig `paths` aliases
70
+
71
+ `voltro test` derives Vite's `resolve.alias` from your app's tsconfig
72
+ `compilerOptions.paths`, so an app that maps `@/* → ./src/*` can test modules
73
+ that import through it without any extra config:
74
+
75
+ ```jsonc
76
+ // tsconfig.json
77
+ { "compilerOptions": { "baseUrl": ".", "paths": { "@/*": ["./src/*"] } } }
78
+ ```
79
+
80
+ ```ts
81
+ import { greeting } from '@/locales/en' // resolves under `voltro test`
82
+ ```
83
+
84
+ Before this, the first person to write a test for each aliased file discovered
85
+ `Cannot find package '@/locales/en' imported from src/lib/i18n.ts` — one file at
86
+ a time — and worked around it with a local `vitest.config.ts` restating what
87
+ tsconfig already said. The dev and build pipelines resolve these already (your
88
+ app runs), so the test runner disagreeing with them was a gap, not a policy.
89
+
90
+ A project-local `vitest.config.ts` still merges on top, so an app that already
91
+ wrote the workaround keeps working.
92
+
69
93
  ## `makeTestContext`
70
94
 
71
95
  `makeTestContext(options?)` returns an **`AppContext`** — the exact `ctx` a mutation / query / action / tool executor receives at runtime — with the deterministic test doubles added on top. Because it *is* an `AppContext`, you pass it straight into a handler: `await myHandler(input, makeTestContext({ … }))`. The acting subject is at `ctx.request.subject`; `ctx.store` is the **real** mixin-wrapped store backed by an in-memory data store (so tenant auto-scoping, soft-delete filtering, audit auto-fill, and the fluent `select` / `update` / `delete` builders behave exactly as in production); `ctx.cache` is a real in-memory cache whose TTLs honour `ctx.clock`.