@voltro/cli 0.22.1 → 0.23.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 (65) hide show
  1. package/CHANGELOG.md +316 -0
  2. package/dist/apiBuild-CD-4JLLA.js +2 -0
  3. package/dist/{apiBuild-DiWxVz-M.js → apiBuild-JQtIhZPy.js} +2 -2
  4. package/dist/bin.js +3 -3
  5. package/dist/{commands-BxRaIOBG.js → commands-Bsu9Buln.js} +1767 -1501
  6. package/dist/dbCommand-CSguuUQn.js +2 -0
  7. package/dist/{dbCommand-DVASmZj2.js → dbCommand-GI7-BVZt.js} +440 -259
  8. package/dist/{dev-Dd3EZzj5.js → dev-DRxF_qOB.js} +1 -1
  9. package/dist/{dev-BRiPgbKw.js → dev-jM07kq_D.js} +1479 -1479
  10. package/dist/index.js +1 -1
  11. package/dist/{inspectMetrics-9ZSuDeqD.js → inspectMetrics-dcC7gnxu.js} +566 -552
  12. package/dist/{serveCommand-DXJOARkO.js → serveCommand-Cl8sicD5.js} +280 -279
  13. package/dist/serveEntry.js +2 -2
  14. package/dist/{start-CsIjcOi-.js → start-C_4VUDT8.js} +327 -325
  15. package/dist/startEntry.js +2 -2
  16. package/package.json +17 -17
  17. package/templates/AGENTS.md +1 -1
  18. package/templates/agent-docs/_index.md +1 -1
  19. package/templates/agent-docs/_manifest.json +1 -1
  20. package/templates/agent-docs/caching.md +21 -5
  21. package/templates/agent-docs/cli.md +44 -0
  22. package/templates/agent-docs/data.md +23 -5
  23. package/templates/agent-docs/database/migrations.md +94 -0
  24. package/templates/agent-docs/plugins.md +42 -0
  25. package/templates/agent-docs/routing.md +23 -1
  26. package/templates/agent-docs/templates/apibackends.md +2 -2
  27. package/templates/agent-docs/whats-new.md +281 -32
  28. package/templates/apps/api-ai/package.json +7 -7
  29. package/templates/apps/api-auth/package.json +8 -8
  30. package/templates/apps/api-backend/package.json +7 -7
  31. package/templates/apps/api-backend-deactivation/package.json +7 -7
  32. package/templates/apps/api-backend-mail/package.json +8 -8
  33. package/templates/apps/api-backend-mariadb/package.json +9 -9
  34. package/templates/apps/api-backend-storage/package.json +8 -8
  35. package/templates/apps/api-data-advanced/package.json +8 -8
  36. package/templates/apps/api-durable/package.json +8 -8
  37. package/templates/apps/api-feature-flags/package.json +9 -9
  38. package/templates/apps/api-governance/package.json +8 -8
  39. package/templates/apps/api-kv/package.json +8 -8
  40. package/templates/apps/api-moderation/package.json +8 -8
  41. package/templates/apps/api-observability/package.json +8 -8
  42. package/templates/apps/api-ratelimit/package.json +8 -8
  43. package/templates/apps/api-rbac/package.json +8 -8
  44. package/templates/apps/api-rest/package.json +7 -7
  45. package/templates/apps/api-saas/package.json +11 -11
  46. package/templates/apps/api-search/package.json +8 -8
  47. package/templates/apps/api-versioning/app.config.ts +6 -4
  48. package/templates/apps/api-versioning/package.json +8 -8
  49. package/templates/apps/api-webhooks/package.json +9 -9
  50. package/templates/apps/changelog/package.json +6 -6
  51. package/templates/apps/edge-functions/package.json +2 -2
  52. package/templates/apps/frontend-admin/package.json +8 -8
  53. package/templates/apps/frontend-app/package.json +8 -8
  54. package/templates/apps/frontend-blank/package.json +7 -7
  55. package/templates/apps/frontend-contact/package.json +7 -7
  56. package/templates/apps/frontend-dashboard/package.json +7 -7
  57. package/templates/apps/frontend-docs/package.json +7 -7
  58. package/templates/apps/frontend-i18n/package.json +6 -6
  59. package/templates/apps/frontend-landing/package.json +7 -7
  60. package/templates/apps/frontend-spa/package.json +7 -7
  61. package/templates/apps/frontend-ssr/package.json +7 -7
  62. package/templates/apps/frontend-ssr-api/package.json +8 -8
  63. package/templates/apps/frontend-static-blog/package.json +6 -6
  64. package/dist/apiBuild-COyPDf3R.js +0 -2
  65. package/dist/dbCommand-CO3eSAZR.js +0 -2
