@voltro/cli 0.28.0 → 0.30.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 +655 -0
- package/THIRD-PARTY-NOTICES.md +90 -4
- package/dist/{apiBuild-eUM32r1u.js → apiBuild-BceikpQf.js} +3 -3
- package/dist/apiBuild-CxvR1Dwt.js +2 -0
- package/dist/bin.js +3 -3
- package/dist/checkCommand-BMr-X48w.js +2427 -0
- package/dist/checkCommand-BOk9y264.js +2 -0
- package/dist/cliRuntime-Oh517vCV.js +96 -0
- package/dist/{commands-CSkrUI1h.js → commands-D4Y1-Nlu.js} +8575 -9366
- package/dist/{dbCommand-CpYgmSw4.js → dbCommand-C7hbl5pk.js} +345 -266
- package/dist/dbCommand-D8nhyppR.js +2 -0
- package/dist/{dev-DlBWWnJQ.js → dev-Cr-teYsz.js} +1 -1
- package/dist/{dev-CEZwJhmb.js → dev-DmnkH-wr.js} +2939 -1832
- package/dist/fileConventions-Cof68_BL.js +33 -0
- package/dist/{frameworkTableAssembly-BwHU9Euq.js → frameworkTableAssembly-BzAajuUv.js} +115 -99
- package/dist/frameworkTableAssembly-DU0aOxjL.js +2 -0
- package/dist/index.d.ts +7 -2
- package/dist/index.js +1 -1
- package/dist/{inspect-CUCCzw2I.js → inspect-BUUjt773.js} +349 -192
- package/dist/inspect-mmBuRXmy.js +2 -0
- package/dist/{inspectMetrics-BU90mvJN.js → inspectMetrics-BeJdP_3b.js} +1068 -978
- package/dist/{manifestBuild-BnzAxp2O.js → manifestBuild-BLrVuSlM.js} +1 -1
- package/dist/manifestBuild-Dj8Jjoto.js +2 -0
- package/dist/sdkgen-CqpOq_fy.js +582 -0
- package/dist/serveCommand-Ciq_wIY8.js +1577 -0
- package/dist/serveEntry.js +2 -2
- package/dist/{start-BGXIf6zT.js → start-ocqB1seX.js} +283 -270
- package/dist/startEntry.js +2 -2
- package/package.json +17 -17
- package/templates/AGENTS.core.md +50 -0
- package/templates/AGENTS.md +54 -2
- package/templates/agent-docs/_index.md +4 -2
- package/templates/agent-docs/_manifest.json +26 -5
- package/templates/agent-docs/ai.md +191 -0
- package/templates/agent-docs/cli.md +143 -6
- package/templates/agent-docs/configuration.md +32 -0
- package/templates/agent-docs/data.md +282 -0
- package/templates/agent-docs/database/migrations.md +47 -0
- package/templates/agent-docs/database/misc.md +57 -0
- package/templates/agent-docs/database/schema.md +3 -1
- package/templates/agent-docs/internationalization.md +169 -8
- package/templates/agent-docs/local-first-mobile.md +426 -0
- package/templates/agent-docs/routing.md +94 -3
- package/templates/agent-docs/schema-driven-ui.md +12 -0
- package/templates/agent-docs/security.md +17 -0
- package/templates/agent-docs/templates/apibackends.md +210 -1
- package/templates/agent-docs/templates/appshells.md +144 -1
- package/templates/agent-docs/templates/mobile.md +60 -0
- package/templates/agent-docs/templates/overview.md +17 -7
- package/templates/agent-docs/whats-new.md +238 -158
- package/templates/agent-docs/workflows.md +376 -0
- package/templates/apps/api-ai/package.json +7 -7
- package/templates/apps/api-auth/package.json +8 -8
- package/templates/apps/api-backend/package.json +7 -7
- package/templates/apps/api-backend-deactivation/package.json +7 -7
- package/templates/apps/api-backend-mail/package.json +8 -8
- package/templates/apps/api-backend-mariadb/package.json +9 -9
- package/templates/apps/api-backend-sqlite/.env.example +19 -0
- package/templates/apps/api-backend-sqlite/README.md +38 -0
- package/templates/apps/api-backend-sqlite/app.config.ts +35 -0
- package/templates/apps/api-backend-sqlite/database/schema.ts +54 -0
- package/templates/apps/api-backend-sqlite/mutations/notes.create.mutation.server.ts +19 -0
- package/templates/apps/api-backend-sqlite/mutations/notes.create.mutation.ts +37 -0
- package/templates/apps/api-backend-sqlite/package.json +30 -0
- package/templates/apps/api-backend-sqlite/queries/notes.query.server.ts +14 -0
- package/templates/apps/api-backend-sqlite/queries/notes.query.ts +20 -0
- package/templates/apps/api-backend-sqlite/template.json +6 -0
- package/templates/apps/api-backend-sqlite/tests/notes.create.test.ts +50 -0
- package/templates/apps/api-backend-sqlite/tsconfig.json +5 -0
- package/templates/apps/api-backend-storage/package.json +8 -8
- package/templates/apps/api-cms/README.md +104 -0
- package/templates/apps/api-cms/actions/content.get.action.server.ts +27 -0
- package/templates/apps/api-cms/actions/content.get.action.ts +19 -0
- package/templates/apps/api-cms/actions/content.types.action.server.ts +26 -0
- package/templates/apps/api-cms/actions/content.types.action.ts +40 -0
- package/templates/apps/api-cms/actions/me.action.server.ts +18 -0
- package/templates/apps/api-cms/actions/me.action.ts +16 -0
- package/templates/apps/api-cms/app.config.ts +61 -0
- package/templates/apps/api-cms/content/blogPost.contentType.ts +39 -0
- package/templates/apps/api-cms/content/index.ts +18 -0
- package/templates/apps/api-cms/content/page.contentType.ts +24 -0
- package/templates/apps/api-cms/database/schema.ts +64 -0
- package/templates/apps/api-cms/mutations/content.publish.mutation.server.ts +19 -0
- package/templates/apps/api-cms/mutations/content.publish.mutation.ts +15 -0
- package/templates/apps/api-cms/mutations/content.saveDraft.mutation.server.ts +36 -0
- package/templates/apps/api-cms/mutations/content.saveDraft.mutation.ts +32 -0
- package/templates/apps/api-cms/mutations/content.unpublish.mutation.server.ts +19 -0
- package/templates/apps/api-cms/mutations/content.unpublish.mutation.ts +11 -0
- package/templates/apps/api-cms/package.json +32 -0
- package/templates/apps/api-cms/queries/content.list.query.server.ts +0 -0
- package/templates/apps/api-cms/queries/content.list.query.ts +27 -0
- package/templates/apps/api-cms/template.json +6 -0
- package/templates/apps/api-cms/tests/content.descriptors.test.ts +64 -0
- package/templates/apps/api-cms/tests/content.write.test.ts +85 -0
- package/templates/apps/api-cms/tsconfig.json +5 -0
- package/templates/apps/api-collab/README.md +70 -0
- package/templates/apps/api-collab/app.config.ts +28 -0
- package/templates/apps/api-collab/database/schema.ts +63 -0
- package/templates/apps/api-collab/mutations/documents.create.mutation.server.ts +19 -0
- package/templates/apps/api-collab/mutations/documents.create.mutation.ts +36 -0
- package/templates/apps/api-collab/mutations/documents.setBody.mutation.server.ts +20 -0
- package/templates/apps/api-collab/mutations/documents.setBody.mutation.ts +30 -0
- package/templates/apps/api-collab/package.json +30 -0
- package/templates/apps/api-collab/queries/documents.query.server.ts +16 -0
- package/templates/apps/api-collab/queries/documents.query.ts +23 -0
- package/templates/apps/api-collab/template.json +6 -0
- package/templates/apps/api-collab/tests/documents.setBody.test.ts +89 -0
- package/templates/apps/api-collab/tsconfig.json +5 -0
- package/templates/apps/api-data-advanced/package.json +8 -8
- package/templates/apps/api-durable/package.json +8 -8
- package/templates/apps/api-feature-flags/package.json +9 -9
- package/templates/apps/api-governance/package.json +8 -8
- package/templates/apps/api-kv/package.json +8 -8
- package/templates/apps/api-moderation/package.json +8 -8
- package/templates/apps/api-observability/package.json +8 -8
- package/templates/apps/api-ratelimit/package.json +8 -8
- package/templates/apps/api-rbac/package.json +8 -8
- package/templates/apps/api-rest/package.json +7 -7
- package/templates/apps/api-saas/package.json +11 -11
- package/templates/apps/api-saas-starter/README.md +103 -0
- package/templates/apps/api-saas-starter/actions/me.action.server.ts +18 -0
- package/templates/apps/api-saas-starter/actions/me.action.ts +20 -0
- package/templates/apps/api-saas-starter/app.config.ts +87 -0
- package/templates/apps/api-saas-starter/database/schema.ts +57 -0
- package/templates/apps/api-saas-starter/mutations/invites.create.mutation.server.ts +26 -0
- package/templates/apps/api-saas-starter/mutations/invites.create.mutation.ts +18 -0
- package/templates/apps/api-saas-starter/mutations/projects.create.mutation.server.ts +29 -0
- package/templates/apps/api-saas-starter/mutations/projects.create.mutation.ts +18 -0
- package/templates/apps/api-saas-starter/package.json +32 -0
- package/templates/apps/api-saas-starter/queries/invites.list.query.server.ts +14 -0
- package/templates/apps/api-saas-starter/queries/invites.list.query.ts +17 -0
- package/templates/apps/api-saas-starter/queries/projects.list.query.server.ts +14 -0
- package/templates/apps/api-saas-starter/queries/projects.list.query.ts +18 -0
- package/templates/apps/api-saas-starter/template.json +6 -0
- package/templates/apps/api-saas-starter/tests/projects.create.test.ts +62 -0
- package/templates/apps/api-saas-starter/tests/session.test.ts +33 -0
- package/templates/apps/api-saas-starter/tsconfig.json +5 -0
- package/templates/apps/api-search/package.json +8 -8
- package/templates/apps/api-status/README.md +88 -0
- package/templates/apps/api-status/app.config.ts +36 -0
- package/templates/apps/api-status/authz.ts +33 -0
- package/templates/apps/api-status/database/schema.ts +70 -0
- package/templates/apps/api-status/mutations/components.create.mutation.server.ts +19 -0
- package/templates/apps/api-status/mutations/components.create.mutation.ts +16 -0
- package/templates/apps/api-status/mutations/incidents.create.mutation.server.ts +25 -0
- package/templates/apps/api-status/mutations/incidents.create.mutation.ts +22 -0
- package/templates/apps/api-status/mutations/incidents.resolve.mutation.server.ts +24 -0
- package/templates/apps/api-status/mutations/incidents.resolve.mutation.ts +19 -0
- package/templates/apps/api-status/mutations/incidents.update.mutation.server.ts +26 -0
- package/templates/apps/api-status/mutations/incidents.update.mutation.ts +21 -0
- package/templates/apps/api-status/package.json +30 -0
- package/templates/apps/api-status/queries/components.list.query.server.ts +14 -0
- package/templates/apps/api-status/queries/components.list.query.ts +13 -0
- package/templates/apps/api-status/queries/incidents.live.query.server.ts +14 -0
- package/templates/apps/api-status/queries/incidents.live.query.ts +20 -0
- package/templates/apps/api-status/queries/updates.list.query.server.ts +14 -0
- package/templates/apps/api-status/queries/updates.list.query.ts +17 -0
- package/templates/apps/api-status/template.json +6 -0
- package/templates/apps/api-status/tests/status.test.ts +70 -0
- package/templates/apps/api-status/tsconfig.json +5 -0
- package/templates/apps/api-versioning/package.json +8 -8
- package/templates/apps/api-webhooks/package.json +9 -9
- package/templates/apps/changelog/package.json +6 -6
- package/templates/apps/edge-functions/package.json +2 -2
- package/templates/apps/frontend-admin/app.config.ts +1 -1
- package/templates/apps/frontend-admin/package.json +8 -8
- package/templates/apps/frontend-admin/src/locales/index.ts +1 -1
- package/templates/apps/frontend-app/app.config.ts +1 -1
- package/templates/apps/frontend-app/package.json +8 -8
- package/templates/apps/frontend-app/src/locales/index.ts +1 -1
- package/templates/apps/frontend-app/src/pages/layout.tsx +1 -1
- package/templates/apps/frontend-app/src/pages/page.tsx +1 -1
- package/templates/apps/frontend-auth/README.md +78 -0
- package/templates/apps/frontend-auth/app.config.ts +34 -0
- package/templates/apps/frontend-auth/package.json +32 -0
- package/templates/apps/frontend-auth/src/components/AuthShell.tsx +35 -0
- package/templates/apps/frontend-auth/src/components/PasswordStrength.tsx +33 -0
- package/templates/apps/frontend-auth/src/config.ts +11 -0
- package/templates/apps/frontend-auth/src/globals.css +105 -0
- package/templates/apps/frontend-auth/src/globals.d.ts +6 -0
- package/templates/apps/frontend-auth/src/lib/auth.ts +34 -0
- package/templates/apps/frontend-auth/src/lib/redirect.test.ts +24 -0
- package/templates/apps/frontend-auth/src/lib/redirect.ts +29 -0
- package/templates/apps/frontend-auth/src/locales/de.ts +66 -0
- package/templates/apps/frontend-auth/src/locales/en.ts +76 -0
- package/templates/apps/frontend-auth/src/locales/index.ts +14 -0
- package/templates/apps/frontend-auth/src/pages/forgot/page.tsx +51 -0
- package/templates/apps/frontend-auth/src/pages/layout.tsx +12 -0
- package/templates/apps/frontend-auth/src/pages/login/page.test.tsx +53 -0
- package/templates/apps/frontend-auth/src/pages/login/page.tsx +67 -0
- package/templates/apps/frontend-auth/src/pages/logout/page.tsx +28 -0
- package/templates/apps/frontend-auth/src/pages/magic/page.tsx +51 -0
- package/templates/apps/frontend-auth/src/pages/page.tsx +26 -0
- package/templates/apps/frontend-auth/src/pages/reset/page.test.tsx +51 -0
- package/templates/apps/frontend-auth/src/pages/reset/page.tsx +63 -0
- package/templates/apps/frontend-auth/src/pages/signup/page.tsx +60 -0
- package/templates/apps/frontend-auth/src/pages/verify/page.tsx +50 -0
- package/templates/apps/frontend-auth/template.json +6 -0
- package/templates/apps/frontend-auth/tsconfig.json +5 -0
- package/templates/apps/frontend-blank/app.config.ts +1 -1
- package/templates/apps/frontend-blank/package.json +7 -7
- package/templates/apps/frontend-blank/src/locales/index.ts +1 -1
- package/templates/apps/frontend-blank/src/pages/layout.tsx +1 -1
- package/templates/apps/frontend-blank/src/pages/page.tsx +1 -1
- package/templates/apps/frontend-cms/README.md +47 -0
- package/templates/apps/frontend-cms/app.config.ts +37 -0
- package/templates/apps/frontend-cms/package.json +33 -0
- package/templates/apps/frontend-cms/src/config.ts +8 -0
- package/templates/apps/frontend-cms/src/globals.css +105 -0
- package/templates/apps/frontend-cms/src/globals.d.ts +6 -0
- package/templates/apps/frontend-cms/src/lib/api.ts +58 -0
- package/templates/apps/frontend-cms/src/locales/de.ts +50 -0
- package/templates/apps/frontend-cms/src/locales/en.ts +55 -0
- package/templates/apps/frontend-cms/src/locales/index.ts +14 -0
- package/templates/apps/frontend-cms/src/pages/(app)/error.tsx +18 -0
- package/templates/apps/frontend-cms/src/pages/(app)/layout.test.tsx +31 -0
- package/templates/apps/frontend-cms/src/pages/(app)/layout.tsx +62 -0
- package/templates/apps/frontend-cms/src/pages/(app)/not-found.tsx +14 -0
- package/templates/apps/frontend-cms/src/pages/(app)/page.test.tsx +110 -0
- package/templates/apps/frontend-cms/src/pages/(app)/page.tsx +159 -0
- package/templates/apps/frontend-cms/src/pages/layout.tsx +12 -0
- package/templates/apps/frontend-cms/src/pages/login/page.test.tsx +58 -0
- package/templates/apps/frontend-cms/src/pages/login/page.tsx +93 -0
- package/templates/apps/frontend-cms/template.json +6 -0
- package/templates/apps/frontend-cms/tsconfig.json +5 -0
- package/templates/apps/frontend-collab/README.md +80 -0
- package/templates/apps/frontend-collab/app.config.ts +38 -0
- package/templates/apps/frontend-collab/package.json +33 -0
- package/templates/apps/frontend-collab/src/locales/de.ts +29 -0
- package/templates/apps/frontend-collab/src/locales/en.ts +31 -0
- package/templates/apps/frontend-collab/src/locales/index.ts +15 -0
- package/templates/apps/frontend-collab/src/pages/layout.tsx +39 -0
- package/templates/apps/frontend-collab/src/pages/page.test.tsx +150 -0
- package/templates/apps/frontend-collab/src/pages/page.tsx +186 -0
- package/templates/apps/frontend-collab/template.json +6 -0
- package/templates/apps/frontend-collab/tsconfig.json +11 -0
- package/templates/apps/frontend-contact/package.json +7 -7
- package/templates/apps/frontend-dashboard/app.config.ts +1 -1
- package/templates/apps/frontend-dashboard/package.json +7 -7
- package/templates/apps/frontend-dashboard/src/locales/index.ts +1 -1
- package/templates/apps/frontend-docs/package.json +7 -7
- package/templates/apps/frontend-i18n/package.json +6 -6
- package/templates/apps/frontend-landing/package.json +7 -7
- package/templates/apps/frontend-portal/README.md +71 -0
- package/templates/apps/frontend-portal/app.config.ts +37 -0
- package/templates/apps/frontend-portal/package.json +32 -0
- package/templates/apps/frontend-portal/src/config.ts +8 -0
- package/templates/apps/frontend-portal/src/globals.css +93 -0
- package/templates/apps/frontend-portal/src/globals.d.ts +6 -0
- package/templates/apps/frontend-portal/src/lib/api.ts +62 -0
- package/templates/apps/frontend-portal/src/locales/de.ts +86 -0
- package/templates/apps/frontend-portal/src/locales/en.ts +93 -0
- package/templates/apps/frontend-portal/src/locales/index.ts +14 -0
- package/templates/apps/frontend-portal/src/pages/(portal)/api-keys/page.test.tsx +71 -0
- package/templates/apps/frontend-portal/src/pages/(portal)/api-keys/page.tsx +124 -0
- package/templates/apps/frontend-portal/src/pages/(portal)/billing/page.test.tsx +86 -0
- package/templates/apps/frontend-portal/src/pages/(portal)/billing/page.tsx +97 -0
- package/templates/apps/frontend-portal/src/pages/(portal)/error.tsx +19 -0
- package/templates/apps/frontend-portal/src/pages/(portal)/invoices/page.tsx +45 -0
- package/templates/apps/frontend-portal/src/pages/(portal)/layout.test.tsx +31 -0
- package/templates/apps/frontend-portal/src/pages/(portal)/layout.tsx +63 -0
- package/templates/apps/frontend-portal/src/pages/(portal)/not-found.tsx +15 -0
- package/templates/apps/frontend-portal/src/pages/(portal)/page.tsx +39 -0
- package/templates/apps/frontend-portal/src/pages/(portal)/profile/page.tsx +79 -0
- package/templates/apps/frontend-portal/src/pages/layout.tsx +12 -0
- package/templates/apps/frontend-portal/src/pages/login/page.test.tsx +58 -0
- package/templates/apps/frontend-portal/src/pages/login/page.tsx +96 -0
- package/templates/apps/frontend-portal/template.json +6 -0
- package/templates/apps/frontend-portal/tsconfig.json +5 -0
- package/templates/apps/frontend-saas/README.md +73 -0
- package/templates/apps/frontend-saas/app.config.ts +46 -0
- package/templates/apps/frontend-saas/package.json +32 -0
- package/templates/apps/frontend-saas/src/config.ts +8 -0
- package/templates/apps/frontend-saas/src/globals.css +85 -0
- package/templates/apps/frontend-saas/src/globals.d.ts +6 -0
- package/templates/apps/frontend-saas/src/lib/api.ts +47 -0
- package/templates/apps/frontend-saas/src/locales/de.ts +72 -0
- package/templates/apps/frontend-saas/src/locales/en.ts +81 -0
- package/templates/apps/frontend-saas/src/locales/index.ts +14 -0
- package/templates/apps/frontend-saas/src/pages/(marketing)/layout.tsx +30 -0
- package/templates/apps/frontend-saas/src/pages/(marketing)/login/page.test.tsx +58 -0
- package/templates/apps/frontend-saas/src/pages/(marketing)/login/page.tsx +96 -0
- package/templates/apps/frontend-saas/src/pages/(marketing)/page.tsx +27 -0
- package/templates/apps/frontend-saas/src/pages/dashboard/billing/page.tsx +72 -0
- package/templates/apps/frontend-saas/src/pages/dashboard/error.tsx +20 -0
- package/templates/apps/frontend-saas/src/pages/dashboard/layout.test.tsx +32 -0
- package/templates/apps/frontend-saas/src/pages/dashboard/layout.tsx +69 -0
- package/templates/apps/frontend-saas/src/pages/dashboard/not-found.tsx +17 -0
- package/templates/apps/frontend-saas/src/pages/dashboard/page.test.tsx +113 -0
- package/templates/apps/frontend-saas/src/pages/dashboard/page.tsx +76 -0
- package/templates/apps/frontend-saas/src/pages/dashboard/team/page.tsx +73 -0
- package/templates/apps/frontend-saas/src/pages/layout.tsx +12 -0
- package/templates/apps/frontend-saas/template.json +6 -0
- package/templates/apps/frontend-saas/tsconfig.json +5 -0
- package/templates/apps/frontend-spa/app.config.ts +1 -1
- package/templates/apps/frontend-spa/package.json +7 -7
- package/templates/apps/frontend-spa/src/locales/index.ts +1 -1
- package/templates/apps/frontend-spa/src/pages/layout.tsx +1 -1
- package/templates/apps/frontend-spa/src/pages/page.tsx +1 -1
- package/templates/apps/frontend-ssr/app.config.ts +1 -1
- package/templates/apps/frontend-ssr/package.json +7 -7
- package/templates/apps/frontend-ssr/src/locales/index.ts +1 -1
- package/templates/apps/frontend-ssr/src/pages/layout.tsx +1 -1
- package/templates/apps/frontend-ssr/src/pages/page.tsx +1 -1
- package/templates/apps/frontend-ssr-api/app.config.ts +1 -1
- package/templates/apps/frontend-ssr-api/package.json +8 -8
- package/templates/apps/frontend-ssr-api/src/locales/index.ts +1 -1
- package/templates/apps/frontend-ssr-api/src/pages/layout.tsx +1 -1
- package/templates/apps/frontend-static-blog/package.json +6 -6
- package/templates/apps/frontend-status/README.md +51 -0
- package/templates/apps/frontend-status/app.config.ts +39 -0
- package/templates/apps/frontend-status/package.json +32 -0
- package/templates/apps/frontend-status/src/config.ts +7 -0
- package/templates/apps/frontend-status/src/globals.css +70 -0
- package/templates/apps/frontend-status/src/globals.d.ts +6 -0
- package/templates/apps/frontend-status/src/lib/status.ts +81 -0
- package/templates/apps/frontend-status/src/locales/de.ts +41 -0
- package/templates/apps/frontend-status/src/locales/en.ts +45 -0
- package/templates/apps/frontend-status/src/locales/index.ts +13 -0
- package/templates/apps/frontend-status/src/pages/layout.tsx +27 -0
- package/templates/apps/frontend-status/src/pages/page.test.tsx +123 -0
- package/templates/apps/frontend-status/src/pages/page.tsx +136 -0
- package/templates/apps/frontend-status/template.json +6 -0
- package/templates/apps/frontend-status/tsconfig.json +11 -0
- package/templates/apps/mobile-app/README.md +58 -0
- package/templates/apps/mobile-app/app.config.ts +33 -0
- package/templates/apps/mobile-app/babel.config.js +5 -0
- package/templates/apps/mobile-app/metro.config.js +20 -0
- package/templates/apps/mobile-app/package.json +33 -0
- package/templates/apps/mobile-app/src/app/_layout.tsx +61 -0
- package/templates/apps/mobile-app/src/app/index.tsx +62 -0
- package/templates/apps/mobile-app/src/app/orders/[id].tsx +19 -0
- package/templates/apps/mobile-app/src/app/settings.tsx +51 -0
- package/templates/apps/mobile-app/src/client.ts +74 -0
- package/templates/apps/mobile-app/src/lib/deeplinks.ts +39 -0
- package/templates/apps/mobile-app/src/lib/notifications.ts +31 -0
- package/templates/apps/mobile-app/src/lib/sync.ts +29 -0
- package/templates/apps/mobile-app/src/persistence.ts +38 -0
- package/templates/apps/mobile-app/template.json +6 -0
- package/templates/apps/mobile-app/tests/deeplinks.test.ts +32 -0
- package/templates/apps/mobile-app/tests/notifications.test.ts +26 -0
- package/templates/apps/mobile-app/tests/sync.test.ts +34 -0
- package/templates/apps/mobile-app/tsconfig.json +10 -0
- package/templates/apps/mobile-app/voltro.mobile.ts +11 -0
- package/templates/baselines/compose/docker-compose.prod.yml +15 -0
- package/templates/baselines/compose-mariadb/docker-compose.prod.yml +15 -0
- package/templates/patches/@effect__cluster@0.60.0.patch +6 -6
- package/dist/apiBuild-DgBS9ayv.js +0 -2
- package/dist/dbCommand-DvguqlzF.js +0 -2
- package/dist/fileConventions-3bffWssN.js +0 -30
- package/dist/frameworkTableAssembly-lrjZtk0G.js +0 -2
- package/dist/inspect-gt8bq-Tz.js +0 -2
- package/dist/manifestBuild-ifczArzr.js +0 -2
- package/dist/serveCommand-ZTn-dPFa.js +0 -1425
|
@@ -19,18 +19,18 @@ The dispatcher routes `voltro <command> [args]` to the matching subcommand and p
|
|
|
19
19
|
|
|
20
20
|
| Group | Commands |
|
|
21
21
|
|---|---|
|
|
22
|
-
| [Scaffolding](/docs/cli/scaffolding) | `create-project`, `add-app`, `list-templates` |
|
|
22
|
+
| [Scaffolding](/docs/cli/scaffolding) | `create-project`, `add-app`, `list-templates`, `new` (scaffold one primitive — `query` / `mutation` / `action` / `workflow` / `page`) |
|
|
23
23
|
| Packages | `package` (`create` / `publishable` / `private` / `status`), `create-package` |
|
|
24
|
-
| [Dev](/docs/cli/dev) | `dev`, `codegen`, `agents-md`, [`env`](/docs/cli/env) (`check` / `sync` / `types` / `turbo`), [`generate`](/docs/cli/scaffolding) (AI app-builder), `dashboard` (serve the DevTools dashboard standalone; `--port`, `VOLTRO_DASHBOARD_APPS`) |
|
|
24
|
+
| [Dev](/docs/cli/dev) | `dev`, `codegen`, `typecheck` (`tsc --noEmit` with the app's own TypeScript), `agents-md`, [`env`](/docs/cli/env) (`check` / `sync` / `types` / `turbo`), [`generate`](/docs/cli/scaffolding) (AI app-builder), `dashboard` (serve the DevTools dashboard standalone; `--port`, `VOLTRO_DASHBOARD_APPS`) |
|
|
25
25
|
| [Build & run](/docs/cli/build-and-start) | `build`, `start`, `serve` |
|
|
26
26
|
| Deploy | `deploy` (`plan` — auto-detect the target tier per app + function), [`serverless`](/docs/deployment/serverless-functions) (`list` / `dev` / `serve` / `build` / `deploy`), [`static`](/docs/deployment/static-sites) (`hosts` / `deploy`), `dormancy` (single-node scale-to-zero: fronts the app, stops it when idle, wakes on the next request; `--idle-grace-ms` / `--tick-ms`) |
|
|
27
|
-
| [Database](/docs/cli/migrate) | `migrate`, `db` (`plan` / `apply` / `plans` / `drift` / `squash` / `restore-snapshot` / `migrate` / `rollback` / `status` / `seed`) |
|
|
27
|
+
| [Database](/docs/cli/migrate) | `migrate`, `db` (`plan` / `apply` / `plans` / `drift` / `squash` / `restore-snapshot` / `migrate` / `rollback` / `status` / `seed`), [`evolve`](/docs/database/migrations/rename-and-drop) (schema-evolution copilot — propose a codemod + branch-verified backfill for a rename / retype / split / drop of an existing column or table) |
|
|
28
28
|
| [Update](/docs/cli/update) | `update` (`--to` / `--dry-run` / `--force` / `--exact`) — bump every `@voltro/*`, install, run the codemods that adapt your source to the new version |
|
|
29
29
|
| [Data transfer](/docs/cli/data) | `data` (`export` / `import` / `unpack` / `inspect` / `backup` / `restore`) — directory + single-file `.vbundle` bundles, streaming assets, masking, at-rest encryption |
|
|
30
30
|
| Ops / infra | `cache` (`status` / `flush` / `invalidate`), `add` (`redis`), `baseline` (`list` / `status` / `set`), `schedule-manifest`, [`storage`](/docs/plugins/storage) (`doctor` / `cors`) |
|
|
31
|
-
| AI / data | `embeddings backfill <table> --text <field> --vector <field>` — (re)embed rows the `vectorEmbedding()` mixin missed (pre-existing rows / a model change); `--dry-run` to preview |
|
|
31
|
+
| AI / data | `embeddings backfill <table> --text <field> --vector <field>` — (re)embed rows the `vectorEmbedding()` mixin missed (pre-existing rows / a model change); `--dry-run` to preview; [`eval`](/docs/ai/agents#evaluating-recorded-runs-voltro-eval) — replay recorded agent runs against golden cases + judge, exit 1 on regression (a deploy gate; `--json` / `--branch` / `--threshold`) |
|
|
32
32
|
| [Inspect & debug](/docs/cli/inspect) | `inspect`, `logs`, `traces`, `workflows`, `cluster`, `check` |
|
|
33
|
-
| [Health & surface](/docs/cli/build-and-start) | [`doctor`](/docs/cli/build-and-start) — serve preflight + the hand-roll detector (names the shipped primitive at the spot you're rebuilding it); [`capabilities`](/docs/cli/build-and-start) (`--json`) — the export surface read from your installed `@voltro/*`, so it can be verified instead of recalled |
|
|
33
|
+
| [Health & surface](/docs/cli/build-and-start) | [`doctor`](/docs/cli/build-and-start) — serve preflight + the hand-roll detector (names the shipped primitive at the spot you're rebuilding it); [`capabilities`](/docs/cli/build-and-start) (`--json`) — the export surface read from your installed `@voltro/*`, so it can be verified instead of recalled; `info` (`--json`) — CLI / node / package-manager / dialect + every installed `@voltro/*` version, flagging lockstep skew (exits 1 on skew) |
|
|
34
34
|
| Harness | `test`, `e2e` |
|
|
35
35
|
| Cloud | `cloud` (`login` / `whoami` / `projects` / `env` / `import`) |
|
|
36
36
|
| Secrets | `secret` (`generate [purpose]` — the right var+format per secret; `generate` alone → a generic secret; `list`) |
|
|
@@ -69,7 +69,8 @@ The CLI reads:
|
|
|
69
69
|
| `VOLTRO_LOG_FORMAT` | `pretty` / `json`. Force the logger's output format. |
|
|
70
70
|
| `VOLTRO_LOG_LEVEL` | `trace` / `debug` / `info` / `warn` / `error` / `fatal`. |
|
|
71
71
|
| `VOLTRO_INSPECT` | `off` to disable the `/_voltro/inspect/*` HTTP endpoints. |
|
|
72
|
-
| `VOLTRO_INSPECT_TOKEN` | Bearer
|
|
72
|
+
| `VOLTRO_INSPECT_TOKEN` | Bearer for the inspect surface. **Fail-closed:** unset → every request is `401`. `voltro dev` mints one per project; `voltro serve` / `voltro start` mint nothing, so a public deploy is closed by default (set it explicitly to open the surface). |
|
|
73
|
+
| `VOLTRO_INSPECT_ALLOWED_HOSTS` | Extra `Host` names allowed to reach the **dev** inspect surface, past its DNS-rebinding guard (comma/space-separated). Loopback names + IP literals are always allowed; any other domain name is refused unless listed here — the api counterpart of vite's `allowedHosts`. |
|
|
73
74
|
| `DB_URL` | Database connection string (falls back to `DB_PRIMARY_URL`; or the discrete `DB_*` / `PG_*` fields). |
|
|
74
75
|
| `VOLTRO_SESSION_SECRET` | Session-cookie signing secret (`@voltro/plugin-auth`). Rotate with zero downtime: move the old value to `VOLTRO_SESSION_SECRET_PREVIOUS` for one session lifetime — cookies signed with either secret keep verifying, and previous-key cookies are re-issued under the new one. |
|
|
75
76
|
| `VOLTRO_DATA_TRANSFER_SECRET` | Gates the prod data-transfer endpoints (`POST /_voltro/admin/{export,import}`); ≥16 chars or the routes don't mount. |
|
|
@@ -116,6 +117,47 @@ voltro build # vite build + SSG pre-render
|
|
|
116
117
|
voltro start # production server
|
|
117
118
|
```
|
|
118
119
|
|
|
120
|
+
### "Scaffold a new primitive the right way"
|
|
121
|
+
|
|
122
|
+
`voltro new <kind> <name>` writes the correct file convention(s) so you don't
|
|
123
|
+
learn the descriptor/executor split or the browser-safe boundary from a boot-time
|
|
124
|
+
error. It refuses to overwrite an existing file unless you pass `--force`.
|
|
125
|
+
|
|
126
|
+
```bash
|
|
127
|
+
voltro new query notes.list # notes.list.query.ts + notes.list.query.server.ts
|
|
128
|
+
voltro new mutation notes.create # descriptor + .server executor pair
|
|
129
|
+
voltro new action notes.touch # descriptor + .server executor pair
|
|
130
|
+
voltro new workflow orders.fulfill # .workflow.tsx descriptor + .workflow.server.tsx executor
|
|
131
|
+
voltro new page about # src-pages page.tsx under the name path
|
|
132
|
+
voltro new query billing.summary --dir queries # write into a subdirectory
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
The descriptor half imports only `@voltro/protocol` (or `@voltro/workflow/define`)
|
|
136
|
+
+ `effect` — browser-safe by construction; the server graph lives in the paired
|
|
137
|
+
`.server` file. Fill in the `TODO`s, then `voltro dev` discovers it.
|
|
138
|
+
|
|
139
|
+
### "Type-check before I commit"
|
|
140
|
+
|
|
141
|
+
```bash
|
|
142
|
+
voltro typecheck # tsc --noEmit against ./tsconfig.json
|
|
143
|
+
voltro typecheck apps/api # a specific app
|
|
144
|
+
voltro typecheck --project tsconfig.build.json
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
It runs the **app's own** TypeScript (a `tsc --noEmit`), so "green" means tests
|
|
148
|
+
AND types. Any flag it doesn't own passes straight through to `tsc`.
|
|
149
|
+
|
|
150
|
+
### "What versions am I actually running?"
|
|
151
|
+
|
|
152
|
+
```bash
|
|
153
|
+
voltro info # CLI, node, package manager, dialect + @voltro/* versions
|
|
154
|
+
voltro info --json # machine-readable; exits 1 on version skew
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
`@voltro/*` ship in lockstep, so a mismatch (e.g. `@voltro/database` a minor
|
|
158
|
+
behind `@voltro/runtime`) means an untested graph. `voltro info` flags it and
|
|
159
|
+
`voltro update` realigns everything.
|
|
160
|
+
|
|
119
161
|
### "Something's wrong — inspect what's running"
|
|
120
162
|
|
|
121
163
|
```bash
|
|
@@ -1025,6 +1067,37 @@ For WebSocket connections to land on the same backend (required for in-process s
|
|
|
1025
1067
|
- Reverse proxy: `lb_policy ip_hash` (Caddy) / `ip_hash` (nginx).
|
|
1026
1068
|
- Or use `@voltro/plugin-cluster` to share subscription state across instances → any-load-balancer-works.
|
|
1027
1069
|
|
|
1070
|
+
## `voltro build api --target <swift|kotlin>` — native SDK generation
|
|
1071
|
+
|
|
1072
|
+
Generate a fully native mobile client from the same API bindings the TypeScript client is generated from. No hand-written models, no drift: the SDK is derived from your app's **capability manifest** — the exact procedure descriptors + JSON Schemas the framework already assembles from source — so every type stays in lockstep with the server.
|
|
1073
|
+
|
|
1074
|
+
```bash
|
|
1075
|
+
voltro build api --target swift apps/acme/api # → apps/acme/api/sdk/swift (Swift Package)
|
|
1076
|
+
voltro build api --target kotlin apps/acme/api # → apps/acme/api/sdk/kotlin (Kotlin Multiplatform)
|
|
1077
|
+
voltro build api --target swift --out ./ios/Sdk --name AcmeClient .
|
|
1078
|
+
```
|
|
1079
|
+
|
|
1080
|
+
Flags:
|
|
1081
|
+
|
|
1082
|
+
- `--target swift | kotlin` — the language to emit. Required.
|
|
1083
|
+
- `--out <dir>` — output directory. Default: `<appDir>/sdk/<target>`.
|
|
1084
|
+
- `--name <PackageName>` — the Swift package / Kotlin module name (PascalCase). Default `VoltroClient`.
|
|
1085
|
+
- `--kotlin-package <dotted>` — Kotlin source package. Default `com.voltro.client`.
|
|
1086
|
+
|
|
1087
|
+
What each package contains:
|
|
1088
|
+
|
|
1089
|
+
| Piece | Swift | Kotlin |
|
|
1090
|
+
|---|---|---|
|
|
1091
|
+
| Type-safe models | `Codable` structs + `String` enums | `@Serializable` data classes + enum classes |
|
|
1092
|
+
| One-shot client (query / mutation / action) | `async throws` methods over `URLSession` | `suspend` methods over Ktor |
|
|
1093
|
+
| Subscription client (streams) | `AsyncThrowingStream` over `URLSessionWebSocketTask` | `Flow` over Ktor WebSockets |
|
|
1094
|
+
| Auth + tenant context | `AuthContext` (bearer + `x-tenant` headers) | `AuthContext` |
|
|
1095
|
+
| Push registration | `PushRegistration` stub | `PushRegistration` stub |
|
|
1096
|
+
|
|
1097
|
+
Type mapping is faithful: `string → String`, `integer → Int`, `number → Double/Double`, `boolean → Bool/Boolean`, arrays → `[T]` / `List<T>`, nested objects → their own named type, string-literal unions → an enum, and an **optional field** (one absent from the schema's `required` set, or a `NullOr`) becomes a Swift `Optional` / Kotlin nullable with a `= nil` / `= null` default.
|
|
1098
|
+
|
|
1099
|
+
**Scope — this is the SDK code generator, not a native runtime.** Deliberately out of scope (they need a native runtime or managed infra, not generated client code): native module bindings (camera, biometrics), the APNs/FCM push **sender** (per-tenant Apple/Firebase credentials, provisioned server-side), and the managed OTA / EAS build pipeline. The generated source is verified at the generator level (golden-string tests over the emitted Swift + Kotlin). Compiling it with `swiftc` / Gradle is the remaining step in your own mobile CI — the framework harness has no Swift/Kotlin toolchain.
|
|
1100
|
+
|
|
1028
1101
|
## `voltro doctor` — preflight a production serve
|
|
1029
1102
|
|
|
1030
1103
|
Production `voltro serve` for an **API** app boots ONLY from the precompiled serve
|
|
@@ -1210,6 +1283,51 @@ since a rename or a deletion is decidable whatever the payload looks like.
|
|
|
1210
1283
|
The required keys come from the live `payloadSchema`, the same source the runtime
|
|
1211
1284
|
validation reads, so the two cannot disagree about what a payload needs.
|
|
1212
1285
|
|
|
1286
|
+
### The junction-FK check
|
|
1287
|
+
|
|
1288
|
+
A link / junction table (`projectMembers`, `todoTagAssignments`) exists to connect two aggregates, so its columns are almost all foreign keys. Declared with `reference(() => projects)` the framework knows the edge — it enforces integrity, auto-indexes the FK, and can walk the reference graph. Declared as a bare `text()` id column the *same* edge is invisible: no FK, no auto-index, and nothing that walks references can follow it. Nothing type-checks the difference.
|
|
1289
|
+
|
|
1290
|
+
`voltro doctor` flags a junction table with an id-shaped column that is a plain scalar and not a `reference()`:
|
|
1291
|
+
|
|
1292
|
+
```
|
|
1293
|
+
junction FKs: 2 junction tables with an id column that is a plain text() and not a reference()
|
|
1294
|
+
'todoListMembers': 'todoListId', 'userId' (part of a composite primary key)
|
|
1295
|
+
'todoTagAssignments': 'todoId', 'tagId' (this table is nothing but link columns)
|
|
1296
|
+
Declare each as reference(() => <table>): the FK is enforced, the column is auto-indexed,
|
|
1297
|
+
and the relationship becomes walkable (a plain text() id column is an invisible edge).
|
|
1298
|
+
```
|
|
1299
|
+
|
|
1300
|
+
It will **not** fire on any `*Id` text column — a `tenantId`, a `traceId`, an external-system reference are all legitimate plain-scalar shapes. It fires only when the table's OWN structure independently says "link table", and it names which signal tripped it so the finding is auditable rather than a bare accusation:
|
|
1301
|
+
|
|
1302
|
+
| Signal | What it means |
|
|
1303
|
+
|---|---|
|
|
1304
|
+
| `part of a composite primary key` | the suspect column is a member of an explicit `primaryKey([...])` — the PK structure alone proves the row is a link |
|
|
1305
|
+
| `sits beside a wired reference() on this table` | a real `reference()` on a same-shaped sibling column, while this one is a bare scalar |
|
|
1306
|
+
| `this table is nothing but link columns` | the whole table is id-shaped columns + bookkeeping (a pure link table) |
|
|
1307
|
+
|
|
1308
|
+
The audit reads the tables' **real declared `ColumnType`s** — the same materialised column definitions the migrator emits DDL from — never source text. So a `reference` is told apart from a plain scalar by its declared type, not a name regex, and a column name that only appears in a comment cannot trip it.
|
|
1309
|
+
|
|
1310
|
+
### Event delivery + scale
|
|
1311
|
+
|
|
1312
|
+
Two events with identical route / subscriber / buffer numbers can mean **opposite** things about a missing message — `each` counts a drop as a loss and tells the subscriber, `latest` supersedes the pending value and says nothing — and that mode is invisible once the app is running. So `voltro doctor` lists every declared event's delivery mode:
|
|
1313
|
+
|
|
1314
|
+
```
|
|
1315
|
+
event delivery: 4 declared events — the mode decides what a MISSING message means
|
|
1316
|
+
'games.started': each
|
|
1317
|
+
'player.moved': latest
|
|
1318
|
+
each — every delivery matters; a slow subscriber loses the oldest and is TOLD how many (the default).
|
|
1319
|
+
latest — a newer delivery supersedes a pending one; a slow subscriber gets the current value, told nothing.
|
|
1320
|
+
```
|
|
1321
|
+
|
|
1322
|
+
It also **warns** on two shapes that will not scale the way the declaration reads — advisory, never blocking:
|
|
1323
|
+
|
|
1324
|
+
| Warning | Why |
|
|
1325
|
+
|---|---|
|
|
1326
|
+
| **routing key has 3+ fields** | every key field is a routing address, and the count of distinct routes is the *product* of the fields' value spaces. Check each is an ADDRESS the delivery is decided by (`arenaId`), not a discriminator the handler reads (`gameType`) — the latter belongs in the payload, not the key. |
|
|
1327
|
+
| **`webhook:` on a per-frame event** | a webhook block on a name like `player.moved` / `cursor.moved` / `*.frameRendered` becomes N HTTP deliveries per second *per subscribed target*. The webhook rate limit **defers** the excess as pending rows rather than failing, so the symptom is a growing table. Publish a coarser event (a summary / state change) for the outside world. |
|
|
1328
|
+
|
|
1329
|
+
The field count comes from the same schema-property reader the runtime validation uses, so it cannot disagree with the key the event actually routes on. Both findings appear in `voltro doctor --json` under `eventDelivery`.
|
|
1330
|
+
|
|
1213
1331
|
### The hand-roll detector
|
|
1214
1332
|
|
|
1215
1333
|
`voltro doctor` also scans your source for shapes the framework already has a
|
|
@@ -1735,16 +1853,35 @@ voltro workflows resume <runId>
|
|
|
1735
1853
|
voltro workflows signal <runId> --name approval # inject a named signal
|
|
1736
1854
|
voltro workflows update <runId> --name …
|
|
1737
1855
|
voltro workflows children <parentExecutionId>
|
|
1856
|
+
voltro workflows flow # the admission queue + ledger
|
|
1857
|
+
voltro workflows pause|unpause <workflowName> # stop/restart admission fleet-wide
|
|
1858
|
+
voltro workflows cancel-many --reason "…" # DRY RUN until --commit
|
|
1859
|
+
voltro workflows replay-many --mode redrive # DRY RUN until --commit
|
|
1860
|
+
voltro workflows inferences # offloaded model calls in flight
|
|
1738
1861
|
```
|
|
1739
1862
|
|
|
1863
|
+
`inferences` shows what nothing else can: a run parked on an offloaded model call reads `suspended` in the run list with no step row yet, so during a slow provider — the moment you would look — the run list has nothing to say.
|
|
1864
|
+
|
|
1740
1865
|
Underneath, workflow state lives in the `_voltro_workflow_runs` + `_voltro_workflow_run_steps` tables and is surfaced live by the dashboard's Workflows panel. The same data is reachable over HTTP:
|
|
1741
1866
|
|
|
1742
1867
|
```bash
|
|
1743
1868
|
curl -s localhost:$PORT/_voltro/inspect/workflows/runs | jq # recent runs
|
|
1744
1869
|
curl -s "localhost:$PORT/_voltro/inspect/workflows/runs/<runId>/steps" | jq # step-by-step
|
|
1745
1870
|
curl -s "localhost:$PORT/_voltro/inspect/workflows/runs/<runId>/events" | jq # the run's event log
|
|
1871
|
+
curl -s "localhost:$PORT/_voltro/inspect/workflows/stats?hours=24" | jq # bucketed run activity (the dashboard chart)
|
|
1746
1872
|
```
|
|
1747
1873
|
|
|
1874
|
+
The runs endpoint filters **server-side**, so a triage query over a large run history costs one narrow page instead of the whole table:
|
|
1875
|
+
|
|
1876
|
+
```bash
|
|
1877
|
+
# multi-status + tag search + source + id-prefix + time range — all composable
|
|
1878
|
+
curl -s "localhost:$PORT/_voltro/inspect/workflows/runs?statuses=failed,cancelled&q=orders&source=workflow-rpc&idPrefix=wfrun_&from=2026-08-01T00:00:00Z&to=2026-08-08T00:00:00Z" | jq
|
|
1879
|
+
```
|
|
1880
|
+
|
|
1881
|
+
`statuses` is a comma list; `q` is a case-insensitive tag substring; `idPrefix` matches the run id **or** the execution id (you never have to know which kind your log line carried); `from`/`to` bound `startedAt`. The dashboard's filter bar sends exactly these params.
|
|
1882
|
+
|
|
1883
|
+
`/workflows/stats` returns ~48 buckets over a trailing window (`hours`, default 24, max 168; optional `tag`), each with `started` / `succeeded` / `failed` / `cancelled` counts, plus per-workflow totals. When the window held more runs than the scan cap, the response says `truncated: true` — the chart renders that as a warning, because a silently-truncated chart shows throughput dropping at exactly the moment it spiked.
|
|
1884
|
+
|
|
1748
1885
|
Each run row carries ID, name, status (running / succeeded / failed / dead), step count, last completed step, and duration. The per-run action endpoint matches `…/workflows/runs/<runId>/<action>` for `cancel` / `retry` / `suspend` / `resume` / `signal` — the `voltro workflows` subcommands and the dashboard's run-detail buttons both POST to these:
|
|
1749
1886
|
|
|
1750
1887
|
```bash
|
|
@@ -265,6 +265,38 @@ const sync = resolveSecretSync('SESSION_SECRET') // env-only fast path (s
|
|
|
265
265
|
|
|
266
266
|
The field-encryption key (for `.encrypted()` columns — see [plugin-governance](/docs/plugins/governance)) resolves through this same backend. `governancePlugin({ fieldEncryption: true })` reads the secret `VOLTRO_FIELD_ENCRYPTION_KEY` (override with `fieldEncryption: { secretKey }`); point `secrets` at your vault and the key never touches an env file.
|
|
267
267
|
|
|
268
|
+
## Live rotation — swap a secret without a restart
|
|
269
|
+
|
|
270
|
+
The boot env gate resolves every secret once, at start-up. Rotating a leaked key normally means a redeploy. `@voltro/env/server` lets a running process cut over to a re-resolved value **and keep accepting the old one for a grace window** — so requests signed with the previous key still verify while callers catch up.
|
|
271
|
+
|
|
272
|
+
```ts
|
|
273
|
+
import { rotateSecretLive, getSecretWithOverlap } from '@voltro/env/server'
|
|
274
|
+
|
|
275
|
+
// Re-resolve WEBHOOK_SIGNING_SECRET through the active backend and cut over,
|
|
276
|
+
// holding the OLD value valid for a 5-minute overlap (the default).
|
|
277
|
+
await rotateSecretLive('WEBHOOK_SIGNING_SECRET', { graceMs: 5 * 60_000 })
|
|
278
|
+
|
|
279
|
+
// A verifier accepts BOTH during the overlap — try current first, fall back:
|
|
280
|
+
const { current, previous } = getSecretWithOverlap('WEBHOOK_SIGNING_SECRET')
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
`getSecretWithOverlap(key)` returns `{ current, previous }` — the same current/previous pattern session verification uses for `VOLTRO_SESSION_SECRET` + `_PREVIOUS`. `previous` is present only while a rotation's grace window is open, then `undefined` (revoked lazily, on read — no timer). For a value you already have in hand (e.g. fetched from your own KMS), the lower-level `refreshEnvValue` installs it directly:
|
|
284
|
+
|
|
285
|
+
```ts
|
|
286
|
+
import { refreshEnvValue } from '@voltro/env'
|
|
287
|
+
|
|
288
|
+
refreshEnvValue('WEBHOOK_SIGNING_SECRET', nextValue, {
|
|
289
|
+
previous: oldValue, // held valid for the grace window
|
|
290
|
+
graceMs: 5 * 60_000,
|
|
291
|
+
})
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
`refreshEnvValue` throws if called **before** the boot env gate ran — a live rotation is a post-boot operation that replaces a value the gate already resolved, not a way to set one that was missing.
|
|
295
|
+
|
|
296
|
+
### What live rotation actually reaches — the honest bound
|
|
297
|
+
|
|
298
|
+
This updates what code that reads a secret **per use** sees: outbound API keys resolved on each call, webhook-signing verification, `.encrypted()` field encryption. It does **not** reconnect a live resource built once, at boot, from the old credential — a database connection pool created with the previous password keeps that connection. Rotating a DB password stays a reconnect concern (drain + rebuild the pool, or redeploy); rotating a signing or outbound key is what this is for.
|
|
299
|
+
|
|
268
300
|
## Testing
|
|
269
301
|
|
|
270
302
|
`setSecretsBackend(backend)` installs a backend for a test; `resetSecretsBackend()` restores the env default. `resolveSecretsBackend(config)` is the pure resolver the boot path uses to turn the `secrets` config value into a backend.
|
|
@@ -1024,6 +1024,67 @@ const message = matchError(err, {
|
|
|
1024
1024
|
}, () => 'Something went wrong')
|
|
1025
1025
|
```
|
|
1026
1026
|
|
|
1027
|
+
## Cross-table business rules — `.rule()`
|
|
1028
|
+
|
|
1029
|
+
A [typed error](#typed-errors) is declared on ONE mutation. A **rule** is declared on a TABLE: an invariant the runtime enforces on EVERY mutation that writes that table, no matter which one did the write. Declare it in the schema with `.rule(name, predicate)`:
|
|
1030
|
+
|
|
1031
|
+
```ts
|
|
1032
|
+
import { table, id, integer, eq } from '@voltro/database'
|
|
1033
|
+
import { database } from '../database/schema'
|
|
1034
|
+
|
|
1035
|
+
export const invoices = table('invoices', {
|
|
1036
|
+
id: id(),
|
|
1037
|
+
total: integer(),
|
|
1038
|
+
}).rule(
|
|
1039
|
+
'totalMatchesLineItems',
|
|
1040
|
+
async (row, { store }) => {
|
|
1041
|
+
const items = await store.query(
|
|
1042
|
+
database.lineItems.where(eq('invoiceId', row.id)).descriptor,
|
|
1043
|
+
)
|
|
1044
|
+
const sum = items.reduce((acc, l) => acc + Number(l.amount), 0)
|
|
1045
|
+
return sum === row.total || { params: { computed: sum, declared: row.total } }
|
|
1046
|
+
},
|
|
1047
|
+
)
|
|
1048
|
+
```
|
|
1049
|
+
|
|
1050
|
+
The predicate receives the **post-write row** and a `context` whose `store` is the SAME transactional store the mutation wrote through — so a cross-table read shares the write's MVCC snapshot and cannot race it. It runs INSIDE the mutation transaction, after the write and before commit, and it is dialect-neutral (the same predicate is correct on all four dialects — no per-dialect code). Return `true` (or nothing) when the invariant holds; return `false` or a `RuleViolationDetail` (`{ params?, field?, message? }`) to signal a violation for THIS row.
|
|
1051
|
+
|
|
1052
|
+
Unlike [`.check()`](/docs/database/columns#db-level-checks) — a single-row SQL `CHECK` the DATABASE enforces as DDL — a rule is a PREDICATE the runtime evaluates, so it can read OTHER tables. Use `.check()` when the database itself must guarantee a single-row constraint; reach for `.rule()` for a cross-table invariant (an invoice total matching its line items, a booking not exceeding a resource's capacity).
|
|
1053
|
+
|
|
1054
|
+
### The violation is a typed error — automatically
|
|
1055
|
+
|
|
1056
|
+
A violated `error`-severity rule rolls the whole mutation back and fails with the typed, wire-preserved **`BusinessRuleViolation`**. You do **not** declare it on the mutation's `error:` — the runtime auto-merges it into every mutation's error union at the wire boundary, exactly like `ScopeError`. A rule declared on a SCHEMA table can fail ANY mutation that writes that table, so no single descriptor could know to declare it; the auto-merge is what keeps the violation a typed error the client decodes by `_tag` rather than an untyped defect.
|
|
1057
|
+
|
|
1058
|
+
Match it on the client the same way as any [typed error](#matching-typed-errors-on-the-client):
|
|
1059
|
+
|
|
1060
|
+
```ts
|
|
1061
|
+
import { errorTag } from '@voltro/protocol'
|
|
1062
|
+
|
|
1063
|
+
try {
|
|
1064
|
+
await createInvoice(input)
|
|
1065
|
+
} catch (err) {
|
|
1066
|
+
if (errorTag(err) === 'BusinessRuleViolation') {
|
|
1067
|
+
// err.rule — the rule name; err.params — the offending values; err.field — the pointer
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
```
|
|
1071
|
+
|
|
1072
|
+
`BusinessRuleViolation` carries `rule` (the declared name), optional `params` (i18n params for the offending values), an optional `field` pointer, and `severity` (always `'error'` on the wire — a warning-severity rule never reaches the client as an error).
|
|
1073
|
+
|
|
1074
|
+
### `severity: 'warning'` — log without blocking
|
|
1075
|
+
|
|
1076
|
+
```ts
|
|
1077
|
+
export const invoices = table('invoices', { id: id(), total: integer() }).rule(
|
|
1078
|
+
'totalMatchesLineItems',
|
|
1079
|
+
async (row, { store }) => true,
|
|
1080
|
+
{ severity: 'warning' },
|
|
1081
|
+
)
|
|
1082
|
+
```
|
|
1083
|
+
|
|
1084
|
+
A `warning`-severity rule LOGS and audits the violation but lets the write commit — useful while backfilling data that does not yet satisfy a newly-added invariant. The default is `'error'` (roll back).
|
|
1085
|
+
|
|
1086
|
+
Rules run on `voltro dev` and `voltro serve` through the same mutation runner, so the two boot paths cannot disagree about whether a rule fires. Inserts and updates on your own tables are re-validated; a delete is not (there is no post-write row to check).
|
|
1087
|
+
|
|
1027
1088
|
## `internal: true` — off the wire entirely
|
|
1028
1089
|
|
|
1029
1090
|
Every discovered `*.mutation.ts` / `*.query.ts` / `*.action.ts` / `*.stream.ts` is
|
|
@@ -1472,6 +1533,88 @@ A subscription that was live and is then skipped goes idle — it does **not**
|
|
|
1472
1533
|
keep serving the snapshot it still holds. Otherwise `skip: !open` would show
|
|
1473
1534
|
last time's data the moment a dialog reopens.
|
|
1474
1535
|
|
|
1536
|
+
## SSR preload — first paint with data
|
|
1537
|
+
|
|
1538
|
+
By default a `useSubscription` on an SSR page flashes its empty/loading state on
|
|
1539
|
+
mount, THEN opens the WebSocket and fetches the first snapshot — even though the
|
|
1540
|
+
server could have fetched that value during the render. **`usePreloadedSubscription`**
|
|
1541
|
+
closes that gap: it reads its first value from the SSR hydration payload, renders
|
|
1542
|
+
real data on the first paint, then upgrades to the live stream the instant its
|
|
1543
|
+
first snapshot lands.
|
|
1544
|
+
|
|
1545
|
+
Two things wire it up: the hook, and a `preload` export on the page.
|
|
1546
|
+
|
|
1547
|
+
```tsx
|
|
1548
|
+
import { usePreloadedSubscription } from '@voltro/client'
|
|
1549
|
+
|
|
1550
|
+
export const preload = ['notes.list']
|
|
1551
|
+
|
|
1552
|
+
export default function NotesPage() {
|
|
1553
|
+
const { data } = usePreloadedSubscription<Note[]>('app', 'notes.list')
|
|
1554
|
+
// `data` is present on the first paint — no loading flash
|
|
1555
|
+
return <NotesTable notes={data} />
|
|
1556
|
+
}
|
|
1557
|
+
```
|
|
1558
|
+
|
|
1559
|
+
`export const preload` lists the subscriptions the page's tree needs at first
|
|
1560
|
+
paint. During the SSR render (`voltro dev` and `voltro start`) the framework runs
|
|
1561
|
+
each one server-side — the SAME `ctx.query(tag, input)` a loader receives — and
|
|
1562
|
+
seeds the result into the hydration payload. The client's
|
|
1563
|
+
`usePreloadedSubscription` finds the seed by the SAME cache key `useSubscription`
|
|
1564
|
+
uses, so the server markup and the client hydration render read an identical
|
|
1565
|
+
value — there is no hydration mismatch. The live subscription still opens and
|
|
1566
|
+
takes over; the seed is only the first value, never the source of truth.
|
|
1567
|
+
|
|
1568
|
+
### `preload` entries
|
|
1569
|
+
|
|
1570
|
+
An entry is either a bare tag or a tag plus an `input` derived from the route
|
|
1571
|
+
params:
|
|
1572
|
+
|
|
1573
|
+
```tsx
|
|
1574
|
+
export const preload = [
|
|
1575
|
+
'teams.list', // no input
|
|
1576
|
+
{ tag: 'project.detail', input: (params) => ({ id: params.id }) },
|
|
1577
|
+
]
|
|
1578
|
+
```
|
|
1579
|
+
|
|
1580
|
+
The `input` you derive here MUST match the `input` you pass the hook — both
|
|
1581
|
+
address the same cache entry:
|
|
1582
|
+
|
|
1583
|
+
```tsx
|
|
1584
|
+
export default function ProjectPage({ params }: { params: { id: string } }) {
|
|
1585
|
+
const { data } = usePreloadedSubscription('app', 'project.detail', { id: params.id })
|
|
1586
|
+
return <ProjectView project={data} />
|
|
1587
|
+
}
|
|
1588
|
+
```
|
|
1589
|
+
|
|
1590
|
+
### Falls back to `useSubscription`
|
|
1591
|
+
|
|
1592
|
+
When no seed exists for the key — a client-side SPA navigation the server never
|
|
1593
|
+
rendered, or the static prerender (which has no live api origin) —
|
|
1594
|
+
`usePreloadedSubscription` behaves EXACTLY like `useSubscription`: it loads until
|
|
1595
|
+
the stream answers. So it is always safe to reach for; the preload is a
|
|
1596
|
+
first-paint optimization, never a correctness dependency. A failed preload is
|
|
1597
|
+
likewise non-fatal — the live subscription still delivers the value on the
|
|
1598
|
+
client, the only loss is the first-paint seed.
|
|
1599
|
+
|
|
1600
|
+
### Seeding by hand
|
|
1601
|
+
|
|
1602
|
+
`export const preload` is sugar over an explicit seed. When a loader ALREADY has
|
|
1603
|
+
the value — you fetched it for the `<title>`, a breadcrumb, or the row name — seed
|
|
1604
|
+
it directly with `seedPreloadedSubscription` (server-side only) instead of
|
|
1605
|
+
fetching it a second time:
|
|
1606
|
+
|
|
1607
|
+
```ts
|
|
1608
|
+
import { seedPreloadedSubscription } from '@voltro/client'
|
|
1609
|
+
|
|
1610
|
+
// inside a loader / layout loader, server-side
|
|
1611
|
+
const notes = await ctx.query('notes.list', {})
|
|
1612
|
+
seedPreloadedSubscription('app', 'notes.list', {}, notes)
|
|
1613
|
+
```
|
|
1614
|
+
|
|
1615
|
+
Calling it outside a server render throws — on the client the live subscription
|
|
1616
|
+
already provides the value, so a client-side seed would be meaningless.
|
|
1617
|
+
|
|
1475
1618
|
## Streams Are Different
|
|
1476
1619
|
|
|
1477
1620
|
For non-database or transient element feeds, use [streams](/docs/data/streams), not subscriptions:
|
|
@@ -2697,6 +2840,145 @@ export default defineAggregate({
|
|
|
2697
2840
|
|
|
2698
2841
|
When no analytics sink is configured the framework provides the no-op sink: the read methods fail with `AnalyticsCapabilityNotSupported({ provider: 'noop' })`, which surfaces as a refresh error (last-good rows are preserved). Provider-specific queries beyond the four-method contract (raw SQL, HyperLogLog) live outside the sink — there is no raw-client escape hatch; query the provider with your own client instance inside the build function where you need them.
|
|
2699
2842
|
|
|
2843
|
+
## Standing IVM siblings — expectations, cost budgets, experiments
|
|
2844
|
+
|
|
2845
|
+
Three more primitives are built on the same engine as `defineAggregate({ incremental })` — they reduce a table to a value maintained incrementally from `store.onChange` CDC deltas (O(1) per write, no re-query). Unlike an aggregate you never `read()` a materialised table; you **observe a live signal** through a framework registry and a `useX(def)` handler-sugar. Each is discovered by its own file convention and wired into both `voltro dev` and `voltro serve`.
|
|
2846
|
+
|
|
2847
|
+
| Primitive | File | Reduces a table to | Observe with |
|
|
2848
|
+
|---|---|---|---|
|
|
2849
|
+
| `defineExpectation` | `*.expectation.ts` | a `holding`/`violated` data-quality signal | `useExpectation` / `ExpectationRegistry` |
|
|
2850
|
+
| `defineCostBudget` | `*.budget.ts` | per-tenant compute-cost attribution + `ok`/`warn`/`exceeded` budgets | `useCostBudget` / `CostRegistry` |
|
|
2851
|
+
| `defineExperiment` | `*.experiment.ts` | per-variant metric + lift (live A/B / holdout) | `useExperiment` / `ExperimentRegistry` |
|
|
2852
|
+
|
|
2853
|
+
They are **soft, observability-grade signals** — none of them ever blocks a write. Rejecting a write is a business rule's job (`table().validate(Schema)`); running an agent/workflow on a change is [`*.reaction.tsx`](/docs/data/reactions). These three only *watch* and *report*.
|
|
2854
|
+
|
|
2855
|
+
### Data-quality expectations (`*.expectation.ts`)
|
|
2856
|
+
|
|
2857
|
+
A `defineExpectation` is a standing data-quality contract over a table. Where dbt tests / Great Expectations run in BATCH and catch bad data hours later, an expectation re-evaluates on every write and tips `holding`↔`violated` the instant an invariant breaks — tracing the violation to the write that caused it (the CDC event's `traceId` / `subjectId` / `procedure`).
|
|
2858
|
+
|
|
2859
|
+
```ts
|
|
2860
|
+
// apps/api/expectations/orderFreshness.expectation.ts
|
|
2861
|
+
import { defineExpectation } from '@voltro/runtime'
|
|
2862
|
+
|
|
2863
|
+
export default defineExpectation({
|
|
2864
|
+
name: 'orders-fresh',
|
|
2865
|
+
on: { table: 'orders' }, // its CDC deltas drive re-evaluation
|
|
2866
|
+
invariant: { kind: 'freshness', column: 'createdAt', maxAgeMs: 10 * 60_000 },
|
|
2867
|
+
severity: 'critical', // 'info' | 'warn' (default) | 'critical' — alerting priority only
|
|
2868
|
+
})
|
|
2869
|
+
```
|
|
2870
|
+
|
|
2871
|
+
The default export must be the return value of `defineExpectation({...})` — the cli discovers `*.expectation.ts` files by that branded shape.
|
|
2872
|
+
|
|
2873
|
+
Four invariant kinds, each reducing the table to one incrementally-maintained metric:
|
|
2874
|
+
|
|
2875
|
+
```ts
|
|
2876
|
+
{ kind: 'freshness', column: 'createdAt', maxAgeMs: 600_000 } // newest row no older than X (re-checked on a clock tick too)
|
|
2877
|
+
{ kind: 'nullRate', column: 'email', maxRate: 0.01 } // ≤ 1% of rows null in `column`
|
|
2878
|
+
{ kind: 'rowCount', min: 1, max: 100_000 } // COUNT(*) within [min, max]
|
|
2879
|
+
{ kind: 'valueBounds', column: 'price', min: 0, max: 1000, maxViolationRate: 0.05 } // ≤ 5% of rows out of [min, max]
|
|
2880
|
+
```
|
|
2881
|
+
|
|
2882
|
+
An optional `on.where` predicate narrows the population the invariant is asserted over — it runs server-side per row and never leaves the server, so it can be any predicate:
|
|
2883
|
+
|
|
2884
|
+
```ts
|
|
2885
|
+
on: { table: 'orders', where: (row) => (row['status'] as string) === 'paid' }
|
|
2886
|
+
```
|
|
2887
|
+
|
|
2888
|
+
Observe one expectation from a handler with `useExpectation`, or read the whole surface off `ExpectationRegistry`:
|
|
2889
|
+
|
|
2890
|
+
```ts
|
|
2891
|
+
import { Effect } from 'effect'
|
|
2892
|
+
import { useExpectation } from '@voltro/runtime'
|
|
2893
|
+
import ordersFresh from '../expectations/orderFreshness.expectation'
|
|
2894
|
+
|
|
2895
|
+
export default (input, _ctx) =>
|
|
2896
|
+
Effect.gen(function* () {
|
|
2897
|
+
const state = yield* useExpectation(ordersFresh)
|
|
2898
|
+
// state: { status: 'holding' | 'violated' | 'unknown', metric, threshold,
|
|
2899
|
+
// since, lastEvaluatedAt, lastCause, ... } | null (null = not registered)
|
|
2900
|
+
return { degraded: state?.status === 'violated' }
|
|
2901
|
+
})
|
|
2902
|
+
```
|
|
2903
|
+
|
|
2904
|
+
`ExpectationRegistry` exposes `snapshot()` (every expectation's state — the inspect feed), `get(name)`, `violations()` (the alerting view), and `subscribe(listener)` for `violated`/`recovered` transitions. A transition carries the causing write's provenance; a `freshness` SLA aging out with no write reports `cause: null` — the honest "no write caused this; the ABSENCE of writes did".
|
|
2905
|
+
|
|
2906
|
+
### Cost budgets (`*.budget.ts`)
|
|
2907
|
+
|
|
2908
|
+
A `defineCostBudget` is a reactive-FinOps primitive: per-tenant / per-subscription compute-cost attribution plus a budget every tenant is held to independently. It is to reactive compute what AI's `requireAiBudget` is to USD spend — a standing per-tenant ceiling that crosses `ok`→`warn`→`exceeded` and recovers on a window rollover.
|
|
2909
|
+
|
|
2910
|
+
```ts
|
|
2911
|
+
// apps/api/budgets/tenantCompute.budget.ts
|
|
2912
|
+
import { defineCostBudget } from '@voltro/runtime'
|
|
2913
|
+
|
|
2914
|
+
export default defineCostBudget({
|
|
2915
|
+
name: 'tenant-compute',
|
|
2916
|
+
limit: 100_000, // the per-tenant ceiling, in the budget's unit
|
|
2917
|
+
unit: 'recompute', // which cost unit to meter; omit ⇒ the tenant's TOTAL across every unit
|
|
2918
|
+
warnAt: 0.8, // fraction of `limit` at which it goes 'warn' (default 0.8; set 1 to disable)
|
|
2919
|
+
window: '24h', // tumbling window — the counter resets each boundary; omit ⇒ cumulative since boot
|
|
2920
|
+
severity: 'warn', // 'info' | 'warn' (default) | 'critical'
|
|
2921
|
+
})
|
|
2922
|
+
```
|
|
2923
|
+
|
|
2924
|
+
Observe one budget for one tenant with `useCostBudget(def, tenantId)`:
|
|
2925
|
+
|
|
2926
|
+
```ts
|
|
2927
|
+
import { Effect } from 'effect'
|
|
2928
|
+
import { useCostBudget } from '@voltro/runtime'
|
|
2929
|
+
import tenantCompute from '../budgets/tenantCompute.budget'
|
|
2930
|
+
|
|
2931
|
+
export default (input, ctx) =>
|
|
2932
|
+
Effect.gen(function* () {
|
|
2933
|
+
const state = yield* useCostBudget(tenantCompute, ctx.tenantId)
|
|
2934
|
+
// state: { status: 'ok' | 'warn' | 'exceeded', spent, limit, warnThreshold, ... } | null
|
|
2935
|
+
return { overBudget: state?.status === 'exceeded' }
|
|
2936
|
+
})
|
|
2937
|
+
```
|
|
2938
|
+
|
|
2939
|
+
`CostRegistry` exposes `attribution()` (per-tenant chargeback/showback rows — `total` + `byUnit` + `bySubscription`), `tenant(tenantId)`, `budgets()`, `budget(name, tenantId)`, `breaches()` (the alerting view), and `subscribe(listener)` for threshold crossings.
|
|
2940
|
+
|
|
2941
|
+
> **Reactive recomputes now feed attribution automatically.** Declaring any `*.budget.ts` wires the dispatcher's `recordCost` tap in both boot paths: every time a source-row change re-runs an affected subscription and pushes it a delta, one `{ unit: 'recompute', amount: 1 }` cost event is attributed to that subscription's tenant (and traceId). So a tenant's `total`/`spent` populates from real reactive work — a `unit: 'recompute'` budget crosses `ok`→`warn`→`exceeded` as deliveries accrue, and recovers on a window rollover. The emission is **per delivered recompute** — one event per subscription the change fanned out to, on both the row-set and computed-query delivery paths. An app that declares NO cost budget wires no tap and allocates nothing on the reactive hot path.
|
|
2942
|
+
|
|
2943
|
+
### Online experiments (`*.experiment.ts`)
|
|
2944
|
+
|
|
2945
|
+
A `defineExperiment` is a live A/B / holdout experiment expressed as IVM aggregates. Assignment is a deterministic salted hash (subject → variant, no stored assignment table, reproducible on the client); the success metric is maintained PER VARIANT from the watched table's CDC — so lift vs a baseline is real-time, with no batch pipeline. It differs from `plugin-flags` (which GATES a code path) — an experiment MEASURES the outcome.
|
|
2946
|
+
|
|
2947
|
+
```ts
|
|
2948
|
+
// apps/api/experiments/checkoutColor.experiment.ts
|
|
2949
|
+
import { defineExperiment } from '@voltro/runtime'
|
|
2950
|
+
|
|
2951
|
+
export default defineExperiment({
|
|
2952
|
+
name: 'checkout-button-color',
|
|
2953
|
+
on: { table: 'orders' }, // its CDC deltas drive the live recompute
|
|
2954
|
+
subject: 'userId', // stable per-subject bucketing (a column name, or a (row) => key fn)
|
|
2955
|
+
variants: [{ name: 'control' }, { name: 'green', weight: 1 }], // ≥ 2 arms; `weight` skews the split (default 1)
|
|
2956
|
+
holdout: 0.1, // 10% held out entirely, for a clean untouched baseline
|
|
2957
|
+
metric: { kind: 'conversionRate', column: 'completed' }, // per-variant success metric
|
|
2958
|
+
baseline: 'control', // which variant lift is measured against (default: the first)
|
|
2959
|
+
})
|
|
2960
|
+
```
|
|
2961
|
+
|
|
2962
|
+
Metric kinds: `{ kind: 'count' }`, `{ kind: 'sum', column }`, `{ kind: 'avg', column }`, and `{ kind: 'conversionRate', column, equals? }` (converted iff `row[column]` is truthy, or `=== equals` when given). `avg` / `conversionRate` are per-subject rates that a lift reads honestly; `count` / `sum` reflect exposure too, so their cross-variant comparison only means "more/less total".
|
|
2963
|
+
|
|
2964
|
+
Observe the live result with `useExperiment`, or off `ExperimentRegistry`:
|
|
2965
|
+
|
|
2966
|
+
```ts
|
|
2967
|
+
import { Effect } from 'effect'
|
|
2968
|
+
import { useExperiment } from '@voltro/runtime'
|
|
2969
|
+
import checkoutColor from '../experiments/checkoutColor.experiment'
|
|
2970
|
+
|
|
2971
|
+
export default (input, _ctx) =>
|
|
2972
|
+
Effect.gen(function* () {
|
|
2973
|
+
const result = yield* useExperiment(checkoutColor)
|
|
2974
|
+
// result: { totalExposure, baseline, variants: [{ variant, isBaseline, isHoldout,
|
|
2975
|
+
// exposure, metric, lift, diff }, ...], lastUpdatedAt, ... } | null
|
|
2976
|
+
return { arms: result?.variants ?? [] }
|
|
2977
|
+
})
|
|
2978
|
+
```
|
|
2979
|
+
|
|
2980
|
+
Each variant row carries its `exposure` (sample size), the maintained `metric`, and — for non-baseline arms — `lift` (`(metric − baseMetric) / baseMetric`) and `diff` (absolute). `ExperimentRegistry` exposes `snapshot()`, `get(name)`, and `subscribe(listener)` for the live recompute stream that a results view redraws from. The same-subject-same-variant assignment is a pure function (`assignVariant(def, subject)`) a client can reproduce.
|
|
2981
|
+
|
|
2700
2982
|
|
|
2701
2983
|
|
|
2702
2984
|
---
|
|
@@ -658,6 +658,53 @@ export const users = table('users', {
|
|
|
658
658
|
|
|
659
659
|
The cleanup is a separate PR after the migration has rolled out. Don't mix the rollout PR with the cleanup PR — the markers ARE the migration's audit trail until it's applied everywhere.
|
|
660
660
|
|
|
661
|
+
## `voltro evolve` — the schema-evolution copilot
|
|
662
|
+
|
|
663
|
+
Adding the `.renamedFrom()` marker by hand is easy for one column. The hard part of changing an EXISTING schema is the *rest*: which handlers read or write that column, whether a rename is safe or needs a backfill, and getting the annotation onto the entity AND every call site without missing one. `voltro evolve` does that reconnaissance and proposes a reviewable plan.
|
|
664
|
+
|
|
665
|
+
Given a change, it reads the **observed graph** (`app.graph.observed.generated.json`, recorded by `voltro dev` — see [`voltro check`](/docs/cli/inspect)) plus the declared manifest, enumerates the real blast radius, and prints three things: a proposed **codemod**, a branch-verified **backfill** plan, and a `voltro check` **verify** step.
|
|
666
|
+
|
|
667
|
+
```bash
|
|
668
|
+
voltro evolve rename-column notes.title --to heading # dry-run: plan + codemod preview
|
|
669
|
+
voltro evolve rename-column notes.title --to heading --write # apply the codemod
|
|
670
|
+
voltro evolve retype-column orders.total --to numeric # reshape → manual codemod + steps
|
|
671
|
+
voltro evolve split-column users.name --into firstName,lastName
|
|
672
|
+
voltro evolve rename-table note --to notes
|
|
673
|
+
voltro evolve drop-column notes.legacy
|
|
674
|
+
voltro evolve rename-column notes.title --to heading --json # for CI / an agent loop
|
|
675
|
+
```
|
|
676
|
+
|
|
677
|
+
It is **dry-run by default** (mirrors `voltro generate`); `--write` applies the codemod through the same `runCodemods` toolkit as `voltro update`. `--json` emits the whole plan for CI or an agent.
|
|
678
|
+
|
|
679
|
+
### The blast radius is observed, not guessed
|
|
680
|
+
|
|
681
|
+
```text
|
|
682
|
+
change: rename-column notes.title → heading
|
|
683
|
+
|
|
684
|
+
blast radius (observed + declared):
|
|
685
|
+
query(notes.list) — observed read
|
|
686
|
+
mutation(notes.update) — observed update
|
|
687
|
+
⚠ 1 declared but NEVER exercised — column use UNKNOWN: notes.archive
|
|
688
|
+
|
|
689
|
+
codemod:
|
|
690
|
+
entity: rename 'notes.title' → 'heading' and add .renamedFrom('title') (catalog rename — data preserved)
|
|
691
|
+
annotate 2 handler site(s) that reference the old field
|
|
692
|
+
|
|
693
|
+
backfill (dry-runs on branch notes-pr-0):
|
|
694
|
+
[safe] catalog RENAME — no data movement
|
|
695
|
+
|
|
696
|
+
verify: voltro check
|
|
697
|
+
```
|
|
698
|
+
|
|
699
|
+
A handler that a `voltro dev` session or a test actually ran is reported with what it did (`observed read` / `observed update`). A handler that is *declared* to touch the table but was **never exercised** is flagged `UNKNOWN` and listed separately — it is never folded into "safe", because no run proves what it does with the column. That honesty is the point: the tool tells you exactly where it cannot vouch for the change.
|
|
700
|
+
|
|
701
|
+
### What the codemod does per kind
|
|
702
|
+
|
|
703
|
+
- **`rename-column`** gets a real `transform`: it renames the field in the `*.entity.ts` AND chains **`.renamedFrom('old')`** (so the differ plans a catalog RENAME, not the lossy drop+create described [above](#renamedfromoldname)), then annotates the handler sites the blast radius found.
|
|
704
|
+
- **`retype-column` / `split-column` / `drop-column` / `rename-table`** are reshaping changes with no single mechanical rewrite, so they get a **`manual`** codemod: a generated, numbered checklist of the edits + the annotation to add, printed for you to apply.
|
|
705
|
+
|
|
706
|
+
`voltro evolve` produces the plan; it does not apply the schema change. **`voltro check` is the gate on the result**, and `voltro db apply` lands it — after `--write`, review the annotated handlers, then run those two.
|
|
707
|
+
|
|
661
708
|
|
|
662
709
|
|
|
663
710
|
---
|