@voltro/cli 0.24.0 → 0.25.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 (90) hide show
  1. package/CHANGELOG.md +426 -0
  2. package/THIRD-PARTY-NOTICES.md +2 -2
  3. package/dist/{apiBuild-B2m4XK_8.js → apiBuild-BqhCSytw.js} +15 -15
  4. package/dist/apiBuild-N1R4V792.js +2 -0
  5. package/dist/bin.js +3 -3
  6. package/dist/{commands-CaIhTsC2.js → commands-7EmYJ9Xg.js} +1774 -1577
  7. package/dist/dbCommand-CIrdFLp9.js +2 -0
  8. package/dist/{dbCommand-CC61CsAc.js → dbCommand-FUU12FvD.js} +265 -256
  9. package/dist/{dev-DPkQVUTP.js → dev-BvHT7WZa.js} +1 -1
  10. package/dist/{dev-NCuEhNxs.js → dev-MacSQ1Ll.js} +2019 -1626
  11. package/dist/fileConventions-3bffWssN.js +30 -0
  12. package/dist/frameworkTableAssembly-BsnCKzQ6.js +2 -0
  13. package/dist/{frameworkTableAssembly-sg41s14F.js → frameworkTableAssembly-Cw5zJz6n.js} +2 -2
  14. package/dist/index.js +1 -1
  15. package/dist/inspect-C9gjHwBk.js +2 -0
  16. package/dist/{inspect-_ldwsAwH.js → inspect-DuLUrZp9.js} +14 -1
  17. package/dist/{inspectMetrics-dcC7gnxu.js → inspectMetrics-EQwH7BI4.js} +15 -15
  18. package/dist/manifestBuild-BVwS1Z_6.js +2 -0
  19. package/dist/{manifestBuild-Bs1Uw22_.js → manifestBuild-Dneq4_Jx.js} +1 -1
  20. package/dist/serveCommand-5ZFiNO1R.js +1241 -0
  21. package/dist/serveEntry.js +2 -2
  22. package/dist/{start-C_4VUDT8.js → start-C-ZWSDpg.js} +2 -2
  23. package/dist/startEntry.js +2 -2
  24. package/package.json +17 -17
  25. package/templates/AGENTS.core.md +12 -5
  26. package/templates/AGENTS.md +14 -7
  27. package/templates/agent-docs/_index.md +2 -2
  28. package/templates/agent-docs/_manifest.json +3 -3
  29. package/templates/agent-docs/cli.md +15 -0
  30. package/templates/agent-docs/data.md +306 -2
  31. package/templates/agent-docs/database/migrations.md +40 -0
  32. package/templates/agent-docs/database/schema.md +80 -0
  33. package/templates/agent-docs/deployment.md +9 -0
  34. package/templates/agent-docs/plugins.md +1 -1
  35. package/templates/agent-docs/reference.md +2 -2
  36. package/templates/agent-docs/security.md +18 -0
  37. package/templates/agent-docs/templates/apibackends.md +5 -5
  38. package/templates/agent-docs/templates/overview.md +1 -1
  39. package/templates/agent-docs/whats-new.md +263 -169
  40. package/templates/agent-docs/workflows.md +44 -20
  41. package/templates/apps/api-ai/package.json +7 -7
  42. package/templates/apps/api-auth/package.json +8 -8
  43. package/templates/apps/api-backend/package.json +7 -7
  44. package/templates/apps/api-backend-deactivation/package.json +7 -7
  45. package/templates/apps/api-backend-mail/package.json +8 -8
  46. package/templates/apps/api-backend-mariadb/package.json +9 -9
  47. package/templates/apps/api-backend-storage/package.json +8 -8
  48. package/templates/apps/api-data-advanced/package.json +8 -8
  49. package/templates/apps/api-durable/README.md +2 -1
  50. package/templates/apps/api-durable/events/orders.event.ts +18 -0
  51. package/templates/apps/api-durable/mutations/orders.place.mutation.server.ts +17 -15
  52. package/templates/apps/api-durable/package.json +8 -8
  53. package/templates/apps/api-durable/triggers/order.placed.trigger.tsx +6 -4
  54. package/templates/apps/api-feature-flags/package.json +9 -9
  55. package/templates/apps/api-governance/package.json +8 -8
  56. package/templates/apps/api-kv/package.json +8 -8
  57. package/templates/apps/api-moderation/package.json +8 -8
  58. package/templates/apps/api-observability/package.json +8 -8
  59. package/templates/apps/api-ratelimit/package.json +8 -8
  60. package/templates/apps/api-rbac/package.json +8 -8
  61. package/templates/apps/api-rest/package.json +7 -7
  62. package/templates/apps/api-saas/package.json +11 -11
  63. package/templates/apps/api-search/package.json +8 -8
  64. package/templates/apps/api-versioning/package.json +8 -8
  65. package/templates/apps/api-webhooks/README.md +2 -2
  66. package/templates/apps/api-webhooks/events/orders.event.ts +35 -0
  67. package/templates/apps/api-webhooks/package.json +9 -9
  68. package/templates/apps/api-webhooks/template.json +1 -1
  69. package/templates/apps/changelog/package.json +6 -6
  70. package/templates/apps/edge-functions/package.json +2 -2
  71. package/templates/apps/frontend-admin/package.json +8 -8
  72. package/templates/apps/frontend-app/package.json +8 -8
  73. package/templates/apps/frontend-blank/package.json +7 -7
  74. package/templates/apps/frontend-contact/package.json +7 -7
  75. package/templates/apps/frontend-dashboard/package.json +7 -7
  76. package/templates/apps/frontend-docs/package.json +7 -7
  77. package/templates/apps/frontend-i18n/package.json +6 -6
  78. package/templates/apps/frontend-landing/package.json +7 -7
  79. package/templates/apps/frontend-spa/package.json +7 -7
  80. package/templates/apps/frontend-ssr/package.json +7 -7
  81. package/templates/apps/frontend-ssr-api/package.json +8 -8
  82. package/templates/apps/frontend-static-blog/package.json +6 -6
  83. package/dist/apiBuild-UEM3QBke.js +0 -2
  84. package/dist/dbCommand-Cr__4ATv.js +0 -2
  85. package/dist/fileConventions-CBNPZVOV.js +0 -29
  86. package/dist/frameworkTableAssembly-DRJrAVr1.js +0 -2
  87. package/dist/inspect-BA67TF6v.js +0 -2
  88. package/dist/manifestBuild-i-fRHg_H.js +0 -2
  89. package/dist/serveCommand-C4gzDZzc.js +0 -1175
  90. package/templates/apps/api-webhooks/events/order.completed.webhook.tsx +0 -22
