@voltro/cli 0.40.0 → 0.42.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 (104) hide show
  1. package/CHANGELOG.md +171 -0
  2. package/dist/{apiBuild-C8xBEYbL.js → apiBuild-B_iH24qf.js} +2 -2
  3. package/dist/apiBuild-RM5W5YXk.js +2 -0
  4. package/dist/bin.js +1 -1
  5. package/dist/{build-CkhFGnKA.js → build-DUdi6F_Q.js} +150 -150
  6. package/dist/{checkCommand-QbHshOac.js → checkCommand-B2RWrpWi.js} +1 -1
  7. package/dist/{checkCommand-BcXJa2IS.js → checkCommand-DWL2_PPR.js} +2 -2
  8. package/dist/{codegenCommand-DF2OJh3O.js → codegenCommand-DkjoJ30Q.js} +2 -2
  9. package/dist/{codemodRunner-BcOvi4A2.js → codemodRunner-sFuWDETH.js} +520 -460
  10. package/dist/{commands-7Z7-HOE3.js → commands-DoTClnSk.js} +22 -22
  11. package/dist/{dashboardCommand-CUycH6dR.js → dashboardCommand-Bro7Jwts.js} +5 -5
  12. package/dist/dataCommand-Rj57y1vg.js +669 -0
  13. package/dist/dataProfile-Cm0YVKSy.js +18 -0
  14. package/dist/{dbCommand-DSwGv9wS.js → dbCommand-B237qI-j.js} +343 -316
  15. package/dist/dbCommand-BvQeKQOb.js +2 -0
  16. package/dist/{dev-Dh1NuYx1.js → dev-D4l_YOzI.js} +22 -22
  17. package/dist/{dev-D8JSKlBM.js → dev-DuqBJC7g.js} +1 -1
  18. package/dist/doctorCommand-Bw3V4L1j.js +2 -0
  19. package/dist/{doctorCommand-C-8-Z9D6.js → doctorCommand-TmZTAepc.js} +385 -279
  20. package/dist/{dormancyCommand-A79Odglm.js → dormancyCommand-CUKhRmGG.js} +1 -1
  21. package/dist/{embeddingsCommand-CoiyHfkY.js → embeddingsCommand-Db-iQM3l.js} +1 -1
  22. package/dist/{envCommand-B6d0s-jZ.js → envCommand-DQ_URZq9.js} +12 -12
  23. package/dist/{evolveCommand-BC5a9lxX.js → evolveCommand-CghRPrGO.js} +2 -2
  24. package/dist/index.js +1 -1
  25. package/dist/{infoCommand-ZqSVzJZ4.js → infoCommand-BCs_ZFnl.js} +1 -1
  26. package/dist/{migrate-BnPw2zC8.js → migrate-DN47p2PG.js} +1 -1
  27. package/dist/{runtimeTrace-BslKJjDY.js → runtimeTrace-jFKvOPBz.js} +1 -1
  28. package/dist/{sdkgen-DQ8uEmmn.js → sdkgen-COQqHB9V.js} +1 -1
  29. package/dist/{serveCommand-Dqb9Ku1-.js → serveCommand-C512kzZA.js} +417 -411
  30. package/dist/serveCommand-gj7MsqXY.js +2 -0
  31. package/dist/serveEntry.js +1 -1
  32. package/dist/{start-dkNYKtgQ.js → start-BjcuFME_.js} +379 -343
  33. package/dist/{start-BA7txHhY.js → start-fifc0xpW.js} +1 -1
  34. package/dist/startEntry.js +1 -1
  35. package/dist/updateCommand-B2alql9k.js +2 -0
  36. package/dist/{updateCommand-eoGpZr3E.js → updateCommand-HZQVBU3T.js} +1 -1
  37. package/dist/webDev-D6QeXw3P.js +2 -0
  38. package/dist/{webDev-bybjZA0o.js → webDev-Dk8lFUzf.js} +642 -525
  39. package/dist/{webhooksCommand-DubZWNOx.js → webhooksCommand-D7ZpH7w0.js} +1 -1
  40. package/package.json +23 -17
  41. package/templates/AGENTS.md +1 -1
  42. package/templates/agent-docs/_index.md +1 -1
  43. package/templates/agent-docs/_manifest.json +1 -1
  44. package/templates/agent-docs/cli.md +79 -6
  45. package/templates/agent-docs/data.md +33 -0
  46. package/templates/agent-docs/plugins/governance.md +8 -0
  47. package/templates/agent-docs/routing.md +141 -28
  48. package/templates/agent-docs/security.md +24 -0
  49. package/templates/agent-docs/whats-new.md +48 -109
  50. package/templates/apps/api-ai/package.json +7 -7
  51. package/templates/apps/api-auth/package.json +8 -8
  52. package/templates/apps/api-backend/package.json +7 -7
  53. package/templates/apps/api-backend-deactivation/package.json +7 -7
  54. package/templates/apps/api-backend-mail/package.json +8 -8
  55. package/templates/apps/api-backend-mariadb/package.json +9 -9
  56. package/templates/apps/api-backend-sqlite/package.json +8 -8
  57. package/templates/apps/api-backend-storage/package.json +8 -8
  58. package/templates/apps/api-cms/package.json +10 -10
  59. package/templates/apps/api-collab/package.json +8 -8
  60. package/templates/apps/api-data-advanced/package.json +8 -8
  61. package/templates/apps/api-durable/package.json +8 -8
  62. package/templates/apps/api-feature-flags/package.json +9 -9
  63. package/templates/apps/api-governance/package.json +8 -8
  64. package/templates/apps/api-kv/package.json +8 -8
  65. package/templates/apps/api-moderation/package.json +8 -8
  66. package/templates/apps/api-observability/package.json +8 -8
  67. package/templates/apps/api-ratelimit/package.json +8 -8
  68. package/templates/apps/api-rbac/package.json +8 -8
  69. package/templates/apps/api-rest/package.json +7 -7
  70. package/templates/apps/api-saas/package.json +11 -11
  71. package/templates/apps/api-saas-starter/package.json +10 -10
  72. package/templates/apps/api-search/package.json +8 -8
  73. package/templates/apps/api-status/package.json +8 -8
  74. package/templates/apps/api-versioning/package.json +8 -8
  75. package/templates/apps/api-webhooks/package.json +9 -9
  76. package/templates/apps/changelog/package.json +6 -6
  77. package/templates/apps/edge-functions/package.json +2 -2
  78. package/templates/apps/frontend-admin/package.json +8 -8
  79. package/templates/apps/frontend-app/package.json +9 -9
  80. package/templates/apps/frontend-auth/package.json +8 -8
  81. package/templates/apps/frontend-blank/package.json +7 -7
  82. package/templates/apps/frontend-cms/package.json +9 -9
  83. package/templates/apps/frontend-collab/package.json +10 -10
  84. package/templates/apps/frontend-contact/package.json +7 -7
  85. package/templates/apps/frontend-dashboard/package.json +7 -7
  86. package/templates/apps/frontend-docs/package.json +7 -7
  87. package/templates/apps/frontend-i18n/package.json +6 -6
  88. package/templates/apps/frontend-landing/package.json +7 -7
  89. package/templates/apps/frontend-portal/package.json +8 -8
  90. package/templates/apps/frontend-saas/package.json +8 -8
  91. package/templates/apps/frontend-spa/package.json +7 -7
  92. package/templates/apps/frontend-ssr/package.json +7 -7
  93. package/templates/apps/frontend-ssr-api/package.json +8 -8
  94. package/templates/apps/frontend-static-blog/package.json +6 -6
  95. package/templates/apps/frontend-status/package.json +8 -8
  96. package/templates/apps/mobile-app/package.json +4 -4
  97. package/dist/apiBuild-BIyGkym8.js +0 -2
  98. package/dist/dataCommand-By78lscK.js +0 -540
  99. package/dist/dataProfile-dW-PsfLB.js +0 -15
  100. package/dist/dbCommand-DcqEyxju.js +0 -2
  101. package/dist/doctorCommand-FhQ7EVYL.js +0 -2
  102. package/dist/serveCommand-BmvEQCMS.js +0 -2
  103. package/dist/updateCommand-BauSVxPp.js +0 -2
  104. package/dist/webDev-CBbKs4dZ.js +0 -2