@@ -1,3 +1,3 @@
1
- import { X as e } from "./inspectMetrics-9ZSuDeqD.js";
2
- import { t } from "./start-CsIjcOi-.js";
1
+ import { Z as e } from "./inspectMetrics-dcC7gnxu.js";
2
+ import { t } from "./start-C_4VUDT8.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.22.1",
3
+ "version": "0.23.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.108.0",
63
63
  "@effect/sql": "^0.52.0",
64
64
  "@effect/workflow": "^0.19.0",
65
- "@voltro/ai": "0.22.1",
66
- "@voltro/cache": "0.22.1",
67
- "@voltro/data-transfer": "0.22.1",
68
- "@voltro/database": "0.22.1",
69
- "@voltro/env": "0.22.1",
70
- "@voltro/kv": "0.22.1",
71
- "@voltro/logger": "0.22.1",
72
- "@voltro/plugin-auth": "0.22.1",
73
- "@voltro/plugin-broadcast": "0.22.1",
74
- "@voltro/plugin-mail": "0.22.1",
75
- "@voltro/plugin-storage": "0.22.1",
76
- "@voltro/plugin-webhooks": "0.22.1",
77
- "@voltro/protocol": "0.22.1",
78
- "@voltro/runtime": "0.22.1",
79
- "@voltro/serverless": "0.22.1",
80
- "@voltro/workflow": "0.22.1",
65
+ "@voltro/ai": "0.23.0",
66
+ "@voltro/cache": "0.23.0",
67
+ "@voltro/data-transfer": "0.23.0",
68
+ "@voltro/database": "0.23.0",
69
+ "@voltro/env": "0.23.0",
70
+ "@voltro/kv": "0.23.0",
71
+ "@voltro/logger": "0.23.0",
72
+ "@voltro/plugin-auth": "0.23.0",
73
+ "@voltro/plugin-broadcast": "0.23.0",
74
+ "@voltro/plugin-mail": "0.23.0",
75
+ "@voltro/plugin-storage": "0.23.0",
76
+ "@voltro/plugin-webhooks": "0.23.0",
77
+ "@voltro/protocol": "0.23.0",
78
+ "@voltro/runtime": "0.23.0",
79
+ "@voltro/serverless": "0.23.0",
80
+ "@voltro/workflow": "0.23.0",
81
81
  "chokidar": "^5.0.0",
82
82
  "ioredis": "^5.11.1",
83
83
  "tinyglobby": "^0.2.17",
@@ -595,7 +595,7 @@ each plugin's own README.
595
595
 
596
596
  | Topic | Open | Summary |
597
597
  |---|---|---|
598
- | **What's new in 0.22.1** | `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. |
598
+ | **What's new in 0.23.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. |
599
599
  | 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. |
600
600
  | 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. |