@@ -1,5 +1,5 @@
1
- import { Z as e } from "./inspectMetrics-dcC7gnxu.js";
1
+ import { Z as e } from "./inspectMetrics-EQwH7BI4.js";
2
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-C4gzDZzc.js";
4
+ import { t as r } from "./serveCommand-5ZFiNO1R.js";
5
5
  export { e as loadDotEnv, n as registerAppModules, t as registerDriver, r as runServe };
@@ -1,5 +1,5 @@
1
- import { $ as e, D as t, F as n, G as r, H as i, J as a, K as o, M as s, T as c, U as l, W as u, Y as d, _ as ee, a as te, b as f, c as p, dt as m, et as h, f as g, ft as _, g as ne, h as v, i as re, lt as y, m as b, mt as x, nt as ie, o as S, ot as C, p as w, pt as ae, r as T, rt as E, s as D, t as O, tt as k, v as A, w as j, x as M, y as oe, z as se } from "./inspectMetrics-dcC7gnxu.js";
2
- import { D as ce, E as le, T as ue, a as de, p as N, w as P } from "./inspect-_ldwsAwH.js";
1
+ import { $ as e, D as t, F as n, G as r, H as i, J as a, K as o, M as s, T as c, U as l, W as u, Y as d, _ as ee, a as te, b as f, c as p, dt as m, et as h, f as g, ft as _, g as ne, h as v, i as re, lt as y, m as b, mt as x, nt as ie, o as S, ot as C, p as w, pt as ae, r as T, rt as E, s as D, t as O, tt as k, v as A, w as j, x as M, y as oe, z as se } from "./inspectMetrics-EQwH7BI4.js";
2
+ import { D as ce, E as le, T as ue, a as de, p as N, w as P } from "./inspect-DuLUrZp9.js";
3
3
  import { t as fe } from "./bootTiming-BdyP9nYw.js";
4
4
  import { dirname as F, extname as I, join as L, resolve as R } from "node:path";
5
5
  import { fileURLToPath as z, pathToFileURL as B } from "node:url";
@@ -1,3 +1,3 @@
1
- import { Z as e } from "./inspectMetrics-dcC7gnxu.js";
2
- import { t } from "./start-C_4VUDT8.js";
1
+ import { Z as e } from "./inspectMetrics-EQwH7BI4.js";
2
+ import { t } from "./start-C-ZWSDpg.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.24.0",
3
+ "version": "0.25.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.24.0",
66
- "@voltro/cache": "0.24.0",
67
- "@voltro/data-transfer": "0.24.0",
68
- "@voltro/database": "0.24.0",
69
- "@voltro/env": "0.24.0",
70
- "@voltro/kv": "0.24.0",
71
- "@voltro/logger": "0.24.0",
72
- "@voltro/plugin-auth": "0.24.0",
73
- "@voltro/plugin-broadcast": "0.24.0",
74
- "@voltro/plugin-mail": "0.24.0",
75
- "@voltro/plugin-storage": "0.24.0",
76
- "@voltro/plugin-webhooks": "0.24.0",
77
- "@voltro/protocol": "0.24.0",
78
- "@voltro/runtime": "0.24.0",
79
- "@voltro/serverless": "0.24.0",
80
- "@voltro/workflow": "0.24.0",
65
+ "@voltro/ai": "0.25.0",
66
+ "@voltro/cache": "0.25.0",
67
+ "@voltro/data-transfer": "0.25.0",
68
+ "@voltro/database": "0.25.0",
69
+ "@voltro/env": "0.25.0",
70
+ "@voltro/kv": "0.25.0",
71
+ "@voltro/logger": "0.25.0",
72
+ "@voltro/plugin-auth": "0.25.0",
73
+ "@voltro/plugin-broadcast": "0.25.0",
74
+ "@voltro/plugin-mail": "0.25.0",
75
+ "@voltro/plugin-storage": "0.25.0",
76
+ "@voltro/plugin-webhooks": "0.25.0",
77
+ "@voltro/protocol": "0.25.0",
78
+ "@voltro/runtime": "0.25.0",
79
+ "@voltro/serverless": "0.25.0",
80
+ "@voltro/workflow": "0.25.0",
81
81
  "chokidar": "^5.0.0",
82
82
  "ioredis": "^5.11.1",
83
83
  "tinyglobby": "^0.2.17",
@@ -39,18 +39,24 @@ Workflows split the same way (`*.workflow.tsx` + `*.workflow.server.tsx`).
39
39
 
40
40
  Ask in order; stop at the first "yes":
41
41
 
42
- 1. **Needs a LIVE feed that auto-updates on writes?** → **query** (`queries/`).
43
- The only streaming primitive. Declares `source: 'table'` so writes invalidate it.
44
- 2. **A single atomic DB write (one commit boundary)?** **mutation** (`mutations/`).
42
+ 1. **Something HAPPENED, with no row behind it?** → **event** (`*.event.ts`).
43
+ A game started, a door opened, a terminal confirmed. It has a time and no
44
+ value afterwards, where a row has a value and no time. Ephemeral, fan-out to
45
+ every listening client, at-most-once. **If you are about to write a table so
46
+ that a subscriber fires, this is what you want.**
47
+ 2. **Needs a LIVE feed that auto-updates on writes?** → **query** (`queries/`).
48
+ The reactive read primitive. Declares `source: 'table'` so writes invalidate it.
49
+ 3. **A single atomic DB write (one commit boundary)?** → **mutation** (`mutations/`).
45
50
  Runs in a transaction; a throw rolls back; ChangeEvents drain only on commit.
46
51
  Declares `target: { table, op }` → drives client auto-optimistic.
47
- 3. **External I/O — HTTP, file, AI, payment, email, signed URL?** → **action**
52
+ 4. **External I/O — HTTP, file, AI, payment, email, signed URL?** → **action**
48
53
  (`actions/`). NOT transactional; no rollback of side effects.