@@ -222,7 +222,7 @@ createVerifier({ secret: [process.env.WEBHOOK_SECRET, process.env.WEBHOOK_SECRET
222
222
  ...t === void 0 ? {} : { payload: t }
223
223
  };
224
224
  }, S = u({ scope: "voltro:webhooks" }), C = ["--out", "--name"], w = async (e) => {
225
- let { walk: t, loadDiscovered: n } = await import("./dev-D8JSKlBM.js"), { outgoingFromEvents: r } = await import("./webhookDiscovery-il9ti-HE.js");
225
+ let { walk: t, loadDiscovered: n } = await import("./dev-DuqBJC7g.js"), { outgoingFromEvents: r } = await import("./webhookDiscovery-il9ti-HE.js");
226
226
  return r((await n(await t(e))).events.map((e) => ({
227
227
  file: e.file,
228
228
  descriptor: e.descriptor
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@voltro/cli",
3
- "version": "0.40.0",
3
+ "version": "0.42.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",
@@ -622,6 +622,12 @@
622
622
  "title": "a rejected credential answers `Unauthenticated`, widening three guard results",
623
623
  "kind": "manual"
624
624
  },
625
+ {
626
+ "version": "0.41.0",
627
+ "id": "0.41.0/01_middleware-defines-where-it-runs",
628
+ "title": "`middleware.ts` exports `defineMiddleware(...)` and can say where it runs",
629
+ "kind": "transform"
630
+ },
625
631
  {
626
632
  "version": "0.5.0",
627
633
  "id": "0.5.0/01_one-terminal-exactly-one",
@@ -709,22 +715,22 @@
709
715
  "@effect/platform-node": "^0.108.0",
710
716
  "@effect/sql": "^0.52.0",
711
717
  "@effect/workflow": "^0.19.0",
712
- "@voltro/ai": "0.40.0",
713
- "@voltro/cache": "0.40.0",
714
- "@voltro/data-transfer": "0.40.0",
715
- "@voltro/database": "0.40.0",
716
- "@voltro/env": "0.40.0",
717
- "@voltro/kv": "0.40.0",
718
- "@voltro/logger": "0.40.0",
719
- "@voltro/plugin-auth": "0.40.0",
720
- "@voltro/plugin-broadcast": "0.40.0",
721
- "@voltro/plugin-mail": "0.40.0",
722
- "@voltro/plugin-storage": "0.40.0",
723
- "@voltro/plugin-webhooks": "0.40.0",
724
- "@voltro/protocol": "0.40.0",
725
- "@voltro/runtime": "0.40.0",
726
- "@voltro/serverless": "0.40.0",
727
- "@voltro/workflow": "0.40.0",
718
+ "@voltro/ai": "0.42.0",
719
+ "@voltro/cache": "0.42.0",
720
+ "@voltro/data-transfer": "0.42.0",
721
+ "@voltro/database": "0.42.0",
722
+ "@voltro/env": "0.42.0",
723
+ "@voltro/kv": "0.42.0",
724
+ "@voltro/logger": "0.42.0",
725
+ "@voltro/plugin-auth": "0.42.0",
726
+ "@voltro/plugin-broadcast": "0.42.0",
727
+ "@voltro/plugin-mail": "0.42.0",
728
+ "@voltro/plugin-storage": "0.42.0",
729
+ "@voltro/plugin-webhooks": "0.42.0",
730
+ "@voltro/protocol": "0.42.0",
731
+ "@voltro/runtime": "0.42.0",
732
+ "@voltro/serverless": "0.42.0",
733
+ "@voltro/workflow": "0.42.0",
728
734
  "chokidar": "^5.0.0",
729
735
  "ioredis": "^5.11.1",
730
736
  "tinyglobby": "^0.2.17",
@@ -707,7 +707,7 @@ each plugin's own README.
707
707
 
708
708
  | Topic | Open | Summary |
709
709
  |---|---|---|
710
- | **What's new in 0.40.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. |
710
+ | **What's new in 0.42.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. |
711
711
  | 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. |
712
712
  | 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. |
713
713
  | 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.40.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. |
12
+ | **What's new in 0.42.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. |
@@ -242,7 +242,7 @@
242
242
  "group": null,
243
243
  "description": "Voltro's file-based router — pages, layouts, render modes, loaders, navigation, islands. The web side of the framework.",
244
244
  "path": "agent-docs/routing.md",
245
- "files": 8
245
+ "files": 9
246
246
  },
247
247
  {
248
248
  "id": "scheduling",
@@ -2525,6 +2525,7 @@ Both stream — a table or a blob is never fully held in memory — and both sur
2525
2525
  voltro data export ./backup-2026-07-01
2526
2526
  voltro data export ./out --tenant org_abc # one tenant + its FK closure
2527
2527
  voltro data export ./out --tables users,posts # an explicit set
2528
+ voltro data export ./out --exclude cluster_locks # everything else
2528
2529
  voltro data export ./out --assets # include stored blobs
2529
2530
  voltro data export ./out --compression gzip # zstd (default) | gzip | none
2530
2531
  voltro data export ./out --consistency snapshot # point-in-time (see below)
@@ -2596,6 +2597,11 @@ Rows are written as newline-delimited JSON — streamable, resumable by line, an
2596
2597
  | *(none)* | Every table (the default). |
2597
2598
  | `--tenant <id>` | Every `tenant()`-scoped table filtered to that tenant, **plus** the FK closure in BOTH directions: (1) the transitive **FK-parent** closure of those rows — closure-pulled shared tables (a global `users` / reference table) are **row-subset to the ids the tenant's rows actually reference**, never exported in full; and (2) the **child** closure — rows that *reference* the tenant's rows (the comments on the tenant's projects) come along too, each scoped to the ids that actually point into the tenant set. The child walk is anchored on the `tenant()` tables, so a row that references **only a shared parent** (a global `users` another tenant also references) is **not** pulled — that would be a cross-tenant leak. A `--tenant` bundle therefore carries the tenant's parents AND children and **no other tenant's rows** — that's what makes it safe as a GDPR / per-tenant takeout. |
2598
2599
  | `--tables a,b` | An explicit set (you own referential integrity; the importer's deferred-FK resolution covers load-order dangles, see below). |
2600
+ | `--exclude a,b` | Everything EXCEPT these, expanded against the live table list. Direct target only. Cannot be combined with `--tables` or `--tenant`; a name that does not exist is refused, because an exclusion that excludes nothing leaves the run looking like it worked. |
2601
+
2602
+ **Every table needs a single-column primary key.** The export is keyset-paginated, so it orders by one column and resumes from the last value on the next page. That column comes from the real primary key — a declared `id()` where there is one, otherwise the table's actual PK, whatever it is called.
2603
+
2604
+ A table with a **composite** primary key, or none at all, is **refused by name** rather than exported. Ordering by one column of a composite key splits equal values across page boundaries, which drops or duplicates rows into a bundle that reports success — and a short backup is discovered at the restore. Leave such a table out with `--exclude`.
2599
2605
 
2600
2606
  Tenant-scope details:
2601
2607
 
@@ -2619,8 +2625,42 @@ voltro data import --target api --api-url https://api.example.com --token $SECRE
2619
2625
  voltro data import ./out --assets # also restore blobs
2620
2626
  voltro data import ./out --no-verify # skip checksum/row-count verification
2621
2627
  voltro data import ./out --force # import despite schema drift AND cross-dialect warnings
2628
+ voltro data import ./out --tables users,teams # load only these tables out of the bundle
2629
+ voltro data import ./out --dry-run # report what would move; write nothing
2630
+ ```
2631
+
2632
+ **`--dry-run` and `--tables` work on BOTH targets**, including `--target api`. On the api
2633
+ path they travel as `x-import-dry-run` / `x-import-tables`, and the response echoes
2634
+ `{ "dryRun": true, "wrote": false }` so a preview is never mistaken for a write.
2635
+
2636
+ A dry run reaches every verdict a real run reaches — schema fit, cross-dialect portability,
2637
+ mode legality, the table selection — and stops at the first line that would write. It does
2638
+ NOT read per-table checksums (those stream during the load) and cannot see a conflict that
2639
+ depends on the target's current rows; the command says both out loud, because a preview
2640
+ over-read is worse than no preview.
2641
+
2642
+ `--tables` names tables the BUNDLE carries. A name it does not carry is refused, listing
2643
+ what it does — a silently-ignored table name is how a run scoped to one table writes the
2644
+ whole bundle.
2645
+
2646
+ ### A flag this command does not read is an ERROR
2647
+
2648
+ Every `voltro data` subcommand declares the flags it reads, per target, and refuses anything
2649
+ else instead of ignoring it:
2650
+
2651
+ ```text
2652
+ ✗ voltro data import: --no-verify is not read with --target api (it is a --target direct
2653
+ flag). Remove it, or change --target.
2654
+ ✗ voltro data export: --mode is not a `export` flag (it belongs to `voltro data import`).
2655
+ ✗ voltro data import: unknown flag --drynrun. Run `voltro data --help` for the flags this
2656
+ command reads.
2622
2657
  ```
2623
2658
 
2659
+ The refusal happens before anything boots, so a mistyped flag costs you a message rather
2660
+ than a run. On a command whose job is moving data into a live system, silence is the wrong
2661
+ default: an accepted-and-ignored flag turns a typo into a no-op whose only evidence of
2662
+ working is that nothing complained.
2663
+
2624
2664
  Import is **integrity-checked** (each table's checksum + row count verified as it decodes; each asset re-hashed against its content address), applies tables in **FK-parent-first order**, and a resumed run skips already-applied tables via the ledger.
2625
2665
 
2626
2666
  ### Postgres targets bulk-load via COPY
@@ -2645,16 +2685,31 @@ identical, only the speed differs.
2645
2685
 
2646
2686
  ### Schema-drift pre-flight
2647
2687
 
2648
- Every bundle records the **schema fingerprint** of the source it was exported from (a stable hash of the schema shape the same fingerprint prod boot uses to detect drift). Before touching the target, `import` **recomputes the target app's fingerprint the same way** and compares it to the bundle's. On a mismatch it **refuses, fail-closed, before any row lands** — a drifted target (a missing column, a renamed table) would otherwise fail mid-load with a raw database error after some rows already committed:
2688
+ The question the pre-flight asks is **"will the rows this bundle carries fit this target?"** not "are these two schemas identical". It compares the **intersection**: for every table the bundle carries, the columns and types must exist on the target, and a column the target REQUIRES (NOT NULL, no default) that the bundle carries no value for is refused too. On a problem it **refuses, fail-closed, before any row lands**:
2649
2689
 
2650
2690
  ```text
2651
- ✗ schema drift: the target schema (fingerprint d6ba94b0…) differs from the bundle's
2652
- source (f67942f6…). Refusing before the table phase.
2653
- items.extra: in the target, not in the bundle
2654
- Re-export against the current schema, or pass --force to import anyway.
2691
+ ✗ schema drift: refusing before the table phase.
2692
+ table 'items' is in the bundle but MISSING from the target — its rows have nowhere to go
2693
+ orders.total: type 'integer' (bundle) vs 'text' (target)
2694
+ users.region: the target requires it (NOT NULL, no default) and the bundle carries no
2695
+ value — every row of this table would fail
2655
2696
  ```
2656
2697
 
2657
- The error names the drift — the tables/columns that moved (down to per-column granularity for the tables the bundle carries) — so you can see exactly what changed. **`--force`** downgrades the refusal to a **loud warning** and proceeds (the operator has accepted that the shapes differ). Over the `--target api` path the same check runs **on the instance** against its declared schema and returns **`409` schema drift** with the fingerprints + diff; `--force` sends `x-import-force: 1` to override.
2698
+ Every line is something that would break the load. Tables and columns the target has and
2699
+ the bundle does not are **untouched by definition** and are never reported — that is the
2700
+ normal shape of any cross-environment seed, and refusing on it would make `--force` the
2701
+ routine way to run an import and take the protection with it.
2702
+
2703
+ Both fingerprints are still reported (they are what you paste when asking for help), and an
2704
+ identical pair is a fast path that skips the comparison. But **differing fingerprints are
2705
+ not on their own a refusal**: a bundle's fingerprint covers its SOURCE schema regardless of
2706
+ export scope, so a one-table export out of a 75-table database carries the 75-table
2707
+ fingerprint.
2708
+
2709
+ **`--force`** downgrades the refusal to a **loud warning** and proceeds. Over the
2710
+ `--target api` path the same check runs **on the instance** against its declared schema and
2711
+ returns **`409` schema drift** with the fingerprints + diff; `--force` sends
2712
+ `x-import-force: 1` to override.
2658
2713
 
2659
2714
  The pre-flight only runs when the importer has a target schema (the CLI introspects it; the API endpoint uses the instance's declared schema). Importing into a fresh/empty database with no comparable schema simply skips the check.
2660
2715
 
@@ -2751,6 +2806,17 @@ Both export and import checkpoint into a small **`.ledger.json`** and can be re-
2751
2806
 
2752
2807
  Net: interrupt an export or import at any point — network drop, `Ctrl-C`, OOM-killed pod — and re-running the same command finishes the job without duplicating work or corrupting the target.
2753
2808
 
2809
+ **A re-run of a COMPLETED import writes nothing, and says so.** The ledger lives in the bundle directory, so re-importing a bundle whose tables are all recorded skips every one of them. That is resume working — but the returned report counts the BUNDLE's rows either way, so `import complete … 10593 rows` would otherwise print over a target you had just truncated. The import warns instead:
2810
+
2811
+ ```text
2812
+ ⚠ resume: 5 of 5 table(s) were already applied by an earlier run of THIS bundle
2813
+ directory, so this run wrote NO rows for them — that is every table in the
2814
+ bundle, so nothing was written at all. Tables: tenants, teams, projects,
2815
+ actors, auditLogs. Delete ./out/.import.ledger.json to force a full re-import.
2816
+ ```
2817
+
2818
+ The `--target api` path is unaffected: the instance unpacks each upload into a fresh temp directory, so it never carries a ledger between runs.
2819
+
2754
2820
  ### Progress & observability
2755
2821
 
2756
2822
  A multi-hour job is not a black box. Both pipelines signal per **table** — never per row, so the reporting never slows the hot path:
@@ -2921,6 +2987,13 @@ The opt-out is `onUnclassified: 'keep'` (or `'null'`), which makes masking **fai
2921
2987
  unclassified columns. Discouraged — it defeats the guarantee; prefer classifying the column.
2922
2988
  See [why fail-closed](/docs/database/sensitivity#why-fail-closed).
2923
2989
 
2990
+ **A policy this build cannot carry out is refused too**, before a row is read. An action of a
2991
+ shape the applier does not understand — a typo like `{ action: 'fake', kind: 'email' }` where
2992
+ the shape is `{ fake: 'email' }` — used to fall through to "copy the value", so the export
2993
+ succeeded and shipped the raw column while the audit line counted it as masked. It is now a
2994
+ `MaskingError` with `invalidActions`, reported separately from `unclassified` because the two
2995
+ have different fixes: one needs a classification, the other needs the policy corrected.
2996
+
2924
2997
  ## `--dry-run` — preview without writing
2925
2998
 
2926
2999
  Preview a masking export **without writing a bundle** — the trust surface before real data
@@ -4767,6 +4767,7 @@ declared it anyway, that is harmless — the union is the same either way.
4767
4767
  | `ServerOnlyColumnWrite` | `@voltro/runtime` | A `crud.create` / `crud.update` input tried to set a [`.serverOnly()`](/docs/database/sensitivity) column — `{ table, columns }`. |
4768
4768
  | `StoreOperationFailed` | `@voltro/runtime` | The underlying store operation failed (transient). |
4769
4769
  | `TableValidationFailed` | `@voltro/runtime` | A `table().validate(Schema)` row check rejected the write. |
4770
+ | `ConstraintViolation` | `@voltro/runtime` | The database refused the write on a foreign key / unique / NOT NULL / CHECK — `{ kind, table, operation, constraint?, column? }`. See below. |
4770
4771
  | `CacheError` | `@voltro/cache` | A cache backend op failed — `{ operation, key, cause }`. |
4771
4772
  | `RateLimited` | `@voltro/plugin-ratelimit` | The limiter rejected the call — `{ limit, retryAfterMs, resetAtMs }`. |
4772
4773
  | `TenantMismatch` | `@voltro/plugin-multitenancy` | `assertOwnTenant(input.tenantId, subject)` rejected a cross-tenant write. |
@@ -4775,6 +4776,38 @@ Each plugin that ships an error (`RateLimited`, `EntitlementExceeded`, `StorageE
4775
4776
 
4776
4777
  All carry `_tag` + typed payloads, all narrow correctly on the client.
4777
4778
 
4779
+ ### When the database refuses the write
4780
+
4781
+ An integrity rule your schema declares — a `reference()`, a `.unique()`, a non-nullable column, a `.check()` — is enforced by the database, not by the handler. When it fires you get a `ConstraintViolation`:
4782
+
4783
+ ```ts
4784
+ { _tag: 'ConstraintViolation',
4785
+ kind: 'foreignKey', table: 'tasks', operation: 'insert',
4786
+ constraint: 'tasks_laneId_fkey' }
4787
+ ```
4788
+
4789
+ `kind` is the field to branch on, and it is the one field every dialect can always fill:
4790
+
4791
+ | `kind` | Means | The caller's fix |
4792
+ |---|---|---|
4793
+ | `foreignKey` | the row you referenced does not exist | point at a real row |
4794
+ | `foreignKeyInUse` | this row may not go — others still reference it | delete the children first, or don't delete |
4795
+ | `unique` | a row with this value already exists | pick another value |
4796
+ | `notNull` | the column requires a value | send one |
4797
+ | `check` | the row does not satisfy a declared `.check()` | fix the value |
4798
+
4799
+ `constraint` and `column` are filled where the dialect names them. sqlite reports a foreign-key failure as the bare sentence `FOREIGN KEY constraint failed` — no name, no column — so `constraint` is absent there and the direction is inferred from the operation.
4800
+
4801
+ **It carries names, never the driver's sentence, and that is deliberate.** The message the database produces contains row DATA on most engines: postgres attaches `Failing row contains (…)` — the *complete* row, every column, [`.sensitive()`](/docs/database/sensitivity) ones included — to a not-null and a check violation; mysql and mssql echo the duplicate value on a unique violation. A constraint name is schema, the same class of fact `TableValidationFailed.table` already puts on the wire. A row is data, and the caller who provoked the error is not automatically entitled to it. The full driver text is in the server log, with the trace id.
4802
+
4803
+ Declare it in `error:` to pattern-match it:
4804
+
4805
+ ```ts
4806
+ error: Schema.Union(ConstraintViolation, MyDomainError),
4807
+ ```
4808
+
4809
+ If you do not, it still reaches the client — collapsed to `InternalError` like any undeclared error, but carrying its own sentence (`ConstraintViolation: foreign key tasks_laneId_fkey on tasks: the referenced row does not exist`) rather than the opaque `Failed to execute statement` a raw `SqlError` produces.
4810
+
4778
4811
  ## Loader errors
4779
4812
 
4780
4813
  Loaders run in the web app — they receive `{ params, query, headers, signal }`, NOT a server `ctx` with `.store`. A loader reaches the backend through `query(...)` (the `POST /rpc` path, server-side only). It short-circuits with the branded control-flow signals `NotFoundError` / `RedirectError`:
@@ -232,6 +232,14 @@ const token = decryptField(row.token) // a non-ciphertext value returns unchan
232
232
 
233
233
  `decryptField` returns a non-`enc:v1:…` value **unchanged**, so you can switch a raw-SQL path to encryption while pre-existing plaintext rows keep working until they're re-written. Both throw a clear error if no cipher is registered (enable `fieldEncryption`). Encryption stops being all-or-nothing tied to going through `ctx.store`.
234
234
 
235
+ **One encoding, both directions.** These helpers and the store middleware write the same thing, so a value written by `encryptField` reads through `ctx.store` and vice versa. That was not always true: the store encoded JSON and the helpers did not, both under the same `enc:v1:` envelope, and a value written by one and read by the other either threw blaming the KEY — while the key was fine — or came back with the JSON quotes still on it and raised nothing.
236
+
237
+ **Rows written under the OLD encoding still read — nothing has to be rewritten.** Both forms are resolved on read, deterministically: after decrypting, a value that does not parse as JSON is the raw form, one that parses to a string is the JSON form, and one that parses to a non-string is decided by the column's declared type (a text column cannot hold a number, so `12345` is a raw string that parsed by accident). This matters because the old form is already on staging and production disks, and a fix that needs the data rewritten before the app works is an outage with a migration attached.
238
+
239
+ Normalising is optional hygiene, and `voltro db encrypt-column` does it: it re-encodes rows in the old form as it goes and reports them separately from the ones it encrypts. It skips anything ambiguous and anything it cannot decrypt.
240
+
241
+ **Backups are unaffected either way.** `voltro data export` reads through the raw store, so an encrypted column travels as ciphertext in whichever encoding it holds, and comes back unchanged.
242
+
235
243
  ## Dashboard panel
236
244
 
237
245
  Both dashboards ship a **Governance** panel (api apps): retention-policy status + field-encryption state + last-sweep results (with a **Run sweep now** button), a **GDPR runner** (enter a subject id → Export / Erase delete / Erase anonymize), a consent-ledger lookup, and the erasure log. Write-actions gate on the `canRunGovernance` capability. Backed by `/_voltro/inspect/plugins/governance/{status,erasures,consent,export,erase,sweep}`.
@@ -1160,35 +1160,12 @@ and `preload` on the page fails.
1160
1160
  You cannot fix that in a loader. `ctx.query` and every `preload` entry are bound
1161
1161
  from ONE cookie string **before any loader runs**, so a layout loader that
1162
1162
  renews the session cannot reach them. `middleware.ts` at the web app root runs
1163
- earlier than both:
1163
+ earlier than both, says which routes it covers, and writes the rotated cookie
1164
+ back so this render and the browser agree.
1164
1165
 
1165
- ```ts
1166
- // middleware.ts server-only. NOT app.config.ts, which is imported into the
1167
- // client bundle whenever an api declares `authHeaders`.
1168
- export default async (req) => {
1169
- const fresh = await refreshSession(req.cookies['sb-session'])
1170
- if (!fresh) return
1171
- return {
1172
- headers: { authorization: `Bearer ${fresh.accessToken}` },
1173
- setCookies: [{ name: 'sb-session', value: fresh.cookie, maxAge: 3600 }],
1174
- }
1175
- }
1176
- ```
1177
-
1178
- **Write the cookie back.** An IdP that rotates refresh tokens (Supabase does, and
1179
- detects reuse) will invalidate the session if you renew server-side and leave the
1180
- browser holding the consumed one. `setCookies` is not an optimisation.
1181
-
1182
- **It cannot redirect or refuse a request**, deliberately: authorization belongs
1183
- on the api, which is the only thing that sees the data, and a web-side hook that
1184
- could refuse would be a second authorization layer beside the real one. For a
1185
- login redirect, throw `RedirectError` from the loader.
1186
-
1187
- Headers you return are merged over the request's, and only auth-shaped ones
1188
- (`authorization`, `x-tenant`, `x-voltro-*`) are forwarded to the api. Cookies
1189
- default to `HttpOnly`, `Path=/`, `SameSite=lax`. The file is loaded once per
1190
- boot; if it throws, the request fails rather than rendering with the credential
1191
- you asked to replace.
1166
+ See [Middleware](/docs/routing/middleware) for the full contract: `match`
1167
+ (`under` / `routes` / `except` / `assets`), the one-middleware-per-route rule,
1168
+ and what it deliberately cannot do.
1192
1169
 
1193
1170
  ## Errors from loaders
1194
1171
 
@@ -2002,3 +1979,139 @@ matches (workspace link and npm install alike).
2002
1979
  How to verify: `curl -sS http://localhost:<port>/@fs/<abs>/src/globals.css | grep
2003
1980
  animate-` should list every kit animation utility you use. If a class is missing,
2004
1981
  the `@source` is misconfigured.
1982
+
1983
+
1984
+
1985
+ ---
1986
+
1987
+ <!-- source: en/routing/middleware.md -->
1988
+ ## Middleware
1989
+
1990
+ _'`middleware.ts` — the web app''s one server-only hook: renew a credential before the SSR render uses it, and say which routes it runs on.'_
1991
+
1992
+ `middleware.ts` at the web app root runs **before** a server render binds its data. It exists for one job — renewing a credential — and it is deliberately narrow about everything else.
1993
+
1994
+ ```ts
1995
+ // middleware.ts — server-only. NOT app.config.ts, which is imported into the
1996
+ // client bundle whenever an api declares `authHeaders`.
1997
+ import { defineMiddleware } from '@voltro/web/middleware'
1998
+
1999
+ export const session = defineMiddleware({
2000
+ match: { under: '/app' },
2001
+ run: async (req) => {
2002
+ const fresh = await refreshSession(req.cookies['sb-session'])
2003
+ if (!fresh) return
2004
+ return {
2005
+ headers: { authorization: `Bearer ${fresh.accessToken}` },
2006
+ setCookies: [{ name: 'sb-session', value: fresh.cookie, maxAge: 3600 }],
2007
+ }
2008
+ },
2009
+ })
2010
+ ```
2011
+
2012
+ ## The problem it solves
2013
+
2014
+ A cookie older than the IdP's token lifetime — practically every first page view of the day for a 1-hour token — makes the api resolve the caller to anonymous, and every loader and `preload` on the page fails.
2015
+
2016
+ **You cannot fix that in a loader.** `ctx.query` and every `preload` entry are bound from ONE cookie string *before any loader runs*, so a layout loader that renews the session cannot reach them. The middleware runs earlier than both.
2017
+
2018
+ ## What it receives, and what it can return
2019
+
2020
+ `run` gets a read-only request and returns `{ headers?, setCookies? }` — or nothing, to change nothing.
2021
+
2022
+ | Field | |
2023
+ |---|---|
2024
+ | `req.pathname` | matched path, no query string |
2025
+ | `req.search` | raw query string including `?`, or `''` |
2026
+ | `req.headers` | incoming headers, lowercased keys |
2027
+ | `req.cookies` | the parsed `Cookie` header |
2028
+ | `req.route` | the matched **route pattern** (`/notes/[id]`), or `undefined` for a non-page request |
2029
+
2030
+ Returned headers are merged over the request's, and only auth-shaped names (`authorization`, `x-tenant`, `x-voltro-*`) are forwarded to the api — a returned `host` would otherwise produce failures that look like anything but a header copy. Cookies default to `HttpOnly`, `Path=/`, `SameSite=lax`, and several are written as separate header lines, never comma-joined (a cookie's `Expires` contains a comma).
2031
+
2032
+ **Write the cookie back.** An IdP that rotates refresh tokens (Supabase does, and detects reuse) will invalidate the session if you renew server-side and leave the browser holding the consumed one. `setCookies` is not an optimisation.
2033
+
2034
+ **A cookie your IdP SDK reads in the browser needs `httpOnly: false`.** The default is `HttpOnly` — right for a cookie only the server touches, and wrong for this one. Supabase's `createBrowserClient` reads the session from `document.cookie`, so a forgotten `false` hands the browser a session it cannot see: the SSR render is perfect, every server-side check passes, and the user is signed out at the first client-side call. `voltro dev` warns once per cookie when a session-shaped name is written with no `httpOnly` decision; setting it explicitly either way silences that.
2035
+
2036
+ ## What you return applies to THIS render
2037
+
2038
+ The middleware produces one view of the request that everything downstream reads:
2039
+
2040
+ | Reader | sees |
2041
+ |---|---|
2042
+ | `ctx.query` and every `preload` | your headers, and the renewed cookie |
2043
+ | `ctx.headers` in a loader | your headers |
2044
+ | `useServerRequest().cookies` / `.headers` | the jar after `setCookies` was applied |
2045
+ | locale resolution (`cookie`, `accept-language`) | the same jar |
2046
+
2047
+ So a hook that renews **only** via `setCookies` — no `headers` at all, which is the normal shape for a cookie-session IdP — still authenticates this render's rpc calls: the `Cookie` header is rebuilt from the updated jar. A `maxAge` of `0` deletes, so a hook that signs someone out renders them signed out. If you return an explicit `cookie` header yourself, yours wins.
2048
+
2049
+ ## `match` — where it runs
2050
+
2051
+ Without a `match`, a middleware runs on every server-rendered route, including your marketing pages. That is an IdP round trip on the page least able to afford one.
2052
+
2053
+ ```ts
2054
+ import { defineMiddleware } from '@voltro/web/middleware'
2055
+
2056
+ export const session = defineMiddleware({
2057
+ match: { under: '/app', except: ['/app/public'] },
2058
+ run: async (req) => { /* … */ },
2059
+ })
2060
+
2061
+ export const adminTenant = defineMiddleware({
2062
+ match: { under: '/admin' },
2063
+ run: async () => ({ headers: { 'x-tenant': 'ops' } }),
2064
+ })
2065
+ ```
2066
+
2067
+ | Field | Means |
2068
+ |---|---|
2069
+ | `under` | a route subtree — `/app` covers `/app` and everything below it, on segment boundaries (never `/application`). A string or an array. |
2070
+ | `routes` | exact route patterns, as the router spells them: `/notes/[id]`. |
2071
+ | `except` | subtrees or patterns to subtract from the two above. |
2072
+ | `assets` | also run on requests that matched no page. Off by default. |
2073
+
2074
+ **Every path here is a ROUTE path, checked against your routes at boot.** A `under: '/ap'` that covers nothing refuses the boot; it does not become a middleware that quietly never fires. An `except` that excludes nothing is reported the same way — it reads as an active rule and is not.
2075
+
2076
+ That is the deliberate difference from the URL-pattern shape you may have met elsewhere:
2077
+
2078
+ ```ts
2079
+ matcher: '/((?!api|_next/static|_next/image|favicon.ico).*)'
2080
+ ```
2081
+
2082
+ You have to know your own asset layout to write that, it breaks when a build tool renames a directory, and it breaks silently. Voltro's hook runs **after** route matching — framework URLs, anything with a file extension, and anything matching no page are already gone — so an app has never had to know an asset path.
2083
+
2084
+ ## One middleware per route
2085
+
2086
+ **At most one middleware may match a given route.** Two hooks writing one `authorization` header have no defensible winner, so an overlap refuses the boot and names both plus the route:
2087
+
2088
+ ```
2089
+ ✗ middleware.ts: 1 problem(s)
2090
+
2091
+ • `session` and `admin` both match /app/admin. A route may have at most one
2092
+ middleware — two hooks writing one `authorization` header have no defined
2093
+ winner. Narrow one with `except`, or fold them into one middleware that
2094
+ branches on `req.route`.
2095
+ ```
2096
+
2097
+ Declaration order is not a semantic, "most specific wins" would silently drop the broader hook — for a session renewal, that means a subtree stops renewing with nothing red anywhere — and merging needs a per-field rule nobody remembers.
2098
+
2099
+ `voltro doctor` reports overlaps and dead matchers before you deploy, and says so out loud when a matcher is built from a variable and it could not read it statically.
2100
+
2101
+ ## Reaching non-page requests
2102
+
2103
+ `assets: true` extends a middleware to requests that matched no page — your files, and paths the router does not serve. It is opt-in and narrow on purpose: there is no render and no rpc call on such a request, so `headers` has nothing to act on and **only `setCookies` takes effect**. The framework's own surface (`/@vite/*`, `/_voltro/*`) is never reachable.
2104
+
2105
+ ## What it deliberately cannot do
2106
+
2107
+ **It cannot redirect or refuse a request.** Authorization belongs on the api, which is the only thing that sees the data; a web-side hook that could refuse would be a second authorization layer beside the real one, and a hook that cannot refuse also cannot be mistaken for a guard. For a login redirect, throw `RedirectError` from the loader.
2108
+
2109
+ It also cannot live in `app.config.ts`: that file is imported into the client bundle whenever an api declares `authHeaders`, and a hook that renews a session reaches for an IdP SDK by definition.
2110
+
2111
+ ## Lifecycle
2112
+
2113
+ The file is loaded **once per boot** — it is app code with a stable identity, and re-importing per request would rebuild whatever an IdP client constructs at module level. A failure to import is fatal rather than degrading to "the app has none", and a middleware that throws fails the request: the render must not proceed on the credential the hook was told to replace.
2114
+
2115
+ **`voltro dev` therefore RESTARTS when you edit it**, the same way a hard-restart field in `app.config.ts` does, and says so in the log. Once-per-boot is documented, and it is still the rule most easily forgotten — everything else in a dev server hot-reloads, so a sabotaged middleware that changes nothing reads as a hook that was never wired.
2116
+
2117
+ It runs on both SSR boot paths, `voltro dev` and `voltro start`, with the cookies written on every response arm.
@@ -51,6 +51,30 @@ job with no diff to review. Full detail — including how plugin routes are
51
51
  covered per-request in the dispatch spine rather than by the boot gate — in
52
52
  [Authorization](/docs/authentication/authorization).
53
53
 
54
+ ### A decode failure on a guarded procedure is NOT a guard verdict
55
+
56
+ The payload is decoded before the handler runs, so a guard on a procedure with a
57
+ malformed payload never gets the chance to refuse. Calling one with an incomplete
58
+ payload therefore answers with a decode error, not a `ScopeError` — and reading
59
+ that as "the guard is not applied" is the wrong conclusion in the dangerous
60
+ direction.
61
+
62
+ A consumer reached exactly that conclusion while auditing a guard, and flipped
63
+ back the moment they sent a complete payload. So a guarded procedure says it in
64
+ the failure:
65
+
66
+ ```
67
+ workAreas.create input (guarded — the guard did NOT run: the payload failed to
68
+ decode first, so this says nothing about access)
69
+ └─ ["type"] └─ is missing
70
+ ```
71
+
72
+ **Measure a guard with a VALID payload.** The ordering itself is not fixable at
73
+ that seam — the request never reaches the handler or the auth middleware, so
74
+ "evaluate guards first" means replacing the protocol layer rather than annotating
75
+ a schema. An `openAccess:` declaration is not an enforced guard and gets no such
76
+ sentence.
77
+
54
78
  ## Outbound HTTP is SSRF-guarded by default
55
79
 
56
80
  The `HttpClient` your handlers `yield*` refuses internal targets: