@voltro/cli 0.51.0 → 0.53.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 +356 -0
- package/THIRD-PARTY-NOTICES.md +8311 -3318
- package/dist/{agentsMd-D6yD7IQv.js → agentsMd-0l980yhL.js} +4 -1
- package/dist/agentsMd-SDDSkyl4.js +2 -0
- package/dist/{apiBuild-CPDHXF72.js → apiBuild-CaPfoWku.js} +11 -5
- package/dist/apiBuild-DHtLXYx9.js +2 -0
- package/dist/bin.js +1 -1
- package/dist/build-D-OnvNMf.js +843 -0
- package/dist/{checkCommand-DNuPiWMc.js → checkCommand-C5elt0tW.js} +92 -46
- package/dist/checkCommand-D2ZduVlh.js +2 -0
- package/dist/{cloudCmd-F4YJeqM3.js → cloudCmd-QUXh-b5w.js} +1 -1
- package/dist/codegen-BWpt3VgF.js +2 -0
- package/dist/{codegen-CrMXs4hb.js → codegen-FEk8AZHb.js} +2 -2
- package/dist/{codegenCommand-BNBHcNNj.js → codegenCommand-BOiWQ5hz.js} +12 -12
- package/dist/{codemodRunner-BDVixlSw.js → codemodRunner-BjtB2lq6.js} +691 -545
- package/dist/{commands-B1OiS9bX.js → commands-DyxAmhP0.js} +36 -36
- package/dist/{dashboardCommand-C-vvPY1B.js → dashboardCommand-BdKTyT13.js} +3 -3
- package/dist/{dataCommand-C1GxXW5q.js → dataCommand-Bab9X7s8.js} +27 -27
- package/dist/{dbCommand-If4Y1xQ-.js → dbCommand-06O2finM.js} +277 -236
- package/dist/dbCommand-B1EXBC6f.js +2 -0
- package/dist/{dev-kdAg9Q7l.js → dev-C6LGF4iY.js} +2998 -2379
- package/dist/dev-GjJWAYo2.js +3 -0
- package/dist/doctorCommand-B0hX0tdz.js +2 -0
- package/dist/{doctorCommand-nKmeW78u.js → doctorCommand-etMkflRc.js} +332 -220
- package/dist/{dormancyCommand-CY3wa_SW.js → dormancyCommand-UwZ1AZzB.js} +1 -1
- package/dist/{embeddingsCommand-BDLIgje_.js → embeddingsCommand-C70zWHwo.js} +1 -1
- package/dist/{envCommand-C6V_xVlT.js → envCommand-dSyKvRkM.js} +15 -15
- package/dist/{evolveCommand-D3c4DSfN.js → evolveCommand-CG0_ebO5.js} +2 -2
- package/dist/fileConventions-DASGEmj-.js +35 -0
- package/dist/{fileTaxonomy-CJfgOllU.js → fileTaxonomy-B7uxipWS.js} +55 -55
- package/dist/fontPipeline-LxIHa1vo.js +2 -0
- package/dist/fontPipeline-Tsh8kZfA.js +152 -0
- package/dist/frameworkTableAssembly-C_7Z-rMs.js +2 -0
- package/dist/{frameworkTableAssembly-BwJVEKLr.js → frameworkTableAssembly-DKx3ba3S.js} +5 -5
- package/dist/imagePipeline-B_GVJgm6.js +2 -0
- package/dist/imagePipeline-CBZmjT4i.js +127 -0
- package/dist/index.js +1 -1
- package/dist/{infoCommand-BnRFEF1o.js → infoCommand-_53iOc_j.js} +1 -1
- package/dist/{inspect-CtL_xTbu.js → inspect-Bd8-9wsi.js} +1 -1
- package/dist/inspect-CuoDInfZ.js +2 -0
- package/dist/{inspectGateHint-BjnFubmH.js → inspectGateHint-4LxkNtrz.js} +1 -1
- package/dist/inspectMetrics-CGF94puw.js +143 -0
- package/dist/manifestBuild-C4-J1-m_.js +2 -0
- package/dist/{manifestBuild-CuU1VrSm.js → manifestBuild-Cqgsx2bM.js} +1 -1
- package/dist/{metaCommands-CfRLra0s.js → metaCommands-Cn2oboG4.js} +9 -3
- package/dist/{migrate-DehuBakM.js → migrate-Cko9rswM.js} +2 -2
- package/dist/{pageConvention-cEiRxdab.js → pageConvention-C938S8oC.js} +1 -1
- package/dist/{privacyCommand-XejDMvmu.js → privacyCommand-DWTQMC6R.js} +2 -2
- package/dist/{probeCommand-C9gazU0H.js → probeCommand-DkGGLknv.js} +83 -24
- package/dist/{projectScaffold-mIX_DpSe.js → projectScaffold-B4dmTlwT.js} +1 -1
- package/dist/{projectScaffold-BIl97_E6.js → projectScaffold-EzlErR4E.js} +1 -1
- package/dist/{renderModeScan-D7J1B7Kw.js → renderModeScan-CUbOeOAg.js} +28 -11
- package/dist/{renderProfile-1OWWAAtx.js → renderProfile-CskIgAfn.js} +2 -2
- package/dist/{runtimeTrace-ZsBU7Tkx.js → runtimeTrace-c0APJz7E.js} +1 -1
- package/dist/{sdkgen-O4XqWOjM.js → sdkgen-BiQCgIEr.js} +1 -1
- package/dist/serveCommand-CueKQgzl.js +2443 -0
- package/dist/serveCommand-DsnrVN3U.js +2 -0
- package/dist/serveEntry.js +1 -1
- package/dist/start-BJzZLbt8.js +3 -0
- package/dist/start-ekPan8BT.js +1510 -0
- package/dist/startEntry.js +1 -1
- package/dist/{staticCommand-Dr2M6tpU.js → staticCommand-xlSL-IWk.js} +1 -1
- package/dist/{test-rXFq4S76.js → test-BWPQcRoB.js} +1 -1
- package/dist/updateCommand-Bqql_rsQ.js +2 -0
- package/dist/{updateCommand-Bs322Q78.js → updateCommand-C_8I8Rzo.js} +139 -115
- package/dist/webDev-C7jWJ5dX.js +2 -0
- package/dist/{webDev-B-ubQEMX.js → webDev-oczpugbx.js} +1767 -913
- package/dist/{webhooksCommand-FLYY9IXh.js → webhooksCommand-4SVPDjKg.js} +1 -1
- package/package.json +72 -18
- package/templates/AGENTS.core.md +11 -0
- package/templates/AGENTS.md +19 -6
- package/templates/agent-docs/_index.md +8 -6
- package/templates/agent-docs/_manifest.json +31 -15
- package/templates/agent-docs/ai.md +2 -2
- package/templates/agent-docs/authentication.md +1 -1
- package/templates/agent-docs/cli.md +97 -15
- package/templates/agent-docs/configuration.md +17 -0
- package/templates/agent-docs/data.md +680 -33
- package/templates/agent-docs/database/advancedqueries.md +7 -7
- package/templates/agent-docs/database/columntypes.md +2 -2
- package/templates/agent-docs/database/querying.md +1 -1
- package/templates/agent-docs/database/schema.md +2 -2
- package/templates/agent-docs/database/seedsdialects.md +2 -2
- package/templates/agent-docs/database/transactions.md +3 -3
- package/templates/agent-docs/deployment.md +30 -3
- package/templates/agent-docs/internationalization.md +2 -2
- package/templates/agent-docs/introduction.md +52 -0
- package/templates/agent-docs/local-first-mobile.md +132 -7
- package/templates/agent-docs/observability.md +2 -0
- package/templates/agent-docs/plugins/ai-flows.md +1 -1
- package/templates/agent-docs/plugins/audit.md +5 -5
- package/templates/agent-docs/plugins/auth.md +1 -1
- package/templates/agent-docs/plugins/cdc-out.md +2 -2
- package/templates/agent-docs/plugins/comments.md +142 -0
- package/templates/agent-docs/plugins/notifications.md +47 -4
- package/templates/agent-docs/plugins/presence.md +16 -3
- package/templates/agent-docs/plugins/prometheus.md +1 -1
- package/templates/agent-docs/plugins/queue.md +129 -0
- package/templates/agent-docs/plugins/{versioning.md → row-history.md} +22 -22
- package/templates/agent-docs/plugins/storage.md +2 -2
- package/templates/agent-docs/plugins.md +38 -12
- package/templates/agent-docs/reference.md +54 -5
- package/templates/agent-docs/routing.md +868 -50
- package/templates/agent-docs/schema-driven-ui.md +292 -5
- package/templates/agent-docs/security.md +125 -8
- package/templates/agent-docs/templates/apibackends.md +14 -14
- package/templates/agent-docs/templates/overview.md +1 -1
- package/templates/agent-docs/whats-new.md +171 -54
- package/templates/apps/api-ai/package.json +6 -7
- package/templates/apps/api-ai/tests/summarize.test.ts +1 -1
- 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 -10
- 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-versioning → api-row-history}/README.md +3 -3
- package/templates/apps/{api-versioning → api-row-history}/actions/documents.asOf.action.server.ts +1 -1
- package/templates/apps/{api-versioning → api-row-history}/actions/documents.history.action.server.ts +1 -1
- package/templates/apps/{api-versioning → api-row-history}/app.config.ts +3 -3
- package/templates/apps/{api-versioning → api-row-history}/database/schema.ts +1 -1
- package/templates/apps/{api-versioning → api-row-history}/mutations/documents.create.mutation.server.ts +1 -1
- package/templates/apps/{api-versioning → api-row-history}/mutations/documents.create.mutation.ts +1 -1
- package/templates/apps/{api-versioning → api-row-history}/mutations/documents.update.mutation.ts +1 -1
- package/templates/apps/{api-versioning → api-row-history}/package.json +8 -8
- package/templates/apps/api-row-history/template.json +6 -0
- package/templates/apps/{api-versioning → api-row-history}/tests/documents.create.test.ts +1 -1
- package/templates/apps/api-saas/app.config.ts +1 -0
- package/templates/apps/api-saas/package.json +10 -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/app.config.ts +26 -2
- package/templates/apps/changelog/content/releases/{0.1.0.mdx → v0-1-0.mdx} +0 -1
- package/templates/apps/changelog/content/releases/{0.2.0.mdx → v0-2-0.mdx} +0 -1
- package/templates/apps/changelog/package.json +8 -8
- package/templates/apps/changelog/src/collections/releases.collection.ts +48 -0
- package/templates/apps/changelog/src/globals.d.ts +1 -1
- package/templates/apps/changelog/src/locales/de.ts +1 -1
- package/templates/apps/changelog/src/locales/en.ts +1 -1
- package/templates/apps/changelog/src/pages/[locale]/[slug]/page.tsx +7 -4
- package/templates/apps/changelog/src/pages/[locale]/mirrors.test.tsx +13 -41
- package/templates/apps/changelog/src/pages/[slug]/page.test.tsx +26 -51
- package/templates/apps/changelog/src/pages/[slug]/page.tsx +21 -19
- package/templates/apps/changelog/src/pages/page.test.tsx +14 -37
- package/templates/apps/changelog/src/pages/page.tsx +18 -12
- 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 +9 -9
- package/templates/apps/frontend-auth/package.json +8 -8
- package/templates/apps/frontend-blank/package.json +7 -7
- package/templates/apps/frontend-cms/package.json +9 -9
- package/templates/apps/frontend-collab/package.json +10 -10
- package/templates/apps/frontend-collab/src/pages/page.test.tsx +10 -9
- package/templates/apps/frontend-collab/src/pages/page.tsx +42 -65
- package/templates/apps/frontend-contact/package.json +7 -7
- package/templates/apps/frontend-dashboard/package.json +7 -7
- package/templates/apps/frontend-docs/content/docs/de/guides/first-page.md +4 -0
- package/templates/apps/frontend-docs/content/docs/de/intro/getting-started.md +4 -0
- package/templates/apps/frontend-docs/content/docs/en/guides/first-page.md +4 -0
- package/templates/apps/frontend-docs/content/docs/en/intro/getting-started.md +4 -0
- package/templates/apps/frontend-docs/package.json +8 -7
- package/templates/apps/frontend-docs/src/collections/docs.collection.ts +21 -0
- package/templates/apps/frontend-docs/src/locales/de.ts +0 -5
- package/templates/apps/frontend-docs/src/locales/en.ts +0 -5
- package/templates/apps/frontend-docs/src/pages/[locale]/docs/[...slug]/page.tsx +17 -9
- package/templates/apps/frontend-docs/src/pages/[locale]/mirrors.test.tsx +10 -5
- package/templates/apps/frontend-docs/src/pages/[locale]/page.tsx +8 -0
- package/templates/apps/frontend-docs/src/pages/docs/[...slug]/page.test.tsx +53 -15
- package/templates/apps/frontend-docs/src/pages/docs/[...slug]/page.tsx +27 -33
- package/templates/apps/frontend-docs/src/pages/page.test.tsx +17 -3
- package/templates/apps/frontend-docs/src/pages/page.tsx +16 -12
- package/templates/apps/frontend-i18n/package.json +6 -6
- package/templates/apps/frontend-landing/package.json +6 -7
- package/templates/apps/frontend-portal/package.json +8 -8
- package/templates/apps/frontend-saas/package.json +8 -8
- 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/content/posts/cms-to-ssg.md +12 -0
- package/templates/apps/frontend-static-blog/content/posts/hello-static.md +12 -0
- package/templates/apps/frontend-static-blog/content/posts/islands-not-hydration.md +14 -0
- package/templates/apps/frontend-static-blog/package.json +8 -6
- package/templates/apps/frontend-static-blog/src/collections/posts.collection.ts +27 -0
- package/templates/apps/frontend-static-blog/src/pages/[locale]/blog/[slug]/page.tsx +7 -4
- package/templates/apps/frontend-static-blog/src/pages/[locale]/mirrors.test.tsx +7 -1
- package/templates/apps/frontend-static-blog/src/pages/blog/[slug]/page.test.tsx +19 -9
- package/templates/apps/frontend-static-blog/src/pages/blog/[slug]/page.tsx +19 -21
- package/templates/apps/frontend-static-blog/src/pages/page.test.tsx +18 -5
- package/templates/apps/frontend-static-blog/src/pages/page.tsx +21 -14
- package/templates/apps/frontend-status/package.json +8 -8
- package/templates/apps/mobile-app/package.json +4 -4
- package/dist/agentsMd-Bu_XQgVf.js +0 -2
- package/dist/apiBuild-GDKuGOMV.js +0 -2
- package/dist/build-DETLZAFt.js +0 -752
- package/dist/checkCommand-CWcnDArJ.js +0 -2
- package/dist/codegen-DiMn2KkZ.js +0 -2
- package/dist/dbCommand-C27HIsGE.js +0 -2
- package/dist/dev-CK522MV5.js +0 -3
- package/dist/doctorCommand-BK4l18eG.js +0 -2
- package/dist/fileConventions-Cof68_BL.js +0 -33
- package/dist/frameworkTableAssembly-CVDB2hCq.js +0 -2
- package/dist/inspect-CuGDYES0.js +0 -2
- package/dist/inspectMetrics-CfdKLh6t.js +0 -72
- package/dist/manifestBuild-CPjhvM62.js +0 -2
- package/dist/serveCommand-BRnPCxVd.js +0 -2
- package/dist/serveCommand-DdiYNBBu.js +0 -2362
- package/dist/start-BLNmWkLa.js +0 -1154
- package/dist/start-Dzicuyw8.js +0 -3
- package/dist/updateCommand-eXB35SEv.js +0 -2
- package/dist/webDev-DposiF3j.js +0 -2
- package/templates/apps/api-versioning/template.json +0 -6
- package/templates/apps/changelog/scripts/generate-rss.mjs +0 -38
- package/templates/apps/changelog/src/lib/releases.ts +0 -21
- package/templates/apps/frontend-static-blog/src/content/posts.ts +0 -64
- /package/templates/apps/{api-versioning → api-row-history}/actions/documents.asOf.action.ts +0 -0
- /package/templates/apps/{api-versioning → api-row-history}/actions/documents.history.action.ts +0 -0
- /package/templates/apps/{api-versioning → api-row-history}/mutations/documents.update.mutation.server.ts +0 -0
- /package/templates/apps/{api-versioning → api-row-history}/tsconfig.json +0 -0
|
@@ -1103,6 +1103,40 @@ target: {
|
|
|
1103
1103
|
|
|
1104
1104
|
`path`, `by`, `match`, and `shapeItem` are browser-safe descriptor data (a dot-path string + pure functions) — the same discipline as `identify`/`shape`.
|
|
1105
1105
|
|
|
1106
|
+
## Declared relations — a junction saved in the same mutation
|
|
1107
|
+
|
|
1108
|
+
A form with a multi-reference field (assigned stores, tags, members) writes a
|
|
1109
|
+
JUNCTION table beside the row. Declare that on the write target and the
|
|
1110
|
+
framework reconciles the links INSIDE the mutation's transaction — no
|
|
1111
|
+
hand-written junction code in the executor, and a failure rolls the whole
|
|
1112
|
+
write back:
|
|
1113
|
+
|
|
1114
|
+
```ts
|
|
1115
|
+
export const employeesUpdate = defineMutation({
|
|
1116
|
+
name: 'employees.update',
|
|
1117
|
+
input: EmployeesUpdateInput, // carries assignedStores: string[]
|
|
1118
|
+
output: Employee,
|
|
1119
|
+
target: {
|
|
1120
|
+
table: 'employees',
|
|
1121
|
+
op: 'update',
|
|
1122
|
+
relations: { assignedStores: 'employee_assigned_stores' },
|
|
1123
|
+
},
|
|
1124
|
+
})
|
|
1125
|
+
```
|
|
1126
|
+
|
|
1127
|
+
After the executor succeeds, `input.assignedStores` is reconciled against the
|
|
1128
|
+
junction via the diff-based link writer (`store.relationLinks`): missing rows
|
|
1129
|
+
inserted, surplus rows deleted, unchanged rows untouched — so reactive
|
|
1130
|
+
subscriptions on the junction see one change per changed row. The anchor
|
|
1131
|
+
column is derived from the junction's `reference()` targets; a self-junction
|
|
1132
|
+
(both columns referencing one table) is refused by name, never guessed.
|
|
1133
|
+
|
|
1134
|
+
The semantics worth knowing: an ABSENT input field leaves the links
|
|
1135
|
+
untouched — absent is not empty; an empty array is the explicit "clear them
|
|
1136
|
+
all". The row id comes from the executor's `output.id`, falling back to
|
|
1137
|
+
`input.id`. The link writes go through `ctx.store`, so undo capture and
|
|
1138
|
+
cross-table rules see them like any other write.
|
|
1139
|
+
|
|
1106
1140
|
## Typed Errors
|
|
1107
1141
|
|
|
1108
1142
|
```ts
|
|
@@ -2026,8 +2060,14 @@ Queries/subscriptions are for live state. Streams are for one-shot element flows
|
|
|
2026
2060
|
## Reconnect
|
|
2027
2061
|
|
|
2028
2062
|
A dropped WebSocket rebuilds the whole client stack — new socket, new RPC
|
|
2029
|
-
client, new subscription cache — and re-subscribes every active query
|
|
2030
|
-
|
|
2063
|
+
client, new subscription cache — and re-subscribes every active query. Inside
|
|
2064
|
+
the resume window (`reactive.resume.windowMs`, default 60 s) the server replays
|
|
2065
|
+
**only the deltas the client missed** — the re-subscribe presents the last
|
|
2066
|
+
materialised revision and the stream continues on the same revision line, so a
|
|
2067
|
+
short offline gap costs a handful of patches instead of every row. Outside the
|
|
2068
|
+
window, for computed queries, for row-filtered apps, or whenever anything is in
|
|
2069
|
+
doubt, the query answers with a fresh snapshot — the delta-resume wire contract
|
|
2070
|
+
lives in [the wire protocol](/docs/data/wire-protocol#reconnect--delta-resume).
|
|
2031
2071
|
|
|
2032
2072
|
**What is on screen while that happens is your last-known-good data, not a
|
|
2033
2073
|
skeleton.** The replacement cache is seeded from the one it retires, so `data`
|
|
@@ -2054,6 +2094,15 @@ Three things are deliberately NOT carried across:
|
|
|
2054
2094
|
- **Entries nothing re-subscribes to.** A screen that unmounted during the
|
|
2055
2095
|
reconnect does not pin its rows; the seed evicts on the normal inactive TTL.
|
|
2056
2096
|
|
|
2097
|
+
### The local-first mirror partitions by subject — the carve-out
|
|
2098
|
+
|
|
2099
|
+
An app using `@voltro/local-first`'s query mirror keeps rows on the DEVICE
|
|
2100
|
+
across reloads. The blank-on-auth rule extends there structurally: every
|
|
2101
|
+
mirrored key carries the subject AND tenant, so the next subject's binding
|
|
2102
|
+
simply never finds the predecessor's rows, and a logout or membership
|
|
2103
|
+
revocation calls `purge()` on the departing partition. Nothing about the
|
|
2104
|
+
in-memory blanking above changes.
|
|
2105
|
+
|
|
2057
2106
|
### An auth change still blanks — on purpose
|
|
2058
2107
|
|
|
2059
2108
|
When the rebuild happens because the connection's *subject* changed — a cookie
|
|
@@ -2158,32 +2207,40 @@ once. Order BETWEEN subscribers was never guaranteed.
|
|
|
2158
2207
|
There is a number, it is not a constant, and which number you get depends on a
|
|
2159
2208
|
property of your **queries** rather than of your scale. Re-derive it on your own
|
|
2160
2209
|
hardware with `node packages/runtime/scripts/fanout-ceiling.mjs`; the figures
|
|
2161
|
-
below are the spread across three runs on a busy developer machine
|
|
2162
|
-
writes per second, against a budget of 100 ms of
|
|
2163
|
-
of one core).
|
|
2210
|
+
below are the spread across three runs on a busy developer machine (12-core,
|
|
2211
|
+
macOS) at 10 matched writes per second, against a budget of 100 ms of
|
|
2212
|
+
event-loop time per second (10% of one core).
|
|
2164
2213
|
|
|
2165
2214
|
| Subscriber population | Marginal CPU per subscriber | Subscribers per node |
|
|
2166
2215
|
| --- | --- | --- |
|
|
2167
|
-
| **Shared** — N clients on the SAME query (a leaderboard, a shared board) | 0.
|
|
2168
|
-
| **Distinct** — N clients each on their OWN query (`where userId = me`) |
|
|
2169
|
-
|
|
2170
|
-
|
|
2171
|
-
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
|
|
2175
|
-
|
|
2176
|
-
|
|
2177
|
-
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
|
|
2182
|
-
|
|
2183
|
-
|
|
2184
|
-
|
|
2185
|
-
|
|
2186
|
-
|
|
2216
|
+
| **Shared** — N clients on the SAME query (a leaderboard, a shared board), every write matches all of them | 0.37–0.41 µs | ≈ 25 000–27 000 |
|
|
2217
|
+
| **Distinct** — N clients each on their OWN query (`where userId = me`), a write matches ONE | flat — per-write cost does not grow with resident subscribers | not set by subscriber count |
|
|
2218
|
+
|
|
2219
|
+
The shared case fans out by design — one read and one diff (the memoisation
|
|
2220
|
+
above), then N emits — and it is the shape that sets the ceiling. The distinct
|
|
2221
|
+
case changed shape entirely with **matcher authority**: a subscription whose
|
|
2222
|
+
query is a plain predicate read is woken by the predicate index alone, so a
|
|
2223
|
+
write to *somebody else's* row is a non-event, not a wake. Per write it costs a
|
|
2224
|
+
bucket lookup plus one delivery, regardless of how many thousands of distinct
|
|
2225
|
+
subscribers are resident. Measured: **0 of 200** subscribers whose predicate
|
|
2226
|
+
matched nothing were woken by a write on their table — **a selective `where`
|
|
2227
|
+
buys real headroom** now.
|
|
2228
|
+
|
|
2229
|
+
What still wakes conservatively (any change on the table), and deliberately —
|
|
2230
|
+
this list is exhaustive:
|
|
2231
|
+
|
|
2232
|
+
- queries with an **eager `.with()` spec**, a setOp (`union`/…) or a CTE — the
|
|
2233
|
+
handler reads rows the root predicate does not describe;
|
|
2234
|
+
- **`dependsOn`** raw reads (the dispatcher can only re-run the descriptor,
|
|
2235
|
+
never the handler);
|
|
2236
|
+
- **computed** queries and **`reactivityChannel`** queries (their own
|
|
2237
|
+
recompute paths, unchanged);
|
|
2238
|
+
- **oversized change events** (`tombstone` / `unrecovered` — row images the
|
|
2239
|
+
matcher cannot see wake the whole table for that one event; `rehydrated`
|
|
2240
|
+
events are judged normally).
|
|
2241
|
+
|
|
2242
|
+
One thing that is easy to assume and is not true:
|
|
2243
|
+
|
|
2187
2244
|
- **It is not 512.** That constant bounds `onChange` LISTENERS — one per declared
|
|
2188
2245
|
subscription file, reaction or aggregate, bound once at boot. Every client
|
|
2189
2246
|
subscription in a process shares the dispatcher's single listener, so ten
|
|
@@ -2195,6 +2252,46 @@ mysql/mariadb (binlog), so a second node needs no extra wiring — the cost bein
|
|
|
2195
2252
|
budgeted here is the matcher and re-query CPU each node spends on ITS OWN
|
|
2196
2253
|
clients.
|
|
2197
2254
|
|
|
2255
|
+
### Deltas are per-query — two queries can briefly diverge
|
|
2256
|
+
|
|
2257
|
+
Every subscription has its own revision line and its own delivery moment. After
|
|
2258
|
+
one write that affects two queries you hold open, the deltas arrive as two
|
|
2259
|
+
independent pushes — usually microseconds apart, but there is no cross-query
|
|
2260
|
+
transaction on the wire, and a render between the two pushes can see query A
|
|
2261
|
+
after the write and query B before it. Within ONE query you never see a partial
|
|
2262
|
+
write (a delta is computed from a committed row set); across queries, design for
|
|
2263
|
+
eventual agreement rather than instantaneous consistency — derive values that
|
|
2264
|
+
must agree atomically inside one query instead of joining two on the client.
|
|
2265
|
+
|
|
2266
|
+
## Raw WebSocket gateways — `defineWebSocket`
|
|
2267
|
+
|
|
2268
|
+
Everything above rides the framework's subscription protocol, and that stays the answer for app realtime — live queries, optimistic patches, reconnect. A **gateway** exists for the other case: a FOREIGN protocol that needs a socket the framework does not speak — a Yjs provider, a legacy device fleet, an MQTT-over-WS bridge. It mounts its own upgrade path beside the rpc socket, in a `*.ws.ts` file discovered on **both** boot paths:
|
|
2269
|
+
|
|
2270
|
+
```ts
|
|
2271
|
+
// gateways/yjs.ws.ts
|
|
2272
|
+
import { defineWebSocket } from '@voltro/protocol'
|
|
2273
|
+
|
|
2274
|
+
export default defineWebSocket({
|
|
2275
|
+
path: '/gateways/yjs',
|
|
2276
|
+
auth: 'subject', // REQUIRED, no default: 'subject' | 'public'
|
|
2277
|
+
onConnection: ({ send, close, onMessage, subject, headers, path }) => {
|
|
2278
|
+
const doc = attachDoc(subject!.id)
|
|
2279
|
+
onMessage((data) => doc.applyUpdate(data)) // binary-safe frames
|
|
2280
|
+
const stop = doc.onUpdate((update) => send(update))
|
|
2281
|
+
return () => { stop(); doc.release() } // teardown
|
|
2282
|
+
},
|
|
2283
|
+
})
|
|
2284
|
+
```
|
|
2285
|
+
|
|
2286
|
+
The contract, in the order it protects you:
|
|
2287
|
+
|
|
2288
|
+
- **`auth` is mandatory and has no default.** `'subject'` runs the SAME auth chain as rpc/SSR *before* the upgrade — an unauthenticated caller gets `401` while the request is still plain http, and the connection is bound to the credential's expiry: when it lapses, the socket closes with application code `4001`, so a foreign client can re-auth and reconnect. `'public'` is a deliberate, written-down decision (a device fleet with protocol-level auth of its own).
|
|
2289
|
+
- **Every gateway path is origin-checked at upgrade** — cross-origin means `403`, which closes cross-site WebSocket hijacking for your protocol exactly as for the framework's socket.
|
|
2290
|
+
- **`onConnection({ send, close, onMessage, subject, headers, path })`** may return a teardown function — it runs on client disconnect, on credential expiry, and on server shutdown, so whatever the handler opened cannot outlive the socket.
|
|
2291
|
+
- A plain GET on a gateway path answers `426 Upgrade Required`; two gateways declaring one path refuse the boot.
|
|
2292
|
+
|
|
2293
|
+
**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.
|
|
2294
|
+
|
|
2198
2295
|
## See also
|
|
2199
2296
|
|
|
2200
2297
|
- [Subscribers (`*.subscribe.ts`)](/docs/data/subscribers) — server-side, best-effort post-commit reactivity to a table (NOT the client hook on this page).
|
|
@@ -2940,6 +3037,44 @@ const requireApiKey: RestGuard = (ctx) =>
|
|
|
2940
3037
|
ctx.subject.type === 'apiKey' ? undefined : { status: 401, message: 'API key required' }
|
|
2941
3038
|
```
|
|
2942
3039
|
|
|
3040
|
+
## Methods — PATCH, HEAD and OPTIONS are first-class
|
|
3041
|
+
|
|
3042
|
+
`method:` accepts `GET`, `POST`, `PUT`, `PATCH`, `DELETE`, `HEAD` and `OPTIONS`, on REST routes and plugin HTTP routes alike. Two details:
|
|
3043
|
+
|
|
3044
|
+
- **HEAD is admitted wherever GET is** (RFC 9110): a `HEAD` request runs the GET route's whole pipeline — method gate, guards, handler — and the transport drops the body. You never declare a second route for it.
|
|
3045
|
+
- **A wrong method is still a precise `405`**, with an `Allow:` header naming exactly the methods mounted on that path — including when several routes share one path.
|
|
3046
|
+
|
|
3047
|
+
## Body limits — `maxBodyBytes`
|
|
3048
|
+
|
|
3049
|
+
Every HTTP body read is capped at 8 MiB by default — `POST /rpc` (as it always was), plugin routes, REST routes and incoming webhooks. The app-wide cap is `http.maxBodyBytes` in `app.config.ts` (env override `VOLTRO_MAX_BODY_BYTES`); a route that legitimately takes more declares its own:
|
|
3050
|
+
|
|
3051
|
+
```ts
|
|
3052
|
+
export default defineRestRoute({
|
|
3053
|
+
method: 'POST',
|
|
3054
|
+
path: '/v1/import',
|
|
3055
|
+
maxBodyBytes: 64 * 1024 * 1024, // this route only — the app cap stays 8 MiB
|
|
3056
|
+
// …
|
|
3057
|
+
})
|
|
3058
|
+
```
|
|
3059
|
+
|
|
3060
|
+
The same per-route override exists on an incoming webhook's handler (`maxBodyBytes`) — fat provider payloads are the normal case there, not the exception. Two details:
|
|
3061
|
+
|
|
3062
|
+
- **Routes that share one PATH share one body read.** The body is read once for the whole group, so the widest `maxBodyBytes` override in the group applies to the group.
|
|
3063
|
+
- An oversized body answers `413` whether it announces itself (`Content-Length`) or arrives chunked — the counter cuts it at the cap and never buffers past it.
|
|
3064
|
+
|
|
3065
|
+
## Conditional GET — `etag: true`
|
|
3066
|
+
|
|
3067
|
+
```ts
|
|
3068
|
+
export default defineRestRoute({
|
|
3069
|
+
method: 'GET',
|
|
3070
|
+
path: '/v1/customers',
|
|
3071
|
+
etag: true, // GET only — ignored elsewhere
|
|
3072
|
+
// …
|
|
3073
|
+
})
|
|
3074
|
+
```
|
|
3075
|
+
|
|
3076
|
+
The route stamps a **weak, content-derived** `ETag` (`W/"<sha-1 of the encoded output>"`) on every `200`, and answers a matching `If-None-Match` with `304 Not Modified` — the tag, no body. Weak on purpose: the transport may vary the BYTES per content-encoding, but the representation is the same. (`voltro start` does the equivalent for the web app's HTML on its own — `If-None-Match` answers `304` for buffered `200`s, with weak `W/"md5"` tags over the *uncompressed* body; `no-store` responses excepted.)
|
|
3077
|
+
|
|
2943
3078
|
## Idempotency (`Idempotency-Key`)
|
|
2944
3079
|
|
|
2945
3080
|
Set `idempotency: true` in `app.config.ts` and every mutating REST request (`POST`/`PUT`/`PATCH`/`DELETE`) that carries an `Idempotency-Key` header is deduplicated:
|
|
@@ -2970,6 +3105,56 @@ This is the Stripe-style contract — the **client** opts in by sending the head
|
|
|
2970
3105
|
- **Inbound webhooks already dedup** via [`@voltro/plugin-webhooks`](/docs/plugins/webhooks) (provider key + `_voltro_webhook_*`) — don't double-cover them.
|
|
2971
3106
|
- **Atomic claim, non-atomic completion.** Two concurrent same-key requests resolve to exactly one execution (the `UNIQUE(scope,key)` insert is the arbiter). But the cached response isn't committed in the handler's own transaction — a crash between the handler committing and the record flipping to `completed` leaves the key in-flight (a retry `409`s until the TTL lapses, then re-runs). REST handlers aren't auto-transactional, so this is the honest ceiling.
|
|
2972
3107
|
|
|
3108
|
+
## API versions — opt-in `version:` + the sunset flow
|
|
3109
|
+
|
|
3110
|
+
A route that will evolve declares its version instead of baking it into the
|
|
3111
|
+
path; `version: 'v2'` mounts under `/v2/…`:
|
|
3112
|
+
|
|
3113
|
+
```ts
|
|
3114
|
+
// v2 — the current shape
|
|
3115
|
+
export const listCustomers = defineRestRoute({
|
|
3116
|
+
method: 'GET',
|
|
3117
|
+
path: '/customers',
|
|
3118
|
+
version: 'v2',
|
|
3119
|
+
output: Schema.Struct({ data: Schema.Array(Customer), nextCursor: Schema.NullOr(Schema.String) }),
|
|
3120
|
+
handler: async (_i, ctx) => ({ data: await ctx.store.query(customers), nextCursor: null }),
|
|
3121
|
+
})
|
|
3122
|
+
|
|
3123
|
+
// v1 — still mounted, deprecated, and gone on a date
|
|
3124
|
+
export const listCustomersV1 = defineRestRoute({
|
|
3125
|
+
method: 'GET',
|
|
3126
|
+
path: '/customers',
|
|
3127
|
+
version: 'v1',
|
|
3128
|
+
deprecated: 'GET /v2/customers', // Deprecation header + replacement pointer
|
|
3129
|
+
sunset: '2027-03-01', // Sunset header; 410 Gone from this date
|
|
3130
|
+
output: Schema.Struct({ customers: Schema.Array(Customer) }),
|
|
3131
|
+
handler: async (_i, ctx) => ({ customers: await ctx.store.query(customers) }),
|
|
3132
|
+
})
|
|
3133
|
+
```
|
|
3134
|
+
|
|
3135
|
+
Two versions are **two descriptors** — the old one is ordinary code (visible,
|
|
3136
|
+
testable, deletable), not an entry in a transformation DSL. While it lives,
|
|
3137
|
+
responses carry `Deprecation: true` + `Sunset:`; past the date it answers
|
|
3138
|
+
`410 Gone` with `{ version: 'v1', replacement: 'GET /v2/customers' }`. Then
|
|
3139
|
+
you delete it. `version` is opt-in: a route without it keeps its literal path
|
|
3140
|
+
(no auto-prefix), and declaring `version:` on a path that already starts with
|
|
3141
|
+
`/vN/` is refused at definition — both spellings at once is never intended.
|
|
3142
|
+
`publicApi` projections version the same way (`spec.version`, default `v1`),
|
|
3143
|
+
and the OpenAPI doc groups each version's operations under a version tag with
|
|
3144
|
+
`x-voltro-api-version` — one document, the `/vN/` paths already separate them.
|
|
3145
|
+
|
|
3146
|
+
**URI versioning only, on purpose.** Header- and media-type-versioning (the
|
|
3147
|
+
NestJS options) are not supported: the OpenAPI document, cache keys and plain
|
|
3148
|
+
`curl` are all path-shaped, and a version a URL cannot express is a version a
|
|
3149
|
+
cached response cannot vary on. If an edge must accept `Accept-Version:`
|
|
3150
|
+
headers, rewrite them to the path prefix at the proxy.
|
|
3151
|
+
|
|
3152
|
+
**And the rpc socket is deliberately outside this.** The generated client is
|
|
3153
|
+
versioned with the server it was generated from — there is no `/v2` for
|
|
3154
|
+
`useMutation`. Honest edge: a browser tab that stayed open across your deploy
|
|
3155
|
+
runs the PREVIOUS client until reload; that skew window exists, it is small,
|
|
3156
|
+
and URL versioning would not remove it.
|
|
3157
|
+
|
|
2973
3158
|
## Projecting an existing procedure — `publicApi`
|
|
2974
3159
|
|
|
2975
3160
|
You often want to *offer* an API you don't consume from your own frontend. When the procedure already exists as a query / mutation / action, you don't need to rewrite it as a REST route — annotate it with `publicApi` and the framework mounts ONE HTTP route that runs the **same** handler, under the same guards:
|
|
@@ -3026,6 +3211,45 @@ Same guarantees as the WebSocket path, because it is the same code: the declarat
|
|
|
3026
3211
|
|
|
3027
3212
|
For a hand-written `defineRestRoute`, the same machinery is available directly — return `sse((emit) => unsubscribe)` from the handler and frame events with `sseFrame(event, data)` (both from `@voltro/protocol/rest`).
|
|
3028
3213
|
|
|
3214
|
+
## Binary downloads — `bytes()`
|
|
3215
|
+
|
|
3216
|
+
A handler that serves a file, an export or any non-JSON body returns `bytes(stream, options)` — imported beside `defineRestRoute` / `sse`:
|
|
3217
|
+
|
|
3218
|
+
```ts
|
|
3219
|
+
import { defineRestRoute, bytes, requireScope } from '@voltro/protocol/rest'
|
|
3220
|
+
|
|
3221
|
+
export default defineRestRoute({
|
|
3222
|
+
method: 'GET',
|
|
3223
|
+
path: '/v1/exports/:id',
|
|
3224
|
+
guards: [requireScope('exports:read')],
|
|
3225
|
+
handler: async ({ params }, ctx) => {
|
|
3226
|
+
const file = await locateExport(params.id)
|
|
3227
|
+
// Lazy thunk form — the source is opened only when the response streams.
|
|
3228
|
+
return bytes(() => openExportStream(file), {
|
|
3229
|
+
contentType: 'application/zip',
|
|
3230
|
+
contentLength: file.size,
|
|
3231
|
+
contentDisposition: `attachment; filename="${file.name}"`,
|
|
3232
|
+
})
|
|
3233
|
+
},
|
|
3234
|
+
})
|
|
3235
|
+
```
|
|
3236
|
+
|
|
3237
|
+
- The first argument is a web `ReadableStream<Uint8Array>` — or the **lazy thunk form** `() => ReadableStream`, which defers opening the source until the response actually streams.
|
|
3238
|
+
- The server **pipes without buffering** — a body larger than the heap is fine (the guarantee is exercised with a 256-MiB stream), and byte streams are **never compressed**.
|
|
3239
|
+
- Everything before the handler still runs — method gate, sunset, input decode, guards — so a streaming route is exactly as gated as a buffered one.
|
|
3240
|
+
- On a plugin HTTP route the same shape is `PluginHttpRouteResult.byteStream`.
|
|
3241
|
+
|
|
3242
|
+
### Idempotency × streams — decided
|
|
3243
|
+
|
|
3244
|
+
`streaming: true` on a method the idempotency binding claims (`POST`/`PUT`/`PATCH`/`DELETE`) is a **mount error**: a stream cannot cache a replayable body, so the idempotency claim could never complete — every retry would `409` until the TTL lapsed. The refusal names the two ways out: serve the stream on `GET`, or keep the idempotency binding away from the app's streaming routes. A handler that returns a stream *without* declaring `streaming: true` is caught at runtime instead — the claim is **released** so a retry re-processes.
|
|
3245
|
+
|
|
3246
|
+
## No multipart parser — a declared boundary
|
|
3247
|
+
|
|
3248
|
+
There is **no multipart parser** on REST or webhook routes — `multipart/form-data` against `/form/*` answers `415`, and a REST handler never sees parsed file parts. That boundary is deliberate, and this list of alternatives is complete:
|
|
3249
|
+
|
|
3250
|
+
- **File uploads** ride [`@voltro/plugin-storage`](/docs/plugins/storage)'s upload routes — a binary PUT plus a resumable, chunked upload with signed tickets. That is the sanctioned file path, not a workaround.
|
|
3251
|
+
- **A provider that delivers webhooks as multipart** (the Mailgun-inbound class) needs, today, either a small parser proxy in front of the endpoint or the provider's JSON delivery mode where it offers one.
|
|
3252
|
+
|
|
3029
3253
|
## REST route vs Action
|
|
3030
3254
|
|
|
3031
3255
|
Both are unary request/response. Pick by transport + audience:
|
|
@@ -4464,7 +4688,7 @@ rather than letting whichever loaded last silently win.
|
|
|
4464
4688
|
## Retries, backoff, dead-letter
|
|
4465
4689
|
|
|
4466
4690
|
| | |
|
|
4467
|
-
|
|
4691
|
+
| --- | --- |
|
|
4468
4692
|
| Retry schedule | exponential — 1s, 2s, 4s … capped at 5 minutes |
|
|
4469
4693
|
| Default attempts | 8 (`maxAttempts` on the handler, or per-enqueue) |
|
|
4470
4694
|
| Exhausted | row moves to `dead`, logged at ERROR, stays in the table |
|
|
@@ -4530,7 +4754,7 @@ delivery-history screen renders. So every attempt appends a row to
|
|
|
4530
4754
|
`_voltro_outbox_attempts`:
|
|
4531
4755
|
|
|
4532
4756
|
| column | |
|
|
4533
|
-
|
|
4757
|
+
| --- | --- |
|
|
4534
4758
|
| `outboxId` | the entry this attempt belongs to |
|
|
4535
4759
|
| `effect` | denormalised — the history stays readable after the entry is purged |
|
|
4536
4760
|
| `attempt` | 1-indexed, monotonic across the entry's whole life |
|
|
@@ -4626,6 +4850,20 @@ bound.
|
|
|
4626
4850
|
- **Mirroring a table outward continuously** → `@voltro/plugin-cdc-out`, which
|
|
4627
4851
|
is built for reverse-ETL with per-pipe ordering.
|
|
4628
4852
|
|
|
4853
|
+
## There is no generic job queue — take X for Y
|
|
4854
|
+
|
|
4855
|
+
Voltro deliberately ships no `defineJob` primitive (priorities, worker pools, a
|
|
4856
|
+
BullMQ equivalent). The outbox, [workflows](/docs/workflows/overview) and
|
|
4857
|
+
[schedules](/docs/scheduling/overview) cover the cases between them, and a third
|
|
4858
|
+
durability primitive would drift from both. What to reach for instead:
|
|
4859
|
+
|
|
4860
|
+
| you want… | take |
|
|
4861
|
+
| --- | --- |
|
|
4862
|
+
| a concurrency-limited worker pool | workflows + [declarative flow control](/docs/workflows/declarative-flow-control) — `concurrency` / `throttle` bound how many runs execute at once |
|
|
4863
|
+
| true priority scheduling (high-priority work overtakes queued low-priority work) | does not exist as a primitive — a workflow draining **your own queue table** in your priority order is the honest build |
|
|
4864
|
+
| a delayed / scheduled message | `delayMs` on `enqueue` (above) for a one-off delayed effect; a [schedule](/docs/scheduling/overview) for recurring time-based work; workflow [`sleep`](/docs/workflows/sleep) for a pause inside a durable process |
|
|
4865
|
+
| exactly-once delivery | does not exist — delivery is at-least-once everywhere, which is the strongest guarantee available without a distributed transaction into the target; **idempotent handlers are mandatory** (see above) |
|
|
4866
|
+
|
|
4629
4867
|
## See also
|
|
4630
4868
|
|
|
4631
4869
|
- [Mutations](/docs/data/mutations) — the transaction boundary this rides
|
|
@@ -4684,7 +4922,7 @@ A streaming query (what `useSubscription` opens) emits a sequence of **subscript
|
|
|
4684
4922
|
{ _tag: 'error', error: { _tag?: string, message: string, ...fields }, revision?: number }
|
|
4685
4923
|
```
|
|
4686
4924
|
|
|
4687
|
-
- **`revision`** — monotonically increasing; lets the client order events
|
|
4925
|
+
- **`revision`** — monotonically increasing; lets the client order events. **Revisions may JUMP forward** — under socket backpressure the server coalesces updates a slow consumer hasn't read yet into one event whose patch is computed against the row set of the last event it actually handed over, so patch continuity holds across the jump. A jump is therefore normal, never a gap; only a *regressing* or repeating revision would be a protocol violation.
|
|
4688
4926
|
- **`emittedAt`** — epoch milliseconds, present on `delta` only.
|
|
4689
4927
|
- **`data`** (snapshot) — the full payload, typed by the query's `output` schema (a row, an array of rows, a computed value — whatever the handler returns).
|
|
4690
4928
|
- **`patch`** (delta) — an id-keyed RFC-6902-style patch against the row set the client last held.
|
|
@@ -4703,6 +4941,70 @@ propagate to the shared connection and stall every *other* subscription on it
|
|
|
4703
4941
|
client surfaces it as `useSubscription(...).error` for that one query key;
|
|
4704
4942
|
siblings keep delivering their snapshots and deltas.
|
|
4705
4943
|
|
|
4944
|
+
### Slow consumers — coalescing and `SubscriptionOverrun`
|
|
4945
|
+
|
|
4946
|
+
A consumer that stops reading (a backgrounded tab, a saturated link) does not
|
|
4947
|
+
grow the server without bound. While its socket is blocked, updates
|
|
4948
|
+
**coalesce**: the server keeps only the newest state per subscription and, when
|
|
4949
|
+
the socket accepts again, sends ONE event — a patch against the last state the
|
|
4950
|
+
consumer was actually handed (`revision` jumps accordingly, see above). Memory
|
|
4951
|
+
per blocked subscription is bounded by construction: one pending state,
|
|
4952
|
+
regardless of how far behind the consumer is.
|
|
4953
|
+
|
|
4954
|
+
A consumer that stays more than `reactive.socket.maxBufferedBytes` (default
|
|
4955
|
+
1 MiB, env `VOLTRO_REACTIVE_MAX_BUFFERED_BYTES`) behind for
|
|
4956
|
+
`reactive.socket.overrunAfterMs` (default 10 s) is closed **loudly**: it
|
|
4957
|
+
receives an `error` event with `error._tag: 'SubscriptionOverrun'` (carrying
|
|
4958
|
+
`bufferedBytes` + `maxBufferedBytes`) and the stream ends — never a silent
|
|
4959
|
+
drop. The client re-subscribes and starts from a fresh snapshot.
|
|
4960
|
+
|
|
4961
|
+
Oversized events are telemetry, not a cap: an event over
|
|
4962
|
+
`reactive.socket.oversizedEventBytes` (default 256 KiB) is delivered normally
|
|
4963
|
+
and counted (`voltro_subscription_oversized_total`) with a WARN naming the
|
|
4964
|
+
query — alongside `voltro_subscription_buffered_bytes`,
|
|
4965
|
+
`voltro_subscription_coalesced_total` and `voltro_subscription_overrun_total`
|
|
4966
|
+
in the Prometheus exporter and the inspect Metrics panel.
|
|
4967
|
+
|
|
4968
|
+
### Reconnect — delta-resume
|
|
4969
|
+
|
|
4970
|
+
A client that reconnects inside the **resume window** does not have to pay for
|
|
4971
|
+
a full snapshot: it sends the last `revision` it materialised in the per-call
|
|
4972
|
+
`voltro-resume-from` request header (the same header surface the idempotency
|
|
4973
|
+
key rides), and the server — which kept the subscription alive server-side for
|
|
4974
|
+
the window after the disconnect — **replays only the deltas that were missed**
|
|
4975
|
+
and re-attaches the stream on the SAME revision line.
|
|
4976
|
+
|
|
4977
|
+
The signal is the first event's tag, not a schema field:
|
|
4978
|
+
|
|
4979
|
+
- **first event `delta`** — the resume was honoured; apply the patch onto the
|
|
4980
|
+
rows you already hold and continue.
|
|
4981
|
+
- **first event `snapshot`** — the resume was declined; reset to the snapshot.
|
|
4982
|
+
This is the answer whenever anything is in doubt, because a wrong snapshot
|
|
4983
|
+
costs bytes while a wrong replay would leak rows.
|
|
4984
|
+
|
|
4985
|
+
`@voltro/client` does both automatically — the reconnect-seeded cache keeps its
|
|
4986
|
+
rows and revision, presents the header, and treats a snapshot-first stream as
|
|
4987
|
+
the reset it already knows how to do. Replayed deltas may **coalesce** exactly
|
|
4988
|
+
as slow-consumer updates do (revisions jump; patch continuity holds).
|
|
4989
|
+
|
|
4990
|
+
A resume is declined — always with a fresh snapshot — when:
|
|
4991
|
+
|
|
4992
|
+
- the window expired (`reactive.resume.windowMs`, default 60 s, env
|
|
4993
|
+
`VOLTRO_REACTIVE_RESUME_WINDOW_MS`), or more deltas were missed than the ring
|
|
4994
|
+
retains (`reactive.resume.maxDeltas`, default 256, env
|
|
4995
|
+
`VOLTRO_REACTIVE_RESUME_MAX_DELTAS`);
|
|
4996
|
+
- the query's `guards:` were revoked while the client was away — the
|
|
4997
|
+
per-delivery re-check keeps running on the detached subscription, and a
|
|
4998
|
+
revocation drops the retained history outright;
|
|
4999
|
+
- the resuming caller is a different subject or tenant (a login, logout or
|
|
5000
|
+
tenant switch between disconnect and resume) — the retained history is keyed
|
|
5001
|
+
by subject AND tenant, so a changed identity simply never finds it;
|
|
5002
|
+
- the app registers a row filter (`setRowFilter`), or the query is a
|
|
5003
|
+
**computed** query — both are excluded from resume by design: a row-filtered
|
|
5004
|
+
subscription's visible row set exists only per delivery, and a computed query
|
|
5005
|
+
re-runs a handler with no delta chain to replay. They reconnect with a fresh
|
|
5006
|
+
snapshot, exactly as before.
|
|
5007
|
+
|
|
4706
5008
|
**Author a live-subscribed getter to return, not throw.** A subscription is a
|
|
4707
5009
|
long-lived stream, so a getter that throws on every re-evaluation is a broken
|
|
4708
5010
|
stream. For an expected-absent row, make the query `output: Schema.NullOr(...)`
|
|
@@ -5035,6 +5337,203 @@ try {
|
|
|
5035
5337
|
|
|
5036
5338
|
|
|
5037
5339
|
|
|
5340
|
+
---
|
|
5341
|
+
|
|
5342
|
+
<!-- source: en/data/content-collections.md -->
|
|
5343
|
+
## Content collections
|
|
5344
|
+
|
|
5345
|
+
_File-based, schema-typed markdown content — defineCollection over content/<name>/**/*.md, an isomorphic getCollection/getEntry, locale trees with fallback, headings for TOCs, data collections, references, and RSS feeds — without installing a markdown dependency._
|
|
5346
|
+
|
|
5347
|
+
A content collection turns a folder of markdown files into typed, rendered
|
|
5348
|
+
content: you declare the frontmatter schema in code, and `getCollection()` /
|
|
5349
|
+
`getEntry()` hand you decoded data plus server-rendered HTML with
|
|
5350
|
+
syntax-highlighted code fences. The markdown engine lives in the framework —
|
|
5351
|
+
**do not install your own `marked` / `remark` / `shiki`**; a second pipeline
|
|
5352
|
+
drifts from the one your artifacts, feeds and templates already use.
|
|
5353
|
+
|
|
5354
|
+
## A blog in 20 lines
|
|
5355
|
+
|
|
5356
|
+
One collection file, one markdown file, one page:
|
|
5357
|
+
|
|
5358
|
+
```ts
|
|
5359
|
+
// src/collections/posts.collection.ts
|
|
5360
|
+
import { Schema } from 'effect'
|
|
5361
|
+
import { defineCollection } from '@voltro/content'
|
|
5362
|
+
|
|
5363
|
+
export const posts = defineCollection({
|
|
5364
|
+
name: 'posts',
|
|
5365
|
+
directory: 'content/posts',
|
|
5366
|
+
schema: Schema.Struct({ title: Schema.String, date: Schema.String }),
|
|
5367
|
+
})
|
|
5368
|
+
export type Post = Schema.Schema.Type<typeof posts.schema>
|
|
5369
|
+
```
|
|
5370
|
+
|
|
5371
|
+
```ts
|
|
5372
|
+
// src/pages/blog/[slug]/page.tsx
|
|
5373
|
+
import { getCollection, getEntry, type ContentEntry } from '@voltro/content'
|
|
5374
|
+
import { useLoaderData } from '@voltro/web'
|
|
5375
|
+
import { posts, type Post } from '../../../collections/posts.collection'
|
|
5376
|
+
|
|
5377
|
+
export const renderMode = 'static' as const
|
|
5378
|
+
export const getStaticPaths = async () =>
|
|
5379
|
+
(await getCollection(posts.name)).map((e) => ({ params: { slug: e.slug } }))
|
|
5380
|
+
export const loader = async ({ params }: { params: { slug: string } }) =>
|
|
5381
|
+
await getEntry<Post>(posts.name, params.slug)
|
|
5382
|
+
|
|
5383
|
+
export default function Post() {
|
|
5384
|
+
const post = useLoaderData<ContentEntry<Post> | null>()
|
|
5385
|
+
if (!post) return <main>Not found</main>
|
|
5386
|
+
return <article dangerouslySetInnerHTML={{ __html: post.html ?? '' }} />
|
|
5387
|
+
}
|
|
5388
|
+
```
|
|
5389
|
+
|
|
5390
|
+
Drop `content/posts/hello.md` with `title:` + `date:` frontmatter and the
|
|
5391
|
+
build pre-renders `/blog/hello` — highlighted code fences included.
|
|
5392
|
+
|
|
5393
|
+
## How it stays out of your bundle
|
|
5394
|
+
|
|
5395
|
+
The loader is **isomorphic**. At build/SSR time it reads the filesystem and
|
|
5396
|
+
renders markdown (shiki runs on the server only). The build also emits JSON
|
|
5397
|
+
artifacts under `dist/assets/content/<name>[.<locale>]/…` — an index (slugs +
|
|
5398
|
+
frontmatter, no bodies) and one file per entry (rendered HTML + headings). On
|
|
5399
|
+
an SPA navigation, the CLIENT branch of `getCollection`/`getEntry` fetches
|
|
5400
|
+
those artifacts. The result: no markdown engine, no highlighter, and no
|
|
5401
|
+
content bodies in your JavaScript bundle. `voltro dev` serves the same
|
|
5402
|
+
artifact shapes on demand and invalidates them when a `content/**` file
|
|
5403
|
+
changes.
|
|
5404
|
+
|
|
5405
|
+
## Frontmatter is a schema, and violations fail the build
|
|
5406
|
+
|
|
5407
|
+
The `schema` is an `effect/Schema` struct decoded per file. A missing or
|
|
5408
|
+
mistyped field is a **build error naming the file** — not a page that renders
|
|
5409
|
+
`undefined`. Numbers in frontmatter arrive as strings; use
|
|
5410
|
+
`Schema.Union(Schema.NumberFromString, Schema.Number)` for numeric fields.
|
|
5411
|
+
`getCollection<A>` returns entries whose `data` is the schema's inferred
|
|
5412
|
+
type — no casts.
|
|
5413
|
+
|
|
5414
|
+
## Slugs come from the path
|
|
5415
|
+
|
|
5416
|
+
`content/posts/hello.md` → `hello`; nested folders stay in the slug
|
|
5417
|
+
(`database/joins.md` → `database/joins`). Two files resolving to one slug
|
|
5418
|
+
(a rename that left both) is a build error.
|
|
5419
|
+
|
|
5420
|
+
## Locale trees + fallback
|
|
5421
|
+
|
|
5422
|
+
A collection with `i18n` treats the first path segment as the locale:
|
|
5423
|
+
|
|
5424
|
+
```ts
|
|
5425
|
+
export const docs = defineCollection({
|
|
5426
|
+
name: 'docs',
|
|
5427
|
+
directory: 'content/docs',
|
|
5428
|
+
schema: Schema.Struct({ title: Schema.String }),
|
|
5429
|
+
i18n: { locales: ['en', 'de'], defaultLocale: 'en', missing: 'fallback' },
|
|
5430
|
+
})
|
|
5431
|
+
```
|
|
5432
|
+
|
|
5433
|
+
`getCollection('docs', { locale: 'de' })` reads the `de/` tree. A slug missing
|
|
5434
|
+
in the requested locale is served from the default tree with
|
|
5435
|
+
`fallback: true` on the entry (render an "untranslated" banner off it) — or
|
|
5436
|
+
omitted entirely with `missing: 'missing'`. Incomplete translations are the
|
|
5437
|
+
normal case; decide the policy per collection instead of improvising per page.
|
|
5438
|
+
|
|
5439
|
+
## Headings as data
|
|
5440
|
+
|
|
5441
|
+
Every rendered entry carries `headings: [{ depth, slug, text }]` — the TOC
|
|
5442
|
+
input. The slugs are the SAME ids stamped on the rendered `<h2 id="…">`
|
|
5443
|
+
elements, so sidebar anchors never drift from the body. For a TOC without a
|
|
5444
|
+
render pass, `extractHeadings(markdown)` (from `@voltro/content/markdown`)
|
|
5445
|
+
computes the same data synchronously.
|
|
5446
|
+
|
|
5447
|
+
## Data collections
|
|
5448
|
+
|
|
5449
|
+
`kind: 'data'` reads `.json` files instead of markdown — the `authors.json`
|
|
5450
|
+
case. Each file decodes whole against the schema; there is no render path:
|
|
5451
|
+
|
|
5452
|
+
```ts
|
|
5453
|
+
export const authors = defineCollection({
|
|
5454
|
+
name: 'authors',
|
|
5455
|
+
directory: 'content/authors',
|
|
5456
|
+
kind: 'data',
|
|
5457
|
+
schema: Schema.Struct({ name: Schema.String, url: Schema.String }),
|
|
5458
|
+
})
|
|
5459
|
+
```
|
|
5460
|
+
|
|
5461
|
+
## References between collections
|
|
5462
|
+
|
|
5463
|
+
`reference('<collection>')` declares a frontmatter field that names an entry
|
|
5464
|
+
of another collection by slug:
|
|
5465
|
+
|
|
5466
|
+
```ts
|
|
5467
|
+
schema: Schema.Struct({
|
|
5468
|
+
title: Schema.String,
|
|
5469
|
+
author: reference('authors'),
|
|
5470
|
+
})
|
|
5471
|
+
```
|
|
5472
|
+
|
|
5473
|
+
The build validates every reference — a dangling one (`author: nobody`) fails
|
|
5474
|
+
the build naming the collection, entry, field and target. Resolve it with
|
|
5475
|
+
`getEntry('authors', entry.data.author)`.
|
|
5476
|
+
|
|
5477
|
+
## RSS feeds from a collection
|
|
5478
|
+
|
|
5479
|
+
Declare feeds in `app.config.ts`; the build writes them next to
|
|
5480
|
+
`sitemap.xml`, and `voltro dev` serves the same XML live:
|
|
5481
|
+
|
|
5482
|
+
```ts
|
|
5483
|
+
export default {
|
|
5484
|
+
// …
|
|
5485
|
+
seo: { siteUrl: 'https://example.com' },
|
|
5486
|
+
feeds: [{
|
|
5487
|
+
path: '/rss.xml',
|
|
5488
|
+
collection: 'posts',
|
|
5489
|
+
title: 'My blog',
|
|
5490
|
+
item: (e) => e.data.draft === 'true' ? null : ({
|
|
5491
|
+
title: e.data.title, link: `/blog/${e.slug}`, date: e.data.date,
|
|
5492
|
+
}),
|
|
5493
|
+
}],
|
|
5494
|
+
}
|
|
5495
|
+
```
|
|
5496
|
+
|
|
5497
|
+
Returning `null` from `item` excludes an entry — that is the **draft filter**:
|
|
5498
|
+
keep a `draft: true` field in your schema and filter it in `item` and in your
|
|
5499
|
+
page loaders (the changelog template's `visibleReleases` helper is the worked
|
|
5500
|
+
example, including future-dated staging).
|
|
5501
|
+
|
|
5502
|
+
## No MDX — islands carry the interactivity
|
|
5503
|
+
|
|
5504
|
+
Collection bodies are **markdown, not MDX**: JSX, `import`s and
|
|
5505
|
+
`{expressions}` in a body are not executed. When a content page needs a live
|
|
5506
|
+
widget, the surrounding PAGE provides it via the islands mechanism — the
|
|
5507
|
+
content stays inert HTML and the widget hydrates alone:
|
|
5508
|
+
|
|
5509
|
+
```tsx
|
|
5510
|
+
// src/pages/blog/[slug]/page.tsx
|
|
5511
|
+
export const interactive = 'islands' as const
|
|
5512
|
+
|
|
5513
|
+
export default function Post() {
|
|
5514
|
+
const post = useLoaderData<ContentEntry<Post>>()
|
|
5515
|
+
return (
|
|
5516
|
+
<main>
|
|
5517
|
+
<ReadingProgress /> {/* an island() component — the ONLY hydrated JS */}
|
|
5518
|
+
<article dangerouslySetInnerHTML={{ __html: post.html ?? '' }} />
|
|
5519
|
+
</main>
|
|
5520
|
+
)
|
|
5521
|
+
}
|
|
5522
|
+
```
|
|
5523
|
+
|
|
5524
|
+
## Limits + neighbors
|
|
5525
|
+
|
|
5526
|
+
- **Images referenced from markdown bodies** are copied as-is (no transform):
|
|
5527
|
+
the [image pipeline](/docs/routing/assets) covers `?image` imports from
|
|
5528
|
+
code. Put content images under `public/` and reference them absolutely.
|
|
5529
|
+
- **Files are DEVELOPER content** — versioned with the code, deployed by the
|
|
5530
|
+
build. Editorial content with drafts, roles and a save/publish pipeline is
|
|
5531
|
+
[`@voltro/cms`](/docs/data/cms). Astro's remote "Content Layer loaders"
|
|
5532
|
+
map to `@voltro/cms` here: remote/editorial sources go through the CMS,
|
|
5533
|
+
not through file collections.
|
|
5534
|
+
|
|
5535
|
+
|
|
5536
|
+
|
|
5038
5537
|
---
|
|
5039
5538
|
|
|
5040
5539
|
<!-- source: en/data/cms.md -->
|
|
@@ -5183,6 +5682,38 @@ const program = Effect.gen(function* () {
|
|
|
5183
5682
|
)
|
|
5184
5683
|
```
|
|
5185
5684
|
|
|
5685
|
+
### ISR revalidation on publish
|
|
5686
|
+
|
|
5687
|
+
A content type can declare which ISR routes fall when its content is
|
|
5688
|
+
published or unpublished — `publish()`/`unpublish()` fire
|
|
5689
|
+
[`revalidatePath` / `revalidateTag`](/docs/routing/render-modes#on-demand-revalidation)
|
|
5690
|
+
for each entry after the write commits, reaching every `voltro start`
|
|
5691
|
+
replica:
|
|
5692
|
+
|
|
5693
|
+
```ts
|
|
5694
|
+
import { defineContentType, Schema } from '@voltro/cms'
|
|
5695
|
+
|
|
5696
|
+
const blogPost = defineContentType({
|
|
5697
|
+
name: 'blogPost',
|
|
5698
|
+
displayName: 'Blog post',
|
|
5699
|
+
pluralName: 'Blog posts',
|
|
5700
|
+
fields: {
|
|
5701
|
+
title: Schema.String.pipe(Schema.maxLength(200)),
|
|
5702
|
+
body: Schema.RichText({ allowImages: true, allowEmbeds: false }),
|
|
5703
|
+
},
|
|
5704
|
+
revalidate: {
|
|
5705
|
+
paths: ['/blog/[slug]', '/blog'],
|
|
5706
|
+
tags: ['blog'],
|
|
5707
|
+
},
|
|
5708
|
+
})
|
|
5709
|
+
```
|
|
5710
|
+
|
|
5711
|
+
You don't need this on postgres for the plain publish case: a route declaring
|
|
5712
|
+
`cacheInvalidatesOn: ['blogPost_published']` is already dropped by CDC when
|
|
5713
|
+
the published table changes. Declare `revalidate` for what CDC can't see —
|
|
5714
|
+
non-postgres dialects, routes whose loaders read the content indirectly, or
|
|
5715
|
+
tag fanout across several routes.
|
|
5716
|
+
|
|
5186
5717
|
## The engine, standalone
|
|
5187
5718
|
|
|
5188
5719
|
The validation/derivation engine is pure and exported on its own (also on
|
|
@@ -5269,15 +5800,16 @@ list; `mediaFields(type)` lists the top-level media field names.
|
|
|
5269
5800
|
## Versioning content
|
|
5270
5801
|
|
|
5271
5802
|
`@voltro/cms` ships no parallel revision system — the derived tables are
|
|
5272
|
-
ordinary database tables, so `@voltro/plugin-
|
|
5273
|
-
|
|
5274
|
-
|
|
5803
|
+
ordinary database tables, so `@voltro/plugin-row-history` gives full row history
|
|
5804
|
+
plus time-travel with no new machinery. The plugin records every table by default —
|
|
5805
|
+
narrow it with `include:` (pass the derived table handles) or `exclude:` if you
|
|
5806
|
+
only want content history — then read a timeline or restore a snapshot:
|
|
5275
5807
|
|
|
5276
5808
|
```ts no-check
|
|
5277
|
-
import {
|
|
5809
|
+
import { rowHistoryPlugin, rowHistory, restoreAsOf } from '@voltro/plugin-row-history'
|
|
5278
5810
|
|
|
5279
5811
|
// Register in your app's plugin list:
|
|
5280
|
-
|
|
5812
|
+
rowHistoryPlugin({})
|
|
5281
5813
|
|
|
5282
5814
|
const timeline = await rowHistory(ctx.store, 'blogPost_published', postId, tenantId)
|
|
5283
5815
|
await restoreAsOf(ctx.store, 'blogPost_published', postId, tenantId, someEarlierDate)
|
|
@@ -5542,3 +6074,118 @@ app's public origin comes from `VOLTRO_PUBLIC_URL`.
|
|
|
5542
6074
|
a secret, not a connection.
|
|
5543
6075
|
- **`redirectTo` is a same-origin path only.** An absolute URL is rejected —
|
|
5544
6076
|
otherwise every app declaring a connection would ship an open redirector.
|
|
6077
|
+
|
|
6078
|
+
|
|
6079
|
+
|
|
6080
|
+
---
|
|
6081
|
+
|
|
6082
|
+
<!-- source: en/data/grpc.md -->
|
|
6083
|
+
## gRPC surface
|
|
6084
|
+
|
|
6085
|
+
_Serve opt-in procedures to generated gRPC clients — proto emitted from your effect/Schema with checked-in field-number stability, unary for mutations/actions, server-streaming for live queries, guards + interceptors + typed errors identical to the socket._
|
|
6086
|
+
|
|
6087
|
+
The gRPC surface serves a NAMED list of your procedures to external gRPC
|
|
6088
|
+
clients — the polyglot-microservice door. The `.proto` is generated from the
|
|
6089
|
+
same `effect/Schema` your procedures already declare, so there is no second
|
|
6090
|
+
contract to maintain; the wire semantics are the framework's own: guards,
|
|
6091
|
+
plugin interceptors and typed errors behave **identically** to the rpc
|
|
6092
|
+
socket, because a gRPC call runs the *same bound runner* every other surface
|
|
6093
|
+
uses (the e2e proves interceptor order side by side).
|
|
6094
|
+
|
|
6095
|
+
```ts
|
|
6096
|
+
// app.config.ts
|
|
6097
|
+
export default {
|
|
6098
|
+
type: 'api' as const,
|
|
6099
|
+
name: 'api',
|
|
6100
|
+
grpc: {
|
|
6101
|
+
port: 50051,
|
|
6102
|
+
procedures: ['orders.get', 'orders.list', 'orders.create'],
|
|
6103
|
+
// tls: { certPath, keyPath, caPath? } — plaintext without it (dev / mesh).
|
|
6104
|
+
},
|
|
6105
|
+
}
|
|
6106
|
+
```
|
|
6107
|
+
|
|
6108
|
+
NOTHING is exposed by default — every tag is named. Booting writes
|
|
6109
|
+
`.framework/grpc.proto` (hand it to any proto codegen) and mounts
|
|
6110
|
+
`grpc.health.v1` health checking plus server reflection (`grpcurl … list`
|
|
6111
|
+
works out of the box). The gRPC packages ship as script-free optional
|
|
6112
|
+
dependencies of `@voltro/cli`; a configured `grpc:` block with them missing
|
|
6113
|
+
refuses the boot by name.
|
|
6114
|
+
|
|
6115
|
+
## Field numbers are managed — `grpc.manifest.json`
|
|
6116
|
+
|
|
6117
|
+
Field numbers are the proto wire identity, so they may never depend on
|
|
6118
|
+
property order. They come from a checked-in manifest in your app root:
|
|
6119
|
+
|
|
6120
|
+
- a **new** field gets the next never-used number — an **inserted** field
|
|
6121
|
+
never renumbers its neighbours;
|
|
6122
|
+
- a **deleted** field's number becomes `reserved` (emitted into the proto,
|
|
6123
|
+
so `protoc` refuses a colliding hand-edit too);
|
|
6124
|
+
- **reusing** a reserved number is a codegen error, never a warning — an old
|
|
6125
|
+
client would silently read the wrong field.
|
|
6126
|
+
|
|
6127
|
+
Commit the manifest with the schema change that moved it: the diff review IS
|
|
6128
|
+
the wire-contract review.
|
|
6129
|
+
|
|
6130
|
+
## The mapping table
|
|
6131
|
+
|
|
6132
|
+
| Schema | proto3 |
|
|
6133
|
+
|---|---|
|
|
6134
|
+
| `Schema.String` / `Number` / `Boolean` | `string` / `double` / `bool` |
|
|
6135
|
+
| integer schemas | `int64` |
|
|
6136
|
+
| `Schema.Array(T)` | `repeated T` |
|
|
6137
|
+
| nested `Schema.Struct` | nested message |
|
|
6138
|
+
| `Schema.Record({ key: String, value: T })` | `map<string, T>` |
|
|
6139
|
+
| `Schema.optional(T)` **and** `Schema.NullOr(T)` | `optional T` — absent and `null` are ONE wire state (proto3 presence) |
|
|
6140
|
+
| string-literal unions | `string` (validated server-side on decode) |
|
|
6141
|
+
| unions of shapes, tuples, recursion, free-form objects | a LOUD per-procedure codegen error naming the schema path |
|
|
6142
|
+
|
|
6143
|
+
Requests are decoded against the descriptor's input schema before the
|
|
6144
|
+
executor runs — proto3 suppresses default values on the wire, and without
|
|
6145
|
+
that decode an empty string would arrive as an absent field and fail
|
|
6146
|
+
somewhere much later.
|
|
6147
|
+
|
|
6148
|
+
## Status codes — complete against the wire error union
|
|
6149
|
+
|
|
6150
|
+
| outcome | gRPC status | trailers |
|
|
6151
|
+
|---|---|---|
|
|
6152
|
+
| no credential on a guarded call | `UNAUTHENTICATED` | |
|
|
6153
|
+
| presented-and-rejected credential | `UNAUTHENTICATED` | |
|
|
6154
|
+
| authenticated, missing scope (`ScopeError`) | `PERMISSION_DENIED` | `voltro-error: scope` |
|
|
6155
|
+
| input fails the schema | `INVALID_ARGUMENT` | `voltro-error: input` |
|
|
6156
|
+
| `BusinessRuleViolation` | `FAILED_PRECONDITION` | `voltro-error: rule` |
|
|
6157
|
+
| `requiresApproval` pending — a FLOW OUTCOME, not a failure | `FAILED_PRECONDITION` | `voltro-pending: approval` + `voltro-approval-id` |
|
|
6158
|
+
| your declared typed error | `FAILED_PRECONDITION` | `voltro-error: <tag>` |
|
|
6159
|
+
| deadline exceeded | `DEADLINE_EXCEEDED` | |
|
|
6160
|
+
| anything else | `INTERNAL` | |
|
|
6161
|
+
|
|
6162
|
+
**Deadlines interrupt the work.** A client deadline (`grpc-timeout`) aborts
|
|
6163
|
+
the executor's fiber through the request signal — the server stops doing the
|
|
6164
|
+
work, it does not merely suppress the response (the e2e pins this with a
|
|
6165
|
+
sleeping action whose post-sleep write never lands).
|
|
6166
|
+
|
|
6167
|
+
## Streaming queries
|
|
6168
|
+
|
|
6169
|
+
A `query` becomes a **server-streaming** rpc: each frame is the CURRENT full
|
|
6170
|
+
snapshot, re-pushed live when the query's `source:` changes — subscribe,
|
|
6171
|
+
mutate from anywhere, and the open stream receives the new frame with no
|
|
6172
|
+
re-request. The per-delivery guard re-check applies (a revoked scope ends
|
|
6173
|
+
the stream with the mapped status), and slow consumers are handled through
|
|
6174
|
+
grpc-js write backpressure — frames coalesce to the latest snapshot rather
|
|
6175
|
+
than buffering unboundedly.
|
|
6176
|
+
|
|
6177
|
+
## Declared limits (v1)
|
|
6178
|
+
|
|
6179
|
+
- **No client- or bidi-streaming**, and `*.stream.ts` procedures are NOT
|
|
6180
|
+
exposable — the fourth kind is a one-shot element stream with its own
|
|
6181
|
+
semantics; put it behind a query or keep it on the socket.
|
|
6182
|
+
- **No gRPC-Web** — a browser talks the framework's own subscription
|
|
6183
|
+
protocol (that is the better browser transport in every dimension we care
|
|
6184
|
+
about); gRPC is for backends.
|
|
6185
|
+
- **No Connect protocol** — connectrpc is NOT gRPC-Web; a connect consumer's
|
|
6186
|
+
alternative today is the [REST/OpenAPI projection](/docs/data/rest-routes).
|
|
6187
|
+
- App realtime stays on the framework's subscription protocol, and gateways
|
|
6188
|
+
exist for the other case: a FOREIGN protocol that needs a socket the
|
|
6189
|
+
framework does not speak — the same boundary
|
|
6190
|
+
[data/subscriptions](/docs/data/subscriptions) draws for raw WebSocket
|
|
6191
|
+
gateways, one sentence, two doors.
|