49
- 4. **Multi-step work that must survive a crash/deploy, retry-from-where-it-died?**
54
+ 5. **Multi-step work that must survive a crash/deploy, retry-from-where-it-died?**
50
55
  → **workflow** (`*.workflow.tsx`). Durable, suspend/resume.
51
56
 
52
57
  | Use case | Primitive |
53
58
  |---|---|
59
+ | A game starts / a door opens / a printer finishes — many screens react | Event |
54
60
  | List todos / messages on a page | Query |
55
61
  | Toggle a todo, send a message, edit a name | Mutation |
56
62
  | Generate a thumbnail / send a welcome email / call an LLM ad-hoc | Action |
@@ -248,6 +254,7 @@ public REST routes, declared via `restRoutes` in `app.config.ts`).
248
254
  | `*.mutation.ts` + `*.mutation.server.ts` | transactional unary write |
249
255
  | `*.action.ts` + `*.action.server.ts` | non-transactional external I/O |
250
256
  | `*.stream.ts` + `*.stream.server.ts` | server→client stream (e.g. AI tokens) |
257
+ | `*.event.ts` | ephemeral fan-out event (`defineEvent`; no `.server` half) |
251
258
  | `*.workflow.tsx` + `*.workflow.server.tsx` | durable multi-step work |
252
259
  | `*.trigger.tsx` | domain event → workflow |
253
260
  | `*.cron.tsx` | scheduled job (single file) |
@@ -39,18 +39,24 @@ Workflows split the same way (`*.workflow.tsx` + `*.workflow.server.tsx`).
39
39
 
40
40
  Ask in order; stop at the first "yes":
41
41
 
42
- 1. **Needs a LIVE feed that auto-updates on writes?** → **query** (`queries/`).
43
- The only streaming primitive. Declares `source: 'table'` so writes invalidate it.
44
- 2. **A single atomic DB write (one commit boundary)?** **mutation** (`mutations/`).
42
+ 1. **Something HAPPENED, with no row behind it?** → **event** (`*.event.ts`).
43
+ A game started, a door opened, a terminal confirmed. It has a time and no
44
+ value afterwards, where a row has a value and no time. Ephemeral, fan-out to
45
+ every listening client, at-most-once. **If you are about to write a table so
46
+ that a subscriber fires, this is what you want.**
47
+ 2. **Needs a LIVE feed that auto-updates on writes?** → **query** (`queries/`).
48
+ The reactive read primitive. Declares `source: 'table'` so writes invalidate it.
49
+ 3. **A single atomic DB write (one commit boundary)?** → **mutation** (`mutations/`).
45
50
  Runs in a transaction; a throw rolls back; ChangeEvents drain only on commit.
46
51
  Declares `target: { table, op }` → drives client auto-optimistic.
47
- 3. **External I/O — HTTP, file, AI, payment, email, signed URL?** → **action**
52
+ 4. **External I/O — HTTP, file, AI, payment, email, signed URL?** → **action**
48
53
  (`actions/`). NOT transactional; no rollback of side effects.
49
- 4. **Multi-step work that must survive a crash/deploy, retry-from-where-it-died?**
54
+ 5. **Multi-step work that must survive a crash/deploy, retry-from-where-it-died?**
50
55
  → **workflow** (`*.workflow.tsx`). Durable, suspend/resume.
51
56
 
52
57
  | Use case | Primitive |
53
58
  |---|---|
59
+ | A game starts / a door opens / a printer finishes — many screens react | Event |
54
60
  | List todos / messages on a page | Query |
55
61
  | Toggle a todo, send a message, edit a name | Mutation |
56
62
  | Generate a thumbnail / send a welcome email / call an LLM ad-hoc | Action |
@@ -248,6 +254,7 @@ public REST routes, declared via `restRoutes` in `app.config.ts`).
248
254
  | `*.mutation.ts` + `*.mutation.server.ts` | transactional unary write |
249
255
  | `*.action.ts` + `*.action.server.ts` | non-transactional external I/O |
250
256
  | `*.stream.ts` + `*.stream.server.ts` | server→client stream (e.g. AI tokens) |
257
+ | `*.event.ts` | ephemeral fan-out event (`defineEvent`; no `.server` half) |
251
258
  | `*.workflow.tsx` + `*.workflow.server.tsx` | durable multi-step work |
252
259
  | `*.trigger.tsx` | domain event → workflow |
253
260
  | `*.cron.tsx` | scheduled job (single file) |
@@ -595,7 +602,7 @@ each plugin's own README.
595
602
 
596
603
  | Topic | Open | Summary |
597
604
  |---|---|---|
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. |
605
+ | **What's new in 0.25.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
606
  | 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
607
  | 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
608
  | 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. |
@@ -677,7 +684,7 @@ each plugin's own README.
677
684
  | storage | `node_modules/@voltro/plugin-storage/README.md` | File storage behind one StorageService — public objects served direct from the bucket/CDN, private objects gated by an access policy + per-object grants. S3 / R2 / GCS / MinIO / filesystem / memory providers, presigned URLs, a dashboard browser. |
678
685
  | tinybird | `node_modules/@voltro/plugin-tinybird/README.md` | Hosted-ClickHouse AnalyticsSink over Tinybird's Events API + Pipes — pay-as-you-go OLAP without operating a cluster. |
679
686
  | versioning | `node_modules/@voltro/plugin-versioning/README.md` | Full row history + time-travel. audit() records who/when; versioning records what-changed-to-what — a value snapshot of every row on every write, with as-of queries. |
680
- | webhooks | `node_modules/@voltro/plugin-webhooks/README.md` | First-class outgoing + incoming webhooks — defineOutgoingEvent / defineIncomingWebhook in *.webhook.tsx files, runtime subscriptions, durable delivery via @effect/workflow, HMAC signing, provider presets, and idempotency. |
687
+ | webhooks | `node_modules/@voltro/plugin-webhooks/README.md` | First-class outgoing + incoming webhooks — declared events with a webhook: block, defineIncomingWebhook in *.webhook.tsx files, runtime subscriptions, durable delivery via @effect/workflow, HMAC signing, provider presets, and idempotency. |
681
688
 
