@voltro/cli 0.33.0 → 0.35.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 +1968 -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-B8aoJvuw.js +2 -0
- package/dist/{apiBuild-h9VHtnlw.js → apiBuild-DYD_ONLD.js} +46 -46
- package/dist/{appGraph-gQ_6GkQQ.js → appGraph-KGDPTuTy.js} +1 -1
- package/dist/appGraph-zuMGKVYX.js +2 -0
- 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-CD8K4XOr.js +711 -0
- package/dist/cacheCommand-DA4OH9xt.js +42 -0
- package/dist/capabilitiesCommand-nq_pz5xd.js +123 -0
- package/dist/checkCommand-Ct9xkTrS.js +232 -0
- package/dist/checkCommand-DKpDLlqu.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-D5wsCmA_.js +54 -0
- package/dist/codegen-CYM3Zqrf.js +605 -0
- package/dist/codegen-ChBi_hVa.js +2 -0
- package/dist/codegenCommand-C4YoQIc2.js +30 -0
- package/dist/codemodRunner-BnFq3Fgu.js +5384 -0
- package/dist/commandRunner-BLAEFLjp.js +47 -0
- package/dist/commands-BE8E7zF3.js +816 -0
- package/dist/connectionConfig-UFlIEiys.js +66 -0
- package/dist/dashboardCommand-D7SgZGaN.js +25 -0
- package/dist/dataCommand-BhYwDgg-.js +537 -0
- package/dist/dataProfile-dW-PsfLB.js +15 -0
- package/dist/dbCommand-DS4b97Is.js +2 -0
- package/dist/{dbCommand-DTLKAfbA.js → dbCommand-O8HA63s2.js} +552 -402
- package/dist/{dev-C_P8FLSx.js → dev-C7sFZq3m.js} +3670 -3169
- package/dist/dev-D2BikO7a.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-CM4Ch9C7.js +2 -0
- package/dist/{checkCommand-xGhRFFg2.js → doctorCommand-DnimF5IM.js} +523 -1278
- package/dist/dormancyCommand-QewYug_s.js +69 -0
- package/dist/e2eCmd-BRabZww-.js +147 -0
- package/dist/embeddingsCommand-BfiLS_QI.js +73 -0
- package/dist/envCommand-CCGPRQY1.js +60 -0
- package/dist/evalCommand-6RUfPen4.js +118 -0
- package/dist/evolveCommand-CHsLCtDf.js +281 -0
- package/dist/fileTaxonomy-CJfgOllU.js +457 -0
- package/dist/frameworkTableAssembly-YyVe32Cb.js +2 -0
- package/dist/frameworkTableAssembly-oMPBKqlE.js +511 -0
- package/dist/generateCommand-DbgcUpGw.js +147 -0
- package/dist/index.d.ts +45 -0
- package/dist/index.js +4 -3
- package/dist/infoCommand-DwOgK1t6.js +60 -0
- package/dist/{inspect-BUUjt773.js → inspect-CjTYzAs_.js} +113 -41
- package/dist/inspect-P4pxoMaV.js +2 -0
- package/dist/inspectCmd-EHFZ9yYu.js +224 -0
- package/dist/inspectFetch-EMuhTG_9.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-D36xK7Zu.js +260 -0
- package/dist/manifestBuild-COkJoyAr.js +2 -0
- package/dist/{manifestBuild-BLrVuSlM.js → manifestBuild-hpPLaGxV.js} +1 -1
- package/dist/metaCommands-7MJfZ5cf.js +196 -0
- package/dist/migrate-BV7I-ZHZ.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/probeCommand-CZfaaUOZ.js +122 -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-CH3eUiMw.js +91 -0
- package/dist/scheduleCmd-DQRu6BZC.js +149 -0
- package/dist/scheduleManifestCmd-D2x0CTTY.js +249 -0
- package/dist/schemaIr-UJybUUZW.js +103 -0
- package/dist/{sdkgen-C81QIkiL.js → sdkgen-BLkvGRfX.js} +111 -209
- package/dist/seedRunner-ZmLSqNe2.js +333 -0
- package/dist/serveCommand-CbDHU6l-.js +2 -0
- package/dist/serveCommand-iwlUBNS1.js +1766 -0
- package/dist/serveEntry.js +5 -5
- package/dist/serverlessCommand-CfJZy6dS.js +482 -0
- package/dist/start-BgN62boB.js +3 -0
- package/dist/start-T4VesWiM.js +1087 -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-DStmCJPi.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-BlMZhWgO.js +2 -0
- package/dist/updateCommand-x0pI_x-B.js +585 -0
- package/dist/webDev-BcykISYQ2.js +2 -0
- package/dist/{inspectMetrics-1xzTKAFx.js → webDev-Dybxew86.js} +988 -1560
- package/dist/webhookDiscovery-CrGAfhIG.js +2 -0
- package/dist/webhookDiscovery-D7VaeMlz.js +51 -0
- package/dist/webhooksCommand-DlAgS2Iw.js +267 -0
- package/dist/workflowsCmd-BGF-mRZ5.js +608 -0
- package/package.json +209 -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 +23 -6
- package/templates/agent-docs/ai.md +370 -0
- package/templates/agent-docs/authentication.md +313 -31
- package/templates/agent-docs/caching.md +6 -0
- package/templates/agent-docs/cli.md +853 -50
- package/templates/agent-docs/data.md +608 -12
- package/templates/agent-docs/database/migrations.md +223 -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 +284 -25
- package/templates/agent-docs/introduction.md +88 -17
- package/templates/agent-docs/local-first-mobile.md +79 -4
- package/templates/agent-docs/multi-tenancy.md +188 -42
- package/templates/agent-docs/observability.md +58 -3
- package/templates/agent-docs/plugins/ai-flows.md +247 -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 +32 -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 +162 -8
- package/templates/agent-docs/plugins/sso-saml.md +47 -8
- package/templates/agent-docs/plugins/storage.md +11 -0
- package/templates/agent-docs/plugins/webhooks.md +105 -0
- package/templates/agent-docs/plugins.md +152 -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 +27 -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 +98 -136
- package/templates/agent-docs/workflows.md +231 -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/appGraph-CvQCte0z.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-BwIrO5nv.js +0 -638
- 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
|
@@ -109,6 +109,138 @@ GET /_voltro/inspect/plugins/flags/audit?flag=beta # one flag's history
|
|
|
109
109
|
|
|
110
110
|
The `POST /toggle` body accepts an optional `actor` (the acting admin id) that is stored on the audit row. On the `memory` tier there is no durable audit (the endpoint returns an empty trail with a note).
|
|
111
111
|
|
|
112
|
+
## Typed flags — `defineFlag()`
|
|
113
|
+
|
|
114
|
+
A flag's VALUE had no type. `FlagVariant.value` is the `FlagVariantValue` union (`string | number | boolean | null | array | object`), so `{ name: 'big', value: 'lots' }` on a flag every reader treats as a number typechecked, and the mistake showed up at the call site as `NaN`.
|
|
115
|
+
|
|
116
|
+
`defineFlag()` gives a flag a value **Schema**, and the flag is browser-safe by construction — ONE declaration, imported by `app.config.ts` and by the component that reads it.
|
|
117
|
+
|
|
118
|
+
```ts
|
|
119
|
+
// apps/api/lib/flags.ts
|
|
120
|
+
import { Schema } from 'effect'
|
|
121
|
+
import { defineFlag } from '@voltro/plugin-flags'
|
|
122
|
+
|
|
123
|
+
export const checkoutButton = defineFlag({
|
|
124
|
+
key: 'checkout.button',
|
|
125
|
+
value: Schema.Literal('blue', 'green'),
|
|
126
|
+
default: 'blue',
|
|
127
|
+
variants: [{ name: 'control', value: 'blue' }, { name: 'green', value: 'green' }],
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
export const pageSize = defineFlag({
|
|
131
|
+
key: 'search.pageSize',
|
|
132
|
+
value: Schema.Number,
|
|
133
|
+
default: 20,
|
|
134
|
+
// default: 'twenty', ← Type 'string' is not assignable to type 'number'
|
|
135
|
+
})
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
Register them with `flagsPlugin({ typedFlags: [checkoutButton, pageSize] })`. Keys share one namespace with `flags: { … }`; declaring a key in both is refused at construction.
|
|
139
|
+
|
|
140
|
+
Read them typed on either side:
|
|
141
|
+
|
|
142
|
+
```ts
|
|
143
|
+
import { flagValue, flagVariant } from '@voltro/plugin-flags'
|
|
144
|
+
const size: number = flagValue(ctx, pageSize) // server
|
|
145
|
+
const arm = flagVariant(ctx, checkoutButton) // the served variant NAME, or null
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
```tsx
|
|
149
|
+
import { useFlagValue } from '@voltro/plugin-flags/web'
|
|
150
|
+
const colour = useFlagValue(checkoutButton) // 'blue' | 'green'
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
### Which half a Schema reaches, precisely
|
|
154
|
+
|
|
155
|
+
**Authored** values — `default`, every `variants[].value` — are checked by `tsc`. That is the headline and it is enforced by tests that fail `typecheck` if those lines ever start compiling.
|
|
156
|
+
|
|
157
|
+
**Runtime** values cannot be. A postgres-tier override row, or a dashboard edit, is JSON long after `tsc` ran. So the same Schema is the runtime gate: an override whose variant values do not decode is **refused whole**, the code-declared definition stands, and the refusal is logged and shown in the dashboard panel. Not partially applied — dropping the one bad arm re-normalises the weights of the rest, silently reallocating every subject.
|
|
158
|
+
|
|
159
|
+
Declaration also DECODES the authored default, which catches what a type cannot: `Schema.Int`'s TypeScript type is `number`, so `default: 20.5` typechecks and is a value the flag could never legally serve.
|
|
160
|
+
|
|
161
|
+
## Dead-flag detection
|
|
162
|
+
|
|
163
|
+
Every flag system accumulates flags nobody removes. `GET /_voltro/inspect/plugins/flags/list` carries a lifecycle report (rendered by the dashboard panel) built on two independent axes — and only some of it is a proof.
|
|
164
|
+
|
|
165
|
+
**`shape`** is decided from the DEFINITION alone. No observation, no window:
|
|
166
|
+
|
|
167
|
+
| shape | meaning |
|
|
168
|
+
|---|---|
|
|
169
|
+
| `constantOn` | `rollout` 100, no targeting, no variants, no live schedule — resolves `true` for everyone, forever |
|
|
170
|
+
| `constantOff` | `enabled: false`, or `rollout: 0` |
|
|
171
|
+
| `expired` | a `schedule.deactivateAt` that has passed — it can never be on again |
|
|
172
|
+
| `notYetActive` | a future `activateAt` — pending, not dead |
|
|
173
|
+
| `conditional` | genuinely selects between callers |
|
|
174
|
+
|
|
175
|
+
**`usage`** is decided from observed evaluations, and exactly one state is a proof:
|
|
176
|
+
|
|
177
|
+
| usage | meaning |
|
|
178
|
+
|---|---|
|
|
179
|
+
| `evaluated` | read server-side inside the threshold |
|
|
180
|
+
| `stale` | reads EXIST in the window and the newest is older than the threshold. **Provable** |
|
|
181
|
+
| `neverObserved` | no server-side read at all. Consistent with "dead" AND with "declared last Tuesday" — reported, never asserted, **never a removal candidate** |
|
|
182
|
+
| `untracked` | nothing is recording |
|
|
183
|
+
|
|
184
|
+
`removalCandidate` is set only by a proof: a constant/expired shape, or `stale`.
|
|
185
|
+
|
|
186
|
+
### What it cannot see
|
|
187
|
+
|
|
188
|
+
Shipped in the payload and rendered in the panel, not buried here:
|
|
189
|
+
|
|
190
|
+
- **Reachability is not decided.** "Not evaluated since \<date>" is a measurement; "this code path is dead" is not decidable in general. A Black-Friday flag, a flag behind an admin route nobody visited this month, and a flag whose last call site was deleted are indistinguishable.
|
|
191
|
+
- **Only SERVER-side reads count** — `isFlagEnabled` / `requireFlag` / `flagValue` / a `gatedBy` interception. `useFlag()` in the browser reads from the bulk set the server already sent, so the key never arrives as a named read. Bulk deliveries are recorded separately and never counted as use: one `useFlags()` poll evaluates the whole registry and would otherwise mark every flag in the app alive forever.
|
|
192
|
+
- **The window is finite**, bounded by the observation table's retention. A flag last read BEFORE the window has no observation at all and reads `neverObserved` — exactly what a flag declared this morning reads.
|
|
193
|
+
- On the day you turn tracking on, nothing has been observed, so every flag is `neverObserved` and nothing is proposed for removal. It is that SPLIT that prevents the day-one "everything is dead" report, not a coverage gate on top of it: such a gate is unreachable, because the observation that dates a stale flag is itself inside the window.
|
|
194
|
+
|
|
195
|
+
### Tunables
|
|
196
|
+
|
|
197
|
+
```ts
|
|
198
|
+
flagsPlugin({
|
|
199
|
+
store: 'postgres',
|
|
200
|
+
usage: {
|
|
201
|
+
track: true, // default: on with store:'postgres' (there is nowhere to write on memory)
|
|
202
|
+
flushIntervalMs: 300_000, // the report resolves to a DAY, so a tighter interval buys nothing
|
|
203
|
+
staleAfterDays: 30,
|
|
204
|
+
retentionDays: 90, // also VOLTRO_FLAG_USAGE_TTL_HOURS; the report's observation ceiling
|
|
205
|
+
},
|
|
206
|
+
})
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
Observations land in `_voltro_feature_flag_usage`, one row per (flag, UTC day, source), retention-swept. `track: true` on the `memory` tier is refused at construction rather than silently observing nothing, and `staleAfterDays > retentionDays` is refused because staleness could then never be proven.
|
|
210
|
+
|
|
211
|
+
## A flag can carry an experiment
|
|
212
|
+
|
|
213
|
+
`defineExperiment` (`@voltro/runtime`) maintains standing A/B results as **live IVM aggregates recomputed per-write from CDC deltas** — real-time uplift with no batch pipeline. A flag can name one:
|
|
214
|
+
|
|
215
|
+
```ts
|
|
216
|
+
export const checkoutButton = defineFlag({
|
|
217
|
+
key: 'checkout.button',
|
|
218
|
+
value: Schema.Literal('blue', 'green'),
|
|
219
|
+
default: 'blue',
|
|
220
|
+
variants: [{ name: 'control', value: 'blue' }, { name: 'green', value: 'green' }],
|
|
221
|
+
experiment: 'checkout-colour',
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
// apps/api/experiments/checkoutColour.experiment.ts
|
|
225
|
+
export default defineExperiment({
|
|
226
|
+
name: 'checkout-colour',
|
|
227
|
+
on: { table: 'orders' },
|
|
228
|
+
variantFrom: 'checkoutArm', // ← the arm the FLAG served
|
|
229
|
+
variants: [{ name: 'control' }, { name: 'green' }],
|
|
230
|
+
metric: { kind: 'conversionRate', column: 'completed' },
|
|
231
|
+
})
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
Persist the served arm on the row you want to measure:
|
|
235
|
+
|
|
236
|
+
```ts
|
|
237
|
+
await ctx.store.insert('orders', { …, checkoutArm: flagVariant(ctx, checkoutButton) })
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
**Why `variantFrom` and not just a name.** The flag assigns by hashing FNV-1a over `${key}:variant`; an experiment in `subject` mode hashes over the EXPERIMENT name. Both are stable and uniform, and they are INDEPENDENT — roughly half the subjects served `green` land in the experiment's `control` arm. The uplift would be live, precise, and measuring a split nobody experienced. One assignment, persisted, read by the experiment.
|
|
241
|
+
|
|
242
|
+
`flagsPlugin({ typedFlags, experiments })` **refuses to construct** when the link is wrong: a missing experiment, an experiment still in `subject` mode, an experiment-side holdout (carved AT assignment, which this experiment does not do), or arm names that do not match. Each of those is otherwise a wrong number rather than an error — a name mismatch shows up as a permanently-empty arm beside a permanently-full one, which reads as "the treatment has no effect".
|
|
243
|
+
|
|
112
244
|
## Three ways to use a flag
|
|
113
245
|
|
|
114
246
|
**1. Declarative gate** — `gatedBy: { '<rpcTag>': '<flag>' }` (exact tag or `/regex/`). An off flag fails the call with typed `FlagDisabled` before the handler runs (merged into every procedure's wire-error union → typed on the client).
|
|
@@ -29,11 +29,11 @@ export default {
|
|
|
29
29
|
{ table: 'events', ttlMs: 90 * 86_400_000 }, // delete after 90 days
|
|
30
30
|
{ table: 'users', ttlMs: 365 * 86_400_000, action: 'anonymize', anonymizeFields: ['email', 'name'] },
|
|
31
31
|
],
|
|
32
|
-
// GDPR: which tables
|
|
32
|
+
// GDPR: DERIVE which tables hold a subject's data from the schema.
|
|
33
|
+
deriveSubjectScopes: { subjectTable: 'users' },
|
|
34
|
+
// …plus anything the schema cannot encode (see below).
|
|
33
35
|
subjectScopes: [
|
|
34
|
-
{ table: '
|
|
35
|
-
{ table: 'posts', subjectField: 'userId' },
|
|
36
|
-
{ table: 'comments', subjectField: 'authorId' },
|
|
36
|
+
{ table: 'audit_trail', subjectField: 'actorRef' },
|
|
37
37
|
],
|
|
38
38
|
sweepIntervalMs: 3_600_000, // default 1h
|
|
39
39
|
}),
|
|
@@ -47,13 +47,111 @@ export default {
|
|
|
47
47
|
|
|
48
48
|
## GDPR — export + erasure (admin-gated)
|
|
49
49
|
|
|
50
|
-
Two admin-only routes (guarded by `requireScope(ADMIN_SCOPE)`)
|
|
50
|
+
Two admin-only routes (guarded by `requireScope(ADMIN_SCOPE)`):
|
|
51
51
|
|
|
52
|
-
- **`governance.export`** `{ subjectId }` → a portable bundle `{ [table]: rows[] }` of everything
|
|
53
|
-
- **`governance.erase`** `{ subjectId, mode? }` → deletes (or anonymises) the subject across every scope; returns an immutable `ErasureLogEntry` (`{ subjectId, at, mode, affected: [{ table, count }] }`).
|
|
52
|
+
- **`governance.export`** `{ subjectId }` → a portable bundle `{ [table]: rows[] }` of everything belonging to the subject.
|
|
53
|
+
- **`governance.erase`** `{ subjectId, mode? }` → deletes (or anonymises) the subject across every scope; returns an immutable `ErasureLogEntry` (`{ subjectId, at, mode, affected: [{ table, count }], truncated? }`).
|
|
54
54
|
|
|
55
55
|
The same operations are available in-handler via `GovernanceService` (`exportSubject` / `eraseSubject`).
|
|
56
56
|
|
|
57
|
+
Erasure runs **deepest-first** — children before parents — so a real foreign key
|
|
58
|
+
neither refuses the delete nor cascades through rows the log entry never counted.
|
|
59
|
+
|
|
60
|
+
### The subject scope is DERIVED from your schema
|
|
61
|
+
|
|
62
|
+
A hand-written list of "every table holding this person's data" is wrong the day
|
|
63
|
+
after someone adds a table — and that list *is* the compliance claim. So
|
|
64
|
+
`deriveSubjectScopes: { subjectTable: 'users' }` walks the schema instead: the
|
|
65
|
+
relations registry plus the `reference()` column graph, outward from the subject.
|
|
66
|
+
|
|
67
|
+
That reaches rows a flat `{ table, subjectField }` entry cannot even express —
|
|
68
|
+
`users → posts → comments` is two hops, so a comment on the subject's post is in
|
|
69
|
+
the export without anyone listing `comments`.
|
|
70
|
+
|
|
71
|
+
**Only CHILD edges are followed** — a table holding a reference to the subject's
|
|
72
|
+
row. Never a parent or lookup edge, and a `manyToMany` follows the JUNCTION only.
|
|
73
|
+
Getting that backwards would not be an over-broad export; it would be an erasure
|
|
74
|
+
that walks from one member into their organisation and deletes everybody else's
|
|
75
|
+
rows.
|
|
76
|
+
|
|
77
|
+
| declaration | followed? |
|
|
78
|
+
|---|---|
|
|
79
|
+
| `many(posts, { foreignKey: 'authorId' })` | ✓ the target carries the FK |
|
|
80
|
+
| `one(profile)` with the FK on the target | ✓ a 1:1 child |
|
|
81
|
+
| `one(country, { sourceKey: 'countryId' })` | — a lookup this row points AT |
|
|
82
|
+
| `manyToMany(orgs, { through: memberships })` | ✓ `memberships` only, never `orgs` |
|
|
83
|
+
| a `reference()` column with no `relations()` block | ✓ found in the column graph |
|
|
84
|
+
|
|
85
|
+
`subjectScopes` is still first-class and is **unioned on top**, never replaced.
|
|
86
|
+
It is the only way to reach a link the schema does not encode: a subject id in a
|
|
87
|
+
plain (non-`reference()`) column, a polymorphic `(ownerType, ownerId)` pair, an
|
|
88
|
+
id inside JSON.
|
|
89
|
+
|
|
90
|
+
### What the derivation cannot see — and says so
|
|
91
|
+
|
|
92
|
+
A list of reachable tables, printed alone, reads as a completeness claim. So the
|
|
93
|
+
blind spots ship in the same payload:
|
|
94
|
+
|
|
95
|
+
```ts
|
|
96
|
+
const gov = yield* GovernanceService
|
|
97
|
+
const { paths, limitations } = gov.subjectGraph()
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
…and at `GET /_voltro/inspect/plugins/governance/subject-graph`. `limitations`
|
|
101
|
+
names every table nothing links to the subject (`unreachable`), everything cut by
|
|
102
|
+
the depth ceiling (`depth-truncated`, default 4 hops) and everything you excluded.
|
|
103
|
+
Structurally outside the graph in every case: **object storage and uploaded
|
|
104
|
+
files, external processors, log and metric sinks, backups, and any subject id
|
|
105
|
+
embedded in JSON or free text.**
|
|
106
|
+
|
|
107
|
+
The plugin also warns at boot if a derived scope reaches one table or fewer —
|
|
108
|
+
that is what a typo'd `subjectTable` looks like, and it is otherwise
|
|
109
|
+
indistinguishable from a working configuration until the first DSAR.
|
|
110
|
+
|
|
111
|
+
### `voltro privacy`
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
voltro privacy scope --subject-table users # the graph + its blind spots, OFFLINE
|
|
115
|
+
voltro privacy scope --json # { reachable, unreachable, depthTruncated }
|
|
116
|
+
voltro privacy export usr_123 --url https://api.example.com --out bundle.json
|
|
117
|
+
voltro privacy erase usr_123 --url https://api.example.com --confirm
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
`scope` needs no database and no running app — schema only, so it belongs in CI
|
|
121
|
+
and in a PR review, where "does our erasure still reach every table" is a question
|
|
122
|
+
somebody can still act on.
|
|
123
|
+
|
|
124
|
+
`export` / `erase` deliberately go through the **running app's** admin-gated
|
|
125
|
+
governance endpoint rather than opening their own connection. A CLI that erased
|
|
126
|
+
directly would bypass your configured `anonymizeFields` and exclusions, bypass the
|
|
127
|
+
erasure log (which is the compliance artefact, not a nicety), and work against a
|
|
128
|
+
schema the deployed app may not be running. `erase` refuses without `--confirm`,
|
|
129
|
+
without `--url`, and without an inspect credential.
|
|
130
|
+
|
|
131
|
+
### Scale
|
|
132
|
+
|
|
133
|
+
Every read is `WHERE <column> IN (<keys>)` on an indexed column, chunked at 500
|
|
134
|
+
keys and memoised across paths that share a prefix — not a full table scan per
|
|
135
|
+
scope. Per table, 50 000 rows is the ceiling; hitting it sets `truncated` on the
|
|
136
|
+
erasure-log entry and exits non-zero from the CLI, because a short erasure
|
|
137
|
+
presented as complete is exactly the failure this is built to prevent.
|
|
138
|
+
|
|
139
|
+
### Crypto-shredding is NOT supported — and should not be faked
|
|
140
|
+
|
|
141
|
+
"Erase a subject by destroying their key" needs a key **per subject**. The shipped
|
|
142
|
+
cipher is one app-wide passphrase-derived key, so there is nothing subject-shaped
|
|
143
|
+
to destroy: deleting it would make *every* subject's `.encrypted()` columns
|
|
144
|
+
unreadable, which is an outage, not an erasure. Per-subject shredding needs
|
|
145
|
+
envelope encryption — a DEK per subject, wrapped by a KEK, with every existing
|
|
146
|
+
ciphertext re-wrapped — which is a re-architecture of the cipher rather than a
|
|
147
|
+
mode of `eraseSubject`.
|
|
148
|
+
|
|
149
|
+
What makes its absence cost less than it sounds: `.encrypted()` columns are
|
|
150
|
+
decrypted transparently on read, so `delete` removes the ciphertext row and
|
|
151
|
+
`anonymize` overwrites the ciphertext with a null. Both erase the data itself
|
|
152
|
+
rather than the key guarding it. Crypto-shredding is an optimisation for erasure
|
|
153
|
+
at rest across backups; it is not the only route to Art. 17.
|
|
154
|
+
|
|
57
155
|
## Consent ledger
|
|
58
156
|
|
|
59
157
|
`governance.consent` `{ purpose, granted }` records the calling subject's decision; `governance.hasConsent` `{ purpose }` reads the latest (latest-write-wins per `(subject, purpose)`).
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Multi-tenancy
|
|
2
2
|
|
|
3
|
-
> The tenant() schema mixin (auto-
|
|
3
|
+
> The tenant() schema mixin (auto-scoped reads, auto-filled inserts, tenant-resolved keyed writes), the assertOwnTenant write-guard, and the typed TenantMismatch error.
|
|
4
4
|
|
|
5
5
|
|
|
6
6
|
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
<!-- source: en/plugins/multitenancy.md -->
|
|
10
10
|
## Multi-tenancy
|
|
11
11
|
|
|
12
|
-
_The tenant() schema mixin (auto-
|
|
12
|
+
_The tenant() schema mixin (auto-scoped reads, auto-filled inserts, tenant-resolved keyed writes), the assertOwnTenant write-guard, and the typed TenantMismatch error._
|
|
13
13
|
|
|
14
14
|
`@voltro/plugin-multitenancy` is the first-party multi-tenancy primitive. It has two surfaces: a **schema mixin** (`tenant()`) and a **write-time guard** (`assertOwnTenant` + the typed `TenantMismatch` error).
|
|
15
15
|
|
|
@@ -37,11 +37,13 @@ for projects that don't want the full barrel.
|
|
|
37
37
|
What the runtime does for a `tenant()`-marked table:
|
|
38
38
|
|
|
39
39
|
- **Reads are auto-scoped.** The runtime AND-merges `eq('tenantId', subject.tenantId)` into every subscription predicate against the table — tenant A never sees tenant B's rows, and a write in tenant A never wakes a subscription in tenant B.
|
|
40
|
-
- **
|
|
40
|
+
- **Inserts are auto-filled.** On insert, `tenantId` is stamped from `ctx.request.subject.tenantId` when the caller didn't pass it explicitly. The wrapper never overrides a value the caller did pass, and it refuses the insert outright when the subject has no tenant.
|
|
41
|
+
- **Set-based writes are auto-scoped.** `updateMany` / `deleteMany` and the fluent `update(t).where(...)` / `delete(t).where(...)` builders get the same `eq('tenantId', …)` AND-merged onto their `WHERE`, so a tenant-blind predicate is confined rather than executed as written.
|
|
42
|
+
- **Keyed-by-id writes are resolved inside the tenant.** `update(t, id, patch)`, `delete(t, id)`, `hardDelete(t, id)` and `patchJson(t, id, …)` address a row by primary key, so the runtime resolves that key within `subject.tenantId` first and fails with `TenantRowNotFound` (`@voltro/runtime`) when there is no such row there. The error is raised identically whether the row is missing or belongs to another tenant — reporting the two differently would let a caller probe for row ids in other tenants.
|
|
41
43
|
|
|
42
44
|
## The write-guard — `assertOwnTenant`
|
|
43
45
|
|
|
44
|
-
|
|
46
|
+
Isolation itself is enforced by the runtime on every path above. What `assertOwnTenant` covers is the one question the framework deliberately does not answer for you: a mutation whose input carries an explicit `tenantId` it intends to USE. Without a check, a client authenticated as tenant A could submit `tenantId: 'B'` and your handler would happily read that claim. Guard such a mutation:
|
|
45
47
|
|
|
46
48
|
```ts
|
|
47
49
|
import { assertOwnTenant, TenantMismatch } from '@voltro/plugin-multitenancy'
|
|
@@ -54,6 +56,8 @@ const execute = async (input: { tenantId: string }, ctx) => {
|
|
|
54
56
|
|
|
55
57
|
`assertOwnTenant(inputTenantId, subject)` throws `TenantMismatch` when `inputTenantId` doesn't equal the subject's `tenantId`. Anonymous subjects have no tenant scope at all, so the guard always throws for them — anonymous + tenant-scoped writes need an `apiKey` / `serviceAccount` subject instead.
|
|
56
58
|
|
|
59
|
+
It checks a **claimed** `input.tenantId`, so a mutation whose input carries none never reaches it. That is why it is an early, typed convenience and not the boundary — the boundary is the store enforcement listed above.
|
|
60
|
+
|
|
57
61
|
## The typed error — `TenantMismatch`
|
|
58
62
|
|
|
59
63
|
`TenantMismatch` is a `Schema.TaggedError` carrying `inputTenantId` + `subjectTenantId` (empty string for anonymous subjects). Declare it on the mutation's `error:` schema so the rpc layer surfaces the rejection typed. **Import it from the browser-safe `@voltro/plugin-multitenancy/guard` subpath in the descriptor (`*.mutation.ts`)** — the package root also re-exports the schema mixin, which pulls `@voltro/database` into the client rpcGroup bundle (a browser-safety violation):
|
|
@@ -65,6 +69,7 @@ import { Schema } from 'effect'
|
|
|
65
69
|
|
|
66
70
|
export const createProject = defineMutation({
|
|
67
71
|
name: 'projects.create',
|
|
72
|
+
guards: [{ scope: 'projects:write' }], // WHO may call; TenantMismatch bounds WHICH tenant
|
|
68
73
|
input: Schema.Struct({ tenantId: Schema.String, name: Schema.String }),
|
|
69
74
|
output: Schema.Struct({ id: Schema.String }),
|
|
70
75
|
error: TenantMismatch,
|
|
@@ -45,7 +45,16 @@ const Room = ({ channel }: { channel: string }) => {
|
|
|
45
45
|
}
|
|
46
46
|
```
|
|
47
47
|
|
|
48
|
-
`usePresence(channel, opts)` heartbeats on an interval (`heartbeatMs`, default 15s) and publishes per-member `meta` (status, cursor position, …). The roster is **push-driven** — `presence.list` is a reactive plugin query
|
|
48
|
+
`usePresence(channel, opts)` heartbeats on an interval (`heartbeatMs`, default 15s) and publishes per-member `meta` (status, cursor position, …). The roster is **push-driven** — `presence.list` is a reactive plugin query whose `source:` is a [reactivity channel](/docs/data/subscriptions#reactivity-channels), so the framework pushes a fresh roster over the subscription transport with NO client polling. `key` defaults to the subject id; pass an explicit `key` for anonymous members.
|
|
49
|
+
|
|
50
|
+
A member is `{ key, meta }`. **It pushes only when the roster actually moves** — a join, a leave, a change to someone's `meta`, a sweep, a peer's delta, a peer's death. A heartbeat that repeats what the server already knows pushes nothing, which is what keeps a large steady room free.
|
|
51
|
+
|
|
52
|
+
> **There is a second `usePresence`, and it is a different hook.**
|
|
53
|
+
> [`@voltro/local-first/react`](/docs/local-first/overview#presence--awareness)
|
|
54
|
+
> exports one for peer-to-peer *awareness* — `usePresence(roomId, self, { channel })`
|
|
55
|
+
> → `{ presence, others, setPresence }` — carrying high-frequency cursor and
|
|
56
|
+
> selection state over a pub/sub channel. This one is the server-backed roster.
|
|
57
|
+
> Different packages, different signatures; pick by the question you are asking.
|
|
49
58
|
|
|
50
59
|
## Typing indicator
|
|
51
60
|
|
|
@@ -85,7 +94,9 @@ an event declared `delivery: 'latest'` rather than in presence metadata.
|
|
|
85
94
|
|
|
86
95
|
## Notes
|
|
87
96
|
|
|
88
|
-
- The roster is push-driven: `presence.list`
|
|
97
|
+
- The roster is push-driven: `presence.list` declares a [reactivity channel](/docs/data/subscriptions#reactivity-channels) as its `source:`, so the framework re-runs it and pushes deltas over the subscription transport — no client polling. There is **no table**, and there is no longer a table NAME either: presence used to declare `_voltro_presence` and never write to it, purely to own a name the reactivity layer would route on. If you have an existing `_voltro_presence`, it is empty and the upgrade does not drop it (the differ never plans a drop for a framework table no app declares) — remove it by hand when convenient.
|
|
98
|
+
- **A repeat heartbeat does not push.** Measured on this machine at 2.7 µs per subscriber per publish, an unconditional push cost a steady room of N clients N² × that per heartbeat interval — ~107 ms of CPU per 15s at N=200, and a per-node ceiling around 750 subscribers that nothing in the app controlled. Publishing only on a real change removes that term entirely; what remains is linear in actual roster churn. Reproduce with `node --import tsx packages/plugin-presence/scripts/rosterFanoutBody.ts`.
|
|
99
|
+
- **A member has no `lastSeen`.** It used to, and it was the owning replica's clock — "active 3 minutes ago" rendered from it is wrong by whatever the skew between two pods is. Use `meta` for anything you need to show.
|
|
89
100
|
- A member counts as online for `timeoutMs` after its last heartbeat. The sweep runs every `timeoutMs / 3`, so a vanished member is gone within roughly 1.3× the window.
|
|
90
101
|
- The sweep needs **no cluster coordination**, and that follows from the design rather than being a shortcut: every member is owned by exactly one replica and nobody else may touch it, so each replica sweeps its own and there is nothing to contend over. (The table version *did* need coordination — its rows were shared.)
|
|
91
102
|
|
|
@@ -22,6 +22,15 @@ upgrade — never the individual calls. The Effect-native rpc interceptors are
|
|
|
22
22
|
the only surface that sees each call **and** the resolved subject (needed for
|
|
23
23
|
per-tenant limits).
|
|
24
24
|
|
|
25
|
+
> **This plugin is opt-in, and nothing rate-limits your RPC surface until you add it.**
|
|
26
|
+
> There is no built-in per-IP, per-API-key or per-tenant request cap — an app that
|
|
27
|
+
> does not install and configure `@voltro/plugin-ratelimit` accepts calls as fast as
|
|
28
|
+
> they arrive. The one throttle that *is* on by default is `plugin-auth`'s
|
|
29
|
+
> [brute-force lockout](/docs/authentication/passwords#brute-force-lockout) on sign-in,
|
|
30
|
+
> and it only covers credential attempts. Put a per-IP cap at your ingress as well —
|
|
31
|
+
> see [production hardening](/docs/deployment/production-hardening); that layer holds
|
|
32
|
+
> per-IP state across replicas, which an in-process limiter cannot.
|
|
33
|
+
|
|
25
34
|
Live — `demo.limited` is capped at 5/min per visitor; the 6th call within a
|
|
26
35
|
minute fails with the typed `RateLimited` error (carrying `retryAfterMs`):
|
|
27
36
|
|
|
@@ -24,15 +24,19 @@ export default {
|
|
|
24
24
|
name: 'api',
|
|
25
25
|
plugins: [
|
|
26
26
|
searchPlugin({
|
|
27
|
-
// backend: defaults to memory
|
|
28
|
-
//
|
|
27
|
+
// backend: defaults to memory — dev + tests only. Under NODE_ENV=production
|
|
28
|
+
// the memory backend REFUSES TO BOOT (see "The memory backend refuses to
|
|
29
|
+
// boot in production" below). Name a durable engine for a deployment:
|
|
30
|
+
// backend: { engine: 'typesense', url, apiKey } | { engine: 'meilisearch', … } | { engine: 'algolia', appId, apiKey }
|
|
29
31
|
indexes: {
|
|
30
32
|
posts: {
|
|
31
33
|
index: 'posts',
|
|
32
34
|
tenantField: 'tenantId', // index documents carry the tenant → query scopes by it
|
|
35
|
+
queryableFields: ['title', 'body', 'status'], // optional: fields a caller may filter/facet/highlight on
|
|
33
36
|
map: (row) => ({ id: String(row.id), title: String(row.title), body: String(row.body) }),
|
|
34
37
|
},
|
|
35
38
|
},
|
|
39
|
+
// allowedEngineParams: ['query_by'], // optional: widen the engineParams allowlist (see below)
|
|
36
40
|
}),
|
|
37
41
|
],
|
|
38
42
|
}
|
|
@@ -42,7 +46,54 @@ export default {
|
|
|
42
46
|
|
|
43
47
|
The plugin declares `onChangeEvent`. On every committed write to a configured table, `applyChange` maps the row via `map(row)` and upserts (insert/update) or removes (delete) the index document. No `*.subscribe.ts`, no manual indexing calls — the tap is the single sync path, and it runs under both `voltro dev` and `voltro serve`.
|
|
44
48
|
|
|
45
|
-
For pre-existing rows, `backfillIndex(backend, spec, rows)` indexes the rows you supply (call it from a `*.startup.tsx` or a one-off script) — or use the **Reindex** button / `POST /reindex` inspect endpoint, which reads the table's current rows for you.
|
|
49
|
+
For pre-existing rows, `backfillIndex(backend, spec, rows)` indexes the rows you supply (call it from a `*.startup.tsx` or a one-off script) — or use the **Reindex** button / `POST /reindex` inspect endpoint, which reads the table's current rows for you. The endpoint **streams** the table (keyset-paginated) and upserts one bounded page at a time — `sync.reindexBatchSize`, default 1000 rows — so memory stays flat no matter how large the table is. `backfillIndex` itself stays a plain array API for small explicit seeds.
|
|
50
|
+
|
|
51
|
+
## When the engine is down
|
|
52
|
+
|
|
53
|
+
The database commit has already happened by the time the tap runs, so a failed index write is **drift**: the row is in your database and missing from — or stale in — your index. That is not left to a log line.
|
|
54
|
+
|
|
55
|
+
1. **Retry.** A failure the backend marks *transient* (network, engine unavailable, 5xx, rate limited) is retried inside the tap's own Effect with capped exponential backoff. A *permanent* failure — an unsupported query shape, a `map(row)` that throws on one row's shape — is **not** retried: repeating it cannot succeed, and it lands in step 2 immediately.
|
|
56
|
+
2. **Record.** A change that outlives the retry is written to the framework-owned `_voltro_search_drift` table, one row per `(index, source row)`. Nothing is lost at that point: the entry lives in your own database — the thing that just committed successfully — while the search engine is what is down.
|
|
57
|
+
3. **Repair.** A cluster-coordinated sweep re-reads each recorded row **from the database** and re-derives its document. It never replays the failed event, and that is what makes repair order-free and idempotent: a row updated three times during an outage converges in one pass, and a row deleted since the failure converges to a removal.
|
|
58
|
+
|
|
59
|
+
The one case that is still a loss is the engine failing **and** the ledger write failing — and that one fails the tap loudly rather than reporting success.
|
|
60
|
+
|
|
61
|
+
Every number here is yours to set (defaults shown):
|
|
62
|
+
|
|
63
|
+
```ts
|
|
64
|
+
searchPlugin({
|
|
65
|
+
indexes: { /* … */ },
|
|
66
|
+
sync: {
|
|
67
|
+
retries: 5, // retries after the first attempt; transient failures only. 0 disables retry
|
|
68
|
+
retryBaseDelayMs: 200, // first backoff — doubles per attempt
|
|
69
|
+
retryMaxDelayMs: 10_000, // ceiling for any single wait
|
|
70
|
+
resyncIntervalMs: 60_000, // repair-sweep interval. 0 turns the sweep off (POST /resync still repairs on demand)
|
|
71
|
+
resyncBatchSize: 200, // max ledger entries repaired per sweep
|
|
72
|
+
reindexBatchSize: 1000, // rows per page for POST /reindex (streamed keyset walk — memory stays flat)
|
|
73
|
+
statsFlushIntervalMs: 5_000, // buffered sync-counter flush cadence. 0 = one durable write per event
|
|
74
|
+
statsFlushMaxBuffered: 1000, // flush early once this many counts are pending
|
|
75
|
+
},
|
|
76
|
+
})
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Drift is visible **without reading logs**:
|
|
80
|
+
|
|
81
|
+
| Endpoint | Shows |
|
|
82
|
+
|---|---|
|
|
83
|
+
| `GET /_voltro/inspect/plugins/search/indexes` | per index: `dropped`, `pendingDrift`, `lastDriftAt`, `drifted` — plus the retry policy actually in force |
|
|
84
|
+
| `GET …/search/drift` | the failing rows themselves, oldest first, with their last error and attempt count |
|
|
85
|
+
| `POST …/search/resync` | runs a repair pass now (`{ scanned, repaired, failed }`) |
|
|
86
|
+
|
|
87
|
+
An entry whose `attempts` keeps climbing is telling you something a retry cannot fix — a `map(row)` that throws on that row, a document the engine rejects. That is the case to look at by hand; everything else drains on its own.
|
|
88
|
+
|
|
89
|
+
`_voltro_search_drift` is applied by the declarative differ on `voltro db apply` and on a `voltro dev` boot, on every dialect — there is nothing to migrate.
|
|
90
|
+
|
|
91
|
+
### Under multiple replicas
|
|
92
|
+
|
|
93
|
+
Index **writes** and index **counters** are deliberately treated differently when your store's change scope is `fleet` (every replica receives every change — postgres `LISTEN/NOTIFY` CDC, for instance):
|
|
94
|
+
|
|
95
|
+
- **The write runs on every replica.** An `upsert`/`remove` of the same document is idempotent, so a duplicate costs write amplification — while electing a single writer would cost a *lost* update whenever that replica dies mid-change, and would import the leadership-gap window with it.
|
|
96
|
+
- **The count runs everywhere too, but is aggregated as a maximum.** Each replica's stats row is already a fleet-wide count of the same changes, so `/indexes` takes the highest rather than the sum — the panel reports one sync per change, not one per replica. Under `local` scope only the replica that actually made the write counts (peers see an echo), and the rows are summed. Neither path needs a leader, so neither has a window in which counting stops.
|
|
46
97
|
|
|
47
98
|
## Querying
|
|
48
99
|
|
|
@@ -90,27 +141,130 @@ const res = await backend.query('posts', {
|
|
|
90
141
|
- **facets** — per-value counts for the named fields (over the full matched set, before paging).
|
|
91
142
|
- **highlight** — matched-term snippets per field, returned as `hit.highlights[field]`.
|
|
92
143
|
- **fuzziness** — a max edit distance (`0` = exact) or `'auto'`.
|
|
93
|
-
- **engineParams** —
|
|
144
|
+
- **engineParams** — the engine's **presentation-only** params (see the next section). Ignored by the memory backend.
|
|
94
145
|
|
|
95
146
|
Native support degrades honestly: memory, Typesense, Meilisearch and Algolia all do filters/facets/highlighting; Typesense honors a numeric typo count (`num_typos` 0–2) while Meilisearch and Algolia only toggle typo tolerance on/off (`fuzziness: 0` disables it, other values keep their built-in tolerance). A backend op fails with a typed `SearchBackendError`.
|
|
96
147
|
|
|
148
|
+
## What the server validates
|
|
149
|
+
|
|
150
|
+
`search.query` is a public wire surface, and three of its inputs — the index name, the field names, and `engineParams` — become the search engine's **control plane**. The plugin validates all three server-side, before the engine sees them, and refuses with a typed error rather than answering a query it cannot scope:
|
|
151
|
+
|
|
152
|
+
| Input | Rule | On violation |
|
|
153
|
+
|---|---|---|
|
|
154
|
+
| `index` | must be one of the indexes you declared in `searchPlugin({ indexes })` | `SearchIndexNotFound` |
|
|
155
|
+
| `filters[].field`, `facets[]`, `highlight.fields[]` | a plain field path (`^[A-Za-z_][A-Za-z0-9_.]*$`), and — if the index declares `queryableFields` — one of those | `SearchFieldRejected` |
|
|
156
|
+
| `engineParams` | only the engine's presentation-only keys (paging, ordering, typo tolerance, highlight shaping) | the key is dropped and logged |
|
|
157
|
+
|
|
158
|
+
```ts
|
|
159
|
+
import { SearchFieldRejected, SearchIndexNotFound } from '@voltro/plugin-search'
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
Why each one, because the reasoning is what tells you whether your own declaration is tight enough:
|
|
163
|
+
|
|
164
|
+
- **An unknown index has no spec, therefore no `tenantField`, therefore no tenant clause.** Answering it would run the caller's query unscoped against whatever collection of that name exists on your engine — and a Typesense / Meilisearch / Algolia instance is usually shared with indexes this app never declared. It is refused, not answered empty.
|
|
165
|
+
- **A field name is spliced into the engine's filter DSL.** Typesense's `filter_by` is one flat string that supports `||`, so a crafted name can re-group the boolean tree around the tenant clause appended after it. The identifier pattern is the floor every index gets; `queryableFields` narrows it further to what your UI actually needs. Your `tenantField` does **not** belong in that list — the tenant clause is injected after this check, by the server, and is never a caller's to name.
|
|
166
|
+
- **`engineParams` is not a filter hatch.** It used to be merged last into the engine's params, so a caller could set `filter_by` (Typesense), `filter` (Meilisearch) or `facetFilters` (Algolia) and overwrite the tenant clause. Keys that could select a different document set — those, plus `query_by`, `restrictSearchableAttributes`, `preset`, `pinned_hits`, `enableRules`, … — are dropped. Express a filter as a `filters[]` clause instead: those are validated *and* ANDed with the tenant scope rather than replacing it.
|
|
167
|
+
|
|
168
|
+
If your app genuinely needs one more engine key, widen the allowlist **server-side**, where it is a deliberate decision instead of a caller's:
|
|
169
|
+
|
|
170
|
+
```ts
|
|
171
|
+
searchPlugin({
|
|
172
|
+
indexes: { /* … */ },
|
|
173
|
+
allowedEngineParams: ['query_by'], // this app trusts callers with this key
|
|
174
|
+
})
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
Document-selecting keys stay refused even when listed there — that is the authority the tenant clause holds.
|
|
178
|
+
|
|
97
179
|
## Backends
|
|
98
180
|
|
|
99
181
|
| Backend | Notes |
|
|
100
182
|
|---|---|
|
|
101
|
-
| `memoryBackend` (default) | Fully in-process; dev + tests.
|
|
183
|
+
| `memoryBackend` (default) | Fully in-process; dev + tests. **Refuses to boot under `NODE_ENV=production`** — see below. |
|
|
102
184
|
| `typesenseBackend` | Optional dep `typesense`. Lazy-loaded. |
|
|
103
185
|
| `meilisearchBackend` | Optional dep `meilisearch`. Lazy-loaded. |
|
|
104
186
|
| `algoliaBackend` | Optional dep `algoliasearch`. Lazy-loaded. |
|
|
105
187
|
|
|
106
188
|
A backend is the `SearchBackend` interface (`upsert` / `remove` / `query`) — bring your own (OpenSearch, Elastic, …).
|
|
107
189
|
|
|
190
|
+
### The memory backend refuses to boot in production
|
|
191
|
+
|
|
192
|
+
This is a **refusal, not a warning, and not a scale caveat**. Under
|
|
193
|
+
`NODE_ENV=production` on the in-memory backend, `onActivate` throws
|
|
194
|
+
`SearchBackendNotDurable` and the process does not start:
|
|
195
|
+
|
|
196
|
+
```text
|
|
197
|
+
plugin-search refuses to boot in production on the in-memory backend.
|
|
198
|
+
|
|
199
|
+
The memory index lives in THIS process's heap. Two consequences, both silent:
|
|
200
|
+
• every replica holds a different index, so a result depends on which replica served you;
|
|
201
|
+
• the index starts EMPTY after every restart/deploy, and nothing re-seeds it automatically.
|
|
202
|
+
|
|
203
|
+
Configure a durable engine in app.config.ts:
|
|
204
|
+
searchPlugin({ backend: { engine: 'typesense', url: …, apiKey: … }, indexes })
|
|
205
|
+
searchPlugin({ backend: { engine: 'meilisearch', url: …, apiKey: … }, indexes })
|
|
206
|
+
searchPlugin({ backend: { engine: 'algolia', appId: …, apiKey: … }, indexes })
|
|
207
|
+
or pass your own `SearchBackend` implementation.
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
**Running one replica does not make it correct**, which is why the refusal is
|
|
211
|
+
not conditional on detecting a cluster. Two independent things are wrong with an
|
|
212
|
+
in-process index in a deployment and only one of them is the multi-replica
|
|
213
|
+
story:
|
|
214
|
+
|
|
215
|
+
1. **Per-process.** N replicas hold N divergent indexes. Which results you get
|
|
216
|
+
depends on which replica served the request — including *zero hits* for a
|
|
217
|
+
document that demonstrably exists.
|
|
218
|
+
2. **Non-durable.** The index lives in the heap, so every restart and every
|
|
219
|
+
deploy starts EMPTY and nothing re-seeds it: `backfillIndex` is a function
|
|
220
|
+
your app calls, not something the plugin does at boot.
|
|
221
|
+
|
|
222
|
+
(2) is what a single replica does not fix. It only removes one of the two ways
|
|
223
|
+
the backend is wrong.
|
|
224
|
+
|
|
225
|
+
Dev is **silent** — the memory backend is exactly right there, and a warning
|
|
226
|
+
that fires on every `voltro dev` boot is a warning nobody reads.
|
|
227
|
+
|
|
228
|
+
#### The escape hatch — `singleProcessMemoryIndex`
|
|
229
|
+
|
|
230
|
+
If this deployment genuinely is ONE process that re-seeds its index at startup,
|
|
231
|
+
say so and the boot proceeds:
|
|
232
|
+
|
|
233
|
+
```ts
|
|
234
|
+
searchPlugin({
|
|
235
|
+
singleProcessMemoryIndex: true, // exactly one process, and it calls backfillIndex at startup
|
|
236
|
+
indexes: { /* … */ },
|
|
237
|
+
})
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
Two things to be clear about before you reach for it:
|
|
241
|
+
|
|
242
|
+
- **It is a claim about your topology, not a mute switch.** The plugin holds you
|
|
243
|
+
to both halves: exactly one process serves search, and a `*.startup.tsx`
|
|
244
|
+
calls `backfillIndex` for every index — because the index *is* empty after
|
|
245
|
+
each restart until something fills it. The boot logs a `note` at info
|
|
246
|
+
restating what you signed up for.
|
|
247
|
+
- **The plugin checks the claim against reality.** When the instance-membership
|
|
248
|
+
registry reports that a peer replica joined, the plugin logs that the
|
|
249
|
+
declaration is now false — in *any* environment, because a peer announcing
|
|
250
|
+
itself is an observation rather than a guess about `NODE_ENV`:
|
|
251
|
+
|
|
252
|
+
```text
|
|
253
|
+
search: replica "<id>" joined, but this app declared `singleProcessMemoryIndex: true`.
|
|
254
|
+
That declaration is now false: each replica has its own in-memory index, so search
|
|
255
|
+
results depend on which one serves the request. Configure a durable backend
|
|
256
|
+
(typesense / meilisearch / algolia) or run one process.
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
Without the declaration, a peer joining while search is served from a heap
|
|
260
|
+
index warns too — same observation, different wording.
|
|
261
|
+
|
|
108
262
|
## Dashboard panel
|
|
109
263
|
|
|
110
|
-
Both dashboards ship a **Search** panel (api apps): the configured indexes with per-index sync stats (docs synced/removed, last reindex) + the resolved backend,
|
|
264
|
+
Both dashboards ship a **Search** panel (api apps): the configured indexes with per-index sync stats (docs synced/removed/dropped, last reindex) + the resolved backend, per-index **drift badges** (`pendingDrift` / `drifted` / last drift time), the **repair queue itself** (the drift-ledger rows, oldest first, with attempt counts and the engine's last error), and two actions — **Reindex** per index (streams the table's current rows back in) and **Resync now** (repairs the drifted rows on demand). Reindex gates on the `canReindexSearch` capability, resync on `canResyncSearch`. Backed by `/_voltro/inspect/plugins/search/{indexes,drift,reindex,resync}`.
|
|
111
265
|
|
|
112
|
-
The sync stats behind this panel are **durable and aggregated across replicas
|
|
266
|
+
The sync stats behind this panel are **durable and aggregated across replicas** — and counted **in memory first**: each replica buffers its per-event counts and flushes them to the stats table once per window (`sync.statsFlushIntervalMs`, default 5 s; early once `statsFlushMaxBuffered` counts are pending), so an indexed-table write never pays a per-write read+CAS against your primary. A graceful shutdown flushes the tail; a hard crash loses at most the current window of *counters* (never a change — the drift ledger, not these counters, is the durable record of what did not reach the engine). `GET /indexes` drains the buffer before reading, so the panel is always current. `statsFlushIntervalMs: 0` restores one durable write per event. They live in a framework-owned `_voltro_search_stats` table (contributed via `extendSchema.tables`; the plugin declares `store:write`), one row per `(index, replica)`, each bumped with an atomic compare-and-set. `/indexes` aggregates every replica's row — summed under `local` change scope, maxed under `fleet` (see [Under multiple replicas](#under-multiple-replicas)) — and takes the most-recent reindex, so the counts are truthful under multiple instances and survive a restart. Zero-infra dev/tests use an in-process stats store; `bindDataStore` swaps in the durable one at boot, along with the drift ledger.
|
|
113
267
|
|
|
114
268
|
## Permissions
|
|
115
269
|
|
|
116
|
-
`store:changes:read` (the ChangeEvent tap) + `store:write` (the durable `_voltro_search_stats` counters) + `inspect:read` (dashboard panel) + `network:outbound:<host>` (for a remote backend).
|
|
270
|
+
`store:changes:read` (the ChangeEvent tap) + `store:write` (the durable `_voltro_search_stats` counters and the `_voltro_search_drift` ledger) + `inspect:read` / `inspect:write` (dashboard panel, incl. reindex + resync) + `network:outbound:<host>` (for a remote backend).
|