@voltro/cli 0.24.0 → 0.26.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 +670 -0
  2. package/THIRD-PARTY-NOTICES.md +2 -2
  3. package/dist/{apiBuild-B2m4XK_8.js → apiBuild-BtrTyKnF.js} +15 -15
  4. package/dist/apiBuild-DDJ0It4j.js +2 -0
  5. package/dist/bin.js +3 -3
  6. package/dist/{commands-CaIhTsC2.js → commands-D-9iYF2D.js} +1889 -1591
  7. package/dist/dbCommand-DrzXimKf.js +2 -0
  8. package/dist/{dbCommand-CC61CsAc.js → dbCommand-uuNCrFAb.js} +172 -163
  9. package/dist/{dev-DPkQVUTP.js → dev-BnWq4jeA.js} +1 -1
  10. package/dist/{dev-NCuEhNxs.js → dev-Dbm6SWtn.js} +2369 -1774
  11. package/dist/fileConventions-3bffWssN.js +30 -0
  12. package/dist/{frameworkTableAssembly-sg41s14F.js → frameworkTableAssembly-BwHU9Euq.js} +12 -8
  13. package/dist/frameworkTableAssembly-lrjZtk0G.js +2 -0
  14. package/dist/index.js +1 -1
  15. package/dist/{inspect-_ldwsAwH.js → inspect-CUCCzw2I.js} +20 -4
  16. package/dist/inspect-gt8bq-Tz.js +2 -0
  17. package/dist/{inspectMetrics-dcC7gnxu.js → inspectMetrics-BU90mvJN.js} +15 -15
  18. package/dist/{manifestBuild-Bs1Uw22_.js → manifestBuild-BnzAxp2O.js} +1 -1
  19. package/dist/manifestBuild-ifczArzr.js +2 -0
  20. package/dist/serveCommand-XBXuwJty.js +1294 -0
  21. package/dist/serveEntry.js +2 -2
  22. package/dist/{start-C_4VUDT8.js → start-BGXIf6zT.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 +339 -2
  31. package/templates/agent-docs/database/migrations.md +40 -0
  32. package/templates/agent-docs/database/schema.md +127 -0
  33. package/templates/agent-docs/deployment.md +28 -0
  34. package/templates/agent-docs/plugins.md +26 -4
  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 +278 -168
  40. package/templates/agent-docs/workflows.md +160 -42
  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-BU90mvJN.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-XBXuwJty.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-BU90mvJN.js";
2
+ import { D as ce, E as le, T as ue, a as de, p as N, w as P } from "./inspect-CUCCzw2I.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-BU90mvJN.js";
2
+ import { t } from "./start-BGXIf6zT.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.26.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.26.0",
66
+ "@voltro/cache": "0.26.0",
67
+ "@voltro/data-transfer": "0.26.0",
68
+ "@voltro/database": "0.26.0",
69
+ "@voltro/env": "0.26.0",
70
+ "@voltro/kv": "0.26.0",
71
+ "@voltro/logger": "0.26.0",
72
+ "@voltro/plugin-auth": "0.26.0",
73
+ "@voltro/plugin-broadcast": "0.26.0",
74
+ "@voltro/plugin-mail": "0.26.0",
75
+ "@voltro/plugin-storage": "0.26.0",
76
+ "@voltro/plugin-webhooks": "0.26.0",
77
+ "@voltro/protocol": "0.26.0",
78
+ "@voltro/runtime": "0.26.0",
79
+ "@voltro/serverless": "0.26.0",
80
+ "@voltro/workflow": "0.26.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) |
@@ -1468,6 +1469,38 @@ Tables with the `tenant()` mixin are scoped by the runtime using `ctx.request.su
1468
1469
 
1469
1470
  The devtools subscription surfaces show active subscribers, recent deltas, and cache state. Use them when a query updates too often or not at all.
1470
1471
 
1472
+ ## Cost — how large may a live query be?
1473
+
1474
+ Every change re-runs the query and diffs the WHOLE result against the previous
1475
+ one, so the cost is linear in the RESULT SIZE, not in the size of the change.
1476
+ Measured on `diffRows`:
1477
+
1478
+ | result rows | one column changed | every row replaced |
1479
+ | --- | --- | --- |
1480
+ | 50 | 45 µs | — |
1481
+ | 500 | 480 µs | — |
1482
+ | 2 000 | 1.23 ms | 1.29 ms |
1483
+ | 5 000 | 3.1 ms | — |
1484
+
1485
+ Two things follow, and the second is the one that surprises people:
1486
+
1487
+ - **The curve is linear, not quadratic.** Per-row cost is flat across a 100×
1488
+ growth (910 ns → 625 ns), so a large result gets slower in proportion and
1489
+ never falls off a cliff.
1490
+ - **A one-column edit costs the same as replacing everything.** 2 000 rows with
1491
+ a single change is 1.23 ms; the same 2 000 rows entirely replaced is 1.29 ms —
1492
+ 5 % more. The cost is the WALK, not the delta. Making your mutation smaller
1493
+ does not make the subscription cheaper.
1494
+
1495
+ So the number to design against is the RESULT SIZE. A few hundred rows is free.
1496
+ A 5 000-row live query costs 3.1 ms of CPU per change, per replica — fine for a
1497
+ dashboard that changes a few times a minute, wrong for one fed by a high-rate
1498
+ writer. Page the query, or narrow it with a predicate, rather than reaching for
1499
+ a bigger machine.
1500
+
1501
+ These numbers are asserted by `rowPatch.perf.test.ts`, so they are current
1502
+ rather than a note somebody wrote down once.
1503
+
1471
1504
  ## See also
1472
1505
 
1473
1506
  - [Subscribers (`*.subscribe.ts`)](/docs/data/subscribers) — server-side, best-effort post-commit reactivity to a table (NOT the client hook on this page).
@@ -1593,6 +1626,310 @@ See [AI streaming](/docs/ai/streaming) and [Agents](/docs/ai/agents) for those p
1593
1626
 
1594
1627
 
1595
1628
 
1629
+ ---
1630
+
1631
+ <!-- source: en/data/events.md -->
1632
+ ## Events
1633
+
1634
+ _`*.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._
1635
+
1636
+ 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.
1637
+
1638
+ That distinction decides which primitive you want, and it is the only decision here that is hard to reverse later:
1639
+
1640
+ | You are modelling | Use | Because |
1641
+ | --- | --- | --- |
1642
+ | **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 |
1643
+ | **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 |
1644
+ | **what must happen, even if we crash** — charge a card, send an invoice | [Outbox](/docs/data/outbox) | needs persistence, retries and a delivery guarantee |
1645
+
1646
+ If you find yourself writing a table so that a subscriber fires, you want an event.
1647
+
1648
+ ## Declare it
1649
+
1650
+ ```ts
1651
+ // events/gameLifecycle.event.ts
1652
+ import { defineEvent } from '@voltro/protocol'
1653
+ import { Schema } from 'effect'
1654
+
1655
+ export const gameStarted = defineEvent({
1656
+ name: 'games.started',
1657
+ key: Schema.Struct({ arenaId: Schema.String }),
1658
+ payload: Schema.Struct({
1659
+ gameId: Schema.String,
1660
+ gameType: Schema.Literal('evo5', 'evo6'),
1661
+ startedAt: Schema.Number,
1662
+ }),
1663
+ guards: [{ scope: 'display:read' }],
1664
+ })
1665
+ ```
1666
+
1667
+ 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.
1668
+
1669
+ **`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.
1670
+
1671
+ **`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.
1672
+
1673
+ **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.
1674
+
1675
+ The name shares the rpc tag space. Two declarations answering to one name fail the **boot**, not the first delivery.
1676
+
1677
+ ## Publish it
1678
+
1679
+ 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.
1680
+
1681
+ ```ts
1682
+ // actions/reportGameEvent.action.server.ts
1683
+ export default (input, ctx) => Effect.gen(function* () {
1684
+ yield* ctx.events.publish(gameStarted, { arenaId: input.arenaId }, {
1685
+ gameId: input.gameId,
1686
+ gameType: 'evo5',
1687
+ startedAt: Date.now(),
1688
+ })
1689
+ })
1690
+ ```
1691
+
1692
+ `publish` works from anywhere with a `ctx`: an action, a mutation, a workflow, a subscriber, a cron, a startup hook.
1693
+
1694
+ **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.
1695
+
1696
+ 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`.
1697
+
1698
+ <Callout>
1699
+ **Both handler styles publish.** `ctx.events.publish` returns an Effect, so the
1700
+ `Effect.gen` form above is the idiomatic one — but `await ctx.events.publish(…)`
1701
+ in a plain `async (input, ctx) => { … }` handler works too and resolves with the
1702
+ same result. It used to hand back an unrun Effect: nothing published, nothing
1703
+ errored, and the handler returned success.
1704
+ </Callout>
1705
+
1706
+ ## Consume it
1707
+
1708
+ ```tsx
1709
+ const { missed, status } = useEvent(gameStarted, arenaId ? { arenaId } : null, (payload) => {
1710
+ scene.switchTo('running', payload.gameId)
1711
+ })
1712
+ ```
1713
+
1714
+ `payload` is typed from the descriptor — a wrong field name is a `tsc` error at this call site.
1715
+
1716
+ 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:
1717
+
1718
+ - **No history on mount.** A fresh subscriber gets what happens *from now on*. No `seen` set, no `initialized` flag, no window.
1719
+ - **Exactly once, even under React `StrictMode`** — where every effect runs twice and a naive subscription fires each handler twice, in development only.
1720
+ - **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.
1721
+ - **A key change is a clean switch** — the old subscription ends before the new one starts.
1722
+ - **`key: null` means "not yet"**: no subscription, `status: 'idle'`. You never need a placeholder key.
1723
+
1724
+ ## What it guarantees — read this before you build on it
1725
+
1726
+ - **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.
1727
+ - **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.
1728
+ - **Guards are re-checked on EVERY delivery**, exactly as a live query's are. A resource-scoped guard (`{ scope: 'arena:read', from: 'arenaId' }`) runs its resolver each time, so un-sharing a resource or ending a membership stops the stream at the next delivery — the client is told, not silently skipped. What this does *not* catch is a ROLE revoked on the subject itself: those scopes were captured when the subscription opened. That half is covered by the credential bound below.
1729
+ - **A subscription cannot outlive the credential that authorized it.** When the session carries an expiry, the stream ends at it — and `useEvent` reconnects immediately, which is a NEW request, so the subject is resolved afresh and the guards run again for real. Still entitled: it continues and your app sees nothing. No longer entitled: the reconnect is refused, loudly. You write no reconnect handling for this; it is the existing retry doing its job. Note the limit precisely — this bounds EXPIRY, not revocation.
1730
+ - **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.
1731
+
1732
+ ### `missed` is a number, not a feeling
1733
+
1734
+ When deliveries are lost, you are told **how many** and **why**:
1735
+
1736
+ ```tsx
1737
+ useEvent(gameStarted, key, handler, {
1738
+ onMissed: ({ count, reason }) => resyncFromServer(count), // reason: 'buffer' | 'resume'
1739
+ })
1740
+ ```
1741
+
1742
+ 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.
1743
+
1744
+ 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".
1745
+
1746
+ ### Reconnects resume; mounts do not
1747
+
1748
+ These read as one contradiction — *never replay history* against *never lose a message* — and they are two different questions:
1749
+
1750
+ - **A first attach** starts empty. Set `rewind: true` on the descriptor if you want the recent buffer instead.
1751
+ - **A reconnect** continues from the last serial that subscription saw. `useEvent` does this for you, including after a deploy or a proxy timeout.
1752
+
1753
+ The buffer is deliberately small — tens of messages, minutes. Anything larger is a durable queue, and the framework already has one.
1754
+
1755
+ ## Triggering a workflow from an event
1756
+
1757
+ ```ts
1758
+ triggerWorkflow({ on: gameStarted, workflow: 'postGameReport' })
1759
+ ```
1760
+
1761
+ `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.
1762
+
1763
+ ## Reaching HTTP receivers too
1764
+
1765
+ An event can also be delivered to subscribed webhook targets — the third audience of the same declaration:
1766
+
1767
+ ```ts
1768
+ export const orderPaid = defineEvent({
1769
+ name: 'orders.paid',
1770
+ key: Schema.Struct({ orderId: Schema.String }),
1771
+ payload: Schema.Struct({ total: Schema.Number }),
1772
+ webhook: { description: 'An order was paid', version: 2 },
1773
+ })
1774
+ ```
1775
+
1776
+ 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.
1777
+
1778
+ The `webhook:` block is namespaced because its settings mean nothing to the other audiences — a top-level `rateLimit` would read as if it throttled client delivery, which it does not — it is a ceiling on webhook deliveries only. Requires [`@voltro/plugin-webhooks`](/docs/plugins/webhooks); absent, it costs nothing.
1779
+
1780
+ ## Across instances
1781
+
1782
+ 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.
1783
+
1784
+ **Each declared event gets its own channel** (`<namespace>:events:<name>`, where
1785
+ the namespace defaults to your app's name — see
1786
+ [broadcast](/docs/plugins/broadcast)), and a
1787
+ replica subscribes to it only while it actually has a local subscriber. This
1788
+ matters as soon as one event is much busier than the others: on a single shared
1789
+ channel every replica receives, decodes and tracks every event of every peer,
1790
+ including the ones it serves no clients for. With five replicas and a high-rate
1791
+ event whose subscribers all sit on one of them, four were doing that work and
1792
+ discarding the result.
1793
+
1794
+ Nothing to configure — it follows from the declaration. The one operational
1795
+ consequence: during a **rolling deploy** replicas on different framework versions
1796
+ use different channel names, so cross-replica delivery is degraded for the length
1797
+ of the rollout. Local delivery on each replica is unaffected throughout.
1798
+
1799
+ ## Throughput — the numbers, and where this is the wrong primitive
1800
+
1801
+ Measured on one core, publish path only:
1802
+
1803
+ | | |
1804
+ | --- | --- |
1805
+ | `bus.publish`, 1–100 subscribers | ~1.5µs (**~670,000/s**) |
1806
+ | `bus.publish`, 1000 subscribers | ~3.1µs (~325,000/s) |
1807
+ | `ctx.events.publish` (validation + size gate + bus) | ~4.1µs (**~240,000/s**) |
1808
+
1809
+ **Across replicas**, measured over a real Redis with two processes — 200 of 200
1810
+ delivered, no loss:
1811
+
1812
+ | p50 | p95 | p99 | max |
1813
+ | --- | --- | --- | --- |
1814
+ | 1.67 ms | 2.91 ms | 6.58 ms | 9.95 ms |
1815
+
1816
+ That is the broker round trip plus both bus hops. It is the number that matters
1817
+ for a display in another pod, and it is the one to compare against a hosted
1818
+ realtime service — where the same hop is a network round trip to someone else's
1819
+ region.
1820
+
1821
+ **Fan-out is nearly free.** One subscriber and a hundred cost the same — the
1822
+ per-publish work dominates, not the delivery loop. What you pay per subscriber is
1823
+ the wire encode on its own subscription, not anything in the bus.
1824
+
1825
+ For a game lifecycle — eight stage events per game, one publish each — that is
1826
+ several orders of magnitude of headroom. Even 100 players at 60Hz (6,000
1827
+ events/s) sits at ~2.5% of one core.
1828
+
1829
+ ### Where it stops being the right tool
1830
+
1831
+ Not at a throughput number, but at a **semantic** one: this primitive guarantees
1832
+ at-most-once delivery of *every* message, with gap accounting. For a 60Hz stream
1833
+ of positions or cursors, that guarantee costs something and buys nothing —
1834
+ **nobody needs frame 1 once frame 2 has arrived.** You want last-value-wins state,
1835
+ not a delivery log.
1836
+
1837
+ ### `delivery: 'latest'` — when only the current value matters
1838
+
1839
+ Declare it, and the framework stops treating a superseded value as a loss:
1840
+
1841
+ ```ts
1842
+ export default defineEvent({
1843
+ name: 'player.moved',
1844
+ key: Schema.Struct({ arenaId: Schema.String }),
1845
+ payload: Schema.Struct({ playerId: Schema.String, x: Schema.Number, y: Schema.Number }),
1846
+ access: 'authenticated',
1847
+ delivery: 'latest',
1848
+ })
1849
+ ```
1850
+
1851
+ | | `each` (default) | `latest` |
1852
+ | --- | --- | --- |
1853
+ | A slow subscriber | keeps the newest, is told how many it lost | receives the current value |
1854
+ | Gap reporting | `missed` is computed and delivered | none — nothing was lost |
1855
+ | Server retention | up to 64 messages / 5 minutes | one value |
1856
+ | Reconnect | replays what is retained, reports the shortfall | hands over the current value |
1857
+
1858
+ The distinction is **semantic, not performance**. `latest` is not "the fast mode":
1859
+ it changes what a missing message *means*. Choosing it for a stream where each
1860
+ delivery matters drops the ones in between; choosing `each` for a per-frame
1861
+ stream makes a slow client work through a backlog to reach a state it could have
1862
+ had immediately, and report a "loss" that was never one.
1863
+
1864
+ The test: **would a consumer be wrong to miss one?**
1865
+
1866
+ <Callout type="warn">
1867
+ `delivery: 'latest'` cannot be combined with `webhook`, and the declaration is
1868
+ refused. `latest` says a superseded delivery did not matter — but a webhook
1869
+ delivery is a durable side effect at a third party, and one already sent cannot
1870
+ be superseded. A 60Hz event with an HTTP audience is also 60 deliveries per
1871
+ second *per subscribed target*, and the webhook rate limit **defers** the excess
1872
+ as pending rows rather than dropping it, so the symptom is a growing table rather
1873
+ than an error anyone would look at. Publish the high-rate event for clients and a
1874
+ separate, coarser one for the outside world.
1875
+ </Callout>
1876
+
1877
+ ### Still worth avoiding
1878
+
1879
+ - **Payloads over a few hundred bytes at high rate.** The size gate stops you at
1880
+ 7,500 bytes, and long before that the wire encode per subscriber becomes the
1881
+ cost. Send a reference.
1882
+ - **Per-frame data as an event at all.** Even under `latest`, 60Hz of positions
1883
+ is 60 encodes per second per subscriber. Coalescing on the client and
1884
+ publishing at 10–20Hz is usually indistinguishable to a human and an order of
1885
+ magnitude cheaper.
1886
+
1887
+ The honest rule: **use an event when a consumer would be wrong to miss one.** If
1888
+ missing one is fine because the next one supersedes it, either declare
1889
+ `delivery: 'latest'` or model it as state — a table, or a value the client
1890
+ replaces.
1891
+
1892
+ ## Testing
1893
+
1894
+ ```ts
1895
+ import { testEventBus } from '@voltro/testing'
1896
+
1897
+ const events = testEventBus()
1898
+ const display = events.subscribe(gameStarted, { arenaId: 'a1' })
1899
+ await events.publish(gameStarted, { arenaId: 'a1' }, { gameId: 'g1', gameType: 'evo5', startedAt: 0 })
1900
+ expect(display.received).toEqual([{ gameId: 'g1', gameType: 'evo5', startedAt: 0 }])
1901
+
1902
+ // Force a loss deterministically instead of racing a queue:
1903
+ events.skipSerials(gameStarted, { arenaId: 'a1' }, 5)
1904
+ ```
1905
+
1906
+ 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.
1907
+
1908
+ ## Evolving a payload
1909
+
1910
+ 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.
1911
+
1912
+ ## Anti-pattern: events as rows
1913
+
1914
+ If you have this, replace it:
1915
+
1916
+ ```tsx
1917
+ // ✗ an events TABLE, reconstructed into "new" on the client
1918
+ const { data } = useSubscription('app', 'realtime.list', { limit: 500 })
1919
+ const seen = useRef(new Set()); const initialized = useRef(false)
1920
+ useEffect(() => { /* mark everything seen on the first pass, then diff */ }, [data])
1921
+ ```
1922
+
1923
+ 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.
1924
+
1925
+ Migrating is mechanical: declare the event, replace the insert with `ctx.events.publish`, replace the hook with `useEvent`, and drop the table.
1926
+
1927
+ ## See also
1928
+
1929
+ [Subscriptions](/docs/data/subscriptions) · [Outbox](/docs/data/outbox) · [Subscribers](/docs/data/subscribers) · [Streams](/docs/data/streams)
1930
+
1931
+
1932
+
1596
1933
  ---
1597
1934
 
1598
1935
  <!-- source: en/data/rest-routes.md -->