601
601
  | 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.22.1** | `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.23.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. |
@@ -89,7 +89,7 @@
89
89
  "group": "database.migrations",
90
90
  "description": "Voltro's planner-based migration system — diff your declared schema against the live DB, classify each change, refuse-to-apply anything risky without explicit intent. Dev auto-applies, prod refuses.",
91
91
  "path": "agent-docs/database/migrations.md",
92
- "files": 16
92
+ "files": 17
93
93
  },
94
94
  {
95
95
  "id": "database/misc",
@@ -286,12 +286,13 @@ The matching `.<primitive>.server.ts` is **unchanged** — caching is a descript
286
286
 
287
287
  ## `scope` is required — and it's a security decision
288
288
 
289
- `scope` has **no default**, because guessing wrong leaks one user's rows to another. The rubric is one question:
289
+ `scope` has **no default**, because guessing wrong leaks one user's rows to another. The rubric is one question, with **three** answers:
290
290
 
291
- > **Does the resolved query depend on the caller?**
291
+ > **Does the resolved query depend on the caller — and on what about them?**
292
292
 
293
- - **Yes** — tenant-filtered, `where authorId = me`, anything row-scoped → **`scope: 'subject'`**. The cache key includes the caller's subject id, so two subjects can never share an entry.
294
- - **No** — the same rows for everyone (reference / lookup data) → **`scope: 'global'`**. One entry shared across all callers.
293
+ - **On the PERSON** — `where authorId = me`, anything row-scoped → **`scope: 'subject'`**. The cache key includes the caller's subject id, so two subjects can never share an entry.
294
+ - **On their ORG only** — an org-wide figure every colleague sees identically → **`scope: 'tenant'`**. One entry per `tenantId`, never shared across orgs.
295
+ - **On neither** — the same rows for everyone (reference / lookup data) → **`scope: 'global'`**. One entry shared across all callers.
295
296
 
296
297
  ```ts
297
298
  // reference data — identical for everyone → global
@@ -302,9 +303,24 @@ export const listCountries = defineQuery({
302
303
  output: Country,
303
304
  cache: { ttl: '1h', scope: 'global' },
304
305
  })
306
+
307
+ // an org-wide statistic — same for all 18 colleagues, never across orgs
308
+ export const last12Months = defineQuery({
309
+ name: 'globalStatistics.last12Months',
310
+ source: ['invoices', 'employees'],
311
+ input: Schema.Struct({}),
312
+ output: Stats,
313
+ cache: { ttl: '5m', scope: 'tenant' },
314
+ })
305
315
  ```
306
316
 
307
- **Never put `scope: 'global'` on a subject-filtered query.** Tenant tables are auto-scoped per subject by the runtime, so a `global` cache over one would serve tenant A's rows to tenant B. When in doubt, use `subject`.
317
+ `'tenant'` exists because the other two were the only options and neither fit an org-wide figure: `'subject'` recomputes it per person eighteen identical computations of the same nine-table statistic for an eighteen-person org and `'global'` shares one entry across tenant boundaries, which for data derived from `subject.tenantId` is not a cache but a leak.
318
+
319
+ A caller with no `tenantId` (an anonymous or system subject) **bypasses** a `'tenant'` cache rather than sharing a null-keyed entry.
320
+
321
+ **Never put `scope: 'global'` on a subject- or tenant-filtered query.** Tenant tables are auto-scoped by the runtime, so a `global` cache over one would serve tenant A's rows to tenant B. **The boot audit checks this**: a `'global'` scope over a `tenant()`-scoped table is reported by `voltro dev` and refused under `VOLTRO_SERVER_ONLY=strict`. It stays silent for `'global'` on reference data — the case the option exists for — and for a query with no declared `source`, where it has nothing to reason about.
322
+
323
+ **`scope: 'tenant'` is not a replacement for modelling.** For a rollup, an aggregate with `tenantId` as an indexed column puts the tenant boundary in the *data* rather than in a cache key, which is better. `'tenant'` is for the other case: a query that must be FRESH and is merely expensive, where an aggregate's refresh interval is the wrong instrument.
308
324
 
309
325
  ## How auto-invalidation works
310
326
 
@@ -1069,6 +1069,21 @@ source counts as a guard, so `requireTeamAccess()` is recognised without any
1069
1069
  configuration. Without that the scan would report every call site of your own
1070
1070
  guards, which is the failure mode that makes a check ignorable.
1071
1071
 
1072
+ It reads your **whole source tree** for those names, not just the
1073
+ convention-named files — guards live in `lib/access.ts`, not in `*.mutation.ts`.
1074
+ The line above the counts tells you what it found, and it is worth reading before
1075
+ you trust the numbers:
1076
+
1077
+ ```txt
1078
+ guard vocabulary: 17 from your source (requireTeamAccess, assertInquiryAccess, …)
1079
+ ```
1080
+
1081
+ If it instead says `framework names only — no exported require*/assert* found in
1082
+ this app` while you know you export some, the counts below it are not meaningful:
1083
+ every call site of your own guards is being reported as unguarded. Check that
1084
+ they are `export`ed and that the name starts with `require` / `assert` followed
1085
+ by a capital.
1086
+
1072
1087
  **An inline ownership check is informational.** `row.userId !== subject.id → new
1073
1088
  AccessDeniedError({})` is correct code — it is listed so you can see where the
1074
1089
  rule lives in a handler rather than on a descriptor, and it never fails the run.
@@ -2606,6 +2621,35 @@ voltro update --codemods-only --from 0.5.0 --to 0.6.0
2606
2621
 
2607
2622
  Every `@voltro/*` entry in `dependencies` and `devDependencies` — in every workspace member, see above — with the range style preserved (`^0.3.0` stays caret, `~0.3.0` stays tilde) unless you pass `--exact`. Non-registry specs (`workspace:*`, `catalog:`, `link:`, …) are left untouched — they're already resolved by your monorepo or catalog.
2608
2623
 
2624
+ ### And the peer dependencies the framework requires
2625
+
2626
+ `@effect/*` are **peer** dependencies, so your app declares them directly. When a
2627
+ release moves its peer range, bumping only `@voltro/*` leaves you installed
2628
+ against the old ones:
2629
+
2630
+ ```txt
2631
+ Aligning peer dependencies the framework requires:
2632
+ @effect/rpc ^0.75.1 → ^0.76.0 (apps/api/package.json)
2633
+ @effect/platform ^0.96.2 → ^0.97.0 (apps/api/package.json)
2634
+ ```
2635
+
2636
+ `update` reads those requirements off the freshly installed `@voltro/*` packages
2637
+ and re-installs if anything moved. Without it your package manager only *warns*,
2638
+ and the app compiles and boots on a graph the framework was never tested against
2639
+ — which is the failure mode with no symptom until there is one.
2640
+
2641
+ It is deliberately conservative:
2642
+
2643
+ - **Only peers you already declare.** One resolved transitively is not `update`'s
2644
+ to add.
2645
+ - **Only when your range is genuinely lower.** Pinned ahead, or pinned exactly at
2646
+ the floor (`0.76.0` vs `^0.76.0`), is left alone — that is a choice.
2647
+ - **Only ranges it can judge** (`^`, `~`, `>=`, exact). A union (`^1 || ^2`), a
2648
+ bounded range, `workspace:` / `catalog:` — untouched.
2649
+
2650
+ If two framework packages disagree about one peer, it says so and changes
2651
+ nothing: that is our bug, not yours to absorb silently.
2652
+
2609
2653
  ## When the install cannot run on this host
2610
2654
 
2611
2655
  Some projects install in a container with their own store, from an offline
@@ -953,7 +953,11 @@ try {
953
953
 
954
954
  **Trace id for debugging.** An error caught from `useMutation` / `useAction` carries a **non-enumerable `__voltroTraceId`** — the bridge to the server logs for that exact call:
955
955
 
956
+ ```ts
957
+ const traceId = (err as { __voltroTraceId?: string }).__voltroTraceId
958
+ // → `voltro logs --trace <traceId>` to see the server-side span
956
959
  ```
960
+
957
961
  `errorTag` lives in `@voltro/protocol` rather than in the client for a reason
958
962
  worth knowing before you decide where your own error handling goes: **`_tag` is a
959
963
  wire concept, and protocol owns the wire.** So a shared error handler in a package
@@ -961,10 +965,6 @@ that has no business depending on `@voltro/client` — a UI kit, an i18n layer
961
965
  can read a tag without taking that dependency. `instanceof` is the thing that
962
966
  does NOT survive the wire: what arrives in the browser was decoded from JSON and
963
967
  never constructed, so match on the tag, not on the class.
964
- ts
965
- const traceId = (err as { __voltroTraceId?: string }).__voltroTraceId
966
- // → `voltro logs --trace <traceId>` to see the server-side span
967
- ```
968
968
 
969
969
  **Exhaustive matching with the generated `matchError`.** Codegen emits a per-app `matchError` (plus `AppError` / `AppErrorTag`) into `rpcGroup.generated.ts`, derived by reference from every descriptor's `error:` schema + your plugins' cross-cutting errors — so there's no hand-maintained tag list to drift out of date (a dead/renamed tag is a compile error):
970
970
 
@@ -1009,6 +1009,24 @@ nobody wrote a client call for it, it is on the wire — the same reasoning as
1009
1009
  whatever authority its caller has. This removes the wire surface, not the need to
1010
1010
  check who is asking; `voltro doctor`'s authz scan still covers it.
1011
1011
 
1012
+ **It cannot be combined with `publicApi` or `exposeAsTool`.** Those add a REST
1013
+ route and an agent tool respectively — opt-ins to a *different* surface — so a
1014
+ procedure carrying both would be unreachable from your own client and reachable
1015
+ from the internet. That combination throws where it is declared:
1016
+
1017
+ ```text
1018
+ auditLog.createFromAction: `internal: true` cannot be combined with `publicApi`.
1019
+ ```
1020
+
1021
+ Neither silent resolution would be right: dropping the REST route breaks a live
1022
+ endpoint invisibly, and keeping it defeats the flag. Drop `internal: true` if the
1023
+ wider surface is intended, or remove the annotation if it is not.
1024
+
1025
+ The flag also removes the procedure from `voltro dev`'s inspect invoker, so the
1026
+ devtools "invoke" panel will not list it. That is deliberate — an internal
1027
+ procedure is the one most likely to carry no guard, since "only server code calls
1028
+ this" is the reason people write them.
1029
+
1012
1030
  ## When Not To Use A Mutation
1013
1031
 
1014
1032
  - **External I/O.** Use an action or workflow.
@@ -3522,7 +3540,7 @@ plugin's `tables` option, then read a timeline or restore a snapshot:
3522
3540
  import { versioningPlugin, rowHistory, restoreAsOf } from '@voltro/plugin-versioning'
3523
3541
 
3524
3542
  // Register in your app's plugin list:
3525
- versioningPlugin({ tables: ['blogPost_published', 'blogPost_drafts'] })
3543
+ versioningPlugin({})
3526
3544
 
3527
3545
  const timeline = await rowHistory(ctx.store, 'blogPost_published', postId, tenantId)
3528
3546
  await restoreAsOf(ctx.store, 'blogPost_published', postId, tenantId, someEarlierDate)
@@ -4,6 +4,69 @@
4
4
 
5
5
 
6
6
 
7
+ ---
8
+
9
+ <!-- source: en/database/migrations/adopt.md -->
10
+ ## Adopting a table into a plugin's
11
+
12
+ _voltro db adopt — move an app's grown table into a plugin-owned one, with a snapshot, a count verify and the drop last._
13
+
14
+ An app that did not start on a green field already has a table for half the plugins it installs. `voltro db adopt` is the way **into** the plugin's table, so using the plugin does not mean running a second source of truth beside it.
15
+
16
+ ```bash
17
+ voltro db adopt --from ai_flows --into _voltro_ai_flows --map ./ai-flows.map.ts
18
+ voltro db adopt --from ai_flows --into _voltro_ai_flows --map ./ai-flows.map.ts --apply
19
+ ```
20
+
21
+ **Dry run unless you pass `--apply`.** The interesting failure here is irreversible and the interesting output is the refusal, so nothing is written until you say so. A refused plan prints no steps at all, rather than a preview of something that will not happen.
22
+
23
+ ## The map file is yours
24
+
25
+ ```ts
26
+ // ai-flows.map.ts
27
+ export default {
28
+ map: {
29
+ name: 'name',
30
+ mode: { expr: `CASE WHEN "allowDeviation" THEN 'agentic' ELSE 'deterministic' END` },
31
+ costMicroUsd: { expr: '"totalCostCents" * 10000' },
32
+ },
33
+ leaveUnset: ['id'],
34
+ }
35
+ ```
36
+
37
+ Read `target: source` — fill the plugin's column FROM this expression of mine, which is the direction the SQL runs. A string is a source column; `{ expr }` is raw SQL against the source row, for the unit conversions and merged fields no tool can infer. Those are domain knowledge, and a tool inventing them silently corrupts data.
38
+
39
+ `leaveUnset` is how "the target's own default fills this" stops looking like "I forgot it".
40
+
41
+ ## What it refuses
42
+
43
+ - **a NOT NULL target column nobody maps to** — the alternative is a silent zero that reads as real data forever after;
44
+ - **a target table that already holds rows** — adopt MOVES rows into a table, it does not merge into one somebody else already wrote;
45
+ - **a typo on either side of the map.**
46
+
47
+ A source column nobody carries across is reported but not fatal: dropping a dead column is deliberate often enough, and "I forgot this" and "I decided" look identical in a map file.
48
+
49
+ ## The order, and why the drop is last
50
+
51
+ 1. **snapshot** — `<table>__adopt_snapshot`, a real table in the same database, so restoring is a statement rather than an operational procedure at 2am. It keeps the columns the adopt left behind.
52
+ 2. **copy**
53
+ 3. **verify by count** — this catches the one failure that is otherwise invisible: a `WHERE` inside a raw expression silently dropping rows.
54
+ 4. **drop the source** — last, and only if the counts match.
55
+
56
+ On a mismatch **both tables stay** and the command says so. The snapshot is never removed after a failed verify — it exists for exactly the run that goes wrong. `--keep-source` copies and verifies without dropping at all.
57
+
58
+ ## Ids, if the typeid prefixes differ
59
+
60
+ The dry run says so before anything runs, because discovering it after the copy is discovering it too late: every row gets a new id, so every reference to the old table has to be rewritten — **including ids embedded in JSON columns**.
61
+
62
+ Rewriting them is **not** automatic. Those ids live in your columns and inside your JSON, and only you know where. The translation table is what the command owes you; the rewrite is what you owe yourself. Doing it automatically is the one place here where being wrong would be silent.
63
+
64
+ ## After the move
65
+
66
+ Your rows are now in a table whose shape the framework evolves — and nothing special happens to them. They migrate exactly like every other row, through the same declarative differ. A narrowing can fail on your data, loudly, the same way it would on anybody's.
67
+
68
+
69
+
7
70
  ---
8
71
 
9
72
  <!-- source: en/database/migrations/index.md -->
@@ -1556,6 +1619,37 @@ no-ops out.
1556
1619
 
1557
1620
  The cloud dashboard surfaces per-env state with a multi-env tab in the [cloud UI](./cloud-ui.md).
1558
1621
 
1622
+ ## File-based migrations in this pipeline
1623
+
1624
+ `voltro db apply` runs pending `migrations/*.ts` **first**, then diffs — the same order the boot path uses.
1625
+
1626
+ `voltro db apply --plan plan.json` does **not** run them. It **refuses** when any are pending:
1627
+
1628
+ ```txt
1629
+ db apply --plan: refusing — 2 pending file-based migration(s).
1630
+ 20260714_090000_split_full_name
1631
+ 20260721_143000_backfill_slug
1632
+
1633
+ These perform the changes a state diff cannot infer, so they change the shape
1634
+ this plan was computed against. Apply them and regenerate the plan:
1635
+
1636
+ voltro db files .
1637
+ voltro db plan --json > plan.json
1638
+ voltro db apply --plan plan.json
1639
+ ```
1640
+
1641
+ That is not caution for its own sake. A saved plan was computed and reviewed against an earlier state; a file migration performs exactly the kind of change (a table split, a cross-table data move) that makes the plan stale. Running the migrations first would trip the fingerprint guard immediately afterwards and leave a half-applied deploy; running them after would apply a plan reviewed against a state that no longer exists.
1642
+
1643
+ **So a pipeline that uses the saved-plan form needs `voltro db files` as its own step**, before the plan is generated:
1644
+
1645
+ ```bash
1646
+ voltro db files . # authored data steps
1647
+ voltro db plan --json > plan.json # diff, now against the corrected shape
1648
+ voltro db apply --plan plan.json # reviewed, fingerprint-guarded
1649
+ ```
1650
+
1651
+ If you use plain `voltro db apply` instead, the first step is already included.
1652
+
1559
1653
 
1560
1654
 
1561
1655
  ---
@@ -132,6 +132,48 @@ Order matters: the framework composes outer→inner, so the rate-limit intercept
132
132
  | `onInstall` / `onActivate` / `onDeactivate` / `onUninstall` | Lifecycle hooks at first-install, boot, shutdown, and removal. |
133
133
  | Schema mixins (`defineMixin`) | The OTHER plugin shape — `audit()`, `tenant()`, `softDelete()` — declared in `@voltro/database`, not via the runtime contract. |
134
134
 
135
+ ## Pointing YOUR table at a plugin's row
136
+
137
+ Every table-carrying plugin exports its table handles, so a column in your schema
138
+ can reference one exactly like it references your own:
139
+
140
+ ```ts
141
+ import { aiFlowsTable } from '@voltro/plugin-ai-flows'
142
+ import { id, reference, table, text } from '@voltro/database'
143
+
144
+ export const flowFavourites = table('flow_favourites', {
145
+ id: id({ prefix: 'fav' }),
146
+ employeeId: reference(() => employees, { onDelete: 'cascade' }),
147
+ // A real foreign key across the plugin boundary. Deleting the flow removes
148
+ // the favourite; the DATABASE enforces it, so no cleanup subscriber exists to
149
+ // forget.
150
+ flowId: reference(() => aiFlowsTable, { onDelete: 'cascade' }),
151
+ note: text().nullable(),
152
+ })
153
+ ```
154
+
155
+ This is not a special primitive — it is `reference()`, with the same
156
+ `onDelete` semantics and the same index defaults. `plugin-storage`'s `assetRef()`
157
+ has always been exactly this under the hood: a `reference(() => _voltroStorageRefsTable,
158
+ { onDelete: 'setNull' })`.
159
+
160
+ **Referencing the table as a VALUE rather than its name as a string is what makes
161
+ this safe across a plugin's own migrations.** When ten plugin tables moved into
162
+ the `_voltro_` namespace in 0.22.0, a `reference(() => table)` followed the rename
163
+ (catalog-only, the constraint travels with the table); a hand-written
164
+ `text()` column holding ids would not have told you anything had changed.
165
+
166
+ **`fk: false`-style decoupling is still available** — declare a plain `text()`
167
+ column instead. Choose it when you deliberately want the app schema independent
168
+ of the plugin's, and accept that nothing then enforces the link. What you should
169
+ NOT do is reach for it by default: an unenforced id column plus a hand-written
170
+ `defineSubscriber` that cleans up on delete is referential integrity re-implemented
171
+ per app, and it is silently wrong the first time somebody forgets it.
172
+
173
+ **`orphanPolicy` is not part of this.** It is migration metadata — how existing
174
+ orphan rows are cleaned up *before* the FK constraint is added — and has no
175
+ runtime semantics. Runtime behaviour comes from `onDelete`.
176
+
135
177
  ## When NOT to write a plugin
136
178
 
137
179
  - **One-off side effect** — just call it from the mutation directly.
@@ -961,6 +961,7 @@ React alone.
961
961
  export const loader = async (ctx: {
962
962
  readonly params: Readonly<Record<string, string>> // URL params from [name] segments
963
963
  readonly pathname: string // matched path (no query string)
964
+ readonly search: string // raw query string incl. `?`, or '' — filled on every path
964
965
  readonly signal: AbortSignal // Aborts if the client disconnects mid-render
965
966
  readonly headers?: Readonly<Record<string, string>> // Request headers (SSR/ISR only — empty for SSG/client)
966
967
  // Call the backend rpc directly — present ONLY when the loader runs
@@ -970,7 +971,28 @@ export const loader = async (ctx: {
970
971
  }) => Promise<unknown>
971
972
  ```
972
973
 
973
- The loader context carries `pathname`, not a `request` object. For the query string during SSR, read it from `useServerRequest().url` inside the component.
974
+ The loader context carries `pathname` and `search`, not a `request` object.
975
+
976
+ `pathname` is deliberately query-free — a loader keyed on `?tab=2` would cache badly. `search` carries the raw query string (with its leading `?`, or `''`), filled identically on client navigation, `voltro dev` SSR and `voltro start` SSR. Parse it with `new URLSearchParams(ctx.search)`.
977
+
978
+ **Reach for `search` when the loader makes a decision, not when it fetches data.** The case it exists for is a redirect target that depends on a parameter:
979
+
980
+ ```ts
981
+ import { RedirectError } from '@voltro/web'
982
+
983
+ export const loader = async (ctx) => {
984
+ const player = await ctx.query('players.byCode', { code: ctx.params['playerCode'] })
985
+ if (!player) {
986
+ // Preserve kiosk mode across the redirect — otherwise a kiosk terminal
987
+ // drops back to normal mode after every failed scan.
988
+ const mode = new URLSearchParams(ctx.search).get('mode')
989
+ throw new RedirectError(`/?error=${ctx.params['playerCode']}${mode ? `&mode=${mode}` : ''}`)
990
+ }
991
+ return { player }
992
+ }
993
+ ```
994
+
995
+ Do not reconstruct this from `window.location.search`: that exists only on the client-navigation path, so a fresh SSR request loses the value — which is the bug the field was added to remove.
974
996
 
975
997
  Use `signal` for any fetch that could outlive the request — pass it to `fetch(url, { signal })` so cancelled requests don't waste CPU.
976
998
 
@@ -2850,7 +2850,7 @@ voltro create-project acme --api=api-versioning
2850
2850
  // app.config.ts
2851
2851
  import { versioningPlugin } from '@voltro/plugin-versioning'
2852
2852
  export default { type: 'api', name: 'AcmeVer', store: 'memory',
2853
- plugins: [versioningPlugin({ tables: ['documents'] })] }
2853
+ plugins: [versioningPlugin({})] }
2854
2854
  ```
2855
2855
 
2856
2856
  The snapshotting is automatic. The point is to **read history through handlers** — the template ships two actions:
@@ -2898,7 +2898,7 @@ The memory history store is single-process. With a SQL store, history persists i
2898
2898
 
2899
2899
  ## Anti-patterns
2900
2900
 
2901
- - **Listing `versioningPlugin({ tables })` and stopping there.** That records history but never shows it. The value is in the READS — wire `rowHistory` / `rowAsOf` into a handler (or the dashboard) like this template does.
2901
+ - **Listing `versioningPlugin()` and stopping there.** That records history but never shows it. The value is in the READS — wire `rowHistory` / `rowAsOf` into a handler (or the dashboard) like this template does.
2902
2902
  - **Versioning every table.** Snapshots cost storage proportional to write volume. Pick the tables whose past values you actually need.
2903
2903
 
2904
2904