@voltro/cli 0.55.0 → 0.56.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 +497 -0
- package/bin/voltro.mjs +24 -0
- package/dist/{apiBuild-CMvLJM_K.js → apiBuild-Bdaetr37.js} +119 -88
- package/dist/apiBuild-Vw1figjO.js +2 -0
- package/dist/bin.js +1 -1
- package/dist/{build-S0QOzqPT.js → build-CI36wL4R.js} +337 -312
- package/dist/buildReport-52gHKgfO.js +64 -0
- package/dist/{checkCommand-fbj9GDjN.js → checkCommand-CAwFXrxA.js} +6 -6
- package/dist/{checkCommand-DNkY5kwF.js → checkCommand-D0QV_zM_.js} +1 -1
- package/dist/{clusterCmd-DrVFCzSj.js → clusterCmd-CdLB1GkT.js} +1 -1
- package/dist/{codegen-SIepQtUl.js → codegen-Bth5lUTU.js} +2 -1
- package/dist/codegen-DbH7NbCR.js +2 -0
- package/dist/{codegenCommand-3TDJezom.js → codegenCommand-CidbQzbv.js} +10 -10
- package/dist/{codemodRunner-C2zxZUIw.js → codemodRunner-BlQPfjzA.js} +222 -0
- package/dist/{commands-BBYJ7Q3B.js → commands-CWjfThXv.js} +35 -35
- package/dist/{dashboardCommand-D2kmyCLL.js → dashboardCommand-BekcY5Ls.js} +3 -3
- package/dist/{dataCommand-BEPPQiTl.js → dataCommand-2pccgbIy.js} +3 -3
- package/dist/{dbCommand-DZTmOFT4.js → dbCommand-DpK_vQET.js} +457 -441
- package/dist/dbCommand-DrycGWWt.js +2 -0
- package/dist/{dev-DfVZaoys.js → dev-B9Gz0k85.js} +1 -1
- package/dist/{dev-Ca_A_S9v.js → dev-Dw263KPu.js} +2603 -2482
- package/dist/doctorCommand-BMWs6aVm.js +2 -0
- package/dist/{doctorCommand-CGZJK_4o.js → doctorCommand-aR_bFmIi.js} +353 -251
- package/dist/{dormancyCommand-DY2rYpTa.js → dormancyCommand-eXTQMbHU.js} +1 -1
- package/dist/{embeddingsCommand-BoCqZsgp.js → embeddingsCommand-CTmiQvwa.js} +1 -1
- package/dist/{envCommand-Bxy2fOjc.js → envCommand-BDUgV7EM.js} +12 -12
- package/dist/{evolveCommand-BsbZ-XDg.js → evolveCommand-YV8qW1LU.js} +2 -2
- package/dist/frameworkTableAssembly-CGNC0qr7.js +2 -0
- package/dist/{frameworkTableAssembly-Do-cf6RJ.js → frameworkTableAssembly-DNOFXfEQ.js} +92 -84
- package/dist/index.js +1 -1
- package/dist/{infoCommand-EmM3jPKD.js → infoCommand-BjVXpMlP.js} +1 -1
- package/dist/inspect-CNYvNXPU.js +1484 -0
- package/dist/inspect-S2rWy1Ys.js +2 -0
- package/dist/{inspectCmd-niF97fAq.js → inspectCmd-CP-G0sVK.js} +1 -1
- package/dist/{inspectFetch-EMuhTG_9.js → inspectFetch-BU1NyzxV.js} +36 -24
- package/dist/{inspectMetrics-CGF94puw.js → inspectMetrics-BY0Sjb2F.js} +19 -19
- package/dist/{logsCmd-B6oNsfaZ.js → logsCmd-BU8uCdys.js} +1 -1
- package/dist/{manifestBuild-DjX5MoXy.js → manifestBuild-CEkjfpwc.js} +1 -1
- package/dist/manifestBuild-DIa_s6u0.js +2 -0
- package/dist/{migrate-CGFZS-1a.js → migrate-SICulyz1.js} +2 -2
- package/dist/precompressAssets-YhTi1aWp.js +40 -0
- package/dist/{probeCommand-Bs3iVBSL.js → probeCommand-6HxEkNDG.js} +2 -2
- package/dist/{runtimeTrace-C1BTpHGQ.js → runtimeTrace-DgYMc09E.js} +1 -1
- package/dist/{scheduleCmd-DQRu6BZC.js → scheduleCmd-DYBUfo_T.js} +1 -1
- package/dist/{sdkgen-CXMwLg9n.js → sdkgen-PY-umd6O.js} +1 -1
- package/dist/{seedRunner-Dgsiwk_e.js → seedRunner-DISBKow-.js} +16 -16
- package/dist/serveCommand-BITS8Hpj.js +2 -0
- package/dist/{serveCommand-C7IrCD58.js → serveCommand-DIJ3ma76.js} +910 -870
- package/dist/serveEntry.js +1 -1
- package/dist/{start-EOV7s1NZ.js → start-B9NGB8gn.js} +575 -546
- package/dist/{start-DH7cat4-.js → start-BFQQkL1i.js} +1 -1
- package/dist/startEntry.js +1 -1
- package/dist/staticCachePolicy-CIyj6DbS.js +15 -0
- package/dist/{test-DO27-x2P.js → test-jipIQ5Mx.js} +1 -1
- package/dist/{tracesCmd-DgtgOUdi.js → tracesCmd-BWYDqMy6.js} +1 -1
- package/dist/{updateCommand-C_jN1w18.js → updateCommand-C9n_Z_oG.js} +8 -2
- package/dist/updateCommand-DsXEAHbd.js +2 -0
- package/dist/webDev-C2dRz9s5.js +2 -0
- package/dist/{webDev-B7vNj4Bq.js → webDev-C53hJdcL.js} +1240 -1271
- package/dist/{webhooksCommand-B1LVcyO3.js → webhooksCommand-uuPu8qQX.js} +1 -1
- package/dist/{workflowsCmd-BGF-mRZ5.js → workflowsCmd-g-DNpaUc.js} +1 -1
- package/package.json +55 -19
- package/templates/AGENTS.core.md +20 -1
- package/templates/AGENTS.md +21 -2
- package/templates/agent-docs/_index.md +1 -1
- package/templates/agent-docs/_manifest.json +1 -1
- package/templates/agent-docs/cli.md +122 -0
- package/templates/agent-docs/data.md +145 -5
- package/templates/agent-docs/database/scaling.md +40 -1
- package/templates/agent-docs/deployment.md +53 -1
- package/templates/agent-docs/internationalization.md +32 -0
- package/templates/agent-docs/local-first-mobile.md +9 -2
- package/templates/agent-docs/observability.md +227 -0
- package/templates/agent-docs/plugins/billing.md +15 -0
- package/templates/agent-docs/plugins/broadcast.md +2 -1
- package/templates/agent-docs/plugins/ratelimit.md +6 -1
- package/templates/agent-docs/plugins/row-history.md +11 -0
- package/templates/agent-docs/plugins.md +19 -0
- package/templates/agent-docs/templates/appshells.md +3 -3
- package/templates/agent-docs/whats-new.md +432 -73
- package/templates/apps/api-ai/package.json +6 -6
- 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-sqlite/package.json +8 -8
- package/templates/apps/api-backend-storage/package.json +8 -8
- package/templates/apps/api-cms/package.json +9 -9
- package/templates/apps/api-collab/package.json +8 -8
- package/templates/apps/api-data-advanced/package.json +8 -8
- package/templates/apps/api-durable/package.json +8 -8
- 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-row-history/package.json +8 -8
- package/templates/apps/api-saas/package.json +11 -11
- package/templates/apps/api-saas-starter/package.json +10 -10
- package/templates/apps/api-search/package.json +8 -8
- package/templates/apps/api-status/package.json +8 -8
- package/templates/apps/api-webhooks/package.json +9 -9
- package/templates/apps/changelog/package.json +7 -7
- package/templates/apps/changelog/src/pages/[locale]/page.tsx +7 -1
- package/templates/apps/edge-functions/package.json +2 -2
- package/templates/apps/frontend-admin/package.json +7 -8
- package/templates/apps/frontend-admin/src/pages/(marketing)/layout.tsx +1 -2
- package/templates/apps/frontend-admin/src/pages/admin/layout.tsx +1 -2
- package/templates/apps/frontend-app/package.json +8 -9
- package/templates/apps/frontend-app/src/pages/layout.tsx +1 -2
- package/templates/apps/frontend-auth/package.json +7 -8
- package/templates/apps/frontend-auth/src/components/AuthShell.tsx +1 -2
- package/templates/apps/frontend-blank/package.json +6 -7
- package/templates/apps/frontend-blank/src/pages/layout.tsx +1 -2
- package/templates/apps/frontend-cms/package.json +8 -9
- package/templates/apps/frontend-cms/src/pages/(app)/layout.tsx +1 -2
- package/templates/apps/frontend-collab/README.md +7 -2
- package/templates/apps/frontend-collab/package.json +9 -10
- package/templates/apps/frontend-collab/src/pages/layout.tsx +1 -2
- package/templates/apps/frontend-collab/src/pages/page.test.tsx +8 -4
- package/templates/apps/frontend-collab/src/pages/page.tsx +10 -5
- package/templates/apps/frontend-contact/package.json +7 -7
- package/templates/apps/frontend-contact/src/components/ContactForm.island.tsx +1 -1
- package/templates/apps/frontend-dashboard/package.json +6 -7
- package/templates/apps/frontend-dashboard/src/pages/(marketing)/layout.tsx +1 -2
- package/templates/apps/frontend-dashboard/src/pages/dashboard/layout.tsx +1 -2
- package/templates/apps/frontend-docs/package.json +8 -8
- package/templates/apps/frontend-i18n/package.json +6 -6
- package/templates/apps/frontend-landing/package.json +7 -7
- package/templates/apps/frontend-portal/package.json +7 -8
- package/templates/apps/frontend-portal/src/pages/(portal)/layout.tsx +1 -2
- package/templates/apps/frontend-saas/package.json +7 -8
- package/templates/apps/frontend-saas/src/pages/(marketing)/layout.tsx +1 -2
- package/templates/apps/frontend-saas/src/pages/dashboard/layout.tsx +1 -2
- package/templates/apps/frontend-spa/package.json +6 -7
- package/templates/apps/frontend-spa/src/pages/layout.tsx +1 -2
- package/templates/apps/frontend-ssr/package.json +6 -7
- package/templates/apps/frontend-ssr/src/pages/layout.tsx +1 -2
- package/templates/apps/frontend-ssr-api/package.json +7 -8
- package/templates/apps/frontend-ssr-api/src/pages/layout.tsx +1 -2
- package/templates/apps/frontend-static-blog/package.json +8 -8
- package/templates/apps/frontend-static-blog/src/components/ReadingProgress.island.tsx +1 -1
- package/templates/apps/frontend-static-blog/src/pages/[locale]/page.tsx +7 -1
- package/templates/apps/frontend-status/package.json +7 -8
- package/templates/apps/frontend-status/src/pages/layout.tsx +1 -2
- package/templates/apps/mobile-app/package.json +4 -4
- package/templates/baselines/compose/docker/api.Dockerfile +61 -5
- package/templates/baselines/compose/docker/web.Dockerfile +55 -10
- package/templates/baselines/compose-mariadb/docker/api.Dockerfile +61 -5
- package/templates/baselines/compose-mariadb/docker/web.Dockerfile +55 -10
- package/dist/apiBuild-Cl0IDx8c.js +0 -2
- package/dist/codegen-CN6vMM4J.js +0 -2
- package/dist/dbCommand-BTyBGhIA.js +0 -2
- package/dist/doctorCommand-djmqEcDC.js +0 -2
- package/dist/frameworkTableAssembly-Df2Ymp2f.js +0 -2
- package/dist/inspect-DCqILJ1G.js +0 -1197
- package/dist/inspect-DGJwpOAb.js +0 -2
- package/dist/manifestBuild-CJ2zvPvT.js +0 -2
- package/dist/serveCommand-Cjt5S9hD.js +0 -2
- package/dist/updateCommand-nnFjDbl4.js +0 -2
- package/dist/webDev-1XpVnYkW.js +0 -2
|
@@ -222,7 +222,7 @@ createVerifier({ secret: [process.env.WEBHOOK_SECRET, process.env.WEBHOOK_SECRET
|
|
|
222
222
|
...t === void 0 ? {} : { payload: t }
|
|
223
223
|
};
|
|
224
224
|
}, S = u({ scope: "voltro:webhooks" }), C = ["--out", "--name"], w = async (e) => {
|
|
225
|
-
let { walk: t, loadDiscovered: n } = await import("./dev-
|
|
225
|
+
let { walk: t, loadDiscovered: n } = await import("./dev-B9Gz0k85.js"), { outgoingFromEvents: r } = await import("./webhookDiscovery-il9ti-HE.js");
|
|
226
226
|
return r((await n(await t(e))).events.map((e) => ({
|
|
227
227
|
file: e.file,
|
|
228
228
|
descriptor: e.descriptor
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { n as e } from "./cliOutput-D1tSBoRM.js";
|
|
2
|
-
import { n as t, o as n, r, t as i } from "./inspectFetch-
|
|
2
|
+
import { n as t, o as n, r, t as i } from "./inspectFetch-BU1NyzxV.js";
|
|
3
3
|
import { ansi as a } from "@voltro/logger";
|
|
4
4
|
//#region src/workflowsCmd.ts
|
|
5
5
|
var o = [
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@voltro/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.56.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",
|
|
@@ -766,6 +766,42 @@
|
|
|
766
766
|
"title": "WidgetKind gained 'rich-text' — total widget registries need one new entry",
|
|
767
767
|
"kind": "manual"
|
|
768
768
|
},
|
|
769
|
+
{
|
|
770
|
+
"version": "0.56.0",
|
|
771
|
+
"id": "0.56.0/01_inspect_answers_are_observations",
|
|
772
|
+
"title": "inspect answers are `Observation`s — the payload is `.data`",
|
|
773
|
+
"kind": "manual"
|
|
774
|
+
},
|
|
775
|
+
{
|
|
776
|
+
"version": "0.56.0",
|
|
777
|
+
"id": "0.56.0/02_broadcast_gap_is_one_object",
|
|
778
|
+
"title": "attachBroadcastBus's onGap now receives a single BroadcastGap object",
|
|
779
|
+
"kind": "manual"
|
|
780
|
+
},
|
|
781
|
+
{
|
|
782
|
+
"version": "0.56.0",
|
|
783
|
+
"id": "0.56.0/03_outbox_drain_needs_an_identity",
|
|
784
|
+
"title": "drainOutbox now requires claimedBy — the fleet-wide exactly-once gate",
|
|
785
|
+
"kind": "manual"
|
|
786
|
+
},
|
|
787
|
+
{
|
|
788
|
+
"version": "0.56.0",
|
|
789
|
+
"id": "0.56.0/04_reaction_dedupe_is_one_claim",
|
|
790
|
+
"title": "runReaction's dedupe is a single atomic claim",
|
|
791
|
+
"kind": "manual"
|
|
792
|
+
},
|
|
793
|
+
{
|
|
794
|
+
"version": "0.56.0",
|
|
795
|
+
"id": "0.56.0/05_plugin_bind_context_claims_a_change",
|
|
796
|
+
"title": "PluginBindContext requires claimChange; change identity moved to @voltro/database",
|
|
797
|
+
"kind": "manual"
|
|
798
|
+
},
|
|
799
|
+
{
|
|
800
|
+
"version": "0.56.0",
|
|
801
|
+
"id": "0.56.0/06_serve_is_api_only",
|
|
802
|
+
"title": "`voltro serve` on a WEB app now refuses — use `voltro start`",
|
|
803
|
+
"kind": "manual"
|
|
804
|
+
},
|
|
769
805
|
{
|
|
770
806
|
"version": "0.6.0",
|
|
771
807
|
"id": "0.6.0/01_no-dev-session-secret",
|
|
@@ -848,24 +884,24 @@
|
|
|
848
884
|
"@effect/platform-node": "^0.108.0",
|
|
849
885
|
"@effect/sql": "^0.52.0",
|
|
850
886
|
"@effect/workflow": "^0.19.0",
|
|
851
|
-
"@voltro/ai": "0.
|
|
852
|
-
"@voltro/cache": "0.
|
|
853
|
-
"@voltro/client": "0.
|
|
854
|
-
"@voltro/content": "0.
|
|
855
|
-
"@voltro/data-transfer": "0.
|
|
856
|
-
"@voltro/database": "0.
|
|
857
|
-
"@voltro/env": "0.
|
|
858
|
-
"@voltro/kv": "0.
|
|
859
|
-
"@voltro/logger": "0.
|
|
860
|
-
"@voltro/plugin-auth": "0.
|
|
861
|
-
"@voltro/plugin-broadcast": "0.
|
|
862
|
-
"@voltro/plugin-mail": "0.
|
|
863
|
-
"@voltro/plugin-storage": "0.
|
|
864
|
-
"@voltro/plugin-webhooks": "0.
|
|
865
|
-
"@voltro/protocol": "0.
|
|
866
|
-
"@voltro/runtime": "0.
|
|
867
|
-
"@voltro/serverless": "0.
|
|
868
|
-
"@voltro/workflow": "0.
|
|
887
|
+
"@voltro/ai": "0.56.0",
|
|
888
|
+
"@voltro/cache": "0.56.0",
|
|
889
|
+
"@voltro/client": "0.56.0",
|
|
890
|
+
"@voltro/content": "0.56.0",
|
|
891
|
+
"@voltro/data-transfer": "0.56.0",
|
|
892
|
+
"@voltro/database": "0.56.0",
|
|
893
|
+
"@voltro/env": "0.56.0",
|
|
894
|
+
"@voltro/kv": "0.56.0",
|
|
895
|
+
"@voltro/logger": "0.56.0",
|
|
896
|
+
"@voltro/plugin-auth": "0.56.0",
|
|
897
|
+
"@voltro/plugin-broadcast": "0.56.0",
|
|
898
|
+
"@voltro/plugin-mail": "0.56.0",
|
|
899
|
+
"@voltro/plugin-storage": "0.56.0",
|
|
900
|
+
"@voltro/plugin-webhooks": "0.56.0",
|
|
901
|
+
"@voltro/protocol": "0.56.0",
|
|
902
|
+
"@voltro/runtime": "0.56.0",
|
|
903
|
+
"@voltro/serverless": "0.56.0",
|
|
904
|
+
"@voltro/workflow": "0.56.0",
|
|
869
905
|
"chokidar": "^5.0.0",
|
|
870
906
|
"ioredis": "^5.11.1",
|
|
871
907
|
"tinyglobby": "^0.2.17",
|
package/templates/AGENTS.core.md
CHANGED
|
@@ -142,6 +142,24 @@ line below replaces something real apps write by hand hundreds of times.
|
|
|
142
142
|
notify-helper at the tail of the mutation body instead makes the reactivity
|
|
143
143
|
invisible — you can only find it by reading every executor. Both are
|
|
144
144
|
best-effort, so genuinely critical delivery still belongs in a workflow.
|
|
145
|
+
**A subscriber runs on EVERY replica.** That is right for a handler that
|
|
146
|
+
refreshes an index or drops a process-local cache, and wrong for one whose
|
|
147
|
+
effect IS a write someone receives — two pods send two mails. When the
|
|
148
|
+
handler is an effect, add `once:` (a deterministic key per change) and
|
|
149
|
+
exactly one replica runs it:
|
|
150
|
+
```ts
|
|
151
|
+
defineSubscriber({
|
|
152
|
+
table: 'absence_requests', on: ['insert'],
|
|
153
|
+
once: true,
|
|
154
|
+
handler: notifyApprovers,
|
|
155
|
+
})
|
|
156
|
+
```
|
|
157
|
+
`once: true` derives the key from the change (content + its position among
|
|
158
|
+
content-identical repeats) — prefer it. A function is for when you want to be
|
|
159
|
+
COARSER than one-per-change; a key you write must tell two genuine changes
|
|
160
|
+
apart, and a row id does not for `update`. `once` is AT MOST once — a replica
|
|
161
|
+
that wins and dies takes the event with it. `defineReaction`'s `dedupeKey`
|
|
162
|
+
gives its act the same fleet-wide gate.
|
|
145
163
|
4. **A roll-up / counter recomputed on every read?** → **`defineAggregate`**
|
|
146
164
|
(`*.aggregate.ts`). Add `incremental:` for `count|sum|avg|min|max` group-bys
|
|
147
165
|
and it is MAINTAINED on write rather than recomputed. `read({ where })`
|
|
@@ -217,6 +235,7 @@ line below replaces something real apps write by hand hundreds of times.
|
|
|
217
235
|
| `const rows = …; if (!rows[0]) throw new NotFound()` | `.one()` |
|
|
218
236
|
| 3+ sequential `store.query` to assemble related data | `relations()` + `.with()` |
|
|
219
237
|
| a notify/webhook helper called at the end of a mutation | `defineSubscriber` / `defineReaction` |
|
|
238
|
+
| a subscriber that sends a mail / notification / webhook | the same, plus **`once:`** — without it, one per replica |
|
|
220
239
|
| `WHERE ownerId = me` in every list handler | `setRowFilter` (covers the SUBSCRIPTION too) |
|
|
221
240
|
| a counter recomputed by scanning rows on every read | `defineAggregate` (+ `incremental:`) |
|
|
222
241
|
| `requireScope(...)` as the first line of every executor | `guards:` on the descriptor |
|
|
@@ -358,7 +377,7 @@ public REST routes, declared via `restRoutes` in `app.config.ts`).
|
|
|
358
377
|
| `*.workflow.tsx` + `*.workflow.server.tsx` | durable multi-step work |
|
|
359
378
|
| `*.trigger.tsx` | domain event → workflow |
|
|
360
379
|
| `*.cron.tsx` | scheduled job (single file) |
|
|
361
|
-
| `*.subscribe.ts` | best-effort post-commit reaction to a table |
|
|
380
|
+
| `*.subscribe.ts` | best-effort post-commit reaction to a table (every replica; `once:` for one) |
|
|
362
381
|
| `*.reaction.tsx` | standing reactive agent |
|
|
363
382
|
| `*.aggregate.ts` | scheduled materialised query |
|
|
364
383
|
| `*.startup.tsx` | run-once boot hook holding a resource |
|
package/templates/AGENTS.md
CHANGED
|
@@ -142,6 +142,24 @@ line below replaces something real apps write by hand hundreds of times.
|
|
|
142
142
|
notify-helper at the tail of the mutation body instead makes the reactivity
|
|
143
143
|
invisible — you can only find it by reading every executor. Both are
|
|
144
144
|
best-effort, so genuinely critical delivery still belongs in a workflow.
|
|
145
|
+
**A subscriber runs on EVERY replica.** That is right for a handler that
|
|
146
|
+
refreshes an index or drops a process-local cache, and wrong for one whose
|
|
147
|
+
effect IS a write someone receives — two pods send two mails. When the
|
|
148
|
+
handler is an effect, add `once:` (a deterministic key per change) and
|
|
149
|
+
exactly one replica runs it:
|
|
150
|
+
```ts
|
|
151
|
+
defineSubscriber({
|
|
152
|
+
table: 'absence_requests', on: ['insert'],
|
|
153
|
+
once: true,
|
|
154
|
+
handler: notifyApprovers,
|
|
155
|
+
})
|
|
156
|
+
```
|
|
157
|
+
`once: true` derives the key from the change (content + its position among
|
|
158
|
+
content-identical repeats) — prefer it. A function is for when you want to be
|
|
159
|
+
COARSER than one-per-change; a key you write must tell two genuine changes
|
|
160
|
+
apart, and a row id does not for `update`. `once` is AT MOST once — a replica
|
|
161
|
+
that wins and dies takes the event with it. `defineReaction`'s `dedupeKey`
|
|
162
|
+
gives its act the same fleet-wide gate.
|
|
145
163
|
4. **A roll-up / counter recomputed on every read?** → **`defineAggregate`**
|
|
146
164
|
(`*.aggregate.ts`). Add `incremental:` for `count|sum|avg|min|max` group-bys
|
|
147
165
|
and it is MAINTAINED on write rather than recomputed. `read({ where })`
|
|
@@ -217,6 +235,7 @@ line below replaces something real apps write by hand hundreds of times.
|
|
|
217
235
|
| `const rows = …; if (!rows[0]) throw new NotFound()` | `.one()` |
|
|
218
236
|
| 3+ sequential `store.query` to assemble related data | `relations()` + `.with()` |
|
|
219
237
|
| a notify/webhook helper called at the end of a mutation | `defineSubscriber` / `defineReaction` |
|
|
238
|
+
| a subscriber that sends a mail / notification / webhook | the same, plus **`once:`** — without it, one per replica |
|
|
220
239
|
| `WHERE ownerId = me` in every list handler | `setRowFilter` (covers the SUBSCRIPTION too) |
|
|
221
240
|
| a counter recomputed by scanning rows on every read | `defineAggregate` (+ `incremental:`) |
|
|
222
241
|
| `requireScope(...)` as the first line of every executor | `guards:` on the descriptor |
|
|
@@ -358,7 +377,7 @@ public REST routes, declared via `restRoutes` in `app.config.ts`).
|
|
|
358
377
|
| `*.workflow.tsx` + `*.workflow.server.tsx` | durable multi-step work |
|
|
359
378
|
| `*.trigger.tsx` | domain event → workflow |
|
|
360
379
|
| `*.cron.tsx` | scheduled job (single file) |
|
|
361
|
-
| `*.subscribe.ts` | best-effort post-commit reaction to a table |
|
|
380
|
+
| `*.subscribe.ts` | best-effort post-commit reaction to a table (every replica; `once:` for one) |
|
|
362
381
|
| `*.reaction.tsx` | standing reactive agent |
|
|
363
382
|
| `*.aggregate.ts` | scheduled materialised query |
|
|
364
383
|
| `*.startup.tsx` | run-once boot hook holding a resource |
|
|
@@ -730,7 +749,7 @@ each plugin's own README.
|
|
|
730
749
|
|
|
731
750
|
| Topic | Open | Summary |
|
|
732
751
|
|---|---|---|
|
|
733
|
-
| **What's new in 0.
|
|
752
|
+
| **What's new in 0.56.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. |
|
|
734
753
|
| 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. |
|
|
735
754
|
| 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. |
|
|
736
755
|
| Caching | `node_modules/@voltro/cli/templates/agent-docs/caching.md` | Voltro's caching layer (@voltro/cache) — an always-on memory default, swappable Redis-compatible backends, a low-level wrap primitive, and automatic query-result invalidation. |
|
|
@@ -9,7 +9,7 @@ each plugin's own README.
|
|
|
9
9
|
|
|
10
10
|
| Topic | Open | Summary |
|
|
11
11
|
|---|---|---|
|
|
12
|
-
| **What's new in 0.
|
|
12
|
+
| **What's new in 0.56.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. |
|
|
@@ -206,7 +206,7 @@
|
|
|
206
206
|
"group": null,
|
|
207
207
|
"description": "OpenTelemetry tracing in Voltro — the auto-emitted spans for every primitive, span attributes and nesting, the three enabling modes (console / OTLP / buffer), and adding your own spans with Effect.withSpan.",
|
|
208
208
|
"path": "agent-docs/observability.md",
|
|
209
|
-
"files":
|
|
209
|
+
"files": 5
|
|
210
210
|
},
|
|
211
211
|
{
|
|
212
212
|
"id": "plugins",
|
|
@@ -1441,6 +1441,86 @@ Type mapping is faithful: `string → String`, `integer → Int`, `number → Do
|
|
|
1441
1441
|
|
|
1442
1442
|
**Scope — this is the SDK code generator, not a native runtime.** Deliberately out of scope (they need a native runtime or managed infra, not generated client code): native module bindings (camera, biometrics), the APNs/FCM push **sender** (per-tenant Apple/Firebase credentials, provisioned server-side), and the managed OTA / EAS build pipeline. The generated source is verified at the generator level (golden-string tests over the emitted Swift + Kotlin). Compiling it with `swiftc` / Gradle is the remaining step in your own mobile CI — the framework harness has no Swift/Kotlin toolchain.
|
|
1443
1443
|
|
|
1444
|
+
## Serving: asset prefix, pre-compression, sourcemaps, keep-alive
|
|
1445
|
+
|
|
1446
|
+
Four knobs that decide what leaves the container. All are `voltro start`
|
|
1447
|
+
concerns; none change what your code does.
|
|
1448
|
+
|
|
1449
|
+
```ts
|
|
1450
|
+
// apps/web/app.config.ts
|
|
1451
|
+
web: {
|
|
1452
|
+
// Serve `assets/` from a CDN. Becomes vite's `base`, so every emitted URL —
|
|
1453
|
+
// entry, modulepreloads, CSS, images, fonts — is written with the prefix at
|
|
1454
|
+
// BUILD time. Pre-rendered HTML still comes from the app; upload
|
|
1455
|
+
// `dist/assets/` to the prefix on deploy. Filenames are content-hashed, so a
|
|
1456
|
+
// previous deploy's assets stay valid for a visitor mid-navigation.
|
|
1457
|
+
assetPrefix: 'https://cdn.example.com/_assets',
|
|
1458
|
+
|
|
1459
|
+
// Emit `.map` files WITHOUT a `//# sourceMappingURL` comment, so nothing in
|
|
1460
|
+
// the shipped JS points at them. For `plugin-sentry`: upload them in your
|
|
1461
|
+
// deploy step and DELETE them before the image is built. `voltro start`
|
|
1462
|
+
// refuses to serve a `.map` regardless, so a forgotten delete is not a leak.
|
|
1463
|
+
sourcemaps: 'hidden',
|
|
1464
|
+
},
|
|
1465
|
+
http: {
|
|
1466
|
+
// Node hangs up an idle keep-alive connection after 5s; every proxy in front
|
|
1467
|
+
// holds one longer, and the request that lands in that window comes back as
|
|
1468
|
+
// a 502. Default 72000 clears nginx-60/ALB-60; raise it above YOUR proxy's
|
|
1469
|
+
// idle timeout. `headersTimeoutMs` must exceed it and is derived if omitted.
|
|
1470
|
+
keepAliveTimeoutMs: 72_000,
|
|
1471
|
+
},
|
|
1472
|
+
```
|
|
1473
|
+
|
|
1474
|
+
**Pre-compression is automatic.** `voltro build` writes `.br` (quality 11) and
|
|
1475
|
+
`.gz` beside every content-hashed asset over 1 KB, and `voltro start` serves the
|
|
1476
|
+
variant when the client accepts it. Measured on one 321 KB chunk, three requests:
|
|
1477
|
+
|
|
1478
|
+
| | time | bytes |
|
|
1479
|
+
|---|---:|---:|
|
|
1480
|
+
| compressed per request (before) | 5.6 / 5.0 / 4.6 ms | 100 665 |
|
|
1481
|
+
| pre-compressed (now) | 1.6 / 1.7 ms | **86 083** |
|
|
1482
|
+
|
|
1483
|
+
Faster *and* smaller: a build can afford brotli q11 where a per-request path
|
|
1484
|
+
cannot. Both encodings of one asset carry the same `ETag` — it is computed over
|
|
1485
|
+
the uncompressed file, so a shared cache sees one representation.
|
|
1486
|
+
|
|
1487
|
+
**A cross-origin api gets a `preconnect`.** When an api's `wsUrl` is on another
|
|
1488
|
+
host, the shell carries `<link rel="preconnect" href="…" crossorigin>` so DNS +
|
|
1489
|
+
TCP + TLS overlap the bundle download instead of following it. Same-origin apis
|
|
1490
|
+
are skipped — the browser already has that connection.
|
|
1491
|
+
|
|
1492
|
+
## `voltro serve <appDir>` — API apps only
|
|
1493
|
+
|
|
1494
|
+
`voltro serve` is the production server for an **API** app. On a **web** app it
|
|
1495
|
+
refuses and points you at `voltro start`:
|
|
1496
|
+
|
|
1497
|
+
```text
|
|
1498
|
+
`voltro serve` is the production server for an API app — this is a WEB app.
|
|
1499
|
+
|
|
1500
|
+
production voltro start .
|
|
1501
|
+
development voltro dev .
|
|
1502
|
+
```
|
|
1503
|
+
|
|
1504
|
+
That is a refusal, not a missing feature. `serve` used to build a web app a
|
|
1505
|
+
second time and hand it to `vite preview`, and both halves were wrong:
|
|
1506
|
+
|
|
1507
|
+
- **In production it never ran.** The launcher's serve fast path requires
|
|
1508
|
+
`.framework/dist-api/serveBundle/serveEntry.js` — an artefact a web build does
|
|
1509
|
+
not produce — so a web app exited 1 pointing at a path that cannot exist,
|
|
1510
|
+
directly after a `voltro build` that had just succeeded.
|
|
1511
|
+
- **Below production it destroyed the build.** That second build had no
|
|
1512
|
+
`@tailwindcss/vite`, no image pipeline and no per-page islands entries. With
|
|
1513
|
+
Tailwind it aborted; without it, it succeeded — and since both builds write
|
|
1514
|
+
`.framework/dist`, which Vite empties, it deleted `dist/server`, the island
|
|
1515
|
+
shells and every pre-rendered page. `voltro start` could then not boot at all.
|
|
1516
|
+
|
|
1517
|
+
So the table is:
|
|
1518
|
+
|
|
1519
|
+
| app | development | production |
|
|
1520
|
+
|---|---|---|
|
|
1521
|
+
| web | `voltro dev` | `voltro start` |
|
|
1522
|
+
| api | `voltro dev` | `voltro serve` |
|
|
1523
|
+
|
|
1444
1524
|
## `voltro doctor` — preflight a production serve
|
|
1445
1525
|
|
|
1446
1526
|
Production `voltro serve` for an **API** app boots ONLY from the precompiled serve
|
|
@@ -1775,6 +1855,7 @@ It covers both halves of the stack:
|
|
|
1775
1855
|
| server | `requireScope(...)` at the top of an executor | `guards:` on the descriptor |
|
|
1776
1856
|
| server | a `token` / `secret` / `password` column with no encryption | `.encrypted()` |
|
|
1777
1857
|
| server | a notify / webhook helper called at a mutation's tail | `defineSubscriber` / `defineReaction` |
|
|
1858
|
+
| server | a `*.subscribe.ts` handler that writes or publishes, with no `once:` | `once: true` on `defineSubscriber` |
|
|
1778
1859
|
| server | `hasMore` + `limit + 1` | `paginateById` |
|
|
1779
1860
|
| server | `.getTime()` / `.toISOString()` mapping a row on the way out | `timestampMs` / `timestampMsOrNull` from `@voltro/database/wire` in the descriptor's `output` struct |
|
|
1780
1861
|
| client | per-field `useState` + a submit flag | `useFormBinding` |
|
|
@@ -1801,6 +1882,28 @@ already uses `.one()`, stays silent.
|
|
|
1801
1882
|
|
|
1802
1883
|
Two of them are worth spelling out, because their advice is not one-line:
|
|
1803
1884
|
|
|
1885
|
+
**The subscriber rule is the second half of the mutation-tail rule.** That one
|
|
1886
|
+
moves an effect OUT of a mutation and into a subscriber, which is right — and
|
|
1887
|
+
lands it on a channel every replica listens to. `store.onChange` is a broadcast:
|
|
1888
|
+
correct for a READER (a cache drop, an index refresh, a live query must run
|
|
1889
|
+
everywhere) and a multiplier for an EFFECT, because there is nothing to make
|
|
1890
|
+
idempotent — the effect IS the write, so each run produces another one. One
|
|
1891
|
+
`INSERT` behind two replicas therefore writes two notification rows, and four
|
|
1892
|
+
with a broadcast bus in front.
|
|
1893
|
+
|
|
1894
|
+
So the rule fires when a handler WRITES (`ctx.store.insert` / `update` /
|
|
1895
|
+
`upsert` / …), publishes (`ctx.publish`), or calls a `notify` / `sendWebhook` /
|
|
1896
|
+
`sendMail`-shaped helper, and the subscriber declares no `once:`. Any `once:`
|
|
1897
|
+
silences it — `true` or a key function — because the question is whether the
|
|
1898
|
+
decision was made, not which way. It reads the handler through the AST, so
|
|
1899
|
+
`handler: notifyApprovers` naming a function in the same file is judged exactly
|
|
1900
|
+
like an inline arrow; a handler IMPORTED from another module is not judged at
|
|
1901
|
+
all, since its body is not in the file being read.
|
|
1902
|
+
|
|
1903
|
+
It stays quiet on a reader on purpose. `once:` on a cache-warming subscriber
|
|
1904
|
+
would silence it on every replica but one, which is worse than the repetition it
|
|
1905
|
+
removes — only the handler's author knows which of the two they wrote.
|
|
1906
|
+
|
|
1804
1907
|
**The credential-column rule skips names that aren't credentials.** A name ending
|
|
1805
1908
|
in `Id` / `_id`, a name ending in `Hash` / `_hash`, and a name beginning with
|
|
1806
1909
|
`vault` are all left alone:
|
|
@@ -4548,6 +4651,25 @@ prints only because your tree matched it.
|
|
|
4548
4651
|
|
|
4549
4652
|
Codemods that span multiple versions run in order (e.g. upgrading `0.2.0 → 0.4.0` runs the `0.3.0` and `0.4.0` codemods in sequence).
|
|
4550
4653
|
|
|
4654
|
+
### When nothing changed, read WHICH nothing
|
|
4655
|
+
|
|
4656
|
+
Two states end an update with no diff, and they mean opposite things: the jump
|
|
4657
|
+
ships no codemods, or it ships some and every one of them decided your project
|
|
4658
|
+
is not affected. The summary names which:
|
|
4659
|
+
|
|
4660
|
+
```text
|
|
4661
|
+
codemods: 2 ship for this jump; none matched your project.
|
|
4662
|
+
· 0.55.0/01_target-relations-declare-columns — its own check found nothing to change
|
|
4663
|
+
· 0.55.0/02_widget-kind-gained-rich-text — its own check found nothing to change
|
|
4664
|
+
```
|
|
4665
|
+
|
|
4666
|
+
`none ship for this jump` is the first. The second lists the ids, because a
|
|
4667
|
+
codemod's own check is a predicate that can be wrong — and a gate that reads
|
|
4668
|
+
the wrong files answers "does not apply" for a project that is fully affected.
|
|
4669
|
+
If you recognise a subject in that list as something your app *does* use, that
|
|
4670
|
+
is a bug in the check rather than a fact about your code; the CHANGELOG entry
|
|
4671
|
+
for the version says what each one looks for.
|
|
4672
|
+
|
|
4551
4673
|
## The database is separate
|
|
4552
4674
|
|
|
4553
4675
|
`voltro update` does **not** touch your database. Framework-owned `_voltro_*` tables (workflow runs, schedules, …) are reconciled by the declarative differ, not by codemods: when a release changes one of those tables, your next `voltro db apply` (or `voltro dev` boot, which auto-applies) picks up the change.
|
|
@@ -2346,6 +2346,38 @@ The contract, in the order it protects you:
|
|
|
2346
2346
|
|
|
2347
2347
|
**The boundary to keep:** if your own UI needs live data, that is a query + `useSubscription`, never a gateway. A gateway hands you raw frames and none of the subscription protocol's guarantees — reach for it only when the CLIENT dictates the protocol.
|
|
2348
2348
|
|
|
2349
|
+
## When the api connects — `web.api.connect`
|
|
2350
|
+
|
|
2351
|
+
Every declared api opens its WebSocket at mount by default. That is `'eager'`,
|
|
2352
|
+
and it is what the framework has always done.
|
|
2353
|
+
|
|
2354
|
+
```ts
|
|
2355
|
+
// apps/web/app.config.ts
|
|
2356
|
+
web: { api: { connect: 'lazy' } }
|
|
2357
|
+
```
|
|
2358
|
+
|
|
2359
|
+
`'lazy'` defers the connection to the FIRST hook that asks for that api —
|
|
2360
|
+
`useSubscription`, `useMutation`, `useAppClient`, any of them. A page that reads
|
|
2361
|
+
no data never opens a socket.
|
|
2362
|
+
|
|
2363
|
+
Two measurements decide whether you want it. Both are from a real browser
|
|
2364
|
+
against a `voltro start`:
|
|
2365
|
+
|
|
2366
|
+
- **`interactive: 'full'` is the default, and it connected regardless.** A
|
|
2367
|
+
pre-rendered documentation page that subscribes to nothing opened a socket;
|
|
2368
|
+
pages set to `interactive: 'none'` or `'islands'` opened none. So the pages
|
|
2369
|
+
paying for a connection they never use are exactly the ordinary ones.
|
|
2370
|
+
- **An open socket keeps a dormancy-managed instance awake.** `isIdleNow`
|
|
2371
|
+
returns false while `connectedClients() > 0` (see
|
|
2372
|
+
[scale-to-zero](/docs/deployment/scale-to-zero)), so one browser tab left open
|
|
2373
|
+
on a pricing page prevents scale-to-zero for as long as it stays open.
|
|
2374
|
+
|
|
2375
|
+
`'eager'` remains the default because `'lazy'` moves WHEN a connection error
|
|
2376
|
+
surfaces — from page load to first data use — and an app that opens its socket
|
|
2377
|
+
for a side effect (a presence ping, an inspect stream) rather than through a data
|
|
2378
|
+
hook would notice the difference. If your app subscribes on every page, the two
|
|
2379
|
+
behave identically.
|
|
2380
|
+
|
|
2349
2381
|
## See also
|
|
2350
2382
|
|
|
2351
2383
|
- [Subscribers (`*.subscribe.ts`)](/docs/data/subscribers) — server-side, best-effort post-commit reactivity to a table (NOT the client hook on this page).
|
|
@@ -3988,7 +4020,7 @@ These helpers give you the secure **handler**, not schema derivation. Deriving t
|
|
|
3988
4020
|
|
|
3989
4021
|
_Per-table post-commit reactivity via file convention. Default-exported defineSubscriber({ table, on, handler }) — fires AFTER commit, best-effort, fire-and-forget for async handlers._
|
|
3990
4022
|
|
|
3991
|
-
Use a `*.subscribe.ts` file when you want code to **run after every commit** to a specific table — refresh a search index,
|
|
4023
|
+
Use a `*.subscribe.ts` file when you want code to **run after every commit** to a specific table — refresh a search index, invalidate a cache, push to a worker queue, emit an external notification. The handler runs on **every replica**; when its effect must not repeat, add [`once:`](#how-often-does-it-run-once-per-replica-unless-you-say-otherwise). The file convention is parallel to `*.startup.ts` / `*.cron.tsx` / `*.webhook.tsx`: drop a file matching the suffix anywhere under `apps/<api>/`, default-export a `defineSubscriber({...})`, the framework discovers + binds it at boot.
|
|
3992
4024
|
|
|
3993
4025
|
Subscribers are deliberately **best-effort** + **non-durable**. For crash-safe async work — "a row changed, now run a workflow" — reach for a [reaction](/docs/data/reactions) instead.
|
|
3994
4026
|
|
|
@@ -4059,6 +4091,90 @@ The `on` filter narrows by operation:
|
|
|
4059
4091
|
|
|
4060
4092
|
Other-table events get filtered out before your handler sees them. The matcher does this at the dispatcher level so subscribers add zero hot-path overhead to writes that don't match their table.
|
|
4061
4093
|
|
|
4094
|
+
## How often does it run? Once per replica — unless you say otherwise
|
|
4095
|
+
|
|
4096
|
+
A subscriber binds to the change stream on **every api instance**. One `INSERT`
|
|
4097
|
+
behind three replicas calls your handler three times.
|
|
4098
|
+
|
|
4099
|
+
That is the right default and not a gap. A handler that refreshes a per-process
|
|
4100
|
+
cache, warms a local index, or updates in-memory state *has* to run everywhere —
|
|
4101
|
+
a fleet-wide gate would leave every other replica stale. The default assumes the
|
|
4102
|
+
handler is **idempotent**.
|
|
4103
|
+
|
|
4104
|
+
It is the wrong default for an **effect** — a notification, a mail, a webhook, a
|
|
4105
|
+
payment — because there is nothing to make idempotent: the effect IS a write, so
|
|
4106
|
+
each run produces another one. Three replicas send three mails.
|
|
4107
|
+
|
|
4108
|
+
`once: true` is the whole answer for most handlers — the framework derives the
|
|
4109
|
+
key:
|
|
4110
|
+
|
|
4111
|
+
```ts
|
|
4112
|
+
export default defineSubscriber({
|
|
4113
|
+
table: 'absence_requests',
|
|
4114
|
+
on: ['insert'],
|
|
4115
|
+
once: true, // exactly one replica runs the handler per change
|
|
4116
|
+
handler: notifyApprovers,
|
|
4117
|
+
})
|
|
4118
|
+
```
|
|
4119
|
+
|
|
4120
|
+
It names the change by its CONTENT plus its position among content-identical
|
|
4121
|
+
repeats. That is not a detail: a fleet change carries no LSN, no commit id and no
|
|
4122
|
+
`traceId` (the last one deliberately, so a local trace is never mis-attributed to
|
|
4123
|
+
a remote write), so content is the only thing two replicas provably agree on —
|
|
4124
|
+
and `A→B`, then `B→A`, then `A→B` again has to count as three changes, not two.
|
|
4125
|
+
|
|
4126
|
+
Pass a **function** when you want to be COARSER than one-per-change: two updates
|
|
4127
|
+
that differ only in a field you do not care about are two changes to `once: true`
|
|
4128
|
+
and can be one to a key you write yourself.
|
|
4129
|
+
|
|
4130
|
+
```ts
|
|
4131
|
+
// apps/api/subscribers/notifyApprovers.subscribe.ts
|
|
4132
|
+
export default defineSubscriber({
|
|
4133
|
+
table: 'absence_requests',
|
|
4134
|
+
on: ['insert'],
|
|
4135
|
+
// Cluster-wide: exactly one replica runs the handler for each change.
|
|
4136
|
+
once: (event) => String((event.new as { id?: string } | null)?.id ?? ''),
|
|
4137
|
+
handler: async (event, ctx) => {
|
|
4138
|
+
for (const approver of await approversOf(ctx, event.new)) {
|
|
4139
|
+
await sendNotification(ctx, { toEmployeeId: approver.id })
|
|
4140
|
+
}
|
|
4141
|
+
},
|
|
4142
|
+
})
|
|
4143
|
+
```
|
|
4144
|
+
|
|
4145
|
+
**A key you write must tell two genuine changes apart.** A row id is enough for
|
|
4146
|
+
`insert` and `delete`, where a row changes state once. It is not enough for
|
|
4147
|
+
`update`: two edits to the same row produce the same id, and the second would be
|
|
4148
|
+
dropped as a duplicate of the first — an effect that silently stops happening for
|
|
4149
|
+
a row that keeps changing. Put something that moves in the key, or use
|
|
4150
|
+
`once: true`:
|
|
4151
|
+
|
|
4152
|
+
```ts
|
|
4153
|
+
once: (event) => {
|
|
4154
|
+
const row = event.new as { id?: string; updatedAt?: Date } | null
|
|
4155
|
+
return `${row?.id ?? ''}:${row?.updatedAt?.toISOString() ?? ''}`
|
|
4156
|
+
},
|
|
4157
|
+
```
|
|
4158
|
+
|
|
4159
|
+
**`once` is AT MOST once, not exactly once.** The claim is taken before the
|
|
4160
|
+
handler runs, so a replica that wins and then dies takes the event with it, and a
|
|
4161
|
+
claim that cannot be written at all (database unreachable) is taken by nobody.
|
|
4162
|
+
Both are loud in the log and neither is retried — a subscriber is best-effort by
|
|
4163
|
+
construction. When the effect must not be lost, the change stream is the wrong
|
|
4164
|
+
seam: run it inside the mutation, or start a workflow from a
|
|
4165
|
+
[reaction](/docs/data/reactions), where durability is the primitive's job.
|
|
4166
|
+
|
|
4167
|
+
The boot log says which one each subscriber got:
|
|
4168
|
+
|
|
4169
|
+
```
|
|
4170
|
+
subscriber: registered table=absence_requests on=["insert"] once=fleet
|
|
4171
|
+
subscriber: registered table=posts on=["insert","update"] once=per-replica
|
|
4172
|
+
```
|
|
4173
|
+
|
|
4174
|
+
Claims live in `_voltro_change_claims` and are swept after an hour
|
|
4175
|
+
(`VOLTRO_CHANGE_CLAIMS_TTL_HOURS`). The key is namespaced per subscriber file, so
|
|
4176
|
+
two subscribers watching one table never lock each other out.
|
|
4177
|
+
|
|
4062
4178
|
## Semantics — best-effort, fire-and-forget
|
|
4063
4179
|
|
|
4064
4180
|
Subscribers are **non-durable** by design:
|
|
@@ -4132,6 +4248,8 @@ export default defineSubscriber({
|
|
|
4132
4248
|
export default defineSubscriber({
|
|
4133
4249
|
table: 'organizations',
|
|
4134
4250
|
on: 'insert',
|
|
4251
|
+
// The POST is an effect: without `once` every replica sends one.
|
|
4252
|
+
once: (event) => String((event.new as { id?: string } | null)?.id ?? ''),
|
|
4135
4253
|
handler: async (event, ctx) => {
|
|
4136
4254
|
if (!event.new) return
|
|
4137
4255
|
const slug = event.new.slug as string
|
|
@@ -4248,9 +4366,17 @@ into the agent's prompt.
|
|
|
4248
4366
|
|
|
4249
4367
|
## Guards (the point)
|
|
4250
4368
|
|
|
4251
|
-
- **`dedupeKey` (required)** — the same logical change acts exactly once
|
|
4252
|
-
|
|
4369
|
+
- **`dedupeKey` (required)** — the same logical change acts exactly once, across
|
|
4370
|
+
the whole fleet. The key is claimed in `_voltro_change_claims` before the act
|
|
4371
|
+
runs (INSERT-wins on a UNIQUE — the same arbiter the cron scheduler uses), so
|
|
4372
|
+
two replicas seeing one change start one workflow, not two. This is also what
|
|
4373
|
+
stops a reaction whose act writes the watched table from self-triggering
|
|
4253
4374
|
forever. `defineReaction` throws at boot if it's missing.
|
|
4375
|
+
|
|
4376
|
+
The claim is taken BEFORE the act, which is what makes it a gate rather than a
|
|
4377
|
+
report — and the cost is stated rather than hidden: an act that THROWS has
|
|
4378
|
+
already consumed its key and is not re-run by a later duplicate. Durability
|
|
4379
|
+
belongs to the workflow the act starts, not to the trigger.
|
|
4254
4380
|
- **`rateLimit` (optional)** — at most `limit` firings per `windowMs`.
|
|
4255
4381
|
- **`costBudgetUsd` (optional)** — a per-tenant AI spend ceiling; over budget,
|
|
4256
4382
|
the reaction refuses (fails closed).
|
|
@@ -4271,8 +4397,9 @@ into the agent's prompt.
|
|
|
4271
4397
|
- Best-effort + fire-and-forget (like subscribers) — a failing act logs +
|
|
4272
4398
|
continues; it can't back-pressure the change stream. Durability comes from a
|
|
4273
4399
|
workflow act (an agent act is best-effort).
|
|
4274
|
-
- `dedupeKey`
|
|
4275
|
-
|
|
4400
|
+
- `dedupeKey` claims survive a restart but not forever: `_voltro_change_claims`
|
|
4401
|
+
is swept after an hour (`VOLTRO_CHANGE_CLAIMS_TTL_HOURS`). A change whose key
|
|
4402
|
+
reappears after that window acts again.
|
|
4276
4403
|
|
|
4277
4404
|
## When to use what
|
|
4278
4405
|
|
|
@@ -4717,6 +4844,19 @@ available without a distributed transaction into the target system, so:
|
|
|
4717
4844
|
**Handlers must be idempotent.** A process that dies between "the remote
|
|
4718
4845
|
accepted it" and "we recorded that" will retry.
|
|
4719
4846
|
|
|
4847
|
+
**On several replicas, that used to be the smaller reason.** Every replica runs
|
|
4848
|
+
the drain, and the drain read every pending row — so an effect was dispatched
|
|
4849
|
+
once *per replica*, on the happy path, every time. It is claimed now: a row moves
|
|
4850
|
+
`pending → delivering` in one atomic statement stamped with the claiming
|
|
4851
|
+
process, so a racing replica loses the row rather than duplicating it, and a
|
|
4852
|
+
claim whose holder stops responding is returned to the queue after its lease
|
|
4853
|
+
(`claimLeaseMs`, default 5 minutes — raise it above your slowest handler).
|
|
4854
|
+
|
|
4855
|
+
That removes the routine duplicate. It does not make delivery exactly-once, and
|
|
4856
|
+
nothing can: the process can still die between the remote accepting and the row
|
|
4857
|
+
being marked. The idempotency requirement stands — it is now about the failure
|
|
4858
|
+
case it was always meant to describe, rather than about every single delivery.
|
|
4859
|
+
|
|
4720
4860
|
## Declaring the handler
|
|
4721
4861
|
|
|
4722
4862
|
One `*.outbox.ts` file per effect:
|
|
@@ -148,6 +148,13 @@ deployments (k8s with `replicas: 3`, multi-pod ECS, etc.) need a
|
|
|
148
148
|
shared store, otherwise a write on instance A doesn't pin reads on
|
|
149
149
|
instance B.
|
|
150
150
|
|
|
151
|
+
**The boot says so now.** When replicas are configured and the RYW store falls
|
|
152
|
+
back to memory on a deployment whose environment says several replicas
|
|
153
|
+
(`POD_NAME`, `FLY_ALLOC_ID`, `K_REVISION`, … — or `REPLICA_COUNT`, which is a
|
|
154
|
+
declaration in both directions), the boot warns and names the two ways out. It
|
|
155
|
+
used to log `ryw policy 'fallback'` as though the policy were in force, and the
|
|
156
|
+
first symptom was a user reloading and seeing their own save gone.
|
|
157
|
+
|
|
151
158
|
Wire Redis:
|
|
152
159
|
|
|
153
160
|
```sh
|
|
@@ -306,6 +313,31 @@ The bus is **additive** to the inline emit path. Local reactivity must survive a
|
|
|
306
313
|
|
|
307
314
|
Because the inline path is never removed, a broker outage degrades **cross-replica** fan-out only — local reactivity keeps working, and the framework logs a warning. The bus reconnects when the broker returns.
|
|
308
315
|
|
|
316
|
+
## When the connection drops
|
|
317
|
+
|
|
318
|
+
Every cross-replica mechanism here rides a connection, and a connection that dies quietly is worse than one that fails loudly: the app keeps serving, the clients keep their sockets, and their live queries simply stop updating. So each path is required to notice, recover, and then **say that it lost something**.
|
|
319
|
+
|
|
320
|
+
**A hole is never patched — it is re-derived.** None of these transports keeps a log. Postgres queues nothing for a listener that is not there; Redis and NATS pub/sub retain nothing at all. So there is nothing to replay, and the only complete recovery is to re-run every live query. That is safe precisely because a live query is idempotent, and it is what the framework does on every one of the events below:
|
|
321
|
+
|
|
322
|
+
| What happened | How it is noticed | What you see |
|
|
323
|
+
|---|---|---|
|
|
324
|
+
| The postgres `LISTEN` connection died (failover, proxy, `pg_terminate_backend`) | A heartbeat sent through the pool goes unanswered on the LISTEN stream | `cdc: reconnecting` → `cdc: reconnected`, then every live query refreshes |
|
|
325
|
+
| A peer's serial jumped — the broker dropped messages | Per-origin serial accounting; the count is exact | `broadcast: missed N change(s) from …` |
|
|
326
|
+
| This replica could not subscribe at boot (the broker was restarting) | The subscribe is retried in the background | `broadcast: could not subscribe` → `broadcast: subscribed`, then a refresh |
|
|
327
|
+
| The Redis or NATS transport re-dialled underneath us | The driver's connection lifecycle | `broadcast: transport disconnected` → a refresh on reconnect |
|
|
328
|
+
|
|
329
|
+
Two consequences worth knowing:
|
|
330
|
+
|
|
331
|
+
- **A broker that is down at boot does not stop the boot.** The replica starts, serves, keeps local reactivity, and joins the bus when the broker returns. A crash loop across the whole fleet is the wrong answer to a broker restart — which is exactly when every replica is dialling at once.
|
|
332
|
+
- **A replica that restarts under a stable name is recognised as a new process.** A StatefulSet pod keeps its `POD_NAME`, and `VOLTRO_REPLICA_ID` is stable by definition, so the name alone cannot tell a restart from a continuation. Each publish carries a per-process epoch so peers reset their watermark instead of quietly ignoring the new process's serials.
|
|
333
|
+
|
|
334
|
+
The postgres heartbeat is idle-only: any traffic on the channel — including another replica's heartbeat — counts as proof the connection works, so a busy channel never pays for one and a fleet pays roughly one probe per idle window however many replicas it has. Tune it with:
|
|
335
|
+
|
|
336
|
+
| Variable | Default | Meaning |
|
|
337
|
+
|---|---|---|
|
|
338
|
+
| `VOLTRO_CDC_HEARTBEAT_MS` | `20000` | Silence on the channel before a probe is sent |
|
|
339
|
+
| `VOLTRO_CDC_HEARTBEAT_TIMEOUT_MS` | `10000` | How long an unanswered probe may go before the consumer is declared dead |
|
|
340
|
+
|
|
309
341
|
## The honest caveat — app-mutation changes only
|
|
310
342
|
|
|
311
343
|
The bus carries changes that flow through **`ctx.store`** (the framework's mutation path). It does **not** capture **out-of-band DB writes** — a `psql` session, a cron job, or a second service writing the same database directly. Those changes never hit `store.onChange`, so they never reach the bus.
|
|
@@ -340,7 +372,14 @@ A changelog table that every replica polls (`SELECT … WHERE seq > :last`) woul
|
|
|
340
372
|
will NOT reach clients on other replicas. Add @voltro/plugin-broadcast (Redis / NATS) to close the gap…
|
|
341
373
|
```
|
|
342
374
|
|
|
343
|
-
When BOTH a native path and the broadcast plugin are wired (e.g. postgres + broadcast), both stay active —
|
|
375
|
+
When BOTH a native path and the broadcast plugin are wired (e.g. postgres + broadcast), both stay active — and they carry **different things**. The native path carries table changes to every replica. The bus carries reactivity *channels* (`publishReactivity`), which are not database writes and so have no native transport at all.
|
|
376
|
+
|
|
377
|
+
```
|
|
378
|
+
[voltro:dev] reactivity: native LISTEN/NOTIFY (postgres) carries table changes;
|
|
379
|
+
@voltro/plugin-broadcast (redis) carries reactivity channels
|
|
380
|
+
```
|
|
381
|
+
|
|
382
|
+
This page used to say the two paths were harmless redundancy because "the own-origin skip dedups". They were not. The own-origin skip only ever covered a replica's own publish coming back to itself — so a change the native transport had already delivered to every replica was re-published by every replica under *its own* origin, and each peer injected it again. N replicas turned one change into N² deliveries: every subscriber, every live-query wake, every plugin tap. At two replicas a `*.subscribe.ts` handler ran **four** times for one `INSERT`, twice per instance.
|
|
344
383
|
|
|
345
384
|
## sqlite and the memory store behind replicas
|
|
346
385
|
|