@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.
- package/CHANGELOG.md +670 -0
- package/THIRD-PARTY-NOTICES.md +2 -2
- package/dist/{apiBuild-B2m4XK_8.js → apiBuild-BtrTyKnF.js} +15 -15
- package/dist/apiBuild-DDJ0It4j.js +2 -0
- package/dist/bin.js +3 -3
- package/dist/{commands-CaIhTsC2.js → commands-D-9iYF2D.js} +1889 -1591
- package/dist/dbCommand-DrzXimKf.js +2 -0
- package/dist/{dbCommand-CC61CsAc.js → dbCommand-uuNCrFAb.js} +172 -163
- package/dist/{dev-DPkQVUTP.js → dev-BnWq4jeA.js} +1 -1
- package/dist/{dev-NCuEhNxs.js → dev-Dbm6SWtn.js} +2369 -1774
- package/dist/fileConventions-3bffWssN.js +30 -0
- package/dist/{frameworkTableAssembly-sg41s14F.js → frameworkTableAssembly-BwHU9Euq.js} +12 -8
- package/dist/frameworkTableAssembly-lrjZtk0G.js +2 -0
- package/dist/index.js +1 -1
- package/dist/{inspect-_ldwsAwH.js → inspect-CUCCzw2I.js} +20 -4
- package/dist/inspect-gt8bq-Tz.js +2 -0
- package/dist/{inspectMetrics-dcC7gnxu.js → inspectMetrics-BU90mvJN.js} +15 -15
- package/dist/{manifestBuild-Bs1Uw22_.js → manifestBuild-BnzAxp2O.js} +1 -1
- package/dist/manifestBuild-ifczArzr.js +2 -0
- package/dist/serveCommand-XBXuwJty.js +1294 -0
- package/dist/serveEntry.js +2 -2
- package/dist/{start-C_4VUDT8.js → start-BGXIf6zT.js} +2 -2
- package/dist/startEntry.js +2 -2
- package/package.json +17 -17
- package/templates/AGENTS.core.md +12 -5
- package/templates/AGENTS.md +14 -7
- package/templates/agent-docs/_index.md +2 -2
- package/templates/agent-docs/_manifest.json +3 -3
- package/templates/agent-docs/cli.md +15 -0
- package/templates/agent-docs/data.md +339 -2
- package/templates/agent-docs/database/migrations.md +40 -0
- package/templates/agent-docs/database/schema.md +127 -0
- package/templates/agent-docs/deployment.md +28 -0
- package/templates/agent-docs/plugins.md +26 -4
- package/templates/agent-docs/reference.md +2 -2
- package/templates/agent-docs/security.md +18 -0
- package/templates/agent-docs/templates/apibackends.md +5 -5
- package/templates/agent-docs/templates/overview.md +1 -1
- package/templates/agent-docs/whats-new.md +278 -168
- package/templates/agent-docs/workflows.md +160 -42
- package/templates/apps/api-ai/package.json +7 -7
- package/templates/apps/api-auth/package.json +8 -8
- package/templates/apps/api-backend/package.json +7 -7
- package/templates/apps/api-backend-deactivation/package.json +7 -7
- package/templates/apps/api-backend-mail/package.json +8 -8
- package/templates/apps/api-backend-mariadb/package.json +9 -9
- package/templates/apps/api-backend-storage/package.json +8 -8
- package/templates/apps/api-data-advanced/package.json +8 -8
- package/templates/apps/api-durable/README.md +2 -1
- package/templates/apps/api-durable/events/orders.event.ts +18 -0
- package/templates/apps/api-durable/mutations/orders.place.mutation.server.ts +17 -15
- package/templates/apps/api-durable/package.json +8 -8
- package/templates/apps/api-durable/triggers/order.placed.trigger.tsx +6 -4
- package/templates/apps/api-feature-flags/package.json +9 -9
- package/templates/apps/api-governance/package.json +8 -8
- package/templates/apps/api-kv/package.json +8 -8
- package/templates/apps/api-moderation/package.json +8 -8
- package/templates/apps/api-observability/package.json +8 -8
- package/templates/apps/api-ratelimit/package.json +8 -8
- package/templates/apps/api-rbac/package.json +8 -8
- package/templates/apps/api-rest/package.json +7 -7
- package/templates/apps/api-saas/package.json +11 -11
- package/templates/apps/api-search/package.json +8 -8
- package/templates/apps/api-versioning/package.json +8 -8
- package/templates/apps/api-webhooks/README.md +2 -2
- package/templates/apps/api-webhooks/events/orders.event.ts +35 -0
- package/templates/apps/api-webhooks/package.json +9 -9
- package/templates/apps/api-webhooks/template.json +1 -1
- package/templates/apps/changelog/package.json +6 -6
- package/templates/apps/edge-functions/package.json +2 -2
- package/templates/apps/frontend-admin/package.json +8 -8
- package/templates/apps/frontend-app/package.json +8 -8
- package/templates/apps/frontend-blank/package.json +7 -7
- package/templates/apps/frontend-contact/package.json +7 -7
- package/templates/apps/frontend-dashboard/package.json +7 -7
- package/templates/apps/frontend-docs/package.json +7 -7
- package/templates/apps/frontend-i18n/package.json +6 -6
- package/templates/apps/frontend-landing/package.json +7 -7
- package/templates/apps/frontend-spa/package.json +7 -7
- package/templates/apps/frontend-ssr/package.json +7 -7
- package/templates/apps/frontend-ssr-api/package.json +8 -8
- package/templates/apps/frontend-static-blog/package.json +6 -6
- package/dist/apiBuild-UEM3QBke.js +0 -2
- package/dist/dbCommand-Cr__4ATv.js +0 -2
- package/dist/fileConventions-CBNPZVOV.js +0 -29
- package/dist/frameworkTableAssembly-DRJrAVr1.js +0 -2
- package/dist/inspect-BA67TF6v.js +0 -2
- package/dist/manifestBuild-i-fRHg_H.js +0 -2
- package/dist/serveCommand-C4gzDZzc.js +0 -1175
- package/templates/apps/api-webhooks/events/order.completed.webhook.tsx +0 -22
package/dist/serveEntry.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { Z as e } from "./inspectMetrics-
|
|
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-
|
|
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-
|
|
2
|
-
import { D as ce, E as le, T as ue, a as de, p as N, w as P } from "./inspect-
|
|
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";
|
package/dist/startEntry.js
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
import { Z as e } from "./inspectMetrics-
|
|
2
|
-
import { t } from "./start-
|
|
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.
|
|
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.
|
|
66
|
-
"@voltro/cache": "0.
|
|
67
|
-
"@voltro/data-transfer": "0.
|
|
68
|
-
"@voltro/database": "0.
|
|
69
|
-
"@voltro/env": "0.
|
|
70
|
-
"@voltro/kv": "0.
|
|
71
|
-
"@voltro/logger": "0.
|
|
72
|
-
"@voltro/plugin-auth": "0.
|
|
73
|
-
"@voltro/plugin-broadcast": "0.
|
|
74
|
-
"@voltro/plugin-mail": "0.
|
|
75
|
-
"@voltro/plugin-storage": "0.
|
|
76
|
-
"@voltro/plugin-webhooks": "0.
|
|
77
|
-
"@voltro/protocol": "0.
|
|
78
|
-
"@voltro/runtime": "0.
|
|
79
|
-
"@voltro/serverless": "0.
|
|
80
|
-
"@voltro/workflow": "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",
|
package/templates/AGENTS.core.md
CHANGED
|
@@ -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. **
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
52
|
+
4. **External I/O — HTTP, file, AI, payment, email, signed URL?** → **action**
|
|
48
53
|
(`actions/`). NOT transactional; no rollback of side effects.
|
|
49
|
-
|
|
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) |
|
package/templates/AGENTS.md
CHANGED
|
@@ -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. **
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
52
|
+
4. **External I/O — HTTP, file, AI, payment, email, signed URL?** → **action**
|
|
48
53
|
(`actions/`). NOT transactional; no rollback of side effects.
|
|
49
|
-
|
|
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.
|
|
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 —
|
|
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.
|
|
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 —
|
|
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":
|
|
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":
|
|
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 —
|
|
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.
|
|
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 -->
|