@voltro/cli 0.52.0 → 0.54.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 +424 -0
- package/THIRD-PARTY-NOTICES.md +8311 -3318
- package/dist/{agentsMd-0l980yhL.js → agentsMd-BaLC10Na.js} +110 -82
- package/dist/agentsMd-DCY1RSs8.js +2 -0
- package/dist/apiBuild-CeUN55uk.js +2 -0
- package/dist/{apiBuild-CSFI8QGq.js → apiBuild-DTWp0S_q.js} +11 -5
- package/dist/bin.js +1 -1
- package/dist/build-D4ygSbnV.js +843 -0
- package/dist/{checkCommand-COmqc2cB.js → checkCommand-Dg1G7Gwd.js} +6 -6
- package/dist/{checkCommand-2SbqzukH.js → checkCommand-L7DTlpIF.js} +1 -1
- package/dist/{cloudCmd-QUXh-b5w.js → cloudCmd-DzKcSYuy.js} +1 -1
- package/dist/{codegen-VF479Cnb.js → codegen-DSLM8Su9.js} +1 -1
- package/dist/codegen-DjgxEOnD.js +2 -0
- package/dist/codegenCommand-CG_Vx4lc.js +41 -0
- package/dist/{codemodRunner-r7J9lIa7.js → codemodRunner-Cd4xkC6u.js} +109 -11
- package/dist/{commands-Cc_nV8WI.js → commands-6Kzi92Np.js} +96 -73
- package/dist/{dashboardCommand-C-gKvwqh.js → dashboardCommand-Cq1PWvI1.js} +5 -5
- package/dist/{dataCommand-BgpBHnlB.js → dataCommand-DYzW8vkv.js} +3 -3
- package/dist/{dbCommand-sHedr-NJ.js → dbCommand-B4NWZtGL.js} +278 -237
- package/dist/dbCommand-CSFWs9ev.js +2 -0
- package/dist/{dev-CRHoCEiy.js → dev-CmuvUKRq.js} +2903 -2306
- package/dist/{dev--A3nsxA3.js → dev-cKUiZZsB.js} +1 -1
- package/dist/{doctorCommand-DtfJ3FA6.js → doctorCommand-DCiFVMtZ.js} +101 -69
- package/dist/doctorCommand-J3qu4E0Y.js +2 -0
- package/dist/{dormancyCommand-Drn7o0No.js → dormancyCommand-w1TrmgYP.js} +1 -1
- package/dist/{embeddingsCommand-Z-jO1fWN.js → embeddingsCommand-CMgPyRTr.js} +1 -1
- package/dist/{envCommand-D4gCrrTZ.js → envCommand-Cyynmcfa.js} +8 -8
- package/dist/{evolveCommand-CMROeKeA.js → evolveCommand-BwvQ8dVH.js} +2 -2
- package/dist/fileConventions-l-RIXbx8.js +36 -0
- package/dist/{fileTaxonomy-DvDUV9wq.js → fileTaxonomy-CbyMQYx_.js} +42 -42
- package/dist/fontPipeline-LxIHa1vo.js +2 -0
- package/dist/fontPipeline-Tsh8kZfA.js +152 -0
- package/dist/{frameworkTableAssembly-w-XnLa3q.js → frameworkTableAssembly-D7LJuALW.js} +5 -5
- package/dist/frameworkTableAssembly-IPD1pUnZ.js +2 -0
- package/dist/imagePipeline-B_GVJgm6.js +2 -0
- package/dist/imagePipeline-CBZmjT4i.js +127 -0
- package/dist/index.js +2 -2
- package/dist/{infoCommand-DXM868o_.js → infoCommand-DlYlUPqs.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/manifestBuild-C4-J1-m_.js +2 -0
- package/dist/{manifestBuild-CuU1VrSm.js → manifestBuild-Cqgsx2bM.js} +1 -1
- package/dist/{metaCommands-C6RFmF1r.js → metaCommands-x7RCi2AF.js} +9 -3
- package/dist/{migrate-D0F-eTlK.js → migrate-BK_Bbx-_.js} +2 -2
- package/dist/mobileCommand-D9O6iq3D.js +428 -0
- package/dist/mobileCommand-DAum7tsG.js +2 -0
- package/dist/{pageConvention-CzUiSbtU.js → pageConvention-CMpfDN6r.js} +1 -1
- package/dist/{privacyCommand-DGdopOI6.js → privacyCommand-BCa2OoZG.js} +1 -1
- package/dist/{probeCommand-C9gazU0H.js → probeCommand-_C0YU207.js} +83 -24
- package/dist/{projectScaffold-EzlErR4E.js → projectScaffold-CJfP-xbT.js} +1 -1
- package/dist/{projectScaffold-B4dmTlwT.js → projectScaffold-CSN0OzBV.js} +2 -2
- package/dist/renderModeScan-43yQ2opo.js +147 -0
- package/dist/{renderProfile-Ck32Fzxr.js → renderProfile-DvrhVJHa.js} +2 -2
- package/dist/{runtimeTrace-BPQyCmC5.js → runtimeTrace-CGWx1Q6l.js} +1 -1
- package/dist/{sdkgen-Se88ifTd.js → sdkgen-CDGHQUFj.js} +1 -1
- package/dist/serveCommand-BiPe8BJm.js +2 -0
- package/dist/{serveCommand-DkP3OT0W.js → serveCommand-Bje09q1v.js} +889 -825
- package/dist/serveEntry.js +1 -1
- package/dist/start-B0bnJgxI.js +3 -0
- package/dist/{start-jw89Xbqy.js → start-Clz-1BHB.js} +633 -455
- package/dist/startEntry.js +1 -1
- package/dist/{staticCommand-BwNEDlSU.js → staticCommand-ey0kYmOT.js} +1 -1
- package/dist/{subcommandNames-DpYs3DXr.js → subcommandNames-CDzfEtKV.js} +3 -3
- package/dist/{templates-BR-fb4SP.js → templates-BTWZkJJT.js} +41 -9
- package/dist/{test-f3amja6a.js → test-D_kW4KMj.js} +1 -1
- package/dist/updateCommand-CIoVDKnj.js +2 -0
- package/dist/{updateCommand-BMk2e4ky.js → updateCommand-CRJlAOaM.js} +139 -115
- package/dist/{webDev-BgWL9gKV.js → webDev-DSI9SOhs.js} +1598 -1028
- package/dist/webDev-DlvZO30c.js +2 -0
- package/dist/{webhooksCommand-CoIO3jbj.js → webhooksCommand-BvzXNHji.js} +1 -1
- package/dist/workspaceDeps-RKEkX92S.js +45 -0
- package/package.json +60 -19
- package/templates/AGENTS.core.md +2 -0
- package/templates/AGENTS.md +8 -4
- package/templates/agent-docs/_index.md +6 -4
- package/templates/agent-docs/_manifest.json +21 -5
- package/templates/agent-docs/ai.md +6 -6
- package/templates/agent-docs/authentication.md +73 -1
- package/templates/agent-docs/cli.md +6 -3
- package/templates/agent-docs/configuration.md +17 -0
- package/templates/agent-docs/data.md +522 -29
- package/templates/agent-docs/database/advancedqueries.md +8 -8
- package/templates/agent-docs/database/columntypes.md +2 -2
- package/templates/agent-docs/database/migrations.md +1 -1
- package/templates/agent-docs/database/querying.md +1 -1
- package/templates/agent-docs/database/schema.md +1 -1
- package/templates/agent-docs/database/seedsdialects.md +65 -3
- package/templates/agent-docs/database/transactions.md +3 -3
- package/templates/agent-docs/deployment.md +8 -0
- package/templates/agent-docs/internationalization.md +4 -2
- package/templates/agent-docs/introduction.md +32 -1
- package/templates/agent-docs/local-first-mobile.md +226 -30
- package/templates/agent-docs/observability.md +5 -1
- package/templates/agent-docs/plugins/atlassian.md +2 -2
- package/templates/agent-docs/plugins/audit.md +2 -2
- package/templates/agent-docs/plugins/auth.md +1 -1
- package/templates/agent-docs/plugins/billing.md +1 -1
- package/templates/agent-docs/plugins/cdc-out.md +8 -3
- package/templates/agent-docs/plugins/comments.md +164 -0
- package/templates/agent-docs/plugins/notifications.md +47 -4
- package/templates/agent-docs/plugins/presence.md +45 -3
- package/templates/agent-docs/plugins/prometheus.md +3 -1
- package/templates/agent-docs/plugins/queue.md +172 -0
- package/templates/agent-docs/plugins.md +17 -13
- package/templates/agent-docs/reference.md +35 -4
- package/templates/agent-docs/routing.md +585 -7
- package/templates/agent-docs/scheduling.md +1 -1
- package/templates/agent-docs/schema-driven-ui.md +226 -4
- package/templates/agent-docs/security.md +3 -3
- package/templates/agent-docs/templates/appshells.md +36 -4
- package/templates/agent-docs/whats-new.md +134 -66
- 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/README.md +3 -3
- package/templates/apps/api-collab/app.config.ts +1 -1
- package/templates/apps/api-collab/database/schema.ts +12 -8
- package/templates/apps/api-collab/mutations/documents.create.mutation.server.ts +1 -1
- package/templates/apps/api-collab/mutations/documents.setBody.mutation.server.ts +1 -1
- package/templates/apps/api-collab/mutations/documents.setBody.mutation.ts +1 -1
- package/templates/apps/api-collab/package.json +8 -8
- package/templates/apps/api-collab/template.json +1 -1
- package/templates/apps/api-collab/tests/documents.setBody.test.ts +10 -2
- 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 -10
- 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 +9 -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/README.md +43 -24
- package/templates/apps/frontend-collab/app.config.ts +3 -3
- package/templates/apps/frontend-collab/package.json +14 -10
- package/templates/apps/frontend-collab/src/locales/de.ts +1 -2
- package/templates/apps/frontend-collab/src/locales/en.ts +1 -2
- package/templates/apps/frontend-collab/src/pages/page.test.tsx +72 -76
- package/templates/apps/frontend-collab/src/pages/page.tsx +45 -22
- package/templates/apps/frontend-collab/template.json +2 -2
- 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 +9 -6
- 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/README.md +48 -0
- package/templates/apps/frontend-landing/app.config.ts +28 -0
- package/templates/apps/frontend-landing/package.json +7 -6
- package/templates/apps/frontend-landing/src/assets/hero.jpg +0 -0
- package/templates/apps/frontend-landing/src/fonts/Geist-Variable.woff2 +0 -0
- package/templates/apps/frontend-landing/src/fonts/LICENSE-Geist.txt +92 -0
- package/templates/apps/frontend-landing/src/globals.css +15 -0
- package/templates/apps/frontend-landing/src/globals.d.ts +17 -0
- package/templates/apps/frontend-landing/src/locales/de.ts +3 -2
- package/templates/apps/frontend-landing/src/locales/en.ts +5 -2
- package/templates/apps/frontend-landing/src/pages/page.test.tsx +79 -0
- package/templates/apps/frontend-landing/src/pages/page.tsx +26 -3
- package/templates/apps/frontend-landing/template.json +2 -2
- 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 +9 -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 +12 -11
- package/templates/apps/mobile-app/src/lib/deeplinks.ts +29 -17
- package/templates/apps/mobile-app/tests/deeplinks.test.ts +16 -0
- package/dist/agentsMd-SDDSkyl4.js +0 -2
- package/dist/apiBuild-BYBpL7Pz.js +0 -2
- package/dist/build-CPgcMQug.js +0 -793
- package/dist/codegen-CctkDO-1.js +0 -2
- package/dist/codegenCommand-DCdG2JN-.js +0 -137
- package/dist/dbCommand-DNb6yeOG.js +0 -2
- package/dist/doctorCommand-CqoWA2p5.js +0 -2
- package/dist/fileConventions-DOqD3lPS.js +0 -34
- package/dist/frameworkTableAssembly-C6ETawPR.js +0 -2
- package/dist/inspect-CuGDYES0.js +0 -2
- package/dist/manifestBuild-CPjhvM62.js +0 -2
- package/dist/renderModeScan-CcH2X1_D.js +0 -120
- package/dist/serveCommand-DLc-BznW.js +0 -2
- package/dist/start-DfL3fOiN.js +0 -3
- package/dist/updateCommand-5gFVfK5q.js +0 -2
- package/dist/webDev-CZbTsDcH.js +0 -2
- 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
|
@@ -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
|
|
@@ -1683,7 +1717,7 @@ created by every migration and diffed on every boot.
|
|
|
1683
1717
|
|
|
1684
1718
|
The other tempting option is to point `source:` at a name that resolves to
|
|
1685
1719
|
nothing. That is worse than the empty table: the [stale-`source` boot
|
|
1686
|
-
warning](#fan-out
|
|
1720
|
+
warning](#fan-out-how-many-subscribers-may-one-change-wake) is the only signal
|
|
1687
1721
|
for a subscription that has gone permanently quiet, and an exemption for a name
|
|
1688
1722
|
you invented disables it for the one case it was built for.
|
|
1689
1723
|
|
|
@@ -1813,7 +1847,7 @@ Two consequences worth knowing:
|
|
|
1813
1847
|
- **Not free per subscriber.** A publish wakes every subscriber of that channel
|
|
1814
1848
|
and re-runs each one's executor; the channel is one routing key, so
|
|
1815
1849
|
subscribers looking at different slices of the state are woken too. Publish on
|
|
1816
|
-
a real change, not on a timer — see [Fan-out](#fan-out
|
|
1850
|
+
a real change, not on a timer — see [Fan-out](#fan-out-how-many-subscribers-may-one-change-wake).
|
|
1817
1851
|
|
|
1818
1852
|
## Query Executor
|
|
1819
1853
|
|
|
@@ -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
|
|
@@ -2123,6 +2172,15 @@ subscriber on every delivery, on purpose — a role revoked or a share withdrawn
|
|
|
2123
2172
|
to end the stream on the very NEXT delivery, not whenever a cache happens to
|
|
2124
2173
|
expire — and each of them can be a database round-trip.
|
|
2125
2174
|
|
|
2175
|
+
**On every transport.** A live query can leave the server three ways — the
|
|
2176
|
+
WebSocket the browser client uses, an [SSE
|
|
2177
|
+
stream](/docs/data/rest-routes#live-updates-over-http-stream-sse), and a
|
|
2178
|
+
[gRPC](/docs/data/grpc) server-streaming rpc — and all three resolve per
|
|
2179
|
+
delivery through the same code: guards re-checked before each frame, row
|
|
2180
|
+
visibility re-derived from the unfiltered base descriptor for each frame, and a
|
|
2181
|
+
revoked scope ending the stream. The transport decides how the frame is
|
|
2182
|
+
framed, never what the subject may see.
|
|
2183
|
+
|
|
2126
2184
|
So deliveries run **concurrently, up to a bound**. The default is 8 in flight.
|
|
2127
2185
|
Measured with 50 subscribers behind a 5 ms guard: 517 ms to serve all of them
|
|
2128
2186
|
serially, 72 ms at 8 lanes.
|
|
@@ -2158,32 +2216,40 @@ once. Order BETWEEN subscribers was never guaranteed.
|
|
|
2158
2216
|
There is a number, it is not a constant, and which number you get depends on a
|
|
2159
2217
|
property of your **queries** rather than of your scale. Re-derive it on your own
|
|
2160
2218
|
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).
|
|
2219
|
+
below are the spread across three runs on a busy developer machine (12-core,
|
|
2220
|
+
macOS) at 10 matched writes per second, against a budget of 100 ms of
|
|
2221
|
+
event-loop time per second (10% of one core).
|
|
2164
2222
|
|
|
2165
2223
|
| Subscriber population | Marginal CPU per subscriber | Subscribers per node |
|
|
2166
2224
|
| --- | --- | --- |
|
|
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
|
-
|
|
2225
|
+
| **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 |
|
|
2226
|
+
| **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 |
|
|
2227
|
+
|
|
2228
|
+
The shared case fans out by design — one read and one diff (the memoisation
|
|
2229
|
+
above), then N emits — and it is the shape that sets the ceiling. The distinct
|
|
2230
|
+
case changed shape entirely with **matcher authority**: a subscription whose
|
|
2231
|
+
query is a plain predicate read is woken by the predicate index alone, so a
|
|
2232
|
+
write to *somebody else's* row is a non-event, not a wake. Per write it costs a
|
|
2233
|
+
bucket lookup plus one delivery, regardless of how many thousands of distinct
|
|
2234
|
+
subscribers are resident. Measured: **0 of 200** subscribers whose predicate
|
|
2235
|
+
matched nothing were woken by a write on their table — **a selective `where`
|
|
2236
|
+
buys real headroom** now.
|
|
2237
|
+
|
|
2238
|
+
What still wakes conservatively (any change on the table), and deliberately —
|
|
2239
|
+
this list is exhaustive:
|
|
2240
|
+
|
|
2241
|
+
- queries with an **eager `.with()` spec**, a setOp (`union`/…) or a CTE — the
|
|
2242
|
+
handler reads rows the root predicate does not describe;
|
|
2243
|
+
- **`dependsOn`** raw reads (the dispatcher can only re-run the descriptor,
|
|
2244
|
+
never the handler);
|
|
2245
|
+
- **computed** queries and **`reactivityChannel`** queries (their own
|
|
2246
|
+
recompute paths, unchanged);
|
|
2247
|
+
- **oversized change events** (`tombstone` / `unrecovered` — row images the
|
|
2248
|
+
matcher cannot see wake the whole table for that one event; `rehydrated`
|
|
2249
|
+
events are judged normally).
|
|
2250
|
+
|
|
2251
|
+
One thing that is easy to assume and is not true:
|
|
2252
|
+
|
|
2187
2253
|
- **It is not 512.** That constant bounds `onChange` LISTENERS — one per declared
|
|
2188
2254
|
subscription file, reaction or aggregate, bound once at boot. Every client
|
|
2189
2255
|
subscription in a process shares the dispatcher's single listener, so ten
|
|
@@ -2195,6 +2261,17 @@ mysql/mariadb (binlog), so a second node needs no extra wiring — the cost bein
|
|
|
2195
2261
|
budgeted here is the matcher and re-query CPU each node spends on ITS OWN
|
|
2196
2262
|
clients.
|
|
2197
2263
|
|
|
2264
|
+
### Deltas are per-query — two queries can briefly diverge
|
|
2265
|
+
|
|
2266
|
+
Every subscription has its own revision line and its own delivery moment. After
|
|
2267
|
+
one write that affects two queries you hold open, the deltas arrive as two
|
|
2268
|
+
independent pushes — usually microseconds apart, but there is no cross-query
|
|
2269
|
+
transaction on the wire, and a render between the two pushes can see query A
|
|
2270
|
+
after the write and query B before it. Within ONE query you never see a partial
|
|
2271
|
+
write (a delta is computed from a committed row set); across queries, design for
|
|
2272
|
+
eventual agreement rather than instantaneous consistency — derive values that
|
|
2273
|
+
must agree atomically inside one query instead of joining two on the client.
|
|
2274
|
+
|
|
2198
2275
|
## Raw WebSocket gateways — `defineWebSocket`
|
|
2199
2276
|
|
|
2200
2277
|
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:
|
|
@@ -3137,7 +3214,9 @@ es.addEventListener('delta', (e) => applyDelta(JSON.parse(e.data)))
|
|
|
3137
3214
|
|
|
3138
3215
|
Each event's `_tag` becomes the SSE `event:` name, so a client listens per kind instead of switching on a payload field. The framing handles the details that bite otherwise: embedded newlines are split across `data:` lines (a raw `\n` would truncate the event), a `retry:` hint is sent, and a keep-alive comment goes out every 15s so proxies don't drop an idle stream.
|
|
3139
3216
|
|
|
3140
|
-
Same guarantees as the WebSocket path, because it is the same code: the declarative `guards:`, the row filter and tenant scoping all run before anything is emitted, and the client's disconnect tears the subscription down (including a disconnect *during* setup). A guard denial arrives as one `error` event rather than an HTTP status — by then the response headers are already sent.
|
|
3217
|
+
Same guarantees as the WebSocket path, because it is the same code: the declarative `guards:`, the [row filter](/docs/authentication/row-level-security) and tenant scoping all run before anything is emitted, and the client's disconnect tears the subscription down (including a disconnect *during* setup). A guard denial arrives as one `error` event rather than an HTTP status — by then the response headers are already sent.
|
|
3218
|
+
|
|
3219
|
+
And they run before **every** event, not only before the first one: the guards are re-checked and the subject's row visibility is re-resolved from the unfiltered base descriptor per delivery, so a scope revoked while the `EventSource` is open ends the stream on the next event, and a membership that ends stops carrying those rows in the next `delta`. An open SSE stream is not a cheaper read path than a fresh `GET`.
|
|
3141
3220
|
|
|
3142
3221
|
`stream: 'sse'` on a mutation or action is ignored: there is nothing to subscribe to.
|
|
3143
3222
|
|
|
@@ -4854,7 +4933,7 @@ A streaming query (what `useSubscription` opens) emits a sequence of **subscript
|
|
|
4854
4933
|
{ _tag: 'error', error: { _tag?: string, message: string, ...fields }, revision?: number }
|
|
4855
4934
|
```
|
|
4856
4935
|
|
|
4857
|
-
- **`revision`** — monotonically increasing; lets the client order events
|
|
4936
|
+
- **`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.
|
|
4858
4937
|
- **`emittedAt`** — epoch milliseconds, present on `delta` only.
|
|
4859
4938
|
- **`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).
|
|
4860
4939
|
- **`patch`** (delta) — an id-keyed RFC-6902-style patch against the row set the client last held.
|
|
@@ -4873,6 +4952,82 @@ propagate to the shared connection and stall every *other* subscription on it
|
|
|
4873
4952
|
client surfaces it as `useSubscription(...).error` for that one query key;
|
|
4874
4953
|
siblings keep delivering their snapshots and deltas.
|
|
4875
4954
|
|
|
4955
|
+
### Slow consumers — coalescing and `SubscriptionOverrun`
|
|
4956
|
+
|
|
4957
|
+
A consumer that stops reading (a backgrounded tab, a saturated link) does not
|
|
4958
|
+
grow the server without bound. While its socket is blocked, updates
|
|
4959
|
+
**coalesce**: the server keeps only the newest state per subscription and, when
|
|
4960
|
+
the socket accepts again, sends ONE event — a patch against the last state the
|
|
4961
|
+
consumer was actually handed (`revision` jumps accordingly, see above). Memory
|
|
4962
|
+
per blocked subscription is bounded by construction: one pending state,
|
|
4963
|
+
regardless of how far behind the consumer is.
|
|
4964
|
+
|
|
4965
|
+
A consumer that stays more than `reactive.socket.maxBufferedBytes` (default
|
|
4966
|
+
1 MiB, env `VOLTRO_REACTIVE_MAX_BUFFERED_BYTES`) behind for
|
|
4967
|
+
`reactive.socket.overrunAfterMs` (default 10 s) is closed **loudly**: it
|
|
4968
|
+
receives an `error` event with `error._tag: 'SubscriptionOverrun'` (carrying
|
|
4969
|
+
`bufferedBytes` + `maxBufferedBytes`) and the stream ends — never a silent
|
|
4970
|
+
drop. The client re-subscribes and starts from a fresh snapshot.
|
|
4971
|
+
|
|
4972
|
+
Oversized events are telemetry, not a cap: an event over
|
|
4973
|
+
`reactive.socket.oversizedEventBytes` (default 256 KiB) is delivered normally
|
|
4974
|
+
and counted (`voltro_subscription_oversized_total`) with a WARN naming the
|
|
4975
|
+
query — alongside `voltro_subscription_buffered_bytes`,
|
|
4976
|
+
`voltro_subscription_coalesced_total` and `voltro_subscription_overrun_total`
|
|
4977
|
+
in the Prometheus exporter and the inspect Metrics panel.
|
|
4978
|
+
|
|
4979
|
+
### Reconnect — delta-resume
|
|
4980
|
+
|
|
4981
|
+
A client that reconnects inside the **resume window** does not have to pay for
|
|
4982
|
+
a full snapshot: it sends the last `revision` it materialised in the per-call
|
|
4983
|
+
`voltro-resume-from` request header (the same header surface the idempotency
|
|
4984
|
+
key rides), and the server — which kept the subscription alive server-side for
|
|
4985
|
+
the window after the disconnect — **replays only the deltas that were missed**
|
|
4986
|
+
and re-attaches the stream on the SAME revision line.
|
|
4987
|
+
|
|
4988
|
+
The signal is the first event's tag, not a schema field:
|
|
4989
|
+
|
|
4990
|
+
- **first event `delta`** — the resume was honoured; apply the patch onto the
|
|
4991
|
+
rows you already hold and continue.
|
|
4992
|
+
- **first event `snapshot`** — the resume was declined; reset to the snapshot.
|
|
4993
|
+
This is the answer whenever anything is in doubt, because a wrong snapshot
|
|
4994
|
+
costs bytes while a wrong replay would leak rows.
|
|
4995
|
+
|
|
4996
|
+
`@voltro/client` does both automatically — the reconnect-seeded cache keeps its
|
|
4997
|
+
rows and revision, presents the header, and treats a snapshot-first stream as
|
|
4998
|
+
the reset it already knows how to do. Replayed deltas may **coalesce** exactly
|
|
4999
|
+
as slow-consumer updates do (revisions jump; patch continuity holds).
|
|
5000
|
+
|
|
5001
|
+
A resume is declined — always with a fresh snapshot — when:
|
|
5002
|
+
|
|
5003
|
+
- the window expired (`reactive.resume.windowMs`, default 60 s, env
|
|
5004
|
+
`VOLTRO_REACTIVE_RESUME_WINDOW_MS`), or more deltas were missed than the ring
|
|
5005
|
+
retains (`reactive.resume.maxDeltas`, default 256, env
|
|
5006
|
+
`VOLTRO_REACTIVE_RESUME_MAX_DELTAS`);
|
|
5007
|
+
- the query's `guards:` were revoked while the client was away — the
|
|
5008
|
+
per-delivery re-check keeps running on the detached subscription, and a
|
|
5009
|
+
revocation drops the retained history outright;
|
|
5010
|
+
- the resuming caller is a different subject or tenant (a login, logout or
|
|
5011
|
+
tenant switch between disconnect and resume) — the retained history is keyed
|
|
5012
|
+
by subject AND tenant, so a changed identity simply never finds it;
|
|
5013
|
+
- the query is a **computed** query — it re-runs a handler, so there is no
|
|
5014
|
+
delta chain to replay;
|
|
5015
|
+
- a registered row filter (`setRowFilter`) can narrow THIS subscription's
|
|
5016
|
+
source table, or the query declares an eager `.with(...)`. A row-filtered
|
|
5017
|
+
subscription's visible row set exists only per delivery, so replaying it
|
|
5018
|
+
could serve rows the subject has since lost.
|
|
5019
|
+
|
|
5020
|
+
**This is per table, not per app.** A filter that declares
|
|
5021
|
+
`tables: [...]` (see [row-level security](/docs/authentication/row-level-security))
|
|
5022
|
+
keeps delta-resume on every subscription whose source is not in that set —
|
|
5023
|
+
the common case, since most filters narrow a handful of tables. Without the
|
|
5024
|
+
declaration the framework cannot know which tables the predicate may reach
|
|
5025
|
+
and excludes them all, which is what a deployment measured as one filter over
|
|
5026
|
+
4 tables costing the feature on all 173 of their queries. Eager loads are
|
|
5027
|
+
excluded wholesale because a relation resolves below the seam that narrows.
|
|
5028
|
+
They reconnect with a fresh
|
|
5029
|
+
snapshot, exactly as before.
|
|
5030
|
+
|
|
4876
5031
|
**Author a live-subscribed getter to return, not throw.** A subscription is a
|
|
4877
5032
|
long-lived stream, so a getter that throws on every re-evaluation is a broken
|
|
4878
5033
|
stream. For an expected-absent row, make the query `output: Schema.NullOr(...)`
|
|
@@ -5205,6 +5360,203 @@ try {
|
|
|
5205
5360
|
|
|
5206
5361
|
|
|
5207
5362
|
|
|
5363
|
+
---
|
|
5364
|
+
|
|
5365
|
+
<!-- source: en/data/content-collections.md -->
|
|
5366
|
+
## Content collections
|
|
5367
|
+
|
|
5368
|
+
_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._
|
|
5369
|
+
|
|
5370
|
+
A content collection turns a folder of markdown files into typed, rendered
|
|
5371
|
+
content: you declare the frontmatter schema in code, and `getCollection()` /
|
|
5372
|
+
`getEntry()` hand you decoded data plus server-rendered HTML with
|
|
5373
|
+
syntax-highlighted code fences. The markdown engine lives in the framework —
|
|
5374
|
+
**do not install your own `marked` / `remark` / `shiki`**; a second pipeline
|
|
5375
|
+
drifts from the one your artifacts, feeds and templates already use.
|
|
5376
|
+
|
|
5377
|
+
## A blog in 20 lines
|
|
5378
|
+
|
|
5379
|
+
One collection file, one markdown file, one page:
|
|
5380
|
+
|
|
5381
|
+
```ts
|
|
5382
|
+
// src/collections/posts.collection.ts
|
|
5383
|
+
import { Schema } from 'effect'
|
|
5384
|
+
import { defineCollection } from '@voltro/content'
|
|
5385
|
+
|
|
5386
|
+
export const posts = defineCollection({
|
|
5387
|
+
name: 'posts',
|
|
5388
|
+
directory: 'content/posts',
|
|
5389
|
+
schema: Schema.Struct({ title: Schema.String, date: Schema.String }),
|
|
5390
|
+
})
|
|
5391
|
+
export type Post = Schema.Schema.Type<typeof posts.schema>
|
|
5392
|
+
```
|
|
5393
|
+
|
|
5394
|
+
```ts
|
|
5395
|
+
// src/pages/blog/[slug]/page.tsx
|
|
5396
|
+
import { getCollection, getEntry, type ContentEntry } from '@voltro/content'
|
|
5397
|
+
import { useLoaderData } from '@voltro/web'
|
|
5398
|
+
import { posts, type Post } from '../../../collections/posts.collection'
|
|
5399
|
+
|
|
5400
|
+
export const renderMode = 'static' as const
|
|
5401
|
+
export const getStaticPaths = async () =>
|
|
5402
|
+
(await getCollection(posts.name)).map((e) => ({ params: { slug: e.slug } }))
|
|
5403
|
+
export const loader = async ({ params }: { params: { slug: string } }) =>
|
|
5404
|
+
await getEntry<Post>(posts.name, params.slug)
|
|
5405
|
+
|
|
5406
|
+
export default function Post() {
|
|
5407
|
+
const post = useLoaderData<ContentEntry<Post> | null>()
|
|
5408
|
+
if (!post) return <main>Not found</main>
|
|
5409
|
+
return <article dangerouslySetInnerHTML={{ __html: post.html ?? '' }} />
|
|
5410
|
+
}
|
|
5411
|
+
```
|
|
5412
|
+
|
|
5413
|
+
Drop `content/posts/hello.md` with `title:` + `date:` frontmatter and the
|
|
5414
|
+
build pre-renders `/blog/hello` — highlighted code fences included.
|
|
5415
|
+
|
|
5416
|
+
## How it stays out of your bundle
|
|
5417
|
+
|
|
5418
|
+
The loader is **isomorphic**. At build/SSR time it reads the filesystem and
|
|
5419
|
+
renders markdown (shiki runs on the server only). The build also emits JSON
|
|
5420
|
+
artifacts under `dist/assets/content/<name>[.<locale>]/…` — an index (slugs +
|
|
5421
|
+
frontmatter, no bodies) and one file per entry (rendered HTML + headings). On
|
|
5422
|
+
an SPA navigation, the CLIENT branch of `getCollection`/`getEntry` fetches
|
|
5423
|
+
those artifacts. The result: no markdown engine, no highlighter, and no
|
|
5424
|
+
content bodies in your JavaScript bundle. `voltro dev` serves the same
|
|
5425
|
+
artifact shapes on demand and invalidates them when a `content/**` file
|
|
5426
|
+
changes.
|
|
5427
|
+
|
|
5428
|
+
## Frontmatter is a schema, and violations fail the build
|
|
5429
|
+
|
|
5430
|
+
The `schema` is an `effect/Schema` struct decoded per file. A missing or
|
|
5431
|
+
mistyped field is a **build error naming the file** — not a page that renders
|
|
5432
|
+
`undefined`. Numbers in frontmatter arrive as strings; use
|
|
5433
|
+
`Schema.Union(Schema.NumberFromString, Schema.Number)` for numeric fields.
|
|
5434
|
+
`getCollection<A>` returns entries whose `data` is the schema's inferred
|
|
5435
|
+
type — no casts.
|
|
5436
|
+
|
|
5437
|
+
## Slugs come from the path
|
|
5438
|
+
|
|
5439
|
+
`content/posts/hello.md` → `hello`; nested folders stay in the slug
|
|
5440
|
+
(`database/joins.md` → `database/joins`). Two files resolving to one slug
|
|
5441
|
+
(a rename that left both) is a build error.
|
|
5442
|
+
|
|
5443
|
+
## Locale trees + fallback
|
|
5444
|
+
|
|
5445
|
+
A collection with `i18n` treats the first path segment as the locale:
|
|
5446
|
+
|
|
5447
|
+
```ts
|
|
5448
|
+
export const docs = defineCollection({
|
|
5449
|
+
name: 'docs',
|
|
5450
|
+
directory: 'content/docs',
|
|
5451
|
+
schema: Schema.Struct({ title: Schema.String }),
|
|
5452
|
+
i18n: { locales: ['en', 'de'], defaultLocale: 'en', missing: 'fallback' },
|
|
5453
|
+
})
|
|
5454
|
+
```
|
|
5455
|
+
|
|
5456
|
+
`getCollection('docs', { locale: 'de' })` reads the `de/` tree. A slug missing
|
|
5457
|
+
in the requested locale is served from the default tree with
|
|
5458
|
+
`fallback: true` on the entry (render an "untranslated" banner off it) — or
|
|
5459
|
+
omitted entirely with `missing: 'missing'`. Incomplete translations are the
|
|
5460
|
+
normal case; decide the policy per collection instead of improvising per page.
|
|
5461
|
+
|
|
5462
|
+
## Headings as data
|
|
5463
|
+
|
|
5464
|
+
Every rendered entry carries `headings: [{ depth, slug, text }]` — the TOC
|
|
5465
|
+
input. The slugs are the SAME ids stamped on the rendered `<h2 id="…">`
|
|
5466
|
+
elements, so sidebar anchors never drift from the body. For a TOC without a
|
|
5467
|
+
render pass, `extractHeadings(markdown)` (from `@voltro/content/markdown`)
|
|
5468
|
+
computes the same data synchronously.
|
|
5469
|
+
|
|
5470
|
+
## Data collections
|
|
5471
|
+
|
|
5472
|
+
`kind: 'data'` reads `.json` files instead of markdown — the `authors.json`
|
|
5473
|
+
case. Each file decodes whole against the schema; there is no render path:
|
|
5474
|
+
|
|
5475
|
+
```ts
|
|
5476
|
+
export const authors = defineCollection({
|
|
5477
|
+
name: 'authors',
|
|
5478
|
+
directory: 'content/authors',
|
|
5479
|
+
kind: 'data',
|
|
5480
|
+
schema: Schema.Struct({ name: Schema.String, url: Schema.String }),
|
|
5481
|
+
})
|
|
5482
|
+
```
|
|
5483
|
+
|
|
5484
|
+
## References between collections
|
|
5485
|
+
|
|
5486
|
+
`reference('<collection>')` declares a frontmatter field that names an entry
|
|
5487
|
+
of another collection by slug:
|
|
5488
|
+
|
|
5489
|
+
```ts
|
|
5490
|
+
schema: Schema.Struct({
|
|
5491
|
+
title: Schema.String,
|
|
5492
|
+
author: reference('authors'),
|
|
5493
|
+
})
|
|
5494
|
+
```
|
|
5495
|
+
|
|
5496
|
+
The build validates every reference — a dangling one (`author: nobody`) fails
|
|
5497
|
+
the build naming the collection, entry, field and target. Resolve it with
|
|
5498
|
+
`getEntry('authors', entry.data.author)`.
|
|
5499
|
+
|
|
5500
|
+
## RSS feeds from a collection
|
|
5501
|
+
|
|
5502
|
+
Declare feeds in `app.config.ts`; the build writes them next to
|
|
5503
|
+
`sitemap.xml`, and `voltro dev` serves the same XML live:
|
|
5504
|
+
|
|
5505
|
+
```ts
|
|
5506
|
+
export default {
|
|
5507
|
+
// …
|
|
5508
|
+
seo: { siteUrl: 'https://example.com' },
|
|
5509
|
+
feeds: [{
|
|
5510
|
+
path: '/rss.xml',
|
|
5511
|
+
collection: 'posts',
|
|
5512
|
+
title: 'My blog',
|
|
5513
|
+
item: (e) => e.data.draft === 'true' ? null : ({
|
|
5514
|
+
title: e.data.title, link: `/blog/${e.slug}`, date: e.data.date,
|
|
5515
|
+
}),
|
|
5516
|
+
}],
|
|
5517
|
+
}
|
|
5518
|
+
```
|
|
5519
|
+
|
|
5520
|
+
Returning `null` from `item` excludes an entry — that is the **draft filter**:
|
|
5521
|
+
keep a `draft: true` field in your schema and filter it in `item` and in your
|
|
5522
|
+
page loaders (the changelog template's `visibleReleases` helper is the worked
|
|
5523
|
+
example, including future-dated staging).
|
|
5524
|
+
|
|
5525
|
+
## No MDX — islands carry the interactivity
|
|
5526
|
+
|
|
5527
|
+
Collection bodies are **markdown, not MDX**: JSX, `import`s and
|
|
5528
|
+
`{expressions}` in a body are not executed. When a content page needs a live
|
|
5529
|
+
widget, the surrounding PAGE provides it via the islands mechanism — the
|
|
5530
|
+
content stays inert HTML and the widget hydrates alone:
|
|
5531
|
+
|
|
5532
|
+
```tsx
|
|
5533
|
+
// src/pages/blog/[slug]/page.tsx
|
|
5534
|
+
export const interactive = 'islands' as const
|
|
5535
|
+
|
|
5536
|
+
export default function Post() {
|
|
5537
|
+
const post = useLoaderData<ContentEntry<Post>>()
|
|
5538
|
+
return (
|
|
5539
|
+
<main>
|
|
5540
|
+
<ReadingProgress /> {/* an island() component — the ONLY hydrated JS */}
|
|
5541
|
+
<article dangerouslySetInnerHTML={{ __html: post.html ?? '' }} />
|
|
5542
|
+
</main>
|
|
5543
|
+
)
|
|
5544
|
+
}
|
|
5545
|
+
```
|
|
5546
|
+
|
|
5547
|
+
## Limits + neighbors
|
|
5548
|
+
|
|
5549
|
+
- **Images referenced from markdown bodies** are copied as-is (no transform):
|
|
5550
|
+
the [image pipeline](/docs/routing/assets) covers `?image` imports from
|
|
5551
|
+
code. Put content images under `public/` and reference them absolutely.
|
|
5552
|
+
- **Files are DEVELOPER content** — versioned with the code, deployed by the
|
|
5553
|
+
build. Editorial content with drafts, roles and a save/publish pipeline is
|
|
5554
|
+
[`@voltro/cms`](/docs/data/cms). Astro's remote "Content Layer loaders"
|
|
5555
|
+
map to `@voltro/cms` here: remote/editorial sources go through the CMS,
|
|
5556
|
+
not through file collections.
|
|
5557
|
+
|
|
5558
|
+
|
|
5559
|
+
|
|
5208
5560
|
---
|
|
5209
5561
|
|
|
5210
5562
|
<!-- source: en/data/cms.md -->
|
|
@@ -5745,3 +6097,144 @@ app's public origin comes from `VOLTRO_PUBLIC_URL`.
|
|
|
5745
6097
|
a secret, not a connection.
|
|
5746
6098
|
- **`redirectTo` is a same-origin path only.** An absolute URL is rejected —
|
|
5747
6099
|
otherwise every app declaring a connection would ship an open redirector.
|
|
6100
|
+
|
|
6101
|
+
|
|
6102
|
+
|
|
6103
|
+
---
|
|
6104
|
+
|
|
6105
|
+
<!-- source: en/data/grpc.md -->
|
|
6106
|
+
## gRPC surface
|
|
6107
|
+
|
|
6108
|
+
_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._
|
|
6109
|
+
|
|
6110
|
+
The gRPC surface serves a NAMED list of your procedures to external gRPC
|
|
6111
|
+
clients — the polyglot-microservice door. The `.proto` is generated from the
|
|
6112
|
+
same `effect/Schema` your procedures already declare, so there is no second
|
|
6113
|
+
contract to maintain; the wire semantics are the framework's own: guards,
|
|
6114
|
+
plugin interceptors and typed errors behave **identically** to the rpc
|
|
6115
|
+
socket, because a gRPC call runs the *same bound runner* every other surface
|
|
6116
|
+
uses (the e2e proves interceptor order side by side).
|
|
6117
|
+
|
|
6118
|
+
```ts
|
|
6119
|
+
// app.config.ts
|
|
6120
|
+
export default {
|
|
6121
|
+
type: 'api' as const,
|
|
6122
|
+
name: 'api',
|
|
6123
|
+
grpc: {
|
|
6124
|
+
port: 50051,
|
|
6125
|
+
procedures: ['orders.get', 'orders.list', 'orders.create'],
|
|
6126
|
+
// tls: { certPath, keyPath, caPath? } — plaintext without it (dev / mesh).
|
|
6127
|
+
// drainMs: 5000, // shutdown drain budget — see below
|
|
6128
|
+
// maxMessageBytes, maxMetadataBytes — grpc-js frame limits
|
|
6129
|
+
},
|
|
6130
|
+
}
|
|
6131
|
+
```
|
|
6132
|
+
|
|
6133
|
+
NOTHING is exposed by default — every tag is named. Booting writes
|
|
6134
|
+
`.framework/grpc.proto` (hand it to any proto codegen) and mounts
|
|
6135
|
+
`grpc.health.v1` health checking plus server reflection (`grpcurl … list`
|
|
6136
|
+
works out of the box). The gRPC packages ship as script-free optional
|
|
6137
|
+
dependencies of `@voltro/cli`; a configured `grpc:` block with them missing
|
|
6138
|
+
refuses the boot by name.
|
|
6139
|
+
|
|
6140
|
+
## Shutdown drains, then forces — `drainMs`
|
|
6141
|
+
|
|
6142
|
+
On SIGTERM the surface flips its health status to `NOT_SERVING` (so a load
|
|
6143
|
+
balancer stops sending it work) and gives open calls **`drainMs`** to finish
|
|
6144
|
+
before force-closing them. Default `5000`; `0` forces immediately; the env
|
|
6145
|
+
override is `VOLTRO_GRPC_DRAIN_MS`.
|
|
6146
|
+
|
|
6147
|
+
Pick it from two numbers only you have. Keep it **below** your orchestrator's
|
|
6148
|
+
termination grace (`terminationGracePeriodSeconds`, `docker stop -t`) — past
|
|
6149
|
+
that point SIGKILL arrives and the drain never completes, so a larger budget
|
|
6150
|
+
buys nothing. Keep it **above** your longest legitimately in-flight unary
|
|
6151
|
+
call, or every rolling deploy force-closes work that would have finished. When
|
|
6152
|
+
the budget is exceeded the surface says so in a warning naming the budget,
|
|
6153
|
+
rather than leaking the port into the next boot.
|
|
6154
|
+
|
|
6155
|
+
## Field numbers are managed — `grpc.manifest.json`
|
|
6156
|
+
|
|
6157
|
+
Field numbers are the proto wire identity, so they may never depend on
|
|
6158
|
+
property order. They come from a checked-in manifest in your app root:
|
|
6159
|
+
|
|
6160
|
+
- a **new** field gets the next never-used number — an **inserted** field
|
|
6161
|
+
never renumbers its neighbours;
|
|
6162
|
+
- a **deleted** field's number becomes `reserved` (emitted into the proto,
|
|
6163
|
+
so `protoc` refuses a colliding hand-edit too);
|
|
6164
|
+
- **reusing** a reserved number is a codegen error, never a warning — an old
|
|
6165
|
+
client would silently read the wrong field.
|
|
6166
|
+
|
|
6167
|
+
Commit the manifest with the schema change that moved it: the diff review IS
|
|
6168
|
+
the wire-contract review.
|
|
6169
|
+
|
|
6170
|
+
## The mapping table
|
|
6171
|
+
|
|
6172
|
+
| Schema | proto3 |
|
|
6173
|
+
|---|---|
|
|
6174
|
+
| `Schema.String` / `Number` / `Boolean` | `string` / `double` / `bool` |
|
|
6175
|
+
| integer schemas | `int64` |
|
|
6176
|
+
| `Schema.Array(T)` | `repeated T` |
|
|
6177
|
+
| nested `Schema.Struct` | nested message |
|
|
6178
|
+
| `Schema.Record({ key: String, value: T })` | `map<string, T>` |
|
|
6179
|
+
| `Schema.optional(T)` **and** `Schema.NullOr(T)` | `optional T` — absent and `null` are ONE wire state (proto3 presence) |
|
|
6180
|
+
| string-literal unions | `string` (validated server-side on decode) |
|
|
6181
|
+
| unions of shapes, tuples, recursion, free-form objects | a LOUD per-procedure codegen error naming the schema path |
|
|
6182
|
+
|
|
6183
|
+
Requests are decoded against the descriptor's input schema before the
|
|
6184
|
+
executor runs — proto3 suppresses default values on the wire, and without
|
|
6185
|
+
that decode an empty string would arrive as an absent field and fail
|
|
6186
|
+
somewhere much later.
|
|
6187
|
+
|
|
6188
|
+
## Status codes — complete against the wire error union
|
|
6189
|
+
|
|
6190
|
+
| outcome | gRPC status | trailers |
|
|
6191
|
+
|---|---|---|
|
|
6192
|
+
| no credential on a guarded call | `UNAUTHENTICATED` | |
|
|
6193
|
+
| presented-and-rejected credential | `UNAUTHENTICATED` | |
|
|
6194
|
+
| authenticated, missing scope (`ScopeError`) | `PERMISSION_DENIED` | `voltro-error: scope` |
|
|
6195
|
+
| input fails the schema | `INVALID_ARGUMENT` | `voltro-error: input` |
|
|
6196
|
+
| `BusinessRuleViolation` | `FAILED_PRECONDITION` | `voltro-error: rule` |
|
|
6197
|
+
| `requiresApproval` pending — a FLOW OUTCOME, not a failure | `FAILED_PRECONDITION` | `voltro-pending: approval` + `voltro-approval-id` |
|
|
6198
|
+
| your declared typed error | `FAILED_PRECONDITION` | `voltro-error: <tag>` |
|
|
6199
|
+
| deadline exceeded | `DEADLINE_EXCEEDED` | |
|
|
6200
|
+
| anything else | `INTERNAL` | |
|
|
6201
|
+
|
|
6202
|
+
**Deadlines interrupt the work.** A client deadline (`grpc-timeout`) aborts
|
|
6203
|
+
the executor's fiber through the request signal — the server stops doing the
|
|
6204
|
+
work, it does not merely suppress the response (the e2e pins this with a
|
|
6205
|
+
sleeping action whose post-sleep write never lands).
|
|
6206
|
+
|
|
6207
|
+
## Streaming queries
|
|
6208
|
+
|
|
6209
|
+
A `query` becomes a **server-streaming** rpc: each frame is the CURRENT full
|
|
6210
|
+
snapshot, re-pushed live when the query's `source:` changes — subscribe,
|
|
6211
|
+
mutate from anywhere, and the open stream receives the new frame with no
|
|
6212
|
+
re-request.
|
|
6213
|
+
|
|
6214
|
+
**Authorization is re-derived per FRAME, not frozen at open.** Before every
|
|
6215
|
+
delivery the framework re-runs the query's `guards:` and re-resolves the
|
|
6216
|
+
subject's [row-level visibility](/docs/authentication/row-level-security)
|
|
6217
|
+
from the unfiltered base descriptor. A revoked scope ends the stream with the
|
|
6218
|
+
mapped status; a membership that ends mid-stream stops carrying those rows in
|
|
6219
|
+
the next frame, with the stream itself untouched. This is the same code the
|
|
6220
|
+
WebSocket and SSE transports run — an open gRPC stream is not a cheaper read
|
|
6221
|
+
path than a fresh call.
|
|
6222
|
+
|
|
6223
|
+
Slow consumers are handled through grpc-js write backpressure — frames
|
|
6224
|
+
coalesce to the latest snapshot rather than buffering unboundedly.
|
|
6225
|
+
|
|
6226
|
+
## Declared limits (v1)
|
|
6227
|
+
|
|
6228
|
+
- **No client- or bidi-streaming**, and `*.stream.ts` procedures are NOT
|
|
6229
|
+
exposable — the fourth kind is a one-shot element stream with its own
|
|
6230
|
+
semantics; put it behind a query or keep it on the socket.
|
|
6231
|
+
- **No gRPC-Web** — a browser talks the framework's own subscription
|
|
6232
|
+
protocol (that is the better browser transport in every dimension we care
|
|
6233
|
+
about); gRPC is for backends.
|
|
6234
|
+
- **No Connect protocol** — connectrpc is NOT gRPC-Web; a connect consumer's
|
|
6235
|
+
alternative today is the [REST/OpenAPI projection](/docs/data/rest-routes).
|
|
6236
|
+
- App realtime stays on the framework's subscription protocol, and gateways
|
|
6237
|
+
exist for the other case: a FOREIGN protocol that needs a socket the
|
|
6238
|
+
framework does not speak — the same boundary
|
|
6239
|
+
[data/subscriptions](/docs/data/subscriptions) draws for raw WebSocket
|
|
6240
|
+
gateways, one sentence, two doors.
|