@voltro/cli 0.33.0 → 0.34.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 +1801 -0
- package/bin/nodeEnvironment.d.mts +30 -0
- package/bin/nodeEnvironment.mjs +158 -0
- package/bin/voltro.mjs +69 -5
- package/dist/addCommand-BNeoeSxe.js +124 -0
- package/dist/addCommand-aXSQveak.js +2 -0
- package/dist/agentsMd-BTchIZku.js +2 -0
- package/dist/agentsMd-mhQMF1bx.js +254 -0
- package/dist/apiBuild-BICVGnEC.js +2 -0
- package/dist/{apiBuild-h9VHtnlw.js → apiBuild-DZF_f0_I.js} +46 -46
- package/dist/appPort-B_HpJ_ck.js +48 -0
- package/dist/baselineCommand-C2ClWZN3.js +2 -0
- package/dist/baselineCommand-DIttzO8A.js +227 -0
- package/dist/bin.js +71 -28
- package/dist/build-CE7Ca9gm.js +711 -0
- package/dist/cacheCommand-DA4OH9xt.js +42 -0
- package/dist/capabilitiesCommand-nq_pz5xd.js +123 -0
- package/dist/checkCommand-Dv8r9tGO.js +231 -0
- package/dist/checkCommand-YZDmkAGd.js +2 -0
- package/dist/{cliArgs-qdZSElM3.js → cliArgs-D4p8n7EE.js} +12 -1
- package/dist/cliError-BmdYnghb.js +10 -0
- package/dist/cliOutput-D1tSBoRM.js +15 -0
- package/dist/{cliRuntime-Oh517vCV.js → cliRuntime-Dh7UDinH.js} +20 -20
- package/dist/cloudClient-DWL-Hw_T.js +67 -0
- package/dist/cloudCmd-Cvv5HGaZ.js +364 -0
- package/dist/clusterCmd-CNXKlcvD.js +54 -0
- package/dist/codegen-CYM3Zqrf.js +605 -0
- package/dist/codegen-ChBi_hVa.js +2 -0
- package/dist/codegenCommand-DC6w2tNZ.js +30 -0
- package/dist/codemodRunner-DRRqXR74.js +5243 -0
- package/dist/commandRunner-BLAEFLjp.js +47 -0
- package/dist/commands-gutsz-Ac.js +808 -0
- package/dist/connectionConfig-UFlIEiys.js +66 -0
- package/dist/dashboardCommand-3YG8p-UA.js +25 -0
- package/dist/dataCommand-qL0r7fPO.js +535 -0
- package/dist/dataProfile-dW-PsfLB.js +15 -0
- package/dist/{dbCommand-DTLKAfbA.js → dbCommand-B6X0FZbq.js} +551 -401
- package/dist/dbCommand-CpdKLeQq.js +2 -0
- package/dist/{dev-C_P8FLSx.js → dev-CerMd0mW.js} +3122 -3129
- package/dist/dev-CoG-ZPx8.js +3 -0
- package/dist/devActivity-Dx_3nnGv.js +100 -0
- package/dist/devActivity.js +1 -1
- package/dist/dialectDriver-CgXnDfec.js +39 -0
- package/dist/discover-C9XKJDco.js +25 -0
- package/dist/doctorCommand-BvqGBwNG.js +2 -0
- package/dist/{checkCommand-xGhRFFg2.js → doctorCommand-CnDXQxfa.js} +520 -1278
- package/dist/dormancyCommand-Dszo57d6.js +69 -0
- package/dist/e2eCmd-BRabZww-.js +147 -0
- package/dist/embeddingsCommand-C0sKVRo1.js +73 -0
- package/dist/envCommand-DPgdV1Bq.js +60 -0
- package/dist/evalCommand-6RUfPen4.js +118 -0
- package/dist/evolveCommand-DHpkgjgH.js +281 -0
- package/dist/fileTaxonomy-CJfgOllU.js +457 -0
- package/dist/frameworkTableAssembly-BGHmck-x.js +2 -0
- package/dist/{frameworkTableAssembly-BwIrO5nv.js → frameworkTableAssembly-DkkP6BgC.js} +184 -148
- package/dist/generateCommand-oibemh97.js +147 -0
- package/dist/index.d.ts +45 -0
- package/dist/index.js +4 -3
- package/dist/infoCommand-BJw9nLUR.js +60 -0
- package/dist/{inspect-BUUjt773.js → inspect-CBqFtAKA.js} +82 -40
- package/dist/inspect-C_T_WGvl.js +2 -0
- package/dist/inspectCmd-Bppy-GGw.js +224 -0
- package/dist/inspectFetch-Cm8_wVvp.js +151 -0
- package/dist/inspectMetrics-CfdKLh6t.js +72 -0
- package/dist/loadEnv-D9nEOClM.js +44 -0
- package/dist/logFileSink-C_D2wRN1.js +105 -0
- package/dist/logsCmd-CCca3KRZ.js +260 -0
- package/dist/manifestBuild-ChsKAhmn.js +2 -0
- package/dist/{manifestBuild-BLrVuSlM.js → manifestBuild-sxpwdKY1.js} +1 -1
- package/dist/metaCommands-7MJfZ5cf.js +196 -0
- package/dist/migrate-CBwOt_iV.js +83 -0
- package/dist/mssqlClusterPatch-_4cE_nun.js +44 -0
- package/dist/newCommand-COWOJ1_E.js +156 -0
- package/dist/nodeEnvironment-cGFAj1J8.js +28 -0
- package/dist/packageCommand-Cug_3Ogl.js +271 -0
- package/dist/pageConvention-cEiRxdab.js +5 -0
- package/dist/privacyCommand-C-Df56U_.js +146 -0
- package/dist/projectScaffold-DmzEKHib.js +2 -0
- package/dist/projectScaffold-LMMtaavR.js +814 -0
- package/dist/renderModeScan-D7J1B7Kw.js +105 -0
- package/dist/renderProfile-1OWWAAtx.js +81 -0
- package/dist/runtimeRegistry-DMeKfTHP.js +81 -0
- package/dist/runtimeTrace-CRxalXTs.js +91 -0
- package/dist/scheduleCmd--jksTrf6.js +69 -0
- package/dist/scheduleManifestCmd-D2x0CTTY.js +249 -0
- package/dist/schemaIr-UJybUUZW.js +103 -0
- package/dist/{sdkgen-C81QIkiL.js → sdkgen-CYJscZC7.js} +111 -209
- package/dist/seedRunner-TFHHiToI.js +329 -0
- package/dist/serveCommand-B_isw7q4.js +1647 -0
- package/dist/serveCommand-DOvbgRnQ.js +2 -0
- package/dist/serveEntry.js +5 -5
- package/dist/serverlessCommand-CfJZy6dS.js +482 -0
- package/dist/start-9LiUOfES.js +1087 -0
- package/dist/start-B-9Nsp-S.js +3 -0
- package/dist/startEntry.js +2 -2
- package/dist/staticCommand-Dr2M6tpU.js +304 -0
- package/dist/storageCommand-Co6NfLqN.js +42 -0
- package/dist/templates-De8IR5-c.js +102 -0
- package/dist/test-CI6iDsYc.js +115 -0
- package/dist/tracesCmd-CkEZQrtt.js +232 -0
- package/dist/tsconfigPaths-BWXBWgcl.js +107 -0
- package/dist/tsxLoader-EuXmSJ1K.js +51 -0
- package/dist/typecheckCommand-BlsWiCNq.js +61 -0
- package/dist/updateCommand-Bkptutss.js +585 -0
- package/dist/updateCommand-us1_hdIC.js +2 -0
- package/dist/{inspectMetrics-1xzTKAFx.js → webDev-CBYvPqQr.js} +975 -1559
- package/dist/webDev-Cg-fFiyd2.js +2 -0
- package/dist/webhookDiscovery-CrGAfhIG.js +2 -0
- package/dist/webhookDiscovery-D7VaeMlz.js +51 -0
- package/dist/webhooksCommand-CID96Rga.js +267 -0
- package/dist/workflowsCmd-D1VTmLMY.js +608 -0
- package/package.json +179 -17
- package/templates/AGENTS.core.md +58 -3
- package/templates/AGENTS.md +64 -7
- package/templates/agent-docs/_index.md +6 -4
- package/templates/agent-docs/_manifest.json +22 -5
- package/templates/agent-docs/ai.md +370 -0
- package/templates/agent-docs/authentication.md +265 -31
- package/templates/agent-docs/caching.md +6 -0
- package/templates/agent-docs/cli.md +794 -50
- package/templates/agent-docs/data.md +550 -11
- package/templates/agent-docs/database/migrations.md +174 -25
- package/templates/agent-docs/database/misc.md +156 -40
- package/templates/agent-docs/database/querying.md +19 -1
- package/templates/agent-docs/database/scaling.md +60 -0
- package/templates/agent-docs/database/schema.md +1 -0
- package/templates/agent-docs/database/seedsdialects.md +208 -19
- package/templates/agent-docs/database/transactions.md +68 -0
- package/templates/agent-docs/deployment.md +69 -3
- package/templates/agent-docs/introduction.md +87 -16
- package/templates/agent-docs/local-first-mobile.md +79 -4
- package/templates/agent-docs/multi-tenancy.md +95 -20
- package/templates/agent-docs/observability.md +58 -3
- package/templates/agent-docs/plugins/ai-flows.md +161 -2
- package/templates/agent-docs/plugins/analytics-postgres.md +1 -1
- package/templates/agent-docs/plugins/audit.md +37 -1
- package/templates/agent-docs/plugins/auth-social.md +143 -0
- package/templates/agent-docs/plugins/auth-workos.md +4 -2
- package/templates/agent-docs/plugins/auth.md +131 -6
- package/templates/agent-docs/plugins/billing.md +132 -15
- package/templates/agent-docs/plugins/cdc-out.md +46 -7
- package/templates/agent-docs/plugins/clickhouse.md +1 -1
- package/templates/agent-docs/plugins/duckdb.md +1 -1
- package/templates/agent-docs/plugins/flags.md +132 -0
- package/templates/agent-docs/plugins/governance.md +105 -7
- package/templates/agent-docs/plugins/multitenancy.md +9 -4
- package/templates/agent-docs/plugins/presence.md +13 -2
- package/templates/agent-docs/plugins/ratelimit.md +9 -0
- package/templates/agent-docs/plugins/search.md +157 -6
- package/templates/agent-docs/plugins/sso-saml.md +47 -8
- package/templates/agent-docs/plugins/webhooks.md +105 -0
- package/templates/agent-docs/plugins.md +150 -18
- package/templates/agent-docs/reference.md +60 -3
- package/templates/agent-docs/releases.md +1117 -0
- package/templates/agent-docs/routing.md +43 -25
- package/templates/agent-docs/scheduling.md +10 -0
- package/templates/agent-docs/schema-driven-ui.md +92 -12
- package/templates/agent-docs/security.md +426 -0
- package/templates/agent-docs/templates/apibackends.md +87 -18
- package/templates/agent-docs/templates/appshells.md +32 -14
- package/templates/agent-docs/templates/overview.md +13 -8
- package/templates/agent-docs/testing.md +211 -14
- package/templates/agent-docs/whats-new.md +1700 -104
- package/templates/agent-docs/workflows.md +130 -14
- package/templates/apps/api-ai/actions/summarize.action.ts +11 -0
- package/templates/apps/api-ai/package.json +8 -7
- package/templates/apps/api-auth/actions/me.action.ts +13 -0
- package/templates/apps/api-auth/package.json +9 -8
- package/templates/apps/api-backend/mutations/notes.create.mutation.ts +9 -0
- package/templates/apps/api-backend/package.json +12 -8
- package/templates/apps/api-backend/queries/notes.query.ts +30 -8
- package/templates/apps/api-backend-deactivation/actions/users.get.action.ts +14 -0
- package/templates/apps/api-backend-deactivation/mutations/users.create.mutation.ts +8 -0
- package/templates/apps/api-backend-deactivation/mutations/users.deactivate.mutation.ts +10 -0
- package/templates/apps/api-backend-deactivation/package.json +8 -7
- package/templates/apps/api-backend-mail/actions/sendWelcome.action.ts +17 -0
- package/templates/apps/api-backend-mail/mutations/notes.create.mutation.ts +9 -0
- package/templates/apps/api-backend-mail/package.json +9 -8
- package/templates/apps/api-backend-mail/queries/notes.query.ts +30 -8
- package/templates/apps/api-backend-mariadb/.env.example +14 -0
- package/templates/apps/api-backend-mariadb/mutations/notes.create.mutation.ts +9 -0
- package/templates/apps/api-backend-mariadb/package.json +10 -9
- package/templates/apps/api-backend-mariadb/queries/notes.query.ts +30 -8
- package/templates/apps/api-backend-sqlite/.env.example +14 -0
- package/templates/apps/api-backend-sqlite/mutations/notes.create.mutation.ts +9 -0
- package/templates/apps/api-backend-sqlite/package.json +9 -8
- package/templates/apps/api-backend-sqlite/queries/notes.query.ts +30 -8
- package/templates/apps/api-backend-storage/actions/uploadAvatar.action.ts +13 -0
- package/templates/apps/api-backend-storage/actions/uploadDocument.action.ts +12 -0
- package/templates/apps/api-backend-storage/mutations/notes.create.mutation.ts +9 -0
- package/templates/apps/api-backend-storage/package.json +9 -8
- package/templates/apps/api-backend-storage/queries/notes.query.ts +30 -8
- package/templates/apps/api-cms/actions/content.get.action.ts +7 -0
- package/templates/apps/api-cms/actions/content.types.action.ts +6 -0
- package/templates/apps/api-cms/actions/me.action.ts +13 -0
- package/templates/apps/api-cms/app.config.ts +19 -0
- package/templates/apps/api-cms/authz.ts +63 -0
- package/templates/apps/api-cms/mutations/content.publish.mutation.ts +10 -0
- package/templates/apps/api-cms/mutations/content.saveDraft.mutation.ts +10 -0
- package/templates/apps/api-cms/mutations/content.unpublish.mutation.ts +5 -0
- package/templates/apps/api-cms/package.json +11 -10
- package/templates/apps/api-cms/queries/content.list.query.ts +23 -7
- package/templates/apps/api-cms/tests/accessDecisions.test.ts +121 -0
- package/templates/apps/api-cms/tests/content.descriptors.test.ts +8 -4
- package/templates/apps/api-collab/mutations/documents.create.mutation.ts +8 -0
- package/templates/apps/api-collab/mutations/documents.setBody.mutation.ts +13 -0
- package/templates/apps/api-collab/package.json +9 -8
- package/templates/apps/api-collab/queries/documents.query.ts +19 -7
- package/templates/apps/api-data-advanced/package.json +9 -8
- package/templates/apps/api-data-advanced/queries/authors.withBooks.query.ts +12 -0
- package/templates/apps/api-data-advanced/queries/books.search.query.ts +9 -0
- package/templates/apps/api-durable/mutations/orders.approve.mutation.ts +17 -0
- package/templates/apps/api-durable/mutations/orders.place.mutation.ts +9 -0
- package/templates/apps/api-durable/package.json +9 -8
- package/templates/apps/api-feature-flags/actions/notes.export.action.ts +16 -0
- package/templates/apps/api-feature-flags/mutations/notes.create.mutation.ts +12 -0
- package/templates/apps/api-feature-flags/package.json +10 -9
- package/templates/apps/api-governance/README.md +30 -8
- package/templates/apps/api-governance/actions/profiles.get.action.server.ts +17 -1
- package/templates/apps/api-governance/actions/profiles.get.action.ts +29 -4
- package/templates/apps/api-governance/database/schema.ts +16 -4
- package/templates/apps/api-governance/mutations/profiles.create.mutation.ts +12 -0
- package/templates/apps/api-governance/package.json +9 -8
- package/templates/apps/api-kv/actions/sync.pull.action.ts +15 -0
- package/templates/apps/api-kv/actions/sync.reset.action.ts +13 -0
- package/templates/apps/api-kv/actions/sync.status.action.ts +7 -0
- package/templates/apps/api-kv/package.json +9 -8
- package/templates/apps/api-kv/queries/events.list.query.ts +19 -8
- package/templates/apps/api-moderation/mutations/comments.create.mutation.ts +11 -0
- package/templates/apps/api-moderation/mutations/posts.create.mutation.ts +13 -0
- package/templates/apps/api-moderation/package.json +9 -8
- package/templates/apps/api-observability/mutations/notes.create.mutation.ts +8 -0
- package/templates/apps/api-observability/package.json +9 -8
- package/templates/apps/api-observability/queries/notes.list.query.ts +13 -0
- package/templates/apps/api-ratelimit/mutations/notes.create.mutation.ts +14 -0
- package/templates/apps/api-ratelimit/package.json +9 -8
- package/templates/apps/api-rbac/package.json +9 -8
- package/templates/apps/api-rest/package.json +8 -7
- package/templates/apps/api-saas/mutations/projects.create.mutation.ts +13 -0
- package/templates/apps/api-saas/package.json +12 -11
- package/templates/apps/api-saas/queries/projects.list.query.ts +11 -0
- package/templates/apps/api-saas-starter/actions/me.action.ts +13 -0
- package/templates/apps/api-saas-starter/app.config.ts +19 -0
- package/templates/apps/api-saas-starter/authz.ts +75 -0
- package/templates/apps/api-saas-starter/mutations/invites.create.mutation.ts +10 -0
- package/templates/apps/api-saas-starter/mutations/projects.create.mutation.ts +9 -0
- package/templates/apps/api-saas-starter/package.json +15 -11
- package/templates/apps/api-saas-starter/queries/invites.list.query.ts +19 -8
- package/templates/apps/api-saas-starter/queries/projects.list.query.ts +18 -7
- package/templates/apps/api-saas-starter/tests/accessDecisions.test.ts +135 -0
- package/templates/apps/api-search/mutations/articles.create.mutation.ts +14 -0
- package/templates/apps/api-search/package.json +9 -8
- package/templates/apps/api-search/queries/articles.list.query.ts +21 -8
- package/templates/apps/api-status/README.md +10 -3
- package/templates/apps/api-status/app.config.ts +8 -3
- package/templates/apps/api-status/authz.ts +5 -3
- package/templates/apps/api-status/package.json +9 -8
- package/templates/apps/api-status/queries/components.list.query.ts +14 -6
- package/templates/apps/api-status/queries/incidents.live.query.ts +23 -10
- package/templates/apps/api-status/queries/updates.list.query.ts +16 -9
- package/templates/apps/api-status/tests/status.test.ts +9 -1
- package/templates/apps/api-versioning/actions/documents.asOf.action.ts +12 -0
- package/templates/apps/api-versioning/actions/documents.history.action.ts +13 -0
- package/templates/apps/api-versioning/mutations/documents.create.mutation.ts +9 -0
- package/templates/apps/api-versioning/mutations/documents.update.mutation.ts +12 -0
- package/templates/apps/api-versioning/package.json +9 -8
- package/templates/apps/api-webhooks/mutations/orders.fulfill.mutation.ts +16 -0
- package/templates/apps/api-webhooks/package.json +10 -9
- package/templates/apps/api-webhooks/queries/orders.list.query.ts +10 -0
- package/templates/apps/changelog/package.json +8 -6
- package/templates/apps/edge-functions/package.json +2 -2
- package/templates/apps/frontend-admin/package.json +10 -8
- package/templates/apps/frontend-admin/src/lib/admin.ts +20 -9
- package/templates/apps/frontend-admin/src/locales/de.ts +11 -1
- package/templates/apps/frontend-admin/src/locales/en.ts +13 -1
- package/templates/apps/frontend-admin/src/pages/admin/[entity]/page.tsx +65 -22
- package/templates/apps/frontend-admin/src/pages/admin/entity.test.tsx +130 -23
- package/templates/apps/frontend-admin/src/pages/admin/layout.tsx +3 -2
- package/templates/apps/frontend-admin/src/pages/admin/page.test.tsx +19 -2
- package/templates/apps/frontend-admin/src/pages/admin/page.tsx +9 -4
- package/templates/apps/frontend-app/app.config.ts +4 -3
- package/templates/apps/frontend-app/package.json +11 -8
- package/templates/apps/frontend-app/src/lib/api.ts +25 -0
- package/templates/apps/frontend-app/src/pages/page.test.tsx +130 -82
- package/templates/apps/frontend-app/src/pages/page.tsx +14 -18
- package/templates/apps/frontend-auth/package.json +10 -8
- package/templates/apps/frontend-blank/package.json +9 -7
- package/templates/apps/frontend-cms/package.json +11 -9
- package/templates/apps/frontend-collab/package.json +12 -9
- package/templates/apps/frontend-collab/src/pages/page.test.tsx +122 -78
- package/templates/apps/frontend-contact/package.json +9 -7
- package/templates/apps/frontend-dashboard/package.json +9 -7
- package/templates/apps/frontend-docs/package.json +9 -7
- package/templates/apps/frontend-i18n/package.json +8 -6
- package/templates/apps/frontend-landing/package.json +9 -7
- package/templates/apps/frontend-portal/package.json +10 -8
- package/templates/apps/frontend-saas/app.config.ts +10 -6
- package/templates/apps/frontend-saas/package.json +10 -8
- package/templates/apps/frontend-saas/src/lib/api.ts +27 -32
- package/templates/apps/frontend-saas/src/pages/dashboard/billing/page.tsx +7 -8
- package/templates/apps/frontend-saas/src/pages/dashboard/page.test.tsx +27 -3
- package/templates/apps/frontend-saas/src/pages/dashboard/page.tsx +4 -4
- package/templates/apps/frontend-saas/src/pages/dashboard/team/page.tsx +3 -4
- package/templates/apps/frontend-spa/package.json +9 -7
- package/templates/apps/frontend-ssr/package.json +9 -7
- package/templates/apps/frontend-ssr-api/package.json +10 -8
- package/templates/apps/frontend-static-blog/package.json +8 -6
- package/templates/apps/frontend-status/package.json +10 -8
- package/templates/apps/mobile-app/README.md +1 -0
- package/templates/apps/mobile-app/package.json +4 -2
- package/templates/apps/mobile-app/src/app/index.tsx +22 -12
- package/templates/apps/mobile-app/src/app/orders/[id].tsx +1 -1
- package/templates/apps/mobile-app/src/lib/api.ts +34 -0
- package/templates/apps/mobile-app/voltro.mobile.ts +4 -2
- package/templates/baselines/bare/.env.example +14 -0
- package/templates/baselines/bare/baseline.json +4 -4
- package/templates/baselines/compose/.env.example +14 -0
- package/templates/baselines/compose/README.md +1 -1
- package/templates/baselines/compose/baseline.json +5 -5
- package/templates/baselines/compose-mariadb/.env.example +14 -0
- package/templates/baselines/compose-mariadb/README.md +1 -1
- package/templates/baselines/compose-mariadb/baseline.json +5 -5
- package/templates/baselines/helm/.env.example +14 -0
- package/templates/baselines/helm/baseline.json +4 -4
- package/dist/apiBuild-C-x9YacA.js +0 -2
- package/dist/checkCommand-DRovTKza.js +0 -2
- package/dist/commands-CJfepbm4.js +0 -11541
- package/dist/dbCommand-b1gum4td.js +0 -2
- package/dist/dev-iiMtlkfs.js +0 -3
- package/dist/devActivity-BhIu6ncs.js +0 -159
- package/dist/frameworkTableAssembly-D-EebUQX.js +0 -2
- package/dist/inspect-mmBuRXmy.js +0 -2
- package/dist/manifestBuild-Dj8Jjoto.js +0 -2
- package/dist/seedRunner-Bqxgp7HZ.js +0 -230
- package/dist/serveCommand-DdaM4Hup.js +0 -1608
- package/dist/start-C0koT0UO.js +0 -1084
- /package/templates/apps/api-ai/actions/{summarize.action.server.tsx → summarize.action.server.ts} +0 -0
|
@@ -168,6 +168,7 @@ import { Schema } from 'effect'
|
|
|
168
168
|
export const listNotes = defineQuery({
|
|
169
169
|
name: 'notes.list',
|
|
170
170
|
source: 'notes',
|
|
171
|
+
guards: [{ scope: 'notes:read' }],
|
|
171
172
|
input: Schema.Struct({}),
|
|
172
173
|
output: Schema.Array(Schema.Struct({
|
|
173
174
|
id: Schema.String,
|
|
@@ -188,6 +189,13 @@ export default () =>
|
|
|
188
189
|
|
|
189
190
|
Save both files and `notes.list` becomes a streaming query in the typed client.
|
|
190
191
|
|
|
192
|
+
> **`guards:` is not decoration here — it is what makes the file boot.** Every
|
|
193
|
+
> wire-exposed procedure must declare exactly one of `guards:`,
|
|
194
|
+
> `openAccess: '<reason>'` or `internal: true`; a descriptor that declares none
|
|
195
|
+
> is refused at boot, naming the file. Which one is right is a real decision,
|
|
196
|
+
> and both of the other two appear on this page below. Full rules:
|
|
197
|
+
> [Authorization](/docs/authentication/authorization#every-procedure-decides-guards-or-openaccess).
|
|
198
|
+
|
|
191
199
|
## Consuming a query
|
|
192
200
|
|
|
193
201
|
```tsx no-check
|
|
@@ -219,6 +227,7 @@ import { Schema } from 'effect'
|
|
|
219
227
|
export const listMessages = defineQuery({
|
|
220
228
|
name: 'messages.list',
|
|
221
229
|
source: 'messages',
|
|
230
|
+
guards: [{ scope: 'messages:read' }],
|
|
222
231
|
input: Schema.Struct({
|
|
223
232
|
channelId: Schema.String,
|
|
224
233
|
limit: Schema.Number,
|
|
@@ -273,6 +282,7 @@ import { Schema } from 'effect'
|
|
|
273
282
|
export const notesSummary = defineQuery({
|
|
274
283
|
name: 'notes.summary',
|
|
275
284
|
source: 'notes',
|
|
285
|
+
guards: [{ scope: 'notes:read' }],
|
|
276
286
|
input: Schema.Struct({}),
|
|
277
287
|
output: Schema.Struct({
|
|
278
288
|
open: Schema.Number,
|
|
@@ -323,8 +333,11 @@ It's a runtime identity (returns the handler unchanged) — the whole value is t
|
|
|
323
333
|
`source` also connects query caches to mutation `target` metadata:
|
|
324
334
|
|
|
325
335
|
```ts
|
|
326
|
-
defineQuery({ name: 'notes.list', source: 'notes', /* ... */ })
|
|
327
|
-
defineMutation({
|
|
336
|
+
defineQuery({ name: 'notes.list', source: 'notes', guards: [{ scope: 'notes:read' }], /* ... */ })
|
|
337
|
+
defineMutation({
|
|
338
|
+
name: 'notes.create', target: { table: 'notes', op: 'insert' },
|
|
339
|
+
guards: [{ scope: 'notes:write' }], /* ... */
|
|
340
|
+
})
|
|
328
341
|
```
|
|
329
342
|
|
|
330
343
|
With that pairing, `useMutation('app', 'notes.create')` can stage an optimistic row in active `notes.list` caches without client-side cache plumbing.
|
|
@@ -340,7 +353,11 @@ export const teamBoard = defineQuery({
|
|
|
340
353
|
name: 'boards.forTeam',
|
|
341
354
|
input: Schema.Struct({ teamId: Schema.String }),
|
|
342
355
|
output: BoardRows,
|
|
343
|
-
//
|
|
356
|
+
// The guard resolves the caller's membership of THIS team — a read of
|
|
357
|
+
// `teamMembers` that happens before the executor runs.
|
|
358
|
+
guards: [{ action: 'view', resourceType: 'team', resource: (input) => input.teamId }],
|
|
359
|
+
// `boards` alone is wrong here — that membership read is part of what the
|
|
360
|
+
// result depends on.
|
|
344
361
|
source: ['boards', 'teamMembers'],
|
|
345
362
|
})
|
|
346
363
|
```
|
|
@@ -390,6 +407,7 @@ import { Schema } from 'effect'
|
|
|
390
407
|
export const listProjects = defineQuery({
|
|
391
408
|
name: 'projects.list',
|
|
392
409
|
source: 'projects',
|
|
410
|
+
guards: [{ scope: 'projects:read' }],
|
|
393
411
|
input: Schema.Struct({}),
|
|
394
412
|
output: Schema.Array(
|
|
395
413
|
Schema.Struct({
|
|
@@ -542,9 +560,12 @@ Queries are streaming RPCs whose elements are **subscription events**: an initia
|
|
|
542
560
|
defineQuery({ name: 'q', guards: [], … }) // ✗ enforces nothing
|
|
543
561
|
defineQuery({ name: 'q', source: '', … }) // ✗ reactive, subscribed to nothing
|
|
544
562
|
defineQuery({ name: 'q', internal: true, overridesPlugin: true, … }) // ✗ removes, replaces nothing
|
|
563
|
+
defineQuery({ name: 'q', openAccess: '', … }) // ✗ a marker with no reason
|
|
564
|
+
defineQuery({ name: 'q', guards: [{ scope: 'x' }], openAccess: 'open', … }) // ✗ two decisions
|
|
565
|
+
defineQuery({ name: 'q', internal: true, openAccess: 'open', … }) // ✗ no wire to decide about
|
|
545
566
|
```
|
|
546
567
|
|
|
547
|
-
The
|
|
568
|
+
The first three are the shapes `defineEvent` refuses too, for the same reasons — a rule that
|
|
548
569
|
holds for one primitive and not another is worse than no rule, because the
|
|
549
570
|
answer then depends on which file you happened to open.
|
|
550
571
|
|
|
@@ -561,6 +582,22 @@ something unreachable in its place: callers get a 404 for something that used to
|
|
|
561
582
|
work, with no diff that says so. Joins the existing refusals of `internal` with
|
|
562
583
|
`publicApi` or `exposeAsTool`.
|
|
563
584
|
|
|
585
|
+
**`openAccess` without a reason** is a marker that says nothing. The reason is
|
|
586
|
+
what a reviewer reads to decide whether this really should be callable without a
|
|
587
|
+
check — `openAccess: 'public pricing, no caller data'`.
|
|
588
|
+
|
|
589
|
+
**`openAccess` + `guards`** is two different access decisions at once: the
|
|
590
|
+
procedure is protected AND open. Keep the guards if a caller must hold a scope;
|
|
591
|
+
drop them if anyone may call it.
|
|
592
|
+
|
|
593
|
+
**`openAccess` + `internal: true`** decides about a surface that does not exist —
|
|
594
|
+
`internal` takes the procedure off the wire. Drop one of the two.
|
|
595
|
+
|
|
596
|
+
> Every wire-exposed procedure must carry ONE of `guards:` / `openAccess:` /
|
|
597
|
+
> `internal: true`, or the boot refuses it. See
|
|
598
|
+
> [Authorization](/docs/authentication/authorization) for the gate and the
|
|
599
|
+
> `security.defaultDeny` field that governs it.
|
|
600
|
+
|
|
564
601
|
|
|
565
602
|
## Loading vs empty — don't conflate them
|
|
566
603
|
|
|
@@ -658,6 +695,7 @@ import { Schema } from 'effect'
|
|
|
658
695
|
export const createNote = defineMutation({
|
|
659
696
|
name: 'notes.create',
|
|
660
697
|
target: { table: 'notes', op: 'insert' },
|
|
698
|
+
guards: [{ scope: 'notes:write' }],
|
|
661
699
|
input: Schema.Struct({
|
|
662
700
|
title: Schema.NonEmptyString,
|
|
663
701
|
body: Schema.String,
|
|
@@ -668,6 +706,13 @@ export const createNote = defineMutation({
|
|
|
668
706
|
})
|
|
669
707
|
```
|
|
670
708
|
|
|
709
|
+
> **`guards:` is what makes this file boot.** A wire-exposed mutation must
|
|
710
|
+
> declare exactly one of `guards:`, `openAccess: '<reason>'` or `internal: true`
|
|
711
|
+
> — a descriptor with none of them is refused at boot, naming the file. A write
|
|
712
|
+
> is also where a rubber-stamp guard costs the most, so name the scope the write
|
|
713
|
+
> actually needs rather than one every caller already holds. Full rules:
|
|
714
|
+
> [Authorization](/docs/authentication/authorization#every-procedure-decides-guards-or-openaccess).
|
|
715
|
+
|
|
671
716
|
Server executor:
|
|
672
717
|
|
|
673
718
|
```ts
|
|
@@ -872,6 +917,7 @@ The default path is declarative:
|
|
|
872
917
|
defineQuery({
|
|
873
918
|
name: 'notes.list',
|
|
874
919
|
source: 'notes',
|
|
920
|
+
guards: [{ scope: 'notes:read' }],
|
|
875
921
|
input: Schema.Struct({}),
|
|
876
922
|
output: Schema.Array(Note),
|
|
877
923
|
})
|
|
@@ -879,6 +925,7 @@ defineQuery({
|
|
|
879
925
|
defineMutation({
|
|
880
926
|
name: 'notes.create',
|
|
881
927
|
target: { table: 'notes', op: 'insert' },
|
|
928
|
+
guards: [{ scope: 'notes:write' }],
|
|
882
929
|
input,
|
|
883
930
|
output,
|
|
884
931
|
})
|
|
@@ -972,6 +1019,7 @@ class NoteQuotaExceeded extends Schema.TaggedError<NoteQuotaExceeded>()('NoteQuo
|
|
|
972
1019
|
export const createNote = defineMutation({
|
|
973
1020
|
name: 'notes.create',
|
|
974
1021
|
target: { table: 'notes', op: 'insert' },
|
|
1022
|
+
guards: [{ scope: 'notes:write' }],
|
|
975
1023
|
input,
|
|
976
1024
|
output,
|
|
977
1025
|
error: NoteQuotaExceeded,
|
|
@@ -1087,10 +1135,16 @@ Rules run on `voltro dev` and `voltro serve` through the same mutation runner, s
|
|
|
1087
1135
|
|
|
1088
1136
|
## `internal: true` — off the wire entirely
|
|
1089
1137
|
|
|
1090
|
-
Every discovered `*.mutation.ts` / `*.query.ts` / `*.action.ts` / `*.stream.ts`
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1138
|
+
Every discovered `*.mutation.ts` / `*.query.ts` / `*.action.ts` / `*.stream.ts`
|
|
1139
|
+
gets a route on the WebSocket — which is why each one has to declare **who may
|
|
1140
|
+
call it**, and why an app carrying an undecided procedure does not boot at all.
|
|
1141
|
+
`internal: true` is the third answer to that question, beside `guards:` and
|
|
1142
|
+
`openAccess:`: there is no wire surface to make a decision about, because the
|
|
1143
|
+
procedure never gets a route. (`publicApi` and `exposeAsTool` go the other way
|
|
1144
|
+
and opt IN to *wider* surfaces — which is why neither combines with `internal`.)
|
|
1145
|
+
|
|
1146
|
+
Reach for it when the caller is other **server** code — a workflow step, a
|
|
1147
|
+
schedule, another executor — and never a browser:
|
|
1094
1148
|
|
|
1095
1149
|
```ts
|
|
1096
1150
|
export const createFromAction = defineMutation({
|
|
@@ -1113,6 +1167,13 @@ anyone logged in. If the only thing keeping a procedure off the wire is that
|
|
|
1113
1167
|
nobody wrote a client call for it, it is on the wire — the same reasoning as
|
|
1114
1168
|
`.serverOnly()` on a column, one level up.
|
|
1115
1169
|
|
|
1170
|
+
That app is why the boot gate exists: all 18 declared no access decision, so
|
|
1171
|
+
today it does not start until each of them says `guards:`, `openAccess:` or
|
|
1172
|
+
`internal: true`. The gate turns "reachable and nobody looked" into a refusal
|
|
1173
|
+
naming every file — but it only forces the question, it cannot answer it, and
|
|
1174
|
+
`internal: true` is the right answer only when no browser is meant to call the
|
|
1175
|
+
procedure at all.
|
|
1176
|
+
|
|
1116
1177
|
**It is not a substitute for a guard.** An internal procedure still runs with
|
|
1117
1178
|
whatever authority its caller has. This removes the wire surface, not the need to
|
|
1118
1179
|
check who is asking; `voltro doctor`'s authz scan still covers it.
|
|
@@ -1172,6 +1233,7 @@ a mutation do:
|
|
|
1172
1233
|
```ts
|
|
1173
1234
|
export const syncIssue = defineAction({
|
|
1174
1235
|
name: 'jira.syncIssue',
|
|
1236
|
+
guards: [{ scope: 'jira:sync' }],
|
|
1175
1237
|
input: Schema.Struct({ key: Schema.String }),
|
|
1176
1238
|
output: Schema.Struct({ ok: Schema.Boolean }),
|
|
1177
1239
|
source: 'jiraIssueCache',
|
|
@@ -1195,6 +1257,7 @@ import { Schema } from 'effect'
|
|
|
1195
1257
|
|
|
1196
1258
|
export const pingExternal = defineAction({
|
|
1197
1259
|
name: 'support.ping',
|
|
1260
|
+
guards: [{ scope: 'support:diagnostics' }],
|
|
1198
1261
|
input: Schema.Struct({ url: Schema.String }),
|
|
1199
1262
|
output: Schema.Struct({
|
|
1200
1263
|
status: Schema.Number,
|
|
@@ -1203,6 +1266,15 @@ export const pingExternal = defineAction({
|
|
|
1203
1266
|
})
|
|
1204
1267
|
```
|
|
1205
1268
|
|
|
1269
|
+
> **`guards:` is what makes this file boot** — a wire-exposed action must declare
|
|
1270
|
+
> exactly one of `guards:`, `openAccess: '<reason>'` or `internal: true`, or the
|
|
1271
|
+
> boot refuses it, naming the file. This one earns a real scope rather than
|
|
1272
|
+
> `openAccess`: it fetches a **caller-supplied URL** from your server. The
|
|
1273
|
+
> [SSRF guard](/docs/security/overview#outbound-http-is-ssrf-guarded-by-default)
|
|
1274
|
+
> keeps that off your internal network, but "anyone on the internet may make this
|
|
1275
|
+
> server issue requests" is still not a claim to make by accident. Full rules:
|
|
1276
|
+
> [Authorization](/docs/authentication/authorization#every-procedure-decides-guards-or-openaccess).
|
|
1277
|
+
|
|
1206
1278
|
Server executor:
|
|
1207
1279
|
|
|
1208
1280
|
```ts
|
|
@@ -1461,6 +1533,7 @@ import { Schema } from 'effect'
|
|
|
1461
1533
|
export const listNotes = defineQuery({
|
|
1462
1534
|
name: 'notes.list',
|
|
1463
1535
|
source: 'notes',
|
|
1536
|
+
guards: [{ scope: 'notes:read' }], // re-checked on every delivery, not just at open
|
|
1464
1537
|
input: Schema.Struct({ archived: Schema.Boolean }),
|
|
1465
1538
|
output: Schema.Array(Schema.Struct({
|
|
1466
1539
|
id: Schema.String,
|
|
@@ -1471,6 +1544,84 @@ export const listNotes = defineQuery({
|
|
|
1471
1544
|
|
|
1472
1545
|
`source` declares which table re-runs computed queries and lets mutations with matching `target` metadata patch the client cache optimistically.
|
|
1473
1546
|
|
|
1547
|
+
## Reactivity channels
|
|
1548
|
+
|
|
1549
|
+
`source:` usually names a table. It does not have to, and when the state a query
|
|
1550
|
+
reads is **not in the database**, naming one is the wrong answer.
|
|
1551
|
+
|
|
1552
|
+
```ts
|
|
1553
|
+
import { defineQuery, reactivityChannel } from '@voltro/protocol'
|
|
1554
|
+
import { Schema } from 'effect'
|
|
1555
|
+
|
|
1556
|
+
// Declared once, in a browser-safe module both sides import.
|
|
1557
|
+
export const jobQueue = reactivityChannel('job-queue')
|
|
1558
|
+
|
|
1559
|
+
export const queueDepth = defineQuery({
|
|
1560
|
+
name: 'jobs.depth',
|
|
1561
|
+
source: jobQueue, // ← a channel, not a table
|
|
1562
|
+
input: Schema.Struct({}),
|
|
1563
|
+
output: Schema.Struct({ pending: Schema.Number }),
|
|
1564
|
+
})
|
|
1565
|
+
```
|
|
1566
|
+
|
|
1567
|
+
Then push it from wherever the state actually changes:
|
|
1568
|
+
|
|
1569
|
+
```ts
|
|
1570
|
+
import { publishReactivity } from '@voltro/protocol'
|
|
1571
|
+
|
|
1572
|
+
publishReactivity(ctx.store, jobQueue) // every subscriber re-runs its executor
|
|
1573
|
+
```
|
|
1574
|
+
|
|
1575
|
+
Everything else is unchanged: the executor returns a plain value, the framework
|
|
1576
|
+
re-runs it and pushes the result over the same subscription transport a
|
|
1577
|
+
table-backed reactive query uses. There is no second client concept and no
|
|
1578
|
+
second push mechanism — `useSubscription` does not know the difference.
|
|
1579
|
+
|
|
1580
|
+
### Why not just declare a table
|
|
1581
|
+
|
|
1582
|
+
Because the alternatives are worse, and the framework shipped one of them for a
|
|
1583
|
+
release. Presence held its roster in memory and still declared a
|
|
1584
|
+
`_voltro_presence` table it never wrote a row to, purely to own a name the
|
|
1585
|
+
reactivity layer would route on — an empty table in every user's database,
|
|
1586
|
+
created by every migration and diffed on every boot.
|
|
1587
|
+
|
|
1588
|
+
The other tempting option is to point `source:` at a name that resolves to
|
|
1589
|
+
nothing. That is worse than the empty table: the [stale-`source` boot
|
|
1590
|
+
warning](#fan-out--how-many-subscribers-may-one-change-wake) is the only signal
|
|
1591
|
+
for a subscription that has gone permanently quiet, and an exemption for a name
|
|
1592
|
+
you invented disables it for the one case it was built for.
|
|
1593
|
+
|
|
1594
|
+
### Pass the channel, not its key
|
|
1595
|
+
|
|
1596
|
+
A channel's routing key is `channel:<name>`, and you can read it off
|
|
1597
|
+
`jobQueue.key`. **Do not write that string into `source:`.** Passing the object
|
|
1598
|
+
creates an import edge from the query to the declaration, which removes the
|
|
1599
|
+
entire class of stale-source bugs for channels: a table `source:` is a string,
|
|
1600
|
+
so a rename leaves the old one behind and `tsc` cannot see it, while a channel
|
|
1601
|
+
that is not imported does not exist to be named. The boot audit reports a
|
|
1602
|
+
`channel:` key nothing declared, for the two ways round it.
|
|
1603
|
+
|
|
1604
|
+
Names are lowercase kebab segments separated by dots — `presence`, `job-queue`,
|
|
1605
|
+
`billing.usage`. A `:` is refused (it is the namespace separator) and so is an
|
|
1606
|
+
uppercase letter (a key that differs only by case reads as one channel and
|
|
1607
|
+
routes as two). Declaring the same name twice returns the same channel.
|
|
1608
|
+
|
|
1609
|
+
### What a channel is not
|
|
1610
|
+
|
|
1611
|
+
- **Not an event.** [`defineEvent`](/docs/data/subscriptions#streams-are-different)
|
|
1612
|
+
carries a PAYLOAD to subscribers with replay, ordering and gap detection. A
|
|
1613
|
+
channel carries nothing — it says "re-read", and the subscriber's own executor
|
|
1614
|
+
decides what that means. Reach for an event when the message matters; a channel
|
|
1615
|
+
when the state does.
|
|
1616
|
+
- **Not cross-replica.** `publishReactivity` wakes subscribers on the node that
|
|
1617
|
+
called it. Fanning a change out to other replicas is
|
|
1618
|
+
[`@voltro/plugin-broadcast`](/docs/plugins/broadcast)'s job, exactly as it is
|
|
1619
|
+
for table changes on a dialect without CDC.
|
|
1620
|
+
- **Not free per subscriber.** A publish wakes every subscriber of that channel
|
|
1621
|
+
and re-runs each one's executor; the channel is one routing key, so
|
|
1622
|
+
subscribers looking at different slices of the state are woken too. Publish on
|
|
1623
|
+
a real change, not on a timer — see [Fan-out](#fan-out--how-many-subscribers-may-one-change-wake).
|
|
1624
|
+
|
|
1474
1625
|
## Query Executor
|
|
1475
1626
|
|
|
1476
1627
|
```ts
|
|
@@ -1628,7 +1779,50 @@ Queries/subscriptions are for live state. Streams are for one-shot element flows
|
|
|
1628
1779
|
|
|
1629
1780
|
## Reconnect
|
|
1630
1781
|
|
|
1631
|
-
|
|
1782
|
+
A dropped WebSocket rebuilds the whole client stack — new socket, new RPC
|
|
1783
|
+
client, new subscription cache — and re-subscribes every active query, each of
|
|
1784
|
+
which answers with a fresh snapshot.
|
|
1785
|
+
|
|
1786
|
+
**What is on screen while that happens is your last-known-good data, not a
|
|
1787
|
+
skeleton.** The replacement cache is seeded from the one it retires, so `data`
|
|
1788
|
+
keeps its previous value and `loading` stays `false` across the gap; the first
|
|
1789
|
+
snapshot on the new stream replaces the stale rows. There is nothing to opt
|
|
1790
|
+
into:
|
|
1791
|
+
|
|
1792
|
+
```tsx
|
|
1793
|
+
const { data, loading } = useSubscription('app', 'notes.list', {})
|
|
1794
|
+
if (loading) return <Skeleton/> // does NOT fire on a reconnect
|
|
1795
|
+
return <NoteList notes={data}/>
|
|
1796
|
+
```
|
|
1797
|
+
|
|
1798
|
+
Use [`useConnectionStatus`](/docs/ui/client-utilities/use-connection-status) if
|
|
1799
|
+
you want to tell the user the rows may be a few seconds old — the data itself
|
|
1800
|
+
never disappears from under them.
|
|
1801
|
+
|
|
1802
|
+
Three things are deliberately NOT carried across:
|
|
1803
|
+
|
|
1804
|
+
- **Optimistic patches.** They are client-local and belong to mutations that
|
|
1805
|
+
died with the old connection, so nothing could ever retract them. They are
|
|
1806
|
+
reverted when their mutation settles.
|
|
1807
|
+
- **A cold-start error.** The new connection re-establishes the truth.
|
|
1808
|
+
- **Entries nothing re-subscribes to.** A screen that unmounted during the
|
|
1809
|
+
reconnect does not pin its rows; the seed evicts on the normal inactive TTL.
|
|
1810
|
+
|
|
1811
|
+
### An auth change still blanks — on purpose
|
|
1812
|
+
|
|
1813
|
+
When the rebuild happens because the connection's *subject* changed — a cookie
|
|
1814
|
+
login, a logout, a tenant switch, i.e. `useReconnect()` — **nothing** is carried
|
|
1815
|
+
over and the screen does go back to its loading state.
|
|
1816
|
+
|
|
1817
|
+
That is not a gap in the feature, it is the point of the gate. The next subject
|
|
1818
|
+
may be entitled to strictly less than the previous one, so painting the previous
|
|
1819
|
+
subject's rows into their session, even for the moment before the first snapshot
|
|
1820
|
+
lands, would be a data exposure. The same rule applies to
|
|
1821
|
+
[`useRefreshSubscriptions`](/docs/ui/client-utilities/use-refresh-subscriptions),
|
|
1822
|
+
which clears each entry's data on the same-socket re-auth path.
|
|
1823
|
+
|
|
1824
|
+
The short version: **a dropped connection keeps your screen, a change of
|
|
1825
|
+
identity clears it.**
|
|
1632
1826
|
|
|
1633
1827
|
## Tenant Scoping
|
|
1634
1828
|
|
|
@@ -1670,6 +1864,82 @@ a bigger machine.
|
|
|
1670
1864
|
These numbers are asserted by `rowPatch.perf.test.ts`, so they are current
|
|
1671
1865
|
rather than a note somebody wrote down once.
|
|
1672
1866
|
|
|
1867
|
+
## Fan-out — how many subscribers may one change wake?
|
|
1868
|
+
|
|
1869
|
+
A change wakes every subscription that reads the changed table, and the framework
|
|
1870
|
+
already collapses the work they share: one READ per distinct query, one DIFF per
|
|
1871
|
+
distinct `(query, base)`, one no-op comparison per distinct `(query, base)`. Fifty
|
|
1872
|
+
screens on one query cost one of each, not fifty.
|
|
1873
|
+
|
|
1874
|
+
What does NOT collapse is what is genuinely per subscriber: re-running the query's
|
|
1875
|
+
`guards:` and re-resolving row-level visibility. Those are re-run for every
|
|
1876
|
+
subscriber on every delivery, on purpose — a role revoked or a share withdrawn has
|
|
1877
|
+
to end the stream on the very NEXT delivery, not whenever a cache happens to
|
|
1878
|
+
expire — and each of them can be a database round-trip.
|
|
1879
|
+
|
|
1880
|
+
So deliveries run **concurrently, up to a bound**. The default is 8 in flight.
|
|
1881
|
+
Measured with 50 subscribers behind a 5 ms guard: 517 ms to serve all of them
|
|
1882
|
+
serially, 72 ms at 8 lanes.
|
|
1883
|
+
|
|
1884
|
+
Set it with `VOLTRO_REACTIVE_DELIVERY_CONCURRENCY`:
|
|
1885
|
+
|
|
1886
|
+
```bash
|
|
1887
|
+
VOLTRO_REACTIVE_DELIVERY_CONCURRENCY=16 voltro serve
|
|
1888
|
+
```
|
|
1889
|
+
|
|
1890
|
+
Raise it when your guards or row filters hit the database and you have pool
|
|
1891
|
+
headroom; set it to `1` for strictly one-at-a-time delivery. A value that is not
|
|
1892
|
+
a positive integer is ignored rather than honoured — a concurrency of `0` is a
|
|
1893
|
+
fan-out that delivers to nobody, and that is reachable through a typo in a values
|
|
1894
|
+
file. Unbounded is deliberately not an option: one round-trip per subscriber at
|
|
1895
|
+
the same instant starves the connection pool the request path shares, which is
|
|
1896
|
+
slower than serial.
|
|
1897
|
+
|
|
1898
|
+
Per-subscriber ordering is unaffected: a change touches each subscription exactly
|
|
1899
|
+
once. Order BETWEEN subscribers was never guaranteed.
|
|
1900
|
+
|
|
1901
|
+
### How many subscribers fit on one node?
|
|
1902
|
+
|
|
1903
|
+
There is a number, it is not a constant, and which number you get depends on a
|
|
1904
|
+
property of your **queries** rather than of your scale. Re-derive it on your own
|
|
1905
|
+
hardware with `node packages/runtime/scripts/fanout-ceiling.mjs`; the figures
|
|
1906
|
+
below are the spread across three runs on a busy developer machine at 10 matched
|
|
1907
|
+
writes per second, against a budget of 100 ms of event-loop time per second (10%
|
|
1908
|
+
of one core).
|
|
1909
|
+
|
|
1910
|
+
| Subscriber population | Marginal CPU per subscriber | Subscribers per node |
|
|
1911
|
+
| --- | --- | --- |
|
|
1912
|
+
| **Shared** — N clients on the SAME query (a leaderboard, a shared board) | 0.5–0.9 µs | ≈ 11 000–20 000 |
|
|
1913
|
+
| **Distinct** — N clients each on their OWN query (`where userId = me`) | 22–29 µs | ≈ 350–450 |
|
|
1914
|
+
|
|
1915
|
+
Ranges rather than single numbers, deliberately: that is the spread three runs
|
|
1916
|
+
produced, and a ceiling quoted to three significant figures from one run is a
|
|
1917
|
+
number somebody will hold you to.
|
|
1918
|
+
|
|
1919
|
+
The shared case is cheap because the memoisation above applies: one read, one
|
|
1920
|
+
diff, N emits. The distinct case gets no sharing at all — the read, the diff and
|
|
1921
|
+
the emit are all per subscriber — and a per-user dashboard is exactly that shape.
|
|
1922
|
+
**Plan against the distinct number**, and note that it scales inversely with your
|
|
1923
|
+
write rate: at 1 matched write per second it is ten times higher.
|
|
1924
|
+
|
|
1925
|
+
Two things that are easy to assume and are not true:
|
|
1926
|
+
|
|
1927
|
+
- **A more selective `where` buys no headroom.** Measured: 200 of 200
|
|
1928
|
+
subscribers whose predicate matched *nothing* were still woken by one write on
|
|
1929
|
+
their table. Every subscription is a dependent of its own table, so a change
|
|
1930
|
+
wakes all of them and each re-queries. The ceiling counts subscribers **on the
|
|
1931
|
+
table**, not subscribers whose predicate matches.
|
|
1932
|
+
- **It is not 512.** That constant bounds `onChange` LISTENERS — one per declared
|
|
1933
|
+
subscription file, reaction or aggregate, bound once at boot. Every client
|
|
1934
|
+
subscription in a process shares the dispatcher's single listener, so ten
|
|
1935
|
+
thousand of them move that count by zero.
|
|
1936
|
+
|
|
1937
|
+
Past the ceiling the lever is horizontal: more nodes, each carrying fewer
|
|
1938
|
+
subscribers. Change fan-out is already fleet-wide on postgres (LISTEN/NOTIFY) and
|
|
1939
|
+
mysql/mariadb (binlog), so a second node needs no extra wiring — the cost being
|
|
1940
|
+
budgeted here is the matcher and re-query CPU each node spends on ITS OWN
|
|
1941
|
+
clients.
|
|
1942
|
+
|
|
1673
1943
|
## See also
|
|
1674
1944
|
|
|
1675
1945
|
- [Subscribers (`*.subscribe.ts`)](/docs/data/subscribers) — server-side, best-effort post-commit reactivity to a table (NOT the client hook on this page).
|
|
@@ -1707,6 +1977,8 @@ import { Schema } from 'effect'
|
|
|
1707
1977
|
|
|
1708
1978
|
export const ticker = defineStream({
|
|
1709
1979
|
name: 'ticker.watch',
|
|
1980
|
+
openAccess: 'public market prices from an upstream feed — reads no table and '
|
|
1981
|
+
+ 'nothing derived from the caller',
|
|
1710
1982
|
input: Schema.Struct({ symbol: Schema.String }),
|
|
1711
1983
|
element: Schema.Struct({
|
|
1712
1984
|
price: Schema.Number,
|
|
@@ -1715,6 +1987,15 @@ export const ticker = defineStream({
|
|
|
1715
1987
|
})
|
|
1716
1988
|
```
|
|
1717
1989
|
|
|
1990
|
+
> **The access decision is what makes this file boot.** A wire-exposed stream
|
|
1991
|
+
> must declare exactly one of `guards:`, `openAccess: '<reason>'` or
|
|
1992
|
+
> `internal: true`, or the boot refuses it, naming the file. This one is
|
|
1993
|
+
> genuinely open, so it says so — and the reason is the point: `openAccess`
|
|
1994
|
+
> takes a sentence, not a boolean, because a reviewer has to be able to check
|
|
1995
|
+
> the claim. A stream carrying *your* rows (an import log, an export feed) wants
|
|
1996
|
+
> `guards:` instead. Full rules:
|
|
1997
|
+
> [Authorization](/docs/authentication/authorization#every-procedure-decides-guards-or-openaccess).
|
|
1998
|
+
|
|
1718
1999
|
Server executor:
|
|
1719
2000
|
|
|
1720
2001
|
```ts
|
|
@@ -2438,6 +2719,7 @@ export default defineQuery({
|
|
|
2438
2719
|
- **Relations** need nothing extra: eager loading is resolved server-side by the executor, so `include` works identically over HTTP.
|
|
2439
2720
|
- **Authorization is the same code** as the WebSocket path — the declarative `guards:`, the row filter, and tenant scoping all run before the handler. A procedure that denies on the socket denies here.
|
|
2440
2721
|
- Also available per endpoint: `scopes` (extra API-key scopes), `rateLimit`, `idempotent`.
|
|
2722
|
+
- **`idempotency:` covers these routes too**, on the same terms as a hand-written `restRoutes` entry above: one binding, one `Idempotency-Key` header, one `_voltro_idempotency` table, and identical behaviour under `voltro dev` and `voltro serve`. Projected routes and hand-written ones go through the same single projection, so it is not possible for one to deduplicate and the other not to.
|
|
2441
2723
|
|
|
2442
2724
|
This pairs with [`crud.list`](/docs/data/crud): `filter` / `paginate` / `sort` / `include` on the executor plus `publicApi: {}` on the descriptor is a complete, filtered, paginated REST list endpoint in one declaration.
|
|
2443
2725
|
|
|
@@ -3004,6 +3286,8 @@ import { Schema } from 'effect'
|
|
|
3004
3286
|
|
|
3005
3287
|
export default defineQuery({
|
|
3006
3288
|
name: 'accounts.list',
|
|
3289
|
+
// the authorization lives here, not in the helper — crud.* adds no guard
|
|
3290
|
+
guards: [{ scope: 'accounts:read' }],
|
|
3007
3291
|
input: Schema.Struct({}),
|
|
3008
3292
|
// note: the wire output OMITS apiSecret, so it never reaches the client
|
|
3009
3293
|
output: Schema.Array(Schema.Struct({ id: Schema.String, name: Schema.String })),
|
|
@@ -3014,6 +3298,8 @@ export default defineQuery({
|
|
|
3014
3298
|
|
|
3015
3299
|
- **Tenant scope.** `crud.list` and `crud.getById` read through `ctx.store`, which auto-scopes a `tenant()` table. They never call `.unscoped()`, so a cross-tenant read is impossible through them — `payslips.list` cannot return another tenant's rows.
|
|
3016
3300
|
- **Redaction.** A column a generated read must never ship — a credential, a token hash, a salary — is stripped from every returned row (reads and the row a `create` / `update` echoes). Two sources: a column marked [`.serverOnly()`](/docs/database/sensitivity) is stripped **automatically** (declare the exposure policy once at the schema and every crud read respects it — the single-source form), plus the per-call `redact: [...]` option for anything not worth a schema marker. Declare the same omission in the descriptor's `output` schema so the column never reaches the client at all; the helper is the runtime guarantee that it doesn't, whatever the schema says.
|
|
3301
|
+
- **No mass assignment of a `.serverOnly()` column.** The same marker is enforced on the **input** side: `crud.create` and `crud.update` **refuse** a payload that sets one, with `ServerOnlyColumnWrite` naming the offending columns, and nothing is written. `.serverOnly()` means *never crosses the wire*, so accepting it inbound is the same violation as leaking it, mirrored — a client could SET a column it is not allowed to READ. It is a refusal rather than a silent strip because a stripped field makes an attack look like a no-op and leaves an honest caller debugging a value that quietly did not land. A key present with the value `undefined` does not count as sent, so optional schema fields are unaffected. When the *server* legitimately needs to write one, do it from the handler with `ctx.store.insert` / `ctx.store.update` — those are unchanged; the refusal is on the generated path, which is the one fed straight from client input.
|
|
3302
|
+
- **Keyed writes stay inside the caller's tenant.** `crud.update` and `crud.remove` address the row by `input.id`, which is client-supplied. On a [`tenant()`](/docs/multi-tenancy/mixin) table the store resolves that id inside `subject.tenantId`, so another tenant's id fails with `TenantRowNotFound` instead of writing. The error is the same whether the row is missing or foreign, on purpose — the pair would otherwise be a cross-tenant existence oracle.
|
|
3017
3303
|
- **`getById` returns `null`, never throws.** A reactive getter that throws takes its shared-WebSocket siblings down with it. `crud.getById` resolves `null` for an absent row.
|
|
3018
3304
|
|
|
3019
3305
|
## The helpers
|
|
@@ -3022,8 +3308,8 @@ export default defineQuery({
|
|
|
3022
3308
|
|---|---|
|
|
3023
3309
|
| `crud.list(table, { redact? })` | tenant-scoped list of every row, redacted |
|
|
3024
3310
|
| `crud.getById(table, { redact? })` | one row by `input.id`, or `null` — redacted |
|
|
3025
|
-
| `crud.create(table, { redact? })` | insert `input`; id/tenant/audit auto-stamped; echoes the redacted row |
|
|
3026
|
-
| `crud.update(table, { redact? })` | patch `{ id, ...patch }`; returns the updated row or `null` |
|
|
3311
|
+
| `crud.create(table, { redact? })` | insert `input`; id/tenant/audit auto-stamped; echoes the redacted row; refuses a `.serverOnly()` field in the input |
|
|
3312
|
+
| `crud.update(table, { redact? })` | patch `{ id, ...patch }`; returns the updated row or `null`; tenant-resolved by id; refuses a `.serverOnly()` field in the patch |
|
|
3027
3313
|
| `crud.remove(table)` | delete `input.id`; returns `{ deleted }` |
|
|
3028
3314
|
| `crud.count(table, { filter? })` | `COUNT(*)` of the filtered, tenant-scoped set — the total for page-based UIs |
|
|
3029
3315
|
|
|
@@ -3428,6 +3714,223 @@ into the agent's prompt.
|
|
|
3428
3714
|
|
|
3429
3715
|
|
|
3430
3716
|
|
|
3717
|
+
---
|
|
3718
|
+
|
|
3719
|
+
<!-- source: en/data/approvals.md -->
|
|
3720
|
+
## Approvals
|
|
3721
|
+
|
|
3722
|
+
_requiresApproval — a mutation or action that needs a second human before it takes effect, with the pending intent in a durable row and self-approval refused._
|
|
3723
|
+
|
|
3724
|
+
A mutation can declare that **one person is not enough**:
|
|
3725
|
+
|
|
3726
|
+
```ts
|
|
3727
|
+
// apps/api/mutations/invoices.refund.mutation.ts
|
|
3728
|
+
import { defineMutation } from '@voltro/protocol'
|
|
3729
|
+
import { Schema } from 'effect'
|
|
3730
|
+
|
|
3731
|
+
export const refundInvoice = defineMutation({
|
|
3732
|
+
name: 'invoices.refund',
|
|
3733
|
+
input: Schema.Struct({ invoiceId: Schema.String, amountCents: Schema.Number }),
|
|
3734
|
+
output: Schema.Struct({ ok: Schema.Boolean }),
|
|
3735
|
+
// The requester still has to be allowed to ASK.
|
|
3736
|
+
guards: [{ scope: 'invoices:refund' }],
|
|
3737
|
+
requiresApproval: {
|
|
3738
|
+
approvers: [{ scope: 'invoices:approve' }],
|
|
3739
|
+
expiresIn: '4h',
|
|
3740
|
+
reason: 'refunds move money out of the account',
|
|
3741
|
+
},
|
|
3742
|
+
})
|
|
3743
|
+
```
|
|
3744
|
+
|
|
3745
|
+
That is the whole declaration. The framework does the rest: the first call is
|
|
3746
|
+
recorded and refused, a second human decides, and the identical call then
|
|
3747
|
+
succeeds exactly once.
|
|
3748
|
+
|
|
3749
|
+
Human-in-the-loop already existed *inside* a durable workflow
|
|
3750
|
+
([`awaitSignal`](/docs/workflows/definition), AI-Flows' human step). This is the
|
|
3751
|
+
same idea for an **ordinary rpc call** — no workflow around it, no status column
|
|
3752
|
+
to hand-roll, and the "who may approve" rule expressed in the same `guards:`
|
|
3753
|
+
vocabulary as everything else.
|
|
3754
|
+
|
|
3755
|
+
## What the caller sees
|
|
3756
|
+
|
|
3757
|
+
The first call does **not** run. It fails with a typed `ApprovalRequired`:
|
|
3758
|
+
|
|
3759
|
+
```ts
|
|
3760
|
+
import { errorTag } from '@voltro/protocol'
|
|
3761
|
+
|
|
3762
|
+
const result = await refund({ invoiceId, amountCents })
|
|
3763
|
+
// throws:
|
|
3764
|
+
// {
|
|
3765
|
+
// _tag: 'ApprovalRequired',
|
|
3766
|
+
// approvalId: 'apv_01j…',
|
|
3767
|
+
// procedure: 'invoices.refund',
|
|
3768
|
+
// expiresAt: '2026-08-12T14:00:00.000Z',
|
|
3769
|
+
// requiredScopes: ['invoices:approve'],
|
|
3770
|
+
// reason: 'refunds move money out of the account',
|
|
3771
|
+
// created: true, // false when an earlier identical call already asked
|
|
3772
|
+
// }
|
|
3773
|
+
```
|
|
3774
|
+
|
|
3775
|
+
It is a typed failure rather than a success with a status field on purpose: a
|
|
3776
|
+
mutation that returned its normal output shape when nothing happened is the
|
|
3777
|
+
easiest thing in the world for a client to mis-handle, and every client already
|
|
3778
|
+
branches on `_tag`.
|
|
3779
|
+
|
|
3780
|
+
For a mutation the **transaction never opens**. For an action the executor's
|
|
3781
|
+
external I/O never happens — which is the only point at which nothing has
|
|
3782
|
+
happened yet, since there is no rollback for an outbound HTTP call.
|
|
3783
|
+
|
|
3784
|
+
## The two built-in procedures
|
|
3785
|
+
|
|
3786
|
+
`__voltro.approvals.pending` is a reactive query over the approvals table, so
|
|
3787
|
+
both sides of the exchange are live with no polling:
|
|
3788
|
+
|
|
3789
|
+
```tsx
|
|
3790
|
+
import { useSubscription, useMutation } from '@voltro/client'
|
|
3791
|
+
import type { PendingApproval } from '@voltro/protocol'
|
|
3792
|
+
|
|
3793
|
+
export const ApprovalQueue = () => {
|
|
3794
|
+
const { data } = useSubscription<ReadonlyArray<PendingApproval>>(
|
|
3795
|
+
'app', '__voltro.approvals.pending', {},
|
|
3796
|
+
)
|
|
3797
|
+
const decide = useMutation('app', '__voltro.approvals.decide')
|
|
3798
|
+
|
|
3799
|
+
return (
|
|
3800
|
+
<ul>
|
|
3801
|
+
{(data ?? []).map((a) => (
|
|
3802
|
+
<li key={a.id}>
|
|
3803
|
+
{a.procedure} — {a.relation === 'to-decide' ? 'awaiting you' : `you asked · ${a.status}`}
|
|
3804
|
+
{a.relation === 'to-decide' && (
|
|
3805
|
+
<button onClick={() => decide.mutate({ approvalId: a.id, decision: 'approve' })}>
|
|
3806
|
+
Approve
|
|
3807
|
+
</button>
|
|
3808
|
+
)}
|
|
3809
|
+
</li>
|
|
3810
|
+
))}
|
|
3811
|
+
</ul>
|
|
3812
|
+
)
|
|
3813
|
+
}
|
|
3814
|
+
```
|
|
3815
|
+
|
|
3816
|
+
Each row carries a `relation`: `'to-decide'` (you may act on it) or
|
|
3817
|
+
`'requested'` (you asked for it). The requester watches their own row flip
|
|
3818
|
+
`pending → approved` and re-fires the mutation; the approver's queue appears
|
|
3819
|
+
without a refresh.
|
|
3820
|
+
|
|
3821
|
+
The feed is **scoped in the handler, not by a descriptor guard** — a row appears
|
|
3822
|
+
only if you requested it or satisfy its recorded approver scopes, so an
|
|
3823
|
+
anonymous caller sees an empty list. There is no scope that means "may see my own
|
|
3824
|
+
approval work", and inventing one would be a guard that reads as protection and
|
|
3825
|
+
enforces nothing.
|
|
3826
|
+
|
|
3827
|
+
## Where the pending intent lives, and why its identity matters
|
|
3828
|
+
|
|
3829
|
+
Between the request and the decision the intent is a row in `_voltro_approvals`
|
|
3830
|
+
— durable, so it survives a restart, a rolling deploy and a replica switch.
|
|
3831
|
+
|
|
3832
|
+
Its identity is **content-addressed**: a digest over the procedure, the
|
|
3833
|
+
requester, the tenant, the canonicalised input, and an optional caller `nonce`.
|
|
3834
|
+
Both directions of getting that wrong are real bugs:
|
|
3835
|
+
|
|
3836
|
+
- too **coarse** (keying on the procedure, say) and two different pending
|
|
3837
|
+
intents share one row, so approving one executes the other's payload;
|
|
3838
|
+
- too **fine** (a fresh id per attempt) and every page refresh, client re-send or
|
|
3839
|
+
transaction replay mints a second approval, asking the human twice for one
|
|
3840
|
+
decision.
|
|
3841
|
+
|
|
3842
|
+
Content-addressing is the only spelling that is stable across a retry and
|
|
3843
|
+
distinct across intents. A `UNIQUE` on that key enforces **at most one live
|
|
3844
|
+
intent per content**. Two deliberately identical requests — the same user really
|
|
3845
|
+
does want to refund the same invoice twice — are expressible by passing a
|
|
3846
|
+
different `nonce`, which is a decision you state rather than one the framework
|
|
3847
|
+
guesses.
|
|
3848
|
+
|
|
3849
|
+
An approval is **consumed** when it admits a call. A replay after that is a new
|
|
3850
|
+
request, not a free second execution.
|
|
3851
|
+
|
|
3852
|
+
## The refusals
|
|
3853
|
+
|
|
3854
|
+
These are the point of the feature, so they are worth reading as a list.
|
|
3855
|
+
|
|
3856
|
+
**Self-approval is refused, unconditionally.** There is no opt-out flag. The
|
|
3857
|
+
whole content of "a second human" is that it is a second one, and a framework
|
|
3858
|
+
that shipped `allowSelfApproval: true` would be shipping a control every team
|
|
3859
|
+
turns off under deadline pressure — with the audit row still reading "approved".
|
|
3860
|
+
|
|
3861
|
+
The check runs **before** the authority check, deliberately: a requester who
|
|
3862
|
+
happens to hold the approver scope is told they cannot approve their own request,
|
|
3863
|
+
which is the accurate reason, instead of being quietly let through.
|
|
3864
|
+
|
|
3865
|
+
**An unauthorised approver** gets `ApprovalForbidden`, naming the scope they
|
|
3866
|
+
lack. The check uses the same guard evaluator the dispatch spine runs, against
|
|
3867
|
+
the scopes recorded **on the row** — including a resource-scoped guard's resolved
|
|
3868
|
+
resource id, so `approvers: [{ scope: 'invoices:approve', resource: (i) => i.invoiceId }]`
|
|
3869
|
+
stays scoped to that invoice at decision time rather than widening into a global
|
|
3870
|
+
scope check.
|
|
3871
|
+
|
|
3872
|
+
**An anonymous decider** is refused. Every anonymous caller compares equal to
|
|
3873
|
+
every other, so the identity the control rests on does not exist. For the same
|
|
3874
|
+
reason, `requiresApproval` combined with `openAccess:` is refused at declaration.
|
|
3875
|
+
|
|
3876
|
+
**Expiry fails closed.** Past `expiresAt` the intent can be neither approved nor
|
|
3877
|
+
executed — including an intent that was approved and then aged out before the
|
|
3878
|
+
requester came back. The requester re-submits and a fresh decision is asked for.
|
|
3879
|
+
|
|
3880
|
+
**One intent, one verdict.** A second decision on the same intent gets
|
|
3881
|
+
`ApprovalNotPending`.
|
|
3882
|
+
|
|
3883
|
+
A rejection is reported to the requester once, as `ApprovalRejected`, on their
|
|
3884
|
+
next attempt. A further attempt after that opens a genuinely new decision — a
|
|
3885
|
+
rejection is a verdict on one request, not a permanent ban on the operation.
|
|
3886
|
+
|
|
3887
|
+
## Declaration-time refusals
|
|
3888
|
+
|
|
3889
|
+
Two shapes read like a control and enforce nothing, so they throw where you can
|
|
3890
|
+
still see both fields:
|
|
3891
|
+
|
|
3892
|
+
```ts
|
|
3893
|
+
// ✗ nobody can approve this — every call would park forever
|
|
3894
|
+
requiresApproval: { approvers: [] }
|
|
3895
|
+
|
|
3896
|
+
// ✗ an unauthenticated requester has no identity, so the self-approval
|
|
3897
|
+
// refusal cannot compare anything and the control degrades to nothing
|
|
3898
|
+
openAccess: 'public', requiresApproval: { approvers: [{ scope: 'x' }] }
|
|
3899
|
+
```
|
|
3900
|
+
|
|
3901
|
+
## Expiry as a tunable
|
|
3902
|
+
|
|
3903
|
+
Precedence: the descriptor's own `expiresIn`, then the app default, then the
|
|
3904
|
+
environment, then 24 hours.
|
|
3905
|
+
|
|
3906
|
+
```ts
|
|
3907
|
+
// app.config.ts
|
|
3908
|
+
export default {
|
|
3909
|
+
approvals: { expiresIn: '4h' },
|
|
3910
|
+
}
|
|
3911
|
+
```
|
|
3912
|
+
|
|
3913
|
+
`VOLTRO_APPROVAL_EXPIRY_HOURS` overrides the built-in default. There is
|
|
3914
|
+
deliberately no "never expires": an approval queue with no floor is a list of
|
|
3915
|
+
decisions nobody made.
|
|
3916
|
+
|
|
3917
|
+
## Agent tools
|
|
3918
|
+
|
|
3919
|
+
`exposeAsTool: { confirm: true }` used to be a report — the inventory showed it
|
|
3920
|
+
and nothing enforced it. It is now backed by this primitive: a confirm tool is
|
|
3921
|
+
mountable only if its descriptor also declares `requiresApproval`, and then the
|
|
3922
|
+
agent's call parks in your approval queue and returns `ApprovalRequired` to the
|
|
3923
|
+
model. See [Agent tools](/docs/ai/tools).
|
|
3924
|
+
|
|
3925
|
+
## The table
|
|
3926
|
+
|
|
3927
|
+
`_voltro_approvals` is created for every app (it is one small table, and the
|
|
3928
|
+
alternative would be a surprise `CREATE TABLE` on the production boot after
|
|
3929
|
+
somebody adds `requiresApproval:` to a mutation). It is bounded by the retention
|
|
3930
|
+
sweep — 30 days by default, `VOLTRO_APPROVALS_TTL_HOURS` to change it.
|
|
3931
|
+
|
|
3932
|
+
|
|
3933
|
+
|
|
3431
3934
|
---
|
|
3432
3935
|
|
|
3433
3936
|
<!-- source: en/data/client-state.md -->
|
|
@@ -3698,6 +4201,20 @@ enqueued by another replica, and rows waiting out a backoff. Without it the
|
|
|
3698
4201
|
guarantee degrades to "delivered unless something went wrong" — which is the
|
|
3699
4202
|
exact case a durable outbox exists for.
|
|
3700
4203
|
|
|
4204
|
+
**The 5 seconds is a floor, not a rate.** Those three reasons are taken one at a
|
|
4205
|
+
time, and only the last needs a clock:
|
|
4206
|
+
|
|
4207
|
+
| the poll exists for… | what brings the loop back |
|
|
4208
|
+
| --- | --- |
|
|
4209
|
+
| a nudge lost to a dead process | the first pass at boot, which is not deferred behind a tick |
|
|
4210
|
+
| a row another replica enqueued | a change event on `_voltro_outbox` |
|
|
4211
|
+
| a row waiting out a backoff | the runner arms for that row's own `nextAttemptAt` |
|
|
4212
|
+
|
|
4213
|
+
So on an empty queue the timer **stops entirely** and the loop waits to be woken.
|
|
4214
|
+
Where no change channel is available it keeps the fixed 5-second tick instead —
|
|
4215
|
+
the poll is then the only thing that can notice another replica's row, and a
|
|
4216
|
+
durable outbox that stops looking is worse than one that polls.
|
|
4217
|
+
|
|
3701
4218
|
## Options
|
|
3702
4219
|
|
|
3703
4220
|
```ts
|
|
@@ -3989,6 +4506,7 @@ Wire them into a mutation / action / query via the `error` field:
|
|
|
3989
4506
|
```ts
|
|
3990
4507
|
export const transfer = defineMutation({
|
|
3991
4508
|
name: 'wallet.transfer',
|
|
4509
|
+
guards: [{ scope: 'wallet:transfer' }],
|
|
3992
4510
|
input: Schema.Struct({ to: Schema.String, amount: Schema.Number }),
|
|
3993
4511
|
output: Schema.Struct({ txId: Schema.String }),
|
|
3994
4512
|
error: Schema.Union(InsufficientFunds, TitleTooLong),
|
|
@@ -4020,6 +4538,25 @@ if (result._tag === 'InsufficientFunds') {
|
|
|
4020
4538
|
}
|
|
4021
4539
|
```
|
|
4022
4540
|
|
|
4541
|
+
### What a rejected write actually rejects with
|
|
4542
|
+
|
|
4543
|
+
**The value is the typed error itself** — the same value `useSubscription` reports on the read side. Not a wrapper around it, and not an Effect `FiberFailure` (whose `_tag` would be `undefined`, so every branch above would silently fall through to the generic one).
|
|
4544
|
+
|
|
4545
|
+
That guarantee covers every write hook, not just `mutate`:
|
|
4546
|
+
|
|
4547
|
+
| Hook | Rejecting call |
|
|
4548
|
+
|---|---|
|
|
4549
|
+
| `useMutation` | `mutate(input)` |
|
|
4550
|
+
| `useAction` | `run(input)` |
|
|
4551
|
+
| `useWorkflow` | `start` · `cancel` · `resume` · `signal` · `update` |
|
|
4552
|
+
| `useWorkflowSignal` / `useWorkflowUpdate` | `signal` · `update` |
|
|
4553
|
+
| `useUpload` | `upload` · `uploadMany` |
|
|
4554
|
+
|
|
4555
|
+
Two consequences worth knowing:
|
|
4556
|
+
|
|
4557
|
+
- A **defect** (an undeclared `throw` server-side, a transport drop) rejects too, but with a plain `Error` — so an `_tag` check on it is `undefined` and falls to your generic branch, which is the intended split. Check `_tag` for the outcomes you declared; treat everything else as unexpected.
|
|
4558
|
+
- Passing `onError` (or `notify.error`) instead **resolves** with `undefined` and hands that same typed value to your handler. See [Mutations](/docs/data/mutations).
|
|
4559
|
+
|
|
4023
4560
|
Tagged errors:
|
|
4024
4561
|
|
|
4025
4562
|
- Are wire-safe — they serialise as JSON (the rpc transport is `RpcSerialization.layerJson`) and restore on the client with the correct `_tag` + payload.
|
|
@@ -4053,6 +4590,8 @@ To distinguish "we know about this" from "this surprised us":
|
|
|
4053
4590
|
| `ScopeError` | `@voltro/protocol` | `requireScope(subject, scope)` failed — `{ required, message }`. |
|
|
4054
4591
|
| `Unauthenticated` | `@voltro/protocol` | The resolved Subject is anonymous but a signed-in caller was required — optional `{ reason }`. |
|
|
4055
4592
|
| `TenantScopeViolation` | `@voltro/runtime` | A tenant-scoped `EffectStore` write had no authenticated subject. |
|
|
4593
|
+
| `TenantRowNotFound` | `@voltro/runtime` | A keyed-by-id write (`store.update(t, id, …)`, `delete`, `hardDelete`, `patchJson`) on a `tenant()` table found no such row **in the caller's tenant**. Raised identically whether the row is missing or belongs to another tenant — the distinction would be a cross-tenant existence oracle. |
|
|
4594
|
+
| `ServerOnlyColumnWrite` | `@voltro/runtime` | A `crud.create` / `crud.update` input tried to set a [`.serverOnly()`](/docs/database/sensitivity) column — `{ table, columns }`. |
|
|
4056
4595
|
| `StoreOperationFailed` | `@voltro/runtime` | The underlying store operation failed (transient). |
|
|
4057
4596
|
| `TableValidationFailed` | `@voltro/runtime` | A `table().validate(Schema)` row check rejected the write. |
|
|
4058
4597
|
| `CacheError` | `@voltro/cache` | A cache backend op failed — `{ operation, key, cause }`. |
|