682
689
 
683
690
  ---
@@ -9,7 +9,7 @@ each plugin's own README.
9
9
 
10
10
  | Topic | Open | Summary |
11
11
  |---|---|---|
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. |
12
+ | **What's new in 0.25.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. |
@@ -91,4 +91,4 @@ each plugin's own README.
91
91
  | storage | `node_modules/@voltro/plugin-storage/README.md` | File storage behind one StorageService — public objects served direct from the bucket/CDN, private objects gated by an access policy + per-object grants. S3 / R2 / GCS / MinIO / filesystem / memory providers, presigned URLs, a dashboard browser. |
92
92
  | tinybird | `node_modules/@voltro/plugin-tinybird/README.md` | Hosted-ClickHouse AnalyticsSink over Tinybird's Events API + Pipes — pay-as-you-go OLAP without operating a cluster. |
93
93
  | versioning | `node_modules/@voltro/plugin-versioning/README.md` | Full row history + time-travel. audit() records who/when; versioning records what-changed-to-what — a value snapshot of every row on every write, with as-of queries. |
94
- | webhooks | `node_modules/@voltro/plugin-webhooks/README.md` | First-class outgoing + incoming webhooks — defineOutgoingEvent / defineIncomingWebhook in *.webhook.tsx files, runtime subscriptions, durable delivery via @effect/workflow, HMAC signing, provider presets, and idempotency. |
94
+ | webhooks | `node_modules/@voltro/plugin-webhooks/README.md` | First-class outgoing + incoming webhooks — declared events with a webhook: block, defineIncomingWebhook in *.webhook.tsx files, runtime subscriptions, durable delivery via @effect/workflow, HMAC signing, provider presets, and idempotency. |
@@ -53,7 +53,7 @@
53
53
  "group": null,
54
54
  "description": "How Voltro's reactive data layer works — queries, mutations, actions, streams, all over one WebSocket with typed errors and tracked dependencies.",
55
55
  "path": "agent-docs/data.md",
56
- "files": 17
56
+ "files": 18
57
57
  },
58
58
  {
59
59
  "id": "database/advancedqueries",
@@ -134,7 +134,7 @@
134
134
  "group": "database.schema",
135
135
  "description": "Five generation schemes — TypeID (default), ULID, numeric, Snowflake, custom. Decision matrix, auto-injection lifecycle, cursor pagination, branded TypeScript types.",
136
136
  "path": "agent-docs/database/schema.md",
137
- "files": 8
137
+ "files": 9
138
138
  },
