@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
|
@@ -41,6 +41,29 @@ Verify in the boot log:
|
|
|
41
41
|
That's it. Reads now flow to the replica unless RYW pins them back.
|
|
42
42
|
Writes always go to primary.
|
|
43
43
|
|
|
44
|
+
## Replica pools take the primary's connection settings
|
|
45
|
+
|
|
46
|
+
A replica is a **connection pool in the same process**, and it is configured by
|
|
47
|
+
the same environment variables the primary reads — `DB_MAX_CONNECTIONS`,
|
|
48
|
+
`PG_SSL`, `DB_SCHEMA`, `DB_STATEMENT_TIMEOUT_MS`, `DB_ACQUIRE_TIMEOUT_MS`. The
|
|
49
|
+
replica URL decides only WHICH server it talks to.
|
|
50
|
+
|
|
51
|
+
That matters for two reasons an operator has to plan for:
|
|
52
|
+
|
|
53
|
+
- **Connection budget.** `DB_REPLICA_URLS` with two entries means this process
|
|
54
|
+
opens **three** pools of `DB_MAX_CONNECTIONS` each, not one. The boot line
|
|
55
|
+
says so:
|
|
56
|
+
|
|
57
|
+
```
|
|
58
|
+
db pool: max=10 per replica (DB_MAX_CONNECTIONS) → max × 3 = 30 per pod × 4 replicas = up to 120 connections.
|
|
59
|
+
This process opens 3 pools of that size — 1 primary + 2 read replica(s) (DB_REPLICA_URLS), each with its own DB_MAX_CONNECTIONS.
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
- **TLS.** `PG_SSL=require` now applies to replica connections as well. A
|
|
63
|
+
`?sslmode=require` in the replica URL still works and still wins over the
|
|
64
|
+
driver default, but an explicit `PG_SSL` outranks it — so the TLS decision is
|
|
65
|
+
made once, for every connection the process opens.
|
|
66
|
+
|
|
44
67
|
## Read-your-writes (RYW)
|
|
45
68
|
|
|
46
69
|
After a mutation commits, the subject who wrote is **pinned to
|
|
@@ -319,6 +342,43 @@ will NOT reach clients on other replicas. Add @voltro/plugin-broadcast (Redis /
|
|
|
319
342
|
|
|
320
343
|
When BOTH a native path and the broadcast plugin are wired (e.g. postgres + broadcast), both stay active — the own-origin skip dedups, so there's no double-emit. The native path is the primary; the bus is harmless redundancy.
|
|
321
344
|
|
|
345
|
+
## sqlite and the memory store behind replicas
|
|
346
|
+
|
|
347
|
+
Neither dialect has a cross-instance path, and neither can get one: a local
|
|
348
|
+
database file and an in-process store are, by construction, **this process's**.
|
|
349
|
+
Putting several replicas in front of one is not a reactivity gap — the replicas
|
|
350
|
+
do not share a database at all, so each one is also reading its own data.
|
|
351
|
+
|
|
352
|
+
On a laptop that is the correct, normal configuration, which is why the framework
|
|
353
|
+
said nothing about it for a long time. It now warns when it can see evidence of
|
|
354
|
+
an **orchestrator**:
|
|
355
|
+
|
|
356
|
+
```
|
|
357
|
+
[voltro:serve] this app runs on 'sqlite' — a local database file — but POD_IP is set
|
|
358
|
+
(Kubernetes), which means several replicas. Neither dialect has ANY cross-instance
|
|
359
|
+
change capture: a client connected to replica A never sees a write made on replica B,
|
|
360
|
+
for every table, and each replica is also reading its OWN data…
|
|
361
|
+
```
|
|
362
|
+
|
|
363
|
+
The evidence is one of `REPLICA_COUNT > 1`, `KUBERNETES_SERVICE_HOST`, `POD_IP`,
|
|
364
|
+
`POD_NAME`, `FLY_ALLOC_ID`, `FLY_MACHINE_ID`, `ECS_CONTAINER_METADATA_URI[_V4]`,
|
|
365
|
+
`K_REVISION`, `CONTAINER_APP_REPLICA_NAME` or `RENDER_INSTANCE_ID`. `HOSTNAME`,
|
|
366
|
+
`NODE_ENV` and `PORT` are deliberately **not** evidence — every single-instance
|
|
367
|
+
container sets those too, and a warning that fires on a laptop gets filtered out
|
|
368
|
+
before it reaches the deployment where it is true.
|
|
369
|
+
|
|
370
|
+
Two ways to silence it, and they are not equivalent:
|
|
371
|
+
|
|
372
|
+
- `REPLICA_COUNT=1` — you are telling the framework there is exactly one process.
|
|
373
|
+
This is the honest one, and it is the only positive evidence *against* that
|
|
374
|
+
exists, so it outranks every platform signal.
|
|
375
|
+
- Declaring `@voltro/plugin-broadcast` with a real provider — not because a bus
|
|
376
|
+
fixes it (the replicas still have separate databases), but because declaring
|
|
377
|
+
one means you have already thought about the question.
|
|
378
|
+
|
|
379
|
+
The real fix is postgres or mysql/mariadb: a shared database with a native
|
|
380
|
+
cross-instance change path.
|
|
381
|
+
|
|
322
382
|
## Where it lives in the codebase
|
|
323
383
|
|
|
324
384
|
- `voltro/packages/plugin-broadcast` — the plugin, the `BroadcastProvider` contract, the redis / nats / memory providers, `attachBroadcastBus`.
|
|
@@ -373,6 +373,7 @@ import { TableValidationFailed } from '@voltro/runtime'
|
|
|
373
373
|
|
|
374
374
|
export const createUser = defineMutation({
|
|
375
375
|
name: 'users.create',
|
|
376
|
+
guards: [{ scope: 'users:write' }],
|
|
376
377
|
input: Schema.Struct({ email: Schema.String }),
|
|
377
378
|
output: Schema.Struct({ id: Schema.String }),
|
|
378
379
|
target: { table: 'users', op: 'insert' },
|
|
@@ -74,21 +74,68 @@ table. If you want one tenant's rows, say so in your own predicate.
|
|
|
74
74
|
|---|---|---|
|
|
75
75
|
| `boot` | On every `voltro dev` boot, **only if the fingerprint changed** | — |
|
|
76
76
|
| `manual` | Only via `voltro db seed --id <name>` or the dashboard | — |
|
|
77
|
-
| `onTenantCreate` |
|
|
78
|
-
| `onSchemaChange` |
|
|
79
|
-
| `cron` |
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
77
|
+
| `onTenantCreate` | Every time a tenant namespace is provisioned, **into that namespace** | a store with namespace isolation |
|
|
78
|
+
| `onSchemaChange` | After a schema apply that touched a watched table (`voltro dev`'s boot auto-migrate, `voltro db apply`, `voltro migrate`) | `watchedTables` |
|
|
79
|
+
| `cron` | On the cron expression, through the coordinated scheduler — one firing fleet-wide | `cron` |
|
|
80
|
+
|
|
81
|
+
### `onTenantCreate`
|
|
82
|
+
|
|
83
|
+
Fires from `provisionTenantNamespace`, after the namespace DDL lands and before
|
|
84
|
+
provisioning resolves — so a caller that awaits it gets a tenant whose tables
|
|
85
|
+
**and** reference data exist, or an error. Three properties worth knowing:
|
|
86
|
+
|
|
87
|
+
- **Steps run scoped to the new namespace**, never the shared tables. A store
|
|
88
|
+
without namespace isolation (sqlite, a single-schema deployment) makes the run
|
|
89
|
+
**refuse** rather than fall back — the fallback would write one tenant's
|
|
90
|
+
fixture into every tenant's data. On a single-namespace deployment, declare
|
|
91
|
+
the seed `lifecycle: 'boot'` instead.
|
|
92
|
+
- **No fingerprint skip.** A new namespace has none of the data, whatever
|
|
93
|
+
another tenant's run recorded. The ledger row is keyed `<seedId>@<namespace>`,
|
|
94
|
+
so N tenants produce N rows in `_voltro_seeds`.
|
|
95
|
+
- **A failure fails provisioning.** Unlike a boot seed, which logs and lets the
|
|
96
|
+
server come up, a failed tenant-create seed rejects the provisioning call — a
|
|
97
|
+
tenant whose namespace exists and whose data does not, reported as success, is
|
|
98
|
+
worse than a loud error. Re-run provisioning (it is idempotent) or
|
|
99
|
+
`voltro db seed --id <name>` once the cause is fixed.
|
|
100
|
+
|
|
101
|
+
### `onSchemaChange`
|
|
102
|
+
|
|
103
|
+
Fires from the migration applier, so it covers every path that applies a schema:
|
|
104
|
+
`voltro dev`'s boot auto-migrate, `voltro db apply` (bare and `--plan`) and
|
|
105
|
+
`voltro migrate`. Every seed whose `watchedTables` intersect the tables the apply
|
|
106
|
+
actually changed runs once, after the apply.
|
|
107
|
+
|
|
108
|
+
Three properties worth knowing:
|
|
109
|
+
|
|
110
|
+
- **Strictly post-apply.** It runs after the audit row is written, so on a
|
|
111
|
+
transactional dialect the DDL has already committed and the seed talks to its
|
|
112
|
+
own `DataStore` rather than the migration's connection.
|
|
113
|
+
- **It cannot fail the migration.** The schema landed; a fixture that throws is
|
|
114
|
+
logged and recorded in `_voltro_seeds`. A successful apply is never reported as
|
|
115
|
+
failed because reference data did not load.
|
|
116
|
+
- **`voltro serve` does not run it** — for the same reason it does not run boot
|
|
117
|
+
seeds. Serve never applies a schema, so there is no schema change for it to
|
|
118
|
+
react to; the pre-deploy `voltro db apply` is where it happens.
|
|
119
|
+
|
|
120
|
+
> This seam was **declared and never called** until 0.34.0: the hook installed,
|
|
121
|
+
> the seeds were discovered, listed and ledgered, and nothing ran. If you
|
|
122
|
+
> declared an `onSchemaChange` seed before that release, expect it to fire on
|
|
123
|
+
> your next apply.
|
|
124
|
+
|
|
125
|
+
### `cron`
|
|
126
|
+
|
|
127
|
+
`cron` seeds are projected into real schedules — the same coordinated scheduler
|
|
128
|
+
`*.cron.tsx` uses, so one firing happens fleet-wide instead of one per replica.
|
|
129
|
+
Both `voltro dev` and `voltro serve` register them at boot and log the schedule
|
|
130
|
+
names they created.
|
|
131
|
+
|
|
132
|
+
The schedule is named `seed:<id>`, which is also the name its runs appear under
|
|
133
|
+
in `_voltro_schedule_runs` and in the dashboard — distinct from a `*.cron.tsx`
|
|
134
|
+
namespace, so a schedule and a seed may share an id without colliding.
|
|
135
|
+
|
|
136
|
+
A cron firing runs the seed **unconditionally**. The fingerprint skip that makes
|
|
137
|
+
a `boot` seed cheap does not apply here: the trigger is the clock, not a change
|
|
138
|
+
in the source.
|
|
92
139
|
|
|
93
140
|
```ts
|
|
94
141
|
defineSeed({
|
|
@@ -96,11 +143,24 @@ defineSeed({
|
|
|
96
143
|
name: 'Rebuild search index nightly',
|
|
97
144
|
lifecycle: 'cron',
|
|
98
145
|
cron: '0 3 * * *', // required for lifecycle: 'cron'
|
|
146
|
+
timezone: 'Europe/Berlin', // optional; defaults to an explicit 'UTC'
|
|
99
147
|
steps: ({ step }) => [ /* … */ ],
|
|
100
148
|
})
|
|
101
149
|
```
|
|
102
150
|
|
|
103
|
-
`
|
|
151
|
+
`timezone` is defaulted rather than required (unlike `defineSchedule`'s, where
|
|
152
|
+
it is mandatory) — reference data rarely cares about a local wall clock. The
|
|
153
|
+
default is an explicit `'UTC'`, never the container's clock.
|
|
154
|
+
|
|
155
|
+
`defineSeed` validates at definition time: `cron` lifecycle without a `cron` field throws, `onSchemaChange` without `watchedTables` throws, and a seed with zero steps throws. The cron EXPRESSION is validated at boot, so a typo fails the boot naming the seed rather than never firing.
|
|
156
|
+
|
|
157
|
+
> **An app with any `*.seed.ts` gets the two schedule ledger tables**
|
|
158
|
+
> (`_voltro_schedule_runs`, `_voltro_schedule_claims`), even without a cron seed.
|
|
159
|
+
> Whether a seed is a cron seed is a field INSIDE the file, and `voltro migrate`
|
|
160
|
+
> never imports your modules — so the table set is decided from the FILENAME, and
|
|
161
|
+
> it has to be decided identically by `voltro dev`, `voltro serve`, `voltro db
|
|
162
|
+
> apply` and `voltro migrate` or the schema fingerprint diverges. Two empty ledger
|
|
163
|
+
> tables is the price of that agreement.
|
|
104
164
|
|
|
105
165
|
## Steps
|
|
106
166
|
|
|
@@ -138,6 +198,7 @@ voltro db seed --store memory # explicit opt-in: in-memory smoke run
|
|
|
138
198
|
|
|
139
199
|
- **Reference data** (must exist in every environment): `lifecycle: 'boot'`, idempotent via `upsertByUnique`. Safe in production.
|
|
140
200
|
- **Demo data** (populate a fresh dashboard): `lifecycle: 'boot'` in dev, or `lifecycle: 'manual'` so it's operator-triggered and never auto-runs in prod.
|
|
201
|
+
- **Per-tenant starter data** (a new tenant's default categories, roles, settings): `lifecycle: 'onTenantCreate'`, which runs scoped to the tenant's own namespace as it is provisioned.
|
|
141
202
|
|
|
142
203
|
See [migrations](/docs/database/migrations) for schema changes — seeds populate data, migrations shape the tables.
|
|
143
204
|
|
|
@@ -237,6 +298,49 @@ It applies to the **runtime query path only**. Migrations (`voltro db apply`) ru
|
|
|
237
298
|
|
|
238
299
|
**Wired for postgres today** (the default dialect), where it maps to the server-side `statement_timeout` — a real, server-enforced cancel (SQLSTATE `57014`), not a client-side disconnect that leaves the query running. Other dialects accept the variable but currently ignore it, and the reasons are honest rather than incidental: `@effect/sql-mssql` exposes only a connection-establishment timeout, not a per-request one; MySQL/MariaDB's `max_execution_time` bounds `SELECT`s only (writes stay unbounded), which would be a misleading half-guarantee; and SQLite is in-process with a single connection, so there is no pool to protect. Unset (or any non-postgres dialect) = no timeout.
|
|
239
300
|
|
|
301
|
+
### Acquire timeout — bound the wait for a free connection
|
|
302
|
+
|
|
303
|
+
`DB_STATEMENT_TIMEOUT_MS` above bounds a query the **server** is running. Nothing bounded a query the client had not sent yet: a request arriving when every pooled connection is busy waited — with no error, no retry and no log line — until something else finished. Those are the two halves of "a request is stuck", and only one of them was covered.
|
|
304
|
+
|
|
305
|
+
Every dialect that can bound an acquire now does, **by default**:
|
|
306
|
+
|
|
307
|
+
| Dialect | Default | What is bounded |
|
|
308
|
+
|---|---|---|
|
|
309
|
+
| postgres | 10 s | the whole acquire — waiting in the pool's queue *and* connecting |
|
|
310
|
+
| mysql / mariadb | 10 s connect · 100 queued waiters | connecting; the wait is bounded by queue **length**, see below |
|
|
311
|
+
| mssql | 10 s | connecting (and the boot probe) |
|
|
312
|
+
| sqlite / turso | — | one in-process connection, no pool to exhaust |
|
|
313
|
+
|
|
314
|
+
An exhausted pool now fails with an error that **names the pool** — `Failed to acquire connection` — at the moment the pool is the cause, instead of surfacing as unexplained latency somewhere with no connection information in it.
|
|
315
|
+
|
|
316
|
+
Move it with `DB_ACQUIRE_TIMEOUT_MS`, in milliseconds. `0` restores the driver's unbounded wait; a negative or non-numeric value falls back to the 10 s default rather than to no bound at all:
|
|
317
|
+
|
|
318
|
+
```sh
|
|
319
|
+
DB_ACQUIRE_TIMEOUT_MS=3000 # 0 = the driver's unbounded wait
|
|
320
|
+
DB_ACQUIRE_QUEUE_LIMIT=2000 # mysql / mariadb only, OPT-IN — read the hazard below
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
Both are read on every command that opens a pool — `voltro dev`, `voltro serve`, `voltro migrate` and every `voltro db …` subcommand alike — so the variable means one thing across your deployment. (`DB_STATEMENT_TIMEOUT_MS` above is deliberately runtime-only: a migration runs legitimately long *statements*. An acquire bound fires when no connection is free at all, which a migration has no more reason to wait forever for than a request does.)
|
|
324
|
+
|
|
325
|
+
That "every command" is literal, and it is worth stating because it has not always been true. Every pool the CLI opens gets its configuration from **one** resolver, so a knob cannot be honoured by one command and ignored by the next. The only two things the command changes are the two named here: `DB_STATEMENT_TIMEOUT_MS` (runtime only) and `DB_DIRECT_URL` / `DB_MIGRATE_URL` (migration only). Everything else — pool size, `DB_SCHEMA`, TLS (`PG_SSL`), the acquire bounds — resolves identically everywhere, including in the two places that open a bare postgres client rather than a pool (the web process's ISR cache and its CDC listener).
|
|
326
|
+
|
|
327
|
+
The same value is `ConnectionConfig.acquireTimeoutMs` when you build a layer yourself:
|
|
328
|
+
|
|
329
|
+
```typescript
|
|
330
|
+
import { postgresDialect } from '@voltro/sql-postgres'
|
|
331
|
+
|
|
332
|
+
const layer = postgresDialect.makeSqlLayer({
|
|
333
|
+
url: process.env.DB_URL!,
|
|
334
|
+
acquireTimeoutMs: 3_000, // 0 = the driver's unbounded wait
|
|
335
|
+
})
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
**mysql/mariadb cannot express a time bound on the waiting half, and the length bound that exists is opt-in.** `mysql2`'s pool queues the waiting caller with no timer at all, so there is nothing to set. The one bound the driver has is a LENGTH — `ConnectionConfig.acquireQueueLimit` / `DB_ACQUIRE_QUEUE_LIMIT`, mysql2's `queueLimit`. **The framework does not set it for you**, and the reason is worth understanding before you do:
|
|
339
|
+
|
|
340
|
+
A time bound self-throttles. The acquire fails only once the wait has elapsed, so nothing can retry it faster than the timeout. A length bound is free: past the limit mysql2 rejects the acquire *synchronously*, so any caller that retries a failed acquire without a delay retries in the same tick — forever. The event loop is never reached again, which means no timer fires and the queue whose depth caused the rejection can never drain. The symptom is a process pinned at 100% CPU with no error, no log line, and an idle database.
|
|
341
|
+
|
|
342
|
+
That caller is not hypothetical: the workflow cluster releases shards one statement per shard (300 by default) and retries a failed release immediately. So set `DB_ACQUIRE_QUEUE_LIMIT` only if you know nothing in your process retries an acquire without backoff, and set it well above the peak concurrency of anything that might. Unset — the default — you get mysql2's unbounded queue: callers wait rather than fail. mssql's pool exposes neither knob, so only establishment is bounded there — stated rather than papered over, for the same reason `DB_STATEMENT_TIMEOUT_MS` is left unwired on the dialects that cannot enforce it honestly.
|
|
343
|
+
|
|
240
344
|
## Local development — bring up all five
|
|
241
345
|
|
|
242
346
|
The framework ships a docker-compose at `voltro/test/docker-compose.yml` that brings up postgres + mysql + mariadb + mssql on distinct ports so per-dialect tests can run side-by-side and the dev fixture never clashes with your starter postgres on `:5432`:
|
|
@@ -378,6 +482,8 @@ The framework wraps `pg` (`node-postgres`) via Effect's typed connection layer.
|
|
|
378
482
|
| `PG_MAX_CONNECTIONS` | `10` | pool ceiling. Tune up for high concurrency; CPU-bound workloads rarely benefit past ~2× cores. |
|
|
379
483
|
| `PG_SSL` | unset | `'require'` (TLS without certificate verification — what production deployments behind RDS / Cloud SQL / Supabase want) / `'disable'` (force plaintext). Any other value — including libpq's `'prefer'`, which node-postgres cannot express (the driver has no TLS-then-plaintext fallback) — fails at boot instead of silently downgrading to plaintext. A `?sslmode=require\|disable` query on `DB_URL` works too; an explicit `PG_SSL` wins when both are set. |
|
|
380
484
|
|
|
485
|
+
`PG_SSL` is read by **every** command that opens a connection — `voltro dev`, `voltro serve`, `voltro start`, `voltro migrate` and every `voltro db …` subcommand — so TLS is not something one command negotiates and the next one skips.
|
|
486
|
+
|
|
381
487
|
## CDC — LISTEN/NOTIFY
|
|
382
488
|
|
|
383
489
|
Every table gets an AFTER trigger that emits a `framework_changes` notification on each insert / update / delete. The dispatcher LISTENs on that channel once per process and fans out to subscribers in-memory.
|
|
@@ -409,10 +515,63 @@ Trade-offs of the LISTEN/NOTIFY path:
|
|
|
409
515
|
- ✅ Sub-frame latency (60fps UIs feel real-time).
|
|
410
516
|
- ✅ Zero polling load even on idle tables.
|
|
411
517
|
- ❌ Requires a long-lived connection per process. Connection-pooled deploys must use a sidecar listener or pgbouncer in session mode.
|
|
412
|
-
- ❌ NOTIFY payloads are capped at
|
|
518
|
+
- ❌ NOTIFY payloads are capped at 8000 bytes. The trigger sends the full row images (`row_to_json(OLD)`/`row_to_json(NEW)`) so subscribers get the pre/post values directly; a wide row that would exceed the cap falls back to a key-only notification. See the delivery guarantee below — the fallback is not free, and it is not the same for a subscription as it is for a tap.
|
|
413
519
|
|
|
414
520
|
Set `CDC=0` to disable + force the inline-emit path (single-process only, no cross-process fan-out). Useful for tests + single-binary deploys.
|
|
415
521
|
|
|
522
|
+
### Oversized rows — what is guaranteed
|
|
523
|
+
|
|
524
|
+
A row whose JSON image exceeds ~8000 bytes (a document, a large `json()` column,
|
|
525
|
+
an embedded array) cannot travel in a NOTIFY payload. The trigger keeps the
|
|
526
|
+
change and the **primary key**, drops the images, and the CDC consumer re-reads
|
|
527
|
+
the row from the database before the event reaches anything. What each consumer
|
|
528
|
+
gets:
|
|
529
|
+
|
|
530
|
+
| Change | What is delivered | `event.oversized` |
|
|
531
|
+
|---|---|---|
|
|
532
|
+
| insert / update | the row **re-read from the database** | `'rehydrated'` |
|
|
533
|
+
| delete | the **primary key only** — a tombstone | `'tombstone'` |
|
|
534
|
+
| key missing, re-read failed, or the row is already gone | both images null | `'unrecovered'` |
|
|
535
|
+
|
|
536
|
+
Read the marker before you treat an image as a snapshot. Three limits are real
|
|
537
|
+
and cannot be engineered away:
|
|
538
|
+
|
|
539
|
+
- **A re-read returns the row as it is NOW.** If a second write lands between
|
|
540
|
+
the change and the re-read, this event carries the newer state — and the
|
|
541
|
+
second change delivers it again. The stream is convergent, not
|
|
542
|
+
point-in-time. Postgres keeps no copy of an image the transport dropped.
|
|
543
|
+
- **`old` is null on an oversized update, and pk-only on an oversized delete.**
|
|
544
|
+
There is nowhere to read a pre-image from. A tombstone is enough to REMOVE the
|
|
545
|
+
row from a search index, an analytics mirror or a CDC stream; it is not a
|
|
546
|
+
record of what the row contained, and `@voltro/plugin-versioning` writes
|
|
547
|
+
`data: null` for one rather than a fabricated empty snapshot.
|
|
548
|
+
- **`'unrecovered'` means the content is gone.** No retry can bring it back —
|
|
549
|
+
it was never delivered. Subscriptions are unaffected (they re-query); taps
|
|
550
|
+
miss that row until the next write to it or a re-seed.
|
|
551
|
+
|
|
552
|
+
Every fallback is counted as `voltro_cdc_oversized_total{outcome=…}` (scrapeable
|
|
553
|
+
via `@voltro/plugin-prometheus` at `/metrics`, or `GET
|
|
554
|
+
/_voltro/inspect/metrics`), the first one per table is logged at `warn`, and
|
|
555
|
+
every `unrecovered` one is logged at `error`. **Alert on
|
|
556
|
+
`outcome="unrecovered"`** — a non-zero rate means the deployment is losing
|
|
557
|
+
changes for its taps.
|
|
558
|
+
|
|
559
|
+
Tunables (options on the postgres store, or environment):
|
|
560
|
+
|
|
561
|
+
| Env | Default | Meaning |
|
|
562
|
+
|---|---|---|
|
|
563
|
+
| `VOLTRO_CDC_REHYDRATE_TIMEOUT_MS` | `5000` | total budget for recovering one oversized change. The LISTEN consumer is serial, so this also bounds how long one oversized row can hold up the change stream. |
|
|
564
|
+
| `VOLTRO_CDC_REHYDRATE_RETRIES` | `2` | re-reads after the first attempt, inside that budget. |
|
|
565
|
+
|
|
566
|
+
The re-read is issued against the schema the write landed in, so it is correct
|
|
567
|
+
under namespace (schema-per-tenant) isolation.
|
|
568
|
+
|
|
569
|
+
**This lives in the database, so it has to be applied.** The trigger function is
|
|
570
|
+
DDL: a database created before this shipped still carries the old body, which
|
|
571
|
+
drops the key and makes every oversized change `'unrecovered'`. `voltro db
|
|
572
|
+
apply` replaces it (`voltro dev` reports it at boot as trigger drift, and the
|
|
573
|
+
`error` log line names the same remedy).
|
|
574
|
+
|
|
416
575
|
## Workflow cluster
|
|
417
576
|
|
|
418
577
|
`@effect/cluster`'s `SqlRunnerStorage` uses `pg_advisory_lock(key)` to claim shard ownership. The framework wires this transparently — set `DB_DIALECT=postgres` + provide a SqlClient layer and `workflowEngineLayer({ runnerStorage: 'sql' })` does the rest.
|
|
@@ -504,9 +663,25 @@ DB_HOST=… DB_PORT=… DB_USER=… DB_PASSWORD=… DB_DATABASE=…
|
|
|
504
663
|
# at migration time.
|
|
505
664
|
```
|
|
506
665
|
|
|
666
|
+
## TLS — honoured, or refused
|
|
667
|
+
|
|
668
|
+
Ask for TLS in the connection URL:
|
|
669
|
+
|
|
670
|
+
```sh
|
|
671
|
+
DB_URL=mysql://app:app@db.internal:3306/app?sslmode=require # TLS, certificate not verified
|
|
672
|
+
DB_URL=mysql://app:app@db.internal:3306/app?ssl=true # the provider-style alias — same thing
|
|
673
|
+
DB_URL=mysql://app:app@db.internal:3306/app?sslmode=disable # explicit plaintext
|
|
674
|
+
```
|
|
675
|
+
|
|
676
|
+
`require` maps to mysql2's `{ rejectUnauthorized: false }` — the connection is **encrypted, the server certificate is not authenticated**. That is the mode managed providers expect, and it is the same meaning `?sslmode=require` has on the postgres dialect.
|
|
677
|
+
|
|
678
|
+
**Anything else fails at boot**, on purpose: `prefer`, `allow`, `verify-ca`, `verify-full`, a CA-profile name, `?ssl=yes`. mysql2 either sends an SSLRequest or it does not, so `prefer` is not expressible; and a verification mode cannot round-trip through the cross-dialect boolean `ssl` field, so honouring the word would mean quietly giving you something weaker than it names. A boot failure is the correct answer to a TLS request that cannot be satisfied.
|
|
679
|
+
|
|
680
|
+
> **This changed.** Before this release the mysql/mariadb dialect had **no TLS path at all** — `?ssl=true` was parsed off the URL and discarded, and the connection went out in plaintext with no warning. If your URL carries `?ssl=` or `?sslmode=`, that request is now real: verify your server accepts TLS before deploying.
|
|
681
|
+
|
|
507
682
|
## Driver: `@effect/sql-mysql2`
|
|
508
683
|
|
|
509
|
-
Wraps the `mysql2` driver (Node.js mysql client). The framework's `MysqlStore` opens a connection pool sized via `DB_MAX_CONNECTIONS` (default 10).
|
|
684
|
+
Wraps the `mysql2` driver (Node.js mysql client). The framework's `MysqlStore` opens a connection pool sized via `DB_MAX_CONNECTIONS` (default 10), with a 10 s bound on **establishing** a connection. Waiting for a busy one is **not** bounded: mysql2 has no timer for that phase, and the length ceiling it does offer (`ConnectionConfig.acquireQueueLimit`) is opt-in because mysql2 rejects past the limit synchronously — which turns any zero-delay acquire retry into an event-loop-starving spin. See the [acquire-timeout table](../dialects) for the full reasoning before you set it.
|
|
510
685
|
|
|
511
686
|
The driver returns BOOLEAN as `0|1` (TINYINT(1) is MySQL's underlying type). The framework's `decodeRowsFromSchema` post-processor converts back to `true|false` for any column the schema registry declares as `boolean()`. Without this, every reactive subscription opening on a BOOLEAN column would see `1` instead of `true` and fail framework Schema validators.
|
|
512
687
|
|
|
@@ -709,6 +884,8 @@ makeMysqlStore({ sqlLayer, variant: 'mariadb', changeStrategy: 'cdc', cdcConfig
|
|
|
709
884
|
|
|
710
885
|
The variant flows through to per-operation getters (`supportsInsertReturning`, `supportsDeleteReturning`, `supportsUpdateReturning`) and to the JSON-agg compiler's dialect branch.
|
|
711
886
|
|
|
887
|
+
TLS and the pool bounds are shared with MySQL, driver-for-driver: `?sslmode=require` / `?ssl=true` encrypts (certificate not verified), an unsupported mode fails at boot rather than falling back to plaintext, and the acquire wait is bounded by queue length because mysql2 has no time-based bound for it. See [MySQL → TLS](./mysql) and the [acquire-timeout table](../dialects).
|
|
888
|
+
|
|
712
889
|
## `UPDATE … RETURNING` does NOT exist — anywhere
|
|
713
890
|
|
|
714
891
|
Despite MariaDB's broad RETURNING support — `INSERT … RETURNING *` since 10.5, `DELETE … RETURNING *` since 10.0 — there is **no UPDATE … RETURNING in any MariaDB version**. The framework's first attempt at supportsReturning treated the whole RETURNING family as one flag and emitted `UPDATE … RETURNING *` on mariadb, which fails with a parse error.
|
|
@@ -914,9 +1091,21 @@ DB_URL=mssql://sa:<password>@localhost:11433/voltro_test
|
|
|
914
1091
|
|
|
915
1092
|
The framework's docker-compose at `voltro/test/docker-compose.yml` brings up MSSQL on `:11433` with an `mssql-init` sidecar that runs `CREATE DATABASE voltro_test` once the server is healthy. mssql doesn't have a `docker-entrypoint-initdb.d` equivalent, so the framework synthesizes one.
|
|
916
1093
|
|
|
1094
|
+
## TLS — honoured, or refused
|
|
1095
|
+
|
|
1096
|
+
```sh
|
|
1097
|
+
DB_URL=mssql://sa:<password>@sql.internal:1433/app?sslmode=require # encrypt, don't verify the certificate
|
|
1098
|
+
DB_URL=mssql://sa:<password>@sql.internal:1433/app?encrypt=1 # tedious' own spelling — same thing
|
|
1099
|
+
DB_URL=mssql://sa:<password>@sql.internal:1433/app?sslmode=disable # explicit plaintext
|
|
1100
|
+
```
|
|
1101
|
+
|
|
1102
|
+
`require` maps to tedious `encrypt: true` + `trustServerCertificate: true` — encrypted, server certificate not authenticated, which is what lets a Developer-edition instance's self-signed certificate work. Unsupported modes (`prefer`, `verify-ca`, `verify-full`, `?encrypt=yes`) **fail at boot** rather than connecting with something weaker than they name.
|
|
1103
|
+
|
|
1104
|
+
> **This changed.** `@effect/sql-mssql` defaults `encrypt` to **false** (it overrides tedious' own `true`), and the framework never read the `ssl` config or the URL query — so every mssql session was plaintext, while a hard-coded `trustServerCertificate: true` made the configuration look TLS-aware. Unset still means plaintext (unchanged); a TLS request in the URL is now real.
|
|
1105
|
+
|
|
917
1106
|
## Driver: `@effect/sql-mssql`
|
|
918
1107
|
|
|
919
|
-
Wraps `tedious` (the Node.js TDS driver). The framework's `MssqlStore` opens a connection pool sized via `DB_MAX_CONNECTIONS` (default 10).
|
|
1108
|
+
Wraps `tedious` (the Node.js TDS driver). The framework's `MssqlStore` opens a connection pool sized via `DB_MAX_CONNECTIONS` (default 10), with a 10 s bound on establishing a connection (`ConnectionConfig.acquireTimeoutMs`). Waiting for a BUSY pooled connection is not bounded here — the client's pool exposes no such knob; see the [acquire-timeout table](../dialects) for what each dialect can enforce.
|
|
920
1109
|
|
|
921
1110
|
Tedious returns BIT as proper boolean (✓) and NVARCHAR as string. JSON columns are NVARCHAR(MAX) under the hood — driver returns the raw string. The framework's `decodeRowsFromSchema` JSON.parses any column declared as `json()` in the schema registry; without this, every reactive subscription reading a JSON column would see strings and fail framework Schema validators.
|
|
922
1111
|
|
|
@@ -47,6 +47,43 @@ await ctx.store.transactional(async (tx) => {
|
|
|
47
47
|
|
|
48
48
|
`transactional()` **must not be nested** — a nested invocation throws to surface a design mistake. Use a single top-level `transactional()` per unit of work.
|
|
49
49
|
|
|
50
|
+
## Typed errors survive a transaction
|
|
51
|
+
|
|
52
|
+
A `Data.TaggedError` thrown inside a transaction reaches the caller — and the browser client — **as itself**: `_tag`, payload and prototype intact, so a mutation's declared `error:` union still matches.
|
|
53
|
+
|
|
54
|
+
```ts
|
|
55
|
+
import { Data } from 'effect'
|
|
56
|
+
|
|
57
|
+
class NoteNotFound extends Data.TaggedError('NoteNotFound')<{ noteId: string }> {}
|
|
58
|
+
|
|
59
|
+
// apps/api/mutations/notes.rename.mutation.ts — auto-transactional
|
|
60
|
+
export default async (input, ctx) => {
|
|
61
|
+
const note = await ctx.store.query({ table: 'notes', /* … */ })
|
|
62
|
+
if (!note) throw new NoteNotFound({ noteId: input.id }) // arrives typed on the client
|
|
63
|
+
// …
|
|
64
|
+
}
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
On the client:
|
|
68
|
+
|
|
69
|
+
```ts
|
|
70
|
+
const res = await client.notes.rename({ id, title })
|
|
71
|
+
if (res.error?._tag === 'NoteNotFound') { /* this branch fires */ }
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
This holds on **every** dialect and on **both** tenancy topologies — shared-schema and namespace isolation — because all four dialect stores and both postgres entry points run through one shared transaction bracket. Never match on `error.message` to identify a transaction failure; the `_tag` is the contract.
|
|
75
|
+
|
|
76
|
+
## Automatic retry on transient conflicts
|
|
77
|
+
|
|
78
|
+
A transaction that fails with a transient contention error is retried automatically: exponential backoff from 10 ms, up to 3 retries (4 attempts total). What counts as transient is per-dialect — postgres `serialization_failure` (40001) and `deadlock_detected` (40P01), mysql/mariadb deadlock and lock-wait timeout, mssql deadlock victim (1205), sqlite `SQLITE_BUSY`/`SQLITE_LOCKED`, and Turso's MVCC write-write conflict.
|
|
79
|
+
|
|
80
|
+
Two properties worth knowing:
|
|
81
|
+
|
|
82
|
+
- **A conflict raised by the COMMIT itself is retried too.** Under `SERIALIZABLE`, the engine can only detect some conflicts at commit time — those are caught and replayed like any other.
|
|
83
|
+
- **Each attempt gets a fresh transaction and a fresh event buffer.** A retried attempt's queued ChangeEvents are discarded with it, so subscribers see exactly one event set: the winning attempt's.
|
|
84
|
+
|
|
85
|
+
Because the body can run more than once, keep `transactional()` bodies **idempotent** — no counters incremented in JS, no external calls (see the anti-patterns below).
|
|
86
|
+
|
|
50
87
|
## Optimistic concurrency
|
|
51
88
|
|
|
52
89
|
The fluent update builder carries an `.expectVersion(n)` guard: the update only matches rows whose `version` equals `n`, and throws `OptimisticLockError` when none match (the row was concurrently changed). Add a `version` column to the table to use it.
|
|
@@ -134,6 +171,37 @@ array is a no-op that returns `[]`.
|
|
|
134
171
|
see each new row's delta — identical to N single inserts. Inside a
|
|
135
172
|
`transactional()` the per-row events queue until commit and drop on a throw.
|
|
136
173
|
|
|
174
|
+
### Large arrays are chunked for you — and stay all-or-nothing
|
|
175
|
+
|
|
176
|
+
Every engine caps what ONE statement may carry, and the caps are far apart:
|
|
177
|
+
|
|
178
|
+
| Dialect | Bind parameters per statement | Rows per `VALUES` |
|
|
179
|
+
| --- | --- | --- |
|
|
180
|
+
| postgres | 65 535 | — |
|
|
181
|
+
| mysql / mariadb | 65 535 | — |
|
|
182
|
+
| **mssql** | **2 098** | **1 000** |
|
|
183
|
+
| sqlite / turso | 32 766 | — |
|
|
184
|
+
|
|
185
|
+
`INSERT … VALUES` binds one parameter per **column per row**, so the row limit
|
|
186
|
+
is `floor(cap / columns)` — a 12-column table caps at 5 461 rows on postgres and
|
|
187
|
+
at **174** on mssql. Past that the driver refuses with its own error about a
|
|
188
|
+
limit you never chose.
|
|
189
|
+
|
|
190
|
+
`insertMany` splits the array for you at that boundary. Two properties are
|
|
191
|
+
guaranteed:
|
|
192
|
+
|
|
193
|
+
- **A fitting array is still ONE statement.** Nothing changes for the normal
|
|
194
|
+
case — no extra round-trips, no behaviour difference.
|
|
195
|
+
- **A chunked insert is still all-or-nothing.** When the call is not already
|
|
196
|
+
inside a `transactional()`, the chunks run in one transaction the framework
|
|
197
|
+
opens, so a failure in the last chunk rolls back the earlier ones. Without
|
|
198
|
+
that, chunking would quietly add partial-success-on-failure to a call that
|
|
199
|
+
never had it.
|
|
200
|
+
|
|
201
|
+
There is nothing to configure. If a SINGLE row is wider than the cap (a
|
|
202
|
+
3 000-column table on mssql) the engine's own error is what you get — a row
|
|
203
|
+
cannot be split.
|
|
204
|
+
|
|
137
205
|
## `upsert` — insert or update on conflict
|
|
138
206
|
|
|
139
207
|
The 90% case: "make this row exist with these values; if it already
|
|
@@ -300,9 +300,9 @@ cause.
|
|
|
300
300
|
| `VOLTRO_STORAGE_SECRET` | optional (`@voltro/plugin-storage`) | Signs private-file grant tokens; falls back to the session secret if unset |
|
|
301
301
|
| `AI_PROVIDER` | when using `@voltro/ai` | `anthropic` / `openai` / `mock` |
|
|
302
302
|
| `AI_API_KEY` | with `AI_PROVIDER` | Provider's API key |
|
|
303
|
-
| `SSR_CACHE` | optional | `memory` (default) or `postgres`
|
|
303
|
+
| `SSR_CACHE` | optional | `memory` (default) or `postgres` for the ISR cache. `postgres` needs a database in the WEB process's env (`DB_URL` / `DB_HOST` / `PG_HOST`) — without one it aborts the boot in production rather than falling back to memory |
|
|
304
304
|
| `VOLTRO_API_ORIGIN` / `VOLTRO_API_ORIGIN_<NAME>` | split web/api deploy with SSR pages | The api's internal origin the WEB pod uses for SSR `ctx.query` (`http://api.<ns>.svc.cluster.local`). Per-api `_<NAME>` (name upper-cased) wins over the shared one and over `serverUrl`. Never sent to the browser |
|
|
305
|
-
| `PORT` | optional | App's listen port
|
|
305
|
+
| `PORT` | optional | App's listen port. Outranks `--port` and `app.config.ts` `port:` — a platform that assigns a port sets this one, so it has to win. Unset, the app binds its declared `port:`, then 4000 (api) / 5173 (web). |
|
|
306
306
|
| `VOLTRO_INSPECT` | optional | `off` to disable `_voltro/inspect/*` in prod |
|
|
307
307
|
| `VOLTRO_INSPECT_TOKEN` | recommended | Bearer token guard on inspect endpoints |
|
|
308
308
|
| `VOLTRO_DASHBOARD_APPS` | dashboard only | JSON `[{name?,url,token?}]` — target apps + inspect tokens the deployed DevTools dashboard shows (served at runtime from `/api/dashboard/config`) |
|
|
@@ -833,6 +833,8 @@ VOLTRO_SESSION_SECRET_PREVIOUS=<old> # still verifies live cookies
|
|
|
833
833
|
|
|
834
834
|
Keep `_PREVIOUS` in place for one session-TTL window, then drop it. Existing cookies verify against `previous` until they naturally expire — no live session is invalidated.
|
|
835
835
|
|
|
836
|
+
> **If you embed the framework's middleware yourself** rather than booting through `voltro serve`, there is no boot gate to catch a missing secret. In that case a presented `voltro:session` cookie that cannot be verified now logs, once per process and at error level, that no credential-expiry bound is being imposed — so realtime subscriptions on that connection will not be cut off when the session expires. It is a diagnostic, not a refusal: a stale `voltro:session` cookie from another app on the same host is a normal thing for a browser to carry, and failing the request would turn that into a denial of service.
|
|
837
|
+
|
|
836
838
|
## 2. Kubernetes health probes
|
|
837
839
|
|
|
838
840
|
`voltro serve` exposes two unauthenticated endpoints, both handled **before** any rate-limit interceptor:
|
|
@@ -1002,12 +1004,21 @@ indexed query instead of scanning every table). Force a full re-introspect with
|
|
|
1002
1004
|
|
|
1003
1005
|
## 4. Request limits & DoS
|
|
1004
1006
|
|
|
1005
|
-
`POST /rpc` (the buffered JSON endpoint the SSR loaders use)
|
|
1007
|
+
`POST /rpc` (the buffered JSON endpoint the SSR loaders use) is capped, and the cap is enforced **as bytes arrive**:
|
|
1006
1008
|
|
|
1007
1009
|
```sh
|
|
1008
1010
|
VOLTRO_MAX_RPC_BODY_BYTES=8388608 # default 8 MiB
|
|
1009
1011
|
```
|
|
1010
1012
|
|
|
1013
|
+
A declared `Content-Length` over the cap is refused up front, so an honest client
|
|
1014
|
+
gets its **413** without uploading anything — a courtesy, not the enforcement,
|
|
1015
|
+
since a `Transfer-Encoding: chunked` body declares no length at all. The byte
|
|
1016
|
+
counter is the enforcement: it stops accumulating the moment the running total
|
|
1017
|
+
crosses the limit, drains the rest of the upload rather than dropping the
|
|
1018
|
+
connection, and answers **413**. Both shapes therefore end in the same status
|
|
1019
|
+
code, and the refusal is logged under the `voltro:security` scope with the cap
|
|
1020
|
+
and the byte count at which the server stopped reading.
|
|
1021
|
+
|
|
1011
1022
|
Scope: this guards the `/rpc` JSON path only. File uploads ride separate storage routes with their own `limits.maxBytes`, and WebSocket frames are capped by the `ws` library default (100 MiB).
|
|
1012
1023
|
|
|
1013
1024
|
**Put per-IP rate limiting and the primary body-size cap at the ingress** — that is the correct layer: it holds per-IP state and works across replicas, which an in-process limit can't.
|
|
@@ -1020,6 +1031,51 @@ nginx.ingress.kubernetes.io/limit-rps: "20"
|
|
|
1020
1031
|
|
|
1021
1032
|
For **app-level** throttling (per-subject / per-tenant, e.g. an expensive action), use `@voltro/plugin-ratelimit` and the plugin `onHttpRequest` interceptor seam. It complements the ingress cap — it does not replace it.
|
|
1022
1033
|
|
|
1034
|
+
**There is no rate limit in the box.** The body cap above is the only request guard the runtime applies by default; `@voltro/plugin-ratelimit` is opt-in, so an app that has not installed and configured it has no per-IP, per-API-key or per-tenant cap on `/rpc` at all. The one on-by-default throttle anywhere in the framework is `plugin-auth`'s [brute-force lockout](/docs/authentication/passwords#brute-force-lockout), which covers sign-in credential attempts and nothing else. Treat the ingress limit as required, not as belt-and-braces.
|
|
1035
|
+
|
|
1036
|
+
### Per-IP limits need a trusted proxy
|
|
1037
|
+
|
|
1038
|
+
The address the runtime rate-limits, geo-blocks and audits by is
|
|
1039
|
+
`socket.remoteAddress` — **not** `x-forwarded-for`, which any client can write.
|
|
1040
|
+
Behind an ingress that means every request counts against the proxy's address —
|
|
1041
|
+
and every `sessions.ipAddress` row records the proxy — until you declare the hop:
|
|
1042
|
+
|
|
1043
|
+
```ts
|
|
1044
|
+
// app.config.ts
|
|
1045
|
+
export default {
|
|
1046
|
+
security: {
|
|
1047
|
+
trustedProxies: ['private'], // or: ['loopback'] · ['10.0.0.0/8'] · ['2'] · ['*']
|
|
1048
|
+
},
|
|
1049
|
+
}
|
|
1050
|
+
```
|
|
1051
|
+
|
|
1052
|
+
The same setting decides whether `x-forwarded-proto` is believed, which is what
|
|
1053
|
+
lets the runtime emit HSTS behind a TLS-terminating load balancer. Override it
|
|
1054
|
+
on a running deployment with `VOLTRO_TRUSTED_PROXIES=private` (comma-separated).
|
|
1055
|
+
|
|
1056
|
+
### Cross-site protection and its allowlist
|
|
1057
|
+
|
|
1058
|
+
Every state-changing request — `POST /rpc`, the `/ws` upgrade, every REST route
|
|
1059
|
+
projected from a `publicApi:` mutation, everything in `apiConfig.restRoutes`,
|
|
1060
|
+
and `POST /v1/api-keys` — refuses a browser request whose `Origin` is neither
|
|
1061
|
+
the `Host` it was sent to nor an allowlisted origin. **A split web/api
|
|
1062
|
+
deployment must declare its web origin** or the browser gets a 403 on every
|
|
1063
|
+
mutation, REST write and socket:
|
|
1064
|
+
|
|
1065
|
+
```ts
|
|
1066
|
+
// app.config.ts
|
|
1067
|
+
export default {
|
|
1068
|
+
security: {
|
|
1069
|
+
allowedOrigins: ['https://app.example.com'],
|
|
1070
|
+
},
|
|
1071
|
+
}
|
|
1072
|
+
```
|
|
1073
|
+
|
|
1074
|
+
The environment override is `VOLTRO_ALLOWED_ORIGINS` (comma-separated).
|
|
1075
|
+
Server-to-server callers (SSR loaders, mobile SDKs, other services) send no
|
|
1076
|
+
`Origin` and are unaffected. Full detail in
|
|
1077
|
+
[Security](/docs/security/overview#cross-site-requests-are-refused).
|
|
1078
|
+
|
|
1023
1079
|
## 5. Multi-tenant isolation
|
|
1024
1080
|
|
|
1025
1081
|
Tables carrying the `tenant()` mixin are auto-scoped to the request's tenant — nothing more to do there. The gap is the **anonymous** request that matches no auth strategy: by default it resolves to a tenant-less anonymous Subject that can read any non-`tenant()` table across the DB.
|
|
@@ -1079,6 +1135,12 @@ sink, stops accepting new connections while **finishing already-accepted
|
|
|
1079
1135
|
requests**, and closes the SQL connection pool **last** (waiting for in-flight
|
|
1080
1136
|
transactions). Verified under concurrent load — accepted requests complete.
|
|
1081
1137
|
|
|
1138
|
+
The **transactional-outbox worker** is part of that sequence: its poll timer and
|
|
1139
|
+
its change subscription are released, and a delivery already in flight is
|
|
1140
|
+
awaited, before the pool closes. An outbox row that was still pending is not
|
|
1141
|
+
lost — it is durable, and the next process's first pass picks it up, which is
|
|
1142
|
+
one of the three reasons that poll exists.
|
|
1143
|
+
|
|
1082
1144
|
**But the app cannot drain a rolling update by itself.** The runtime
|
|
1083
1145
|
(`NodeRuntime`) owns the `SIGTERM` signal and closes the HTTP listener promptly —
|
|
1084
1146
|
so a request that *arrives* during shutdown is refused. Failing readiness from
|
|
@@ -1315,6 +1377,10 @@ Lower the lease for **faster failover**, at the cost of **false-positive reclaim
|
|
|
1315
1377
|
- [ ] Serving pods run `voltro serve` (not `voltro dev`), with `VOLTRO_AUTO_MIGRATE=0`
|
|
1316
1378
|
- [ ] Schema applied by a pre-deploy Job / initContainer (`voltro db apply`), not in the serving pod
|
|
1317
1379
|
- [ ] `VOLTRO_MAX_RPC_BODY_BYTES` sane; ingress caps body size + per-IP rate
|
|
1380
|
+
- [ ] `VOLTRO_TRUSTED_PROXIES` set if you run behind an ingress AND rate-limit per IP
|
|
1381
|
+
- [ ] `VOLTRO_ALLOWED_ORIGINS` set if the web app is on a different origin than the api
|
|
1382
|
+
- [ ] Security headers reviewed (`VOLTRO_SECURITY_HEADERS`, `VOLTRO_CSP`); HSTS reaching the browser over https
|
|
1383
|
+
- [ ] Every `*.webhook.tsx` declares its verification, and each signature-verified one has its `VOLTRO_WEBHOOK_SECRET_<ID>`
|
|
1318
1384
|
- [ ] `auth.anonymousTenantRequired: true` (unless the app serves anonymous public data)
|
|
1319
1385
|
- [ ] `OTEL_EXPORTER_OTLP_ENDPOINT` + `OTEL_SERVICE_NAME` pointed at your collector
|
|
1320
1386
|
- [ ] `VOLTRO_LOG_FORMAT=json`; `sentryPlugin()` + `SENTRY_DSN` for errors
|