139
139
  {
140
140
  "id": "database/seedsdialects",
@@ -608,7 +608,7 @@
608
608
  {
609
609
  "slug": "webhooks",
610
610
  "title": "Webhooks",
611
- "description": "First-class outgoing + incoming webhooks — defineOutgoingEvent / defineIncomingWebhook in *.webhook.tsx files, runtime subscriptions, durable delivery via @effect/workflow, HMAC signing, provider presets, and idempotency.",
611
+ "description": "First-class outgoing + incoming webhooks — declared events with a webhook: block, defineIncomingWebhook in *.webhook.tsx files, runtime subscriptions, durable delivery via @effect/workflow, HMAC signing, provider presets, and idempotency.",
612
612
  "pkg": "@voltro/plugin-webhooks",
613
613
  "doc": "plugins/webhooks.md"
614
614
  }
@@ -2185,6 +2185,21 @@ A native dump is opaque: it doesn't say which dialect made it, which schema shap
2185
2185
 
2186
2186
  A backup with no stamp (older, or hand-made) restores with a caution rather than a hard stop.
2187
2187
 
2188
+ ### The restore drill — prove the backup, don't assume it
2189
+
2190
+ ```bash
2191
+ voltro data restore ./backups/2026-07-01 --drill --drill-url postgres://…/scratch
2192
+ # or set DRILL_DB_URL and just: voltro data restore ./backups/2026-07-01 --drill
2193
+ ```
2194
+
2195
+ `--drill` restores the artifact into a **throwaway** database (from `--drill-url` / `DRILL_DB_URL`) and verifies it — **without ever touching the live DB**. It refuses a drill target that resolves to your live connection (a drill that `--clean`s production is the disaster it exists to rehearse against). After the restore it introspects the throwaway DB and compares its schema fingerprint to the backup's stamp:
2196
+
2197
+ - **zero tables restored** → FAIL (the dump is empty or unreadable — this backup would not recover you),
2198
+ - **fingerprint disagrees with the stamp** → FAIL (the restore didn't reproduce what was backed up),
2199
+ - **tables + matching fingerprint** → PASS.
2200
+
2201
+ It exits non-zero on any FAIL, so a scheduled CI job turns a silently-broken backup into a red build. Run it against your latest artifact on a cron — a backup you've never restored is a hypothesis, and this is how you keep it a fact. (The verify is schema-level; a full app boot against the restored DB is a heavier check you can layer on top.)
2202
+
2188
2203
  ### Point-in-time recovery (PITR) is your database's job, not the framework's
2189
2204
 
2190
2205
  `backup` is a point-in-time **snapshot**. "Restore to 14:32, just before the bad deploy" (PITR) needs continuous WAL/binlog archiving, which lives at the database/provider layer — pg's `archive_command` + a base backup (pgBackRest / WAL-G), a managed provider's continuous backup (RDS, Cloud SQL, Neon, PlanetScale). The framework deliberately does **not** reimplement it: layer PITR under these native snapshots at the infra layer. A weekly `voltro data backup` + provider PITR together give you both a portable artifact and a fine-grained restore point.
@@ -60,6 +60,7 @@ Voltro ships more building blocks than this one section holds — each is a type
60
60
  | **Mutation** | `*.mutation.ts` | transactional write → [Mutations](/docs/data/mutations) |
61
61
  | **Action** | `*.action.ts` | unary external I/O → [Actions](/docs/data/actions) |
62
62
  | **Stream** | `*.stream.ts` | one-shot element push → [Streams](/docs/data/streams) |
63
+ | **Event** | `*.event.ts` | ephemeral fan-out to many clients → [Events](/docs/data/events) |
63
64
  | **REST route** | `*.route.tsx` | public raw-HTTP endpoint → [REST routes](/docs/data/rest-routes) |
64
65
  | **Aggregate** | `*.aggregate.ts` | scheduled materialised query → [Aggregates](/docs/data/aggregates) |
65
66
  | **Subscriber** | `*.subscribe.ts` | per-table post-commit reaction → [Subscribers](/docs/data/subscribers) |
@@ -75,7 +76,7 @@ Voltro ships more building blocks than this one section holds — each is a type
75
76
 
76
77
  ## What's in this section
77
78
 
78
- **Primitives** — [Queries](/docs/data/queries) (reactive reads + dependency tracking) · [Mutations](/docs/data/mutations) (transactional writes, typed errors, auto-optimistic) · [Actions](/docs/data/actions) (unary RPC, no transaction) · [Subscriptions](/docs/data/subscriptions) (the reactive engine behind snapshots/deltas) · [Streams](/docs/data/streams) (`defineStream` element push) · [REST routes](/docs/data/rest-routes) (public raw-HTTP for third parties) · [Aggregates](/docs/data/aggregates) (scheduled materialised queries) · [Subscribers](/docs/data/subscribers) (per-table post-commit reactions).
79
+ **Primitives** — [Queries](/docs/data/queries) (reactive reads + dependency tracking) · [Mutations](/docs/data/mutations) (transactional writes, typed errors, auto-optimistic) · [Actions](/docs/data/actions) (unary RPC, no transaction) · [Subscriptions](/docs/data/subscriptions) (the reactive engine behind snapshots/deltas) · [Streams](/docs/data/streams) (`defineStream` element push) · [Events](/docs/data/events) (`defineEvent` — things that HAPPEN, with no row behind them) · [REST routes](/docs/data/rest-routes) (public raw-HTTP for third parties) · [Aggregates](/docs/data/aggregates) (scheduled materialised queries) · [Subscribers](/docs/data/subscribers) (per-table post-commit reactions).
79
80
 
80
81
  **Protocol & errors** — [Wire protocol](/docs/data/wire-protocol) (framing, multiplexing) · [Error handling](/docs/data/errors) (Schema-tagged errors, retries, client narrowing).
81
82
 
@@ -130,7 +131,7 @@ Every executor receives `ctx.request.subject` — the typed identity of the call
130
131
  | Transient server-to-client element stream | `*.stream.ts` + `*.stream.server.ts` + `useAgentStream` |
131
132
  | Durable persisted AI chat | `*.agent.tsx` or action + query over `agent_messages` |
132
133
  | Background job | `*.workflow.tsx` |
133
- | Fan a domain event out to one or more workflows | [`*.trigger.tsx`](/docs/workflows/event-triggers) + `ctx.events.emit(...)` |
134
+ | Fan a domain event out to one or more workflows | [`*.trigger.tsx`](/docs/workflows/event-triggers) + `ctx.events.publish(...)` |
134
135
  | Pre-computed query result (top-N, summary) | [`*.aggregate.ts`](/docs/data/aggregates) |
135
136
  | React to every commit on a table (server-side) | [`*.subscribe.ts`](/docs/data/subscribers) |
136
137
  | Event ingestion + analytical aggregates over events | [Analytics sink](/docs/plugins/analytics) |
@@ -1593,6 +1594,309 @@ See [AI streaming](/docs/ai/streaming) and [Agents](/docs/ai/agents) for those p
1593
1594
 
1594
1595
 
1595
1596
 
1597
+ ---
1598
+
1599
+ <!-- source: en/data/events.md -->
1600
+ ## Events
1601
+
1602
+ _`*.event.ts` — ephemeral fan-out to connected clients with defineEvent, ctx.events.publish and useEvent. At-most-once, live, and it tells you when it lost something._
1603
+
1604
+ An **event** is a thing that *happened*. It has a time and no value afterwards — where a table row has a value and no time.
1605
+
1606
+ That distinction decides which primitive you want, and it is the only decision here that is hard to reverse later:
1607
+
1608
+ | You are modelling | Use | Because |
1609
+ | --- | --- | --- |
1610
+ | **what happened** — a game started, a door opened, a terminal confirmed a payment | **Events** (this page) | nothing to store; a late arrival wants what happens *next*, not the history |
1611
+ | **what is** — the current roster, an order's status, a document | [Queries](/docs/data/queries) + [Subscriptions](/docs/data/subscriptions) | a late arrival wants the current value immediately |
1612
+ | **what must happen, even if we crash** — charge a card, send an invoice | [Outbox](/docs/data/outbox) | needs persistence, retries and a delivery guarantee |
1613
+
1614
+ If you find yourself writing a table so that a subscriber fires, you want an event.
1615
+
1616
+ ## Declare it
1617
+
1618
+ ```ts
1619
+ // events/gameLifecycle.event.ts
1620
+ import { defineEvent } from '@voltro/protocol'
1621
+ import { Schema } from 'effect'
1622
+
1623
+ export const gameStarted = defineEvent({
1624
+ name: 'games.started',
1625
+ key: Schema.Struct({ arenaId: Schema.String }),
1626
+ payload: Schema.Struct({
1627
+ gameId: Schema.String,
1628
+ gameType: Schema.Literal('evo5', 'evo6'),
1629
+ startedAt: Schema.Number,
1630
+ }),
1631
+ guards: [{ scope: 'display:read' }],
1632
+ })
1633
+ ```
1634
+
1635
+ A `*.event.ts` file is **browser-safe** and may hold several declarations — a lifecycle's stages are one concept. Client and server import the *same* value, which is what makes the key and payload types identical at both ends.
1636
+
1637
+ **`key` is the address, and only the address.** A subscriber receives events published under a key it asked for, so the server never sends the others at all. Put in it what *routes* (`arenaId`) and nothing else — a discriminator your handler reads (`gameType`) is payload. Every key field fragments the subscriber set.
1638
+
1639
+ **`guards` decide who may listen**, in the same vocabulary a query uses, and they are checked *before* the subscription is registered — a refused client never holds one. The routing key is the guard input, so a resource-scoped guard (`{ scope: 'arena:read', from: 'arenaId' }`) sees which arena was asked for. A refusal reaches the client as the `ScopeError` the rpc declares.
1640
+
1641
+ **The tenant is not part of the key and must never be added.** It comes from the subject on both sides, so a cross-tenant delivery is impossible by construction rather than by remembering to filter.
1642
+
1643
+ The name shares the rpc tag space. Two declarations answering to one name fail the **boot**, not the first delivery.
1644
+
1645
+ ## Publish it
1646
+
1647
+ Only the server publishes. A client-originated event is an [action](/docs/data/actions) that publishes — which means every publish has already passed a guard-checked, typed handler, and there is no "who may write to this channel" question to answer.
1648
+
1649
+ ```ts
1650
+ // actions/reportGameEvent.action.server.ts
1651
+ export default (input, ctx) => Effect.gen(function* () {
1652
+ yield* ctx.events.publish(gameStarted, { arenaId: input.arenaId }, {
1653
+ gameId: input.gameId,
1654
+ gameType: 'evo5',
1655
+ startedAt: Date.now(),
1656
+ })
1657
+ })
1658
+ ```
1659
+
1660
+ `publish` works from anywhere with a `ctx`: an action, a mutation, a workflow, a subscriber, a cron, a startup hook.
1661
+
1662
+ **Inside a mutation it fires on COMMIT, and not at all on rollback.** That is not a nicety: a display reacting to a game start the database rolled back happens on every constraint violation, every deadlock retry, every guard that fails *after* the publish line. Outside a transaction it fires immediately.
1663
+
1664
+ Three typed errors reach the **producer**, so a mismatch is one failing call rather than every consumer's handler breaking on a field that is not there: `EventPayloadInvalid`, `EventKeyInvalid`, `EventPayloadTooLarge`.
1665
+
1666
+ <Callout>
1667
+ **Both handler styles publish.** `ctx.events.publish` returns an Effect, so the
1668
+ `Effect.gen` form above is the idiomatic one — but `await ctx.events.publish(…)`
1669
+ in a plain `async (input, ctx) => { … }` handler works too and resolves with the
1670
+ same result. It used to hand back an unrun Effect: nothing published, nothing
1671
+ errored, and the handler returned success.
1672
+ </Callout>
1673
+
1674
+ ## Consume it
1675
+
1676
+ ```tsx
1677
+ const { missed, status } = useEvent(gameStarted, arenaId ? { arenaId } : null, (payload) => {
1678
+ scene.switchTo('running', payload.gameId)
1679
+ })
1680
+ ```
1681
+
1682
+ `payload` is typed from the descriptor — a wrong field name is a `tsc` error at this call site.
1683
+
1684
+ Everything you would otherwise hand-roll is gone, and each of these was a real bug in apps that built this on a reactive list:
1685
+
1686
+ - **No history on mount.** A fresh subscriber gets what happens *from now on*. No `seen` set, no `initialized` flag, no window.
1687
+ - **Exactly once, even under React `StrictMode`** — where every effect runs twice and a naive subscription fires each handler twice, in development only.
1688
+ - **A changing handler does not resubscribe.** Every call site passes an inline arrow; putting it in a dependency array rebuilds the subscription on every render and loses whatever arrives in the gap.
1689
+ - **A key change is a clean switch** — the old subscription ends before the new one starts.
1690
+ - **`key: null` means "not yet"**: no subscription, `status: 'idle'`. You never need a placeholder key.
1691
+
1692
+ ## What it guarantees — read this before you build on it
1693
+
1694
+ - **At-most-once, best-effort, live.** No persistence, no retry, no redelivery. For guarantees use the [outbox](/docs/data/outbox); this is the other axis.
1695
+ - **Ordered per publishing instance per key.** *Not* globally per key — two instances publishing the same key have no shared counter, and we do not promise an order we cannot keep.
1696
+ - **Guards are re-checked when the subject changes**, not per delivery. Revoke a role and the stream ends.
1697
+ - **Payloads are capped at 7,500 bytes** of encoded envelope, on **every** dialect. An event says something happened, so carry a photo *reference*, not a photo.
1698
+
1699
+ ### `missed` is a number, not a feeling
1700
+
1701
+ When deliveries are lost, you are told **how many** and **why**:
1702
+
1703
+ ```tsx
1704
+ useEvent(gameStarted, key, handler, {
1705
+ onMissed: ({ count, reason }) => resyncFromServer(count), // reason: 'buffer' | 'resume'
1706
+ })
1707
+ ```
1708
+
1709
+ Every delivery carries a serial, and the server keeps the highest it has seen — so a loss is arithmetic (what you were owed, minus what could be replayed), never an estimate. `buffer` means your client fell behind and the oldest were dropped; `resume` means a reconnect asked for messages older than the server still holds.
1710
+
1711
+ This matters more than it sounds. Silence is the one outcome nothing can be built on: a display cannot tell "no game started" from "I missed the start signal".
1712
+
1713
+ ### Reconnects resume; mounts do not
1714
+
1715
+ These read as one contradiction — *never replay history* against *never lose a message* — and they are two different questions:
1716
+
1717
+ - **A first attach** starts empty. Set `rewind: true` on the descriptor if you want the recent buffer instead.
1718
+ - **A reconnect** continues from the last serial that subscription saw. `useEvent` does this for you, including after a deploy or a proxy timeout.
1719
+
1720
+ The buffer is deliberately small — tens of messages, minutes. Anything larger is a durable queue, and the framework already has one.
1721
+
1722
+ ## Triggering a workflow from an event
1723
+
1724
+ ```ts
1725
+ triggerWorkflow({ on: gameStarted, workflow: 'postGameReport' })
1726
+ ```
1727
+
1728
+ `on:` takes the descriptor and reads its name, so renaming the event moves this call site with it. The older string form (`event: 'games.started'`) still works and is going away: with a string, a rename leaves the trigger matching nothing and the workflow simply never runs again — nothing errors, which is the same silence this whole primitive exists to remove.
1729
+
1730
+ ## Reaching HTTP receivers too
1731
+
1732
+ An event can also be delivered to subscribed webhook targets — the third audience of the same declaration:
1733
+
1734
+ ```ts
1735
+ export const orderPaid = defineEvent({
1736
+ name: 'orders.paid',
1737
+ key: Schema.Struct({ orderId: Schema.String }),
1738
+ payload: Schema.Struct({ total: Schema.Number }),
1739
+ webhook: { description: 'An order was paid', version: 2 },
1740
+ })
1741
+ ```
1742
+
1743
+ One `publish` now reaches connected clients, every matching workflow trigger, and every subscribed HTTP target. Without this an app that does both declares the thing twice, in two shapes, and the two drift.
1744
+
1745
+ The `webhook:` block is namespaced because its settings mean nothing to the other audiences — a top-level `retry` would read as if it applied to client delivery, which is at-most-once by design and has no retry at all. Requires [`@voltro/plugin-webhooks`](/docs/plugins/webhooks); absent, it costs nothing.
1746
+
1747
+ ## Across instances
1748
+
1749
+ Local delivery always works. For fan-out across replicas the event rides postgres `LISTEN/NOTIFY` or [`@voltro/plugin-broadcast`](/docs/plugins/broadcast) (Redis / NATS), exactly like change events — and a broker outage degrades cross-replica delivery without touching local subscribers.
1750
+
1751
+ **Each declared event gets its own channel** (`<namespace>:events:<name>`, where
1752
+ the namespace defaults to your app's name — see
1753
+ [broadcast](/docs/plugins/broadcast)), and a
1754
+ replica subscribes to it only while it actually has a local subscriber. This
1755
+ matters as soon as one event is much busier than the others: on a single shared
1756
+ channel every replica receives, decodes and tracks every event of every peer,
1757
+ including the ones it serves no clients for. With five replicas and a high-rate
1758
+ event whose subscribers all sit on one of them, four were doing that work and
1759
+ discarding the result.
1760
+
1761
+ Nothing to configure — it follows from the declaration. The one operational
1762
+ consequence: during a **rolling deploy** replicas on different framework versions
1763
+ use different channel names, so cross-replica delivery is degraded for the length
1764
+ of the rollout. Local delivery on each replica is unaffected throughout.
1765
+
1766
+ ## Throughput — the numbers, and where this is the wrong primitive
1767
+
1768
+ Measured on one core, publish path only:
1769
+
1770
+ | | |
1771
+ | --- | --- |
1772
+ | `bus.publish`, 1–100 subscribers | ~1.5µs (**~670,000/s**) |
1773
+ | `bus.publish`, 1000 subscribers | ~3.1µs (~325,000/s) |
1774
+ | `ctx.events.publish` (validation + size gate + bus) | ~4.1µs (**~240,000/s**) |
1775
+
1776
+ **Across replicas**, measured over a real Redis with two processes — 200 of 200
1777
+ delivered, no loss:
1778
+
1779
+ | p50 | p95 | p99 | max |
1780
+ | --- | --- | --- | --- |
1781
+ | 1.67 ms | 2.91 ms | 6.58 ms | 9.95 ms |
1782
+
1783
+ That is the broker round trip plus both bus hops. It is the number that matters
1784
+ for a display in another pod, and it is the one to compare against a hosted
1785
+ realtime service — where the same hop is a network round trip to someone else's
1786
+ region.
1787
+
1788
+ **Fan-out is nearly free.** One subscriber and a hundred cost the same — the
1789
+ per-publish work dominates, not the delivery loop. What you pay per subscriber is
1790
+ the wire encode on its own subscription, not anything in the bus.
1791
+
1792
+ For a game lifecycle — eight stage events per game, one publish each — that is
1793
+ several orders of magnitude of headroom. Even 100 players at 60Hz (6,000
1794
+ events/s) sits at ~2.5% of one core.
1795
+
1796
+ ### Where it stops being the right tool
1797
+
1798
+ Not at a throughput number, but at a **semantic** one: this primitive guarantees
1799
+ at-most-once delivery of *every* message, with gap accounting. For a 60Hz stream
1800
+ of positions or cursors, that guarantee costs something and buys nothing —
1801
+ **nobody needs frame 1 once frame 2 has arrived.** You want last-value-wins state,
1802
+ not a delivery log.
1803
+
1804
+ ### `delivery: 'latest'` — when only the current value matters
1805
+
1806
+ Declare it, and the framework stops treating a superseded value as a loss:
1807
+
1808
+ ```ts
1809
+ export default defineEvent({
1810
+ name: 'player.moved',
1811
+ key: Schema.Struct({ arenaId: Schema.String }),
1812
+ payload: Schema.Struct({ playerId: Schema.String, x: Schema.Number, y: Schema.Number }),
1813
+ access: 'authenticated',
1814
+ delivery: 'latest',
1815
+ })
1816
+ ```
1817
+
1818
+ | | `each` (default) | `latest` |
1819
+ | --- | --- | --- |
1820
+ | A slow subscriber | keeps the newest, is told how many it lost | receives the current value |
1821
+ | Gap reporting | `missed` is computed and delivered | none — nothing was lost |
1822
+ | Server retention | up to 64 messages / 5 minutes | one value |
1823
+ | Reconnect | replays what is retained, reports the shortfall | hands over the current value |
1824
+
1825
+ The distinction is **semantic, not performance**. `latest` is not "the fast mode":
1826
+ it changes what a missing message *means*. Choosing it for a stream where each
1827
+ delivery matters drops the ones in between; choosing `each` for a per-frame
1828
+ stream makes a slow client work through a backlog to reach a state it could have
1829
+ had immediately, and report a "loss" that was never one.
1830
+
1831
+ The test: **would a consumer be wrong to miss one?**
1832
+
1833
+ <Callout type="warn">
1834
+ `delivery: 'latest'` cannot be combined with `webhook`, and the declaration is
1835
+ refused. `latest` says a superseded delivery did not matter — but a webhook
1836
+ delivery is a durable side effect at a third party, and one already sent cannot
1837
+ be superseded. A 60Hz event with an HTTP audience is also 60 deliveries per
1838
+ second *per subscribed target*, and the webhook rate limit **defers** the excess
1839
+ as pending rows rather than dropping it, so the symptom is a growing table rather
1840
+ than an error anyone would look at. Publish the high-rate event for clients and a
1841
+ separate, coarser one for the outside world.
1842
+ </Callout>
1843
+
1844
+ ### Still worth avoiding
1845
+
1846
+ - **Payloads over a few hundred bytes at high rate.** The size gate stops you at
1847
+ 7,500 bytes, and long before that the wire encode per subscriber becomes the
1848
+ cost. Send a reference.
1849
+ - **Per-frame data as an event at all.** Even under `latest`, 60Hz of positions
1850
+ is 60 encodes per second per subscriber. Coalescing on the client and
1851
+ publishing at 10–20Hz is usually indistinguishable to a human and an order of
1852
+ magnitude cheaper.
1853
+
1854
+ The honest rule: **use an event when a consumer would be wrong to miss one.** If
1855
+ missing one is fine because the next one supersedes it, either declare
1856
+ `delivery: 'latest'` or model it as state — a table, or a value the client
1857
+ replaces.
1858
+
1859
+ ## Testing
1860
+
1861
+ ```ts
1862
+ import { testEventBus } from '@voltro/testing'
1863
+
1864
+ const events = testEventBus()
1865
+ const display = events.subscribe(gameStarted, { arenaId: 'a1' })
1866
+ await events.publish(gameStarted, { arenaId: 'a1' }, { gameId: 'g1', gameType: 'evo5', startedAt: 0 })
1867
+ expect(display.received).toEqual([{ gameId: 'g1', gameType: 'evo5', startedAt: 0 }])
1868
+
1869
+ // Force a loss deterministically instead of racing a queue:
1870
+ events.skipSerials(gameStarted, { arenaId: 'a1' }, 5)
1871
+ ```
1872
+
1873
+ It drives the real bus and the real publish path — validation, the size gate and serials all behave as they do in production — so a test cannot pass on a payload the server would reject.
1874
+
1875
+ ## Evolving a payload
1876
+
1877
+ Clients decode against **their own** copy of the schema. Adding a field is safe. **Removing a required field breaks clients still running the old bundle**, loudly, at decode time — which is better than a silent `undefined` in a handler, and worth knowing if you ship to devices that do not reload for months. Treat those deployments as additive-only.
1878
+
1879
+ ## Anti-pattern: events as rows
1880
+
1881
+ If you have this, replace it:
1882
+
1883
+ ```tsx
1884
+ // ✗ an events TABLE, reconstructed into "new" on the client
1885
+ const { data } = useSubscription('app', 'realtime.list', { limit: 500 })
1886
+ const seen = useRef(new Set()); const initialized = useRef(false)
1887
+ useEffect(() => { /* mark everything seen on the first pass, then diff */ }, [data])
1888
+ ```
1889
+
1890
+ Three bugs in nine lines, and every consumer has to get all three right: the `seen` set, the `initialized` flag (without it, loading the page replays 500 old events into a live system), and `limit` (a silent ceiling — nothing tells you when more than 500 arrive between renders). The table also grows forever and holds rows nobody reads twice.
1891
+
1892
+ Migrating is mechanical: declare the event, replace the insert with `ctx.events.publish`, replace the hook with `useEvent`, and drop the table.
1893
+
1894
+ ## See also
1895
+
1896
+ [Subscriptions](/docs/data/subscriptions) · [Outbox](/docs/data/outbox) · [Subscribers](/docs/data/subscribers) · [Streams](/docs/data/streams)
1897
+
1898
+
1899
+
1596
1900
  ---
1597
1901
 
1598
1902
  <!-- source: en/data/rest-routes.md -->
@@ -1556,6 +1556,46 @@ no-ops out.
1556
1556
 
1557
1557
  The cloud dashboard surfaces per-env state with a multi-env tab in the [cloud UI](./cloud-ui.md).
1558
1558
 
1559
+ ## Rehearsing a migration against real data
1560
+
1561
+ The strongest check on this pipeline is not that each command exits 0 — it is that **no row moved that you did not ask to move**. A consumer running a MariaDB cutover built this loop and it caught three defects the framework's own suite did not; the method is theirs, written up here because the next person should not have to invent it.
1562
+
1563
+ 1. **Restore a backup into a throwaway database.**
1564
+
1565
+ ```sh
1566
+ voltro data backup ./rehearsal .
1567
+ DB_URL=$SCRATCH_URL voltro data restore ./rehearsal
1568
+ ```
1569
+
1570
+ Use `data backup` / `data restore` — the NATIVE path — not the logical `data export`. The logical exporter re-shapes rows through the **current** declared schema, and the state a rehearsal exists to migrate *from* is precisely the one that does not match it.
1571
+
1572
+ 2. **Take an exact census, before.**
1573
+
1574
+ ```sql
1575
+ SELECT table_name, COUNT(*) FROM ... -- one COUNT(*) per table
1576
+ ```
1577
+
1578
+ It must be `COUNT(*)`. `information_schema.TABLE_ROWS` is an **estimate** on InnoDB — routinely off by thousands, and it is what a fast version of this check would reach for. The slowness is the point.
1579
+
1580
+ 3. **Run the exact production command sequence** — the same one your deploy Job runs, in the same order:
1581
+
1582
+ ```sh
1583
+ voltro db files .
1584
+ voltro db plan --json > plan.json
1585
+ voltro db apply --plan plan.json
1586
+ ```
1587
+
1588
+ 4. **Take the census again, and require the difference to be explainable.**
1589
+
1590
+ A healthy run moves one row: the `_voltro_migration_plans` ledger entry. Anything else is a question, not a result.
1591
+
1592
+ **Two things the census must get right**, both learned by using it:
1593
+
1594
+ - **A soft drop is not a loss.** With `VOLTRO_SOFT_DROP=1` a dropped table reappears as `<name>__dropped_<YYYYMMDDHHMMSS>` with its rows intact. Reporting that as a vanished table trains people to ignore the check; reporting it as clean hides a real drop. Give it its own category.
1595
+ - **A new table is not a discrepancy.** A migration that adds one produces a table with no "before" count. Say so explicitly rather than letting a zero read as data loss.
1596
+
1597
+ To rehearse a schema several months old — the realistic case — craft the backup deliberately: a table a later migration added, a column a later one narrowed, a column the schema no longer declares. The planner is state-based, so it diffs live against declared and never replays a history; a six-month-old dump costs exactly one diff.
1598
+
1559
1599
  ## File-based migrations in this pipeline
1560
1600
 
1561
1601
  `voltro db apply` runs pending `migrations/*.ts` **first**, then diffs — the same order the boot path uses.