@voltro/cli 0.33.0 → 0.34.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +1801 -0
- package/bin/nodeEnvironment.d.mts +30 -0
- package/bin/nodeEnvironment.mjs +158 -0
- package/bin/voltro.mjs +69 -5
- package/dist/addCommand-BNeoeSxe.js +124 -0
- package/dist/addCommand-aXSQveak.js +2 -0
- package/dist/agentsMd-BTchIZku.js +2 -0
- package/dist/agentsMd-mhQMF1bx.js +254 -0
- package/dist/apiBuild-BICVGnEC.js +2 -0
- package/dist/{apiBuild-h9VHtnlw.js → apiBuild-DZF_f0_I.js} +46 -46
- package/dist/appPort-B_HpJ_ck.js +48 -0
- package/dist/baselineCommand-C2ClWZN3.js +2 -0
- package/dist/baselineCommand-DIttzO8A.js +227 -0
- package/dist/bin.js +71 -28
- package/dist/build-CE7Ca9gm.js +711 -0
- package/dist/cacheCommand-DA4OH9xt.js +42 -0
- package/dist/capabilitiesCommand-nq_pz5xd.js +123 -0
- package/dist/checkCommand-Dv8r9tGO.js +231 -0
- package/dist/checkCommand-YZDmkAGd.js +2 -0
- package/dist/{cliArgs-qdZSElM3.js → cliArgs-D4p8n7EE.js} +12 -1
- package/dist/cliError-BmdYnghb.js +10 -0
- package/dist/cliOutput-D1tSBoRM.js +15 -0
- package/dist/{cliRuntime-Oh517vCV.js → cliRuntime-Dh7UDinH.js} +20 -20
- package/dist/cloudClient-DWL-Hw_T.js +67 -0
- package/dist/cloudCmd-Cvv5HGaZ.js +364 -0
- package/dist/clusterCmd-CNXKlcvD.js +54 -0
- package/dist/codegen-CYM3Zqrf.js +605 -0
- package/dist/codegen-ChBi_hVa.js +2 -0
- package/dist/codegenCommand-DC6w2tNZ.js +30 -0
- package/dist/codemodRunner-DRRqXR74.js +5243 -0
- package/dist/commandRunner-BLAEFLjp.js +47 -0
- package/dist/commands-gutsz-Ac.js +808 -0
- package/dist/connectionConfig-UFlIEiys.js +66 -0
- package/dist/dashboardCommand-3YG8p-UA.js +25 -0
- package/dist/dataCommand-qL0r7fPO.js +535 -0
- package/dist/dataProfile-dW-PsfLB.js +15 -0
- package/dist/{dbCommand-DTLKAfbA.js → dbCommand-B6X0FZbq.js} +551 -401
- package/dist/dbCommand-CpdKLeQq.js +2 -0
- package/dist/{dev-C_P8FLSx.js → dev-CerMd0mW.js} +3122 -3129
- package/dist/dev-CoG-ZPx8.js +3 -0
- package/dist/devActivity-Dx_3nnGv.js +100 -0
- package/dist/devActivity.js +1 -1
- package/dist/dialectDriver-CgXnDfec.js +39 -0
- package/dist/discover-C9XKJDco.js +25 -0
- package/dist/doctorCommand-BvqGBwNG.js +2 -0
- package/dist/{checkCommand-xGhRFFg2.js → doctorCommand-CnDXQxfa.js} +520 -1278
- package/dist/dormancyCommand-Dszo57d6.js +69 -0
- package/dist/e2eCmd-BRabZww-.js +147 -0
- package/dist/embeddingsCommand-C0sKVRo1.js +73 -0
- package/dist/envCommand-DPgdV1Bq.js +60 -0
- package/dist/evalCommand-6RUfPen4.js +118 -0
- package/dist/evolveCommand-DHpkgjgH.js +281 -0
- package/dist/fileTaxonomy-CJfgOllU.js +457 -0
- package/dist/frameworkTableAssembly-BGHmck-x.js +2 -0
- package/dist/{frameworkTableAssembly-BwIrO5nv.js → frameworkTableAssembly-DkkP6BgC.js} +184 -148
- package/dist/generateCommand-oibemh97.js +147 -0
- package/dist/index.d.ts +45 -0
- package/dist/index.js +4 -3
- package/dist/infoCommand-BJw9nLUR.js +60 -0
- package/dist/{inspect-BUUjt773.js → inspect-CBqFtAKA.js} +82 -40
- package/dist/inspect-C_T_WGvl.js +2 -0
- package/dist/inspectCmd-Bppy-GGw.js +224 -0
- package/dist/inspectFetch-Cm8_wVvp.js +151 -0
- package/dist/inspectMetrics-CfdKLh6t.js +72 -0
- package/dist/loadEnv-D9nEOClM.js +44 -0
- package/dist/logFileSink-C_D2wRN1.js +105 -0
- package/dist/logsCmd-CCca3KRZ.js +260 -0
- package/dist/manifestBuild-ChsKAhmn.js +2 -0
- package/dist/{manifestBuild-BLrVuSlM.js → manifestBuild-sxpwdKY1.js} +1 -1
- package/dist/metaCommands-7MJfZ5cf.js +196 -0
- package/dist/migrate-CBwOt_iV.js +83 -0
- package/dist/mssqlClusterPatch-_4cE_nun.js +44 -0
- package/dist/newCommand-COWOJ1_E.js +156 -0
- package/dist/nodeEnvironment-cGFAj1J8.js +28 -0
- package/dist/packageCommand-Cug_3Ogl.js +271 -0
- package/dist/pageConvention-cEiRxdab.js +5 -0
- package/dist/privacyCommand-C-Df56U_.js +146 -0
- package/dist/projectScaffold-DmzEKHib.js +2 -0
- package/dist/projectScaffold-LMMtaavR.js +814 -0
- package/dist/renderModeScan-D7J1B7Kw.js +105 -0
- package/dist/renderProfile-1OWWAAtx.js +81 -0
- package/dist/runtimeRegistry-DMeKfTHP.js +81 -0
- package/dist/runtimeTrace-CRxalXTs.js +91 -0
- package/dist/scheduleCmd--jksTrf6.js +69 -0
- package/dist/scheduleManifestCmd-D2x0CTTY.js +249 -0
- package/dist/schemaIr-UJybUUZW.js +103 -0
- package/dist/{sdkgen-C81QIkiL.js → sdkgen-CYJscZC7.js} +111 -209
- package/dist/seedRunner-TFHHiToI.js +329 -0
- package/dist/serveCommand-B_isw7q4.js +1647 -0
- package/dist/serveCommand-DOvbgRnQ.js +2 -0
- package/dist/serveEntry.js +5 -5
- package/dist/serverlessCommand-CfJZy6dS.js +482 -0
- package/dist/start-9LiUOfES.js +1087 -0
- package/dist/start-B-9Nsp-S.js +3 -0
- package/dist/startEntry.js +2 -2
- package/dist/staticCommand-Dr2M6tpU.js +304 -0
- package/dist/storageCommand-Co6NfLqN.js +42 -0
- package/dist/templates-De8IR5-c.js +102 -0
- package/dist/test-CI6iDsYc.js +115 -0
- package/dist/tracesCmd-CkEZQrtt.js +232 -0
- package/dist/tsconfigPaths-BWXBWgcl.js +107 -0
- package/dist/tsxLoader-EuXmSJ1K.js +51 -0
- package/dist/typecheckCommand-BlsWiCNq.js +61 -0
- package/dist/updateCommand-Bkptutss.js +585 -0
- package/dist/updateCommand-us1_hdIC.js +2 -0
- package/dist/{inspectMetrics-1xzTKAFx.js → webDev-CBYvPqQr.js} +975 -1559
- package/dist/webDev-Cg-fFiyd2.js +2 -0
- package/dist/webhookDiscovery-CrGAfhIG.js +2 -0
- package/dist/webhookDiscovery-D7VaeMlz.js +51 -0
- package/dist/webhooksCommand-CID96Rga.js +267 -0
- package/dist/workflowsCmd-D1VTmLMY.js +608 -0
- package/package.json +179 -17
- package/templates/AGENTS.core.md +58 -3
- package/templates/AGENTS.md +64 -7
- package/templates/agent-docs/_index.md +6 -4
- package/templates/agent-docs/_manifest.json +22 -5
- package/templates/agent-docs/ai.md +370 -0
- package/templates/agent-docs/authentication.md +265 -31
- package/templates/agent-docs/caching.md +6 -0
- package/templates/agent-docs/cli.md +794 -50
- package/templates/agent-docs/data.md +550 -11
- package/templates/agent-docs/database/migrations.md +174 -25
- package/templates/agent-docs/database/misc.md +156 -40
- package/templates/agent-docs/database/querying.md +19 -1
- package/templates/agent-docs/database/scaling.md +60 -0
- package/templates/agent-docs/database/schema.md +1 -0
- package/templates/agent-docs/database/seedsdialects.md +208 -19
- package/templates/agent-docs/database/transactions.md +68 -0
- package/templates/agent-docs/deployment.md +69 -3
- package/templates/agent-docs/introduction.md +87 -16
- package/templates/agent-docs/local-first-mobile.md +79 -4
- package/templates/agent-docs/multi-tenancy.md +95 -20
- package/templates/agent-docs/observability.md +58 -3
- package/templates/agent-docs/plugins/ai-flows.md +161 -2
- package/templates/agent-docs/plugins/analytics-postgres.md +1 -1
- package/templates/agent-docs/plugins/audit.md +37 -1
- package/templates/agent-docs/plugins/auth-social.md +143 -0
- package/templates/agent-docs/plugins/auth-workos.md +4 -2
- package/templates/agent-docs/plugins/auth.md +131 -6
- package/templates/agent-docs/plugins/billing.md +132 -15
- package/templates/agent-docs/plugins/cdc-out.md +46 -7
- package/templates/agent-docs/plugins/clickhouse.md +1 -1
- package/templates/agent-docs/plugins/duckdb.md +1 -1
- package/templates/agent-docs/plugins/flags.md +132 -0
- package/templates/agent-docs/plugins/governance.md +105 -7
- package/templates/agent-docs/plugins/multitenancy.md +9 -4
- package/templates/agent-docs/plugins/presence.md +13 -2
- package/templates/agent-docs/plugins/ratelimit.md +9 -0
- package/templates/agent-docs/plugins/search.md +157 -6
- package/templates/agent-docs/plugins/sso-saml.md +47 -8
- package/templates/agent-docs/plugins/webhooks.md +105 -0
- package/templates/agent-docs/plugins.md +150 -18
- package/templates/agent-docs/reference.md +60 -3
- package/templates/agent-docs/releases.md +1117 -0
- package/templates/agent-docs/routing.md +43 -25
- package/templates/agent-docs/scheduling.md +10 -0
- package/templates/agent-docs/schema-driven-ui.md +92 -12
- package/templates/agent-docs/security.md +426 -0
- package/templates/agent-docs/templates/apibackends.md +87 -18
- package/templates/agent-docs/templates/appshells.md +32 -14
- package/templates/agent-docs/templates/overview.md +13 -8
- package/templates/agent-docs/testing.md +211 -14
- package/templates/agent-docs/whats-new.md +1700 -104
- package/templates/agent-docs/workflows.md +130 -14
- package/templates/apps/api-ai/actions/summarize.action.ts +11 -0
- package/templates/apps/api-ai/package.json +8 -7
- package/templates/apps/api-auth/actions/me.action.ts +13 -0
- package/templates/apps/api-auth/package.json +9 -8
- package/templates/apps/api-backend/mutations/notes.create.mutation.ts +9 -0
- package/templates/apps/api-backend/package.json +12 -8
- package/templates/apps/api-backend/queries/notes.query.ts +30 -8
- package/templates/apps/api-backend-deactivation/actions/users.get.action.ts +14 -0
- package/templates/apps/api-backend-deactivation/mutations/users.create.mutation.ts +8 -0
- package/templates/apps/api-backend-deactivation/mutations/users.deactivate.mutation.ts +10 -0
- package/templates/apps/api-backend-deactivation/package.json +8 -7
- package/templates/apps/api-backend-mail/actions/sendWelcome.action.ts +17 -0
- package/templates/apps/api-backend-mail/mutations/notes.create.mutation.ts +9 -0
- package/templates/apps/api-backend-mail/package.json +9 -8
- package/templates/apps/api-backend-mail/queries/notes.query.ts +30 -8
- package/templates/apps/api-backend-mariadb/.env.example +14 -0
- package/templates/apps/api-backend-mariadb/mutations/notes.create.mutation.ts +9 -0
- package/templates/apps/api-backend-mariadb/package.json +10 -9
- package/templates/apps/api-backend-mariadb/queries/notes.query.ts +30 -8
- package/templates/apps/api-backend-sqlite/.env.example +14 -0
- package/templates/apps/api-backend-sqlite/mutations/notes.create.mutation.ts +9 -0
- package/templates/apps/api-backend-sqlite/package.json +9 -8
- package/templates/apps/api-backend-sqlite/queries/notes.query.ts +30 -8
- package/templates/apps/api-backend-storage/actions/uploadAvatar.action.ts +13 -0
- package/templates/apps/api-backend-storage/actions/uploadDocument.action.ts +12 -0
- package/templates/apps/api-backend-storage/mutations/notes.create.mutation.ts +9 -0
- package/templates/apps/api-backend-storage/package.json +9 -8
- package/templates/apps/api-backend-storage/queries/notes.query.ts +30 -8
- package/templates/apps/api-cms/actions/content.get.action.ts +7 -0
- package/templates/apps/api-cms/actions/content.types.action.ts +6 -0
- package/templates/apps/api-cms/actions/me.action.ts +13 -0
- package/templates/apps/api-cms/app.config.ts +19 -0
- package/templates/apps/api-cms/authz.ts +63 -0
- package/templates/apps/api-cms/mutations/content.publish.mutation.ts +10 -0
- package/templates/apps/api-cms/mutations/content.saveDraft.mutation.ts +10 -0
- package/templates/apps/api-cms/mutations/content.unpublish.mutation.ts +5 -0
- package/templates/apps/api-cms/package.json +11 -10
- package/templates/apps/api-cms/queries/content.list.query.ts +23 -7
- package/templates/apps/api-cms/tests/accessDecisions.test.ts +121 -0
- package/templates/apps/api-cms/tests/content.descriptors.test.ts +8 -4
- package/templates/apps/api-collab/mutations/documents.create.mutation.ts +8 -0
- package/templates/apps/api-collab/mutations/documents.setBody.mutation.ts +13 -0
- package/templates/apps/api-collab/package.json +9 -8
- package/templates/apps/api-collab/queries/documents.query.ts +19 -7
- package/templates/apps/api-data-advanced/package.json +9 -8
- package/templates/apps/api-data-advanced/queries/authors.withBooks.query.ts +12 -0
- package/templates/apps/api-data-advanced/queries/books.search.query.ts +9 -0
- package/templates/apps/api-durable/mutations/orders.approve.mutation.ts +17 -0
- package/templates/apps/api-durable/mutations/orders.place.mutation.ts +9 -0
- package/templates/apps/api-durable/package.json +9 -8
- package/templates/apps/api-feature-flags/actions/notes.export.action.ts +16 -0
- package/templates/apps/api-feature-flags/mutations/notes.create.mutation.ts +12 -0
- package/templates/apps/api-feature-flags/package.json +10 -9
- package/templates/apps/api-governance/README.md +30 -8
- package/templates/apps/api-governance/actions/profiles.get.action.server.ts +17 -1
- package/templates/apps/api-governance/actions/profiles.get.action.ts +29 -4
- package/templates/apps/api-governance/database/schema.ts +16 -4
- package/templates/apps/api-governance/mutations/profiles.create.mutation.ts +12 -0
- package/templates/apps/api-governance/package.json +9 -8
- package/templates/apps/api-kv/actions/sync.pull.action.ts +15 -0
- package/templates/apps/api-kv/actions/sync.reset.action.ts +13 -0
- package/templates/apps/api-kv/actions/sync.status.action.ts +7 -0
- package/templates/apps/api-kv/package.json +9 -8
- package/templates/apps/api-kv/queries/events.list.query.ts +19 -8
- package/templates/apps/api-moderation/mutations/comments.create.mutation.ts +11 -0
- package/templates/apps/api-moderation/mutations/posts.create.mutation.ts +13 -0
- package/templates/apps/api-moderation/package.json +9 -8
- package/templates/apps/api-observability/mutations/notes.create.mutation.ts +8 -0
- package/templates/apps/api-observability/package.json +9 -8
- package/templates/apps/api-observability/queries/notes.list.query.ts +13 -0
- package/templates/apps/api-ratelimit/mutations/notes.create.mutation.ts +14 -0
- package/templates/apps/api-ratelimit/package.json +9 -8
- package/templates/apps/api-rbac/package.json +9 -8
- package/templates/apps/api-rest/package.json +8 -7
- package/templates/apps/api-saas/mutations/projects.create.mutation.ts +13 -0
- package/templates/apps/api-saas/package.json +12 -11
- package/templates/apps/api-saas/queries/projects.list.query.ts +11 -0
- package/templates/apps/api-saas-starter/actions/me.action.ts +13 -0
- package/templates/apps/api-saas-starter/app.config.ts +19 -0
- package/templates/apps/api-saas-starter/authz.ts +75 -0
- package/templates/apps/api-saas-starter/mutations/invites.create.mutation.ts +10 -0
- package/templates/apps/api-saas-starter/mutations/projects.create.mutation.ts +9 -0
- package/templates/apps/api-saas-starter/package.json +15 -11
- package/templates/apps/api-saas-starter/queries/invites.list.query.ts +19 -8
- package/templates/apps/api-saas-starter/queries/projects.list.query.ts +18 -7
- package/templates/apps/api-saas-starter/tests/accessDecisions.test.ts +135 -0
- package/templates/apps/api-search/mutations/articles.create.mutation.ts +14 -0
- package/templates/apps/api-search/package.json +9 -8
- package/templates/apps/api-search/queries/articles.list.query.ts +21 -8
- package/templates/apps/api-status/README.md +10 -3
- package/templates/apps/api-status/app.config.ts +8 -3
- package/templates/apps/api-status/authz.ts +5 -3
- package/templates/apps/api-status/package.json +9 -8
- package/templates/apps/api-status/queries/components.list.query.ts +14 -6
- package/templates/apps/api-status/queries/incidents.live.query.ts +23 -10
- package/templates/apps/api-status/queries/updates.list.query.ts +16 -9
- package/templates/apps/api-status/tests/status.test.ts +9 -1
- package/templates/apps/api-versioning/actions/documents.asOf.action.ts +12 -0
- package/templates/apps/api-versioning/actions/documents.history.action.ts +13 -0
- package/templates/apps/api-versioning/mutations/documents.create.mutation.ts +9 -0
- package/templates/apps/api-versioning/mutations/documents.update.mutation.ts +12 -0
- package/templates/apps/api-versioning/package.json +9 -8
- package/templates/apps/api-webhooks/mutations/orders.fulfill.mutation.ts +16 -0
- package/templates/apps/api-webhooks/package.json +10 -9
- package/templates/apps/api-webhooks/queries/orders.list.query.ts +10 -0
- package/templates/apps/changelog/package.json +8 -6
- package/templates/apps/edge-functions/package.json +2 -2
- package/templates/apps/frontend-admin/package.json +10 -8
- package/templates/apps/frontend-admin/src/lib/admin.ts +20 -9
- package/templates/apps/frontend-admin/src/locales/de.ts +11 -1
- package/templates/apps/frontend-admin/src/locales/en.ts +13 -1
- package/templates/apps/frontend-admin/src/pages/admin/[entity]/page.tsx +65 -22
- package/templates/apps/frontend-admin/src/pages/admin/entity.test.tsx +130 -23
- package/templates/apps/frontend-admin/src/pages/admin/layout.tsx +3 -2
- package/templates/apps/frontend-admin/src/pages/admin/page.test.tsx +19 -2
- package/templates/apps/frontend-admin/src/pages/admin/page.tsx +9 -4
- package/templates/apps/frontend-app/app.config.ts +4 -3
- package/templates/apps/frontend-app/package.json +11 -8
- package/templates/apps/frontend-app/src/lib/api.ts +25 -0
- package/templates/apps/frontend-app/src/pages/page.test.tsx +130 -82
- package/templates/apps/frontend-app/src/pages/page.tsx +14 -18
- package/templates/apps/frontend-auth/package.json +10 -8
- package/templates/apps/frontend-blank/package.json +9 -7
- package/templates/apps/frontend-cms/package.json +11 -9
- package/templates/apps/frontend-collab/package.json +12 -9
- package/templates/apps/frontend-collab/src/pages/page.test.tsx +122 -78
- package/templates/apps/frontend-contact/package.json +9 -7
- package/templates/apps/frontend-dashboard/package.json +9 -7
- package/templates/apps/frontend-docs/package.json +9 -7
- package/templates/apps/frontend-i18n/package.json +8 -6
- package/templates/apps/frontend-landing/package.json +9 -7
- package/templates/apps/frontend-portal/package.json +10 -8
- package/templates/apps/frontend-saas/app.config.ts +10 -6
- package/templates/apps/frontend-saas/package.json +10 -8
- package/templates/apps/frontend-saas/src/lib/api.ts +27 -32
- package/templates/apps/frontend-saas/src/pages/dashboard/billing/page.tsx +7 -8
- package/templates/apps/frontend-saas/src/pages/dashboard/page.test.tsx +27 -3
- package/templates/apps/frontend-saas/src/pages/dashboard/page.tsx +4 -4
- package/templates/apps/frontend-saas/src/pages/dashboard/team/page.tsx +3 -4
- package/templates/apps/frontend-spa/package.json +9 -7
- package/templates/apps/frontend-ssr/package.json +9 -7
- package/templates/apps/frontend-ssr-api/package.json +10 -8
- package/templates/apps/frontend-static-blog/package.json +8 -6
- package/templates/apps/frontend-status/package.json +10 -8
- package/templates/apps/mobile-app/README.md +1 -0
- package/templates/apps/mobile-app/package.json +4 -2
- package/templates/apps/mobile-app/src/app/index.tsx +22 -12
- package/templates/apps/mobile-app/src/app/orders/[id].tsx +1 -1
- package/templates/apps/mobile-app/src/lib/api.ts +34 -0
- package/templates/apps/mobile-app/voltro.mobile.ts +4 -2
- package/templates/baselines/bare/.env.example +14 -0
- package/templates/baselines/bare/baseline.json +4 -4
- package/templates/baselines/compose/.env.example +14 -0
- package/templates/baselines/compose/README.md +1 -1
- package/templates/baselines/compose/baseline.json +5 -5
- package/templates/baselines/compose-mariadb/.env.example +14 -0
- package/templates/baselines/compose-mariadb/README.md +1 -1
- package/templates/baselines/compose-mariadb/baseline.json +5 -5
- package/templates/baselines/helm/.env.example +14 -0
- package/templates/baselines/helm/baseline.json +4 -4
- package/dist/apiBuild-C-x9YacA.js +0 -2
- package/dist/checkCommand-DRovTKza.js +0 -2
- package/dist/commands-CJfepbm4.js +0 -11541
- package/dist/dbCommand-b1gum4td.js +0 -2
- package/dist/dev-iiMtlkfs.js +0 -3
- package/dist/devActivity-BhIu6ncs.js +0 -159
- package/dist/frameworkTableAssembly-D-EebUQX.js +0 -2
- package/dist/inspect-mmBuRXmy.js +0 -2
- package/dist/manifestBuild-Dj8Jjoto.js +0 -2
- package/dist/seedRunner-Bqxgp7HZ.js +0 -230
- package/dist/serveCommand-DdaM4Hup.js +0 -1608
- package/dist/start-C0koT0UO.js +0 -1084
- /package/templates/apps/api-ai/actions/{summarize.action.server.tsx → summarize.action.server.ts} +0 -0
|
@@ -23,9 +23,13 @@ Everything in it is in-memory and synchronous-friendly — no docker, no running
|
|
|
23
23
|
|
|
24
24
|
| Export | Use |
|
|
25
25
|
|---|---|
|
|
26
|
-
| `makeTestContext(options?)` | The request `ctx` a handler/tool sees at runtime — subject-scoped `ctx.store`, `clock`, `
|
|
26
|
+
| `makeTestContext(options?)` | The request `ctx` a handler/tool sees at runtime — subject-scoped `ctx.store`, `clock`, `webhooks`, `llm`, optional `ai`. → [Unit testing](/docs/testing/unit-testing) |
|
|
27
27
|
| `mockStore(seed)` | Seed rows: `makeTestContext({ store: mockStore({ todos: [...] }) })`. |
|
|
28
|
-
| `
|
|
28
|
+
| `user(id, opts?)` / `apiKey` / `serviceAccount` / `anonymous` / `system` | Subject factories — the acting identity, instead of a hand-written `{ type: 'user', … }` literal. |
|
|
29
|
+
| `MockClock` / `MockLLM` | Deterministic clock, queued LLM responses. |
|
|
30
|
+
| `invoke(descriptor, handler, input, ctx)` | Run a procedure the way the dispatcher does — guards, decode, transaction, plugin interceptors. → [Unit testing](/docs/testing/unit-testing) |
|
|
31
|
+
| `makeTestApp({ ctx, restRoutes, publicApi, strategies })` | The request-level harness — a real HTTP request through the real REST pipeline. → [Unit testing](/docs/testing/unit-testing#request-level-testing-maketestapp) |
|
|
32
|
+
| `fixtureRow(table, over?)` / `defineFactory(table, opts?)` | Complete a required-column row; a named factory with defaults, traits and associations. |
|
|
29
33
|
| `makeVoltroTestClient(config)` | The frontend harness — render a component against mocked `useSubscription` / `useMutation`, from the `@voltro/testing/client` subpath. → [Component testing](/docs/testing/component-testing) |
|
|
30
34
|
| `makeWorkflowRunner({ ctx, workflows })` | Drive a workflow in-process and assert on its steps. → [Workflows](/docs/testing/workflows) |
|
|
31
35
|
| `runDialectParity(fixture)` | Run one fixture across a SQL dialect for portability — from the `@voltro/testing/dialect` subpath. → [Dialect parity](/docs/testing/dialect-parity) |
|
|
@@ -35,10 +39,11 @@ Everything in it is in-memory and synchronous-friendly — no docker, no running
|
|
|
35
39
|
Voltro's primitives are designed so the cheapest test covers the most surface. Reach for the lowest layer that proves what you need:
|
|
36
40
|
|
|
37
41
|
1. **Unit — handlers, tools, mutations, queries.** Call the executor directly with a `makeTestContext()` ctx. The store is the real mixin-wrapped store, so tenant scoping, soft-delete, and audit auto-fill all behave as in production — without a database. This is 90% of your tests. See [Unit testing](/docs/testing/unit-testing).
|
|
38
|
-
2. **
|
|
39
|
-
3. **
|
|
40
|
-
4. **
|
|
41
|
-
5. **
|
|
42
|
+
2. **Request — the transport hop.** `makeTestApp` sends a real request through the framework's own REST pipeline: the `x-tenant` header, the auth strategy chain, path-param decode, the 405/404/410 the transport produces, HTTP idempotency, and a `publicApi` annotation's scopes. Everything a handler test structurally cannot reach, and still no server, no port, no docker. See [Unit testing → request-level testing](/docs/testing/unit-testing#request-level-testing-maketestapp).
|
|
43
|
+
3. **Component — the reactive frontend.** `makeVoltroTestClient` renders a component against mocked `useSubscription` / `useMutation`, so loading, empty, stream-error and failing-write branches are all assertable without a running server. See [Component testing](/docs/testing/component-testing).
|
|
44
|
+
4. **Workflow — durable, multi-step logic.** `makeWorkflowRunner` drives a workflow end-to-end in-process and hands back the same step log the dashboard shows, including real retry counts. See [Workflows](/docs/testing/workflows).
|
|
45
|
+
5. **Parity — hand-written SQL that must stay portable.** `runDialectParity` runs a fixed scenario suite against a dialect you stand up. Mostly for dialect-package authors and apps with `unsafe()` SQL. See [Dialect parity](/docs/testing/dialect-parity).
|
|
46
|
+
6. **End-to-end — the running stack.** `voltro e2e` boots the api + web siblings, runs each `e2e/**/*.spec.ts` as a plain tsx script against the live processes (with `WEB_URL` + `API_URL` in the environment), and tears them down. No test runner and no browser ship with it — bring your own driver. See [`voltro e2e`](/docs/cli/inspect#voltro-e2e).
|
|
42
47
|
|
|
43
48
|
Agents are integration tests; tools are unit tests. Don't reach for an e2e harness to assert logic a `makeTestContext` test can prove.
|
|
44
49
|
|
|
@@ -66,7 +71,7 @@ voltro e2e # boot api + web siblings, run tests, tear down
|
|
|
66
71
|
<!-- source: en/testing/unit-testing.md -->
|
|
67
72
|
## Unit testing
|
|
68
73
|
|
|
69
|
-
_makeTestContext — the in-memory request ctx
|
|
74
|
+
_makeTestContext, invoke and makeTestApp — the in-memory request ctx, the handler hop and the transport hop. The real mixin-wrapped store, subject factories, row factories, plugin services, and the deterministic clock / webhook / LLM doubles._
|
|
70
75
|
|
|
71
76
|
## tsconfig `paths` aliases
|
|
72
77
|
|
|
@@ -144,8 +149,30 @@ test('searchDocs returns matching rows', async () => {
|
|
|
144
149
|
| `env` | ambient `process.env` | Env values sealed into the boot snapshot so handler code reading `getSecret('X')` / `serverEnv.X` resolves under test. Merged over `process.env` (these win). |
|
|
145
150
|
| `relations` | — | `relations()` specs to register for this context — the boot sweep's stand-in. See [Eager loads under test](#eager-loads-under-test). |
|
|
146
151
|
| `rowFilter` | the registered filter | A row filter for this context only, instead of the process-global `setRowFilter(...)`. See [Row-level security under test](#row-level-security-under-test). |
|
|
152
|
+
| `plugins` | `[]` | Plugins whose rpc interceptors wrap every `invoke` **and** whose `services:` layer is provided to Effect-mode handlers. See [Plugin services](#plugin-services-mail-storage-your-own). |
|
|
153
|
+
| `layers` | `[]` | Your app's own `layers:` from `app.config.ts` — the Effect services a handler `yield*`s. Merged last, so a user layer overrides a plugin layer declaring the same Tag. |
|
|
147
154
|
|
|
148
|
-
The returned `TestContext` carries `{ clock,
|
|
155
|
+
The returned `TestContext` carries `{ clock, webhooks, llm, ai?, request, access, cache, kv, store, storeForTenant, outbox, load, loadMany, withSubject, withTenant }` — read the acting subject at `ctx.request.subject` and the in-memory cache at `ctx.cache`.
|
|
156
|
+
|
|
157
|
+
### Subjects — `user()`, `apiKey()`, `anonymous()`, `system()`
|
|
158
|
+
|
|
159
|
+
Every authenticated `Subject` requires a `tenantId`, so a test that hand-writes `{ type: 'user', id: 'u1', tenantId: 't1', scopes: [] }` is restating the same four fields at every call site. Use the factories:
|
|
160
|
+
|
|
161
|
+
```ts
|
|
162
|
+
import { user, apiKey, serviceAccount, anonymous, system, TEST_TENANT_ID } from '@voltro/testing'
|
|
163
|
+
|
|
164
|
+
user('u1') // tenant TEST_TENANT_ID, NO scopes → guards deny
|
|
165
|
+
user('u1', { scopes: ['notes:write'] }) // the caller a guard should let through
|
|
166
|
+
user('u2', { tenantId: 'other' }) // a different tenant — the store hides the first one's rows
|
|
167
|
+
apiKey('key_1', { scopes: ['orders:read'] }) // what a Bearer key resolves to
|
|
168
|
+
anonymous('acme') // unauthenticated, tenant from the `x-tenant` header
|
|
169
|
+
system('job:nightly') // the CROSS-tenant machine actor (tenantId null, admin:full)
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
Two decisions worth knowing, because they decide what your tests mean:
|
|
173
|
+
|
|
174
|
+
- **The default tenant is shared** (`TEST_TENANT_ID`). `user('a')` and `user('b')` are two members of ONE tenant, so a cross-subject read is a row-level question. A per-call-unique tenant would make the store hide everything and every such test would pass for the wrong reason.
|
|
175
|
+
- **`scopes` defaults to empty, not to a bypass.** `user('b')` is refused by any `guards:` — which is the assertion most negative tests are written to make.
|
|
149
176
|
|
|
150
177
|
## `invoke` — run a handler through its guards, its input Schema, and its transaction
|
|
151
178
|
|
|
@@ -343,8 +370,96 @@ Retries are always on and carry no backoff here. Production's jitter exists to d
|
|
|
343
370
|
|
|
344
371
|
### What `invoke` still does NOT cover
|
|
345
372
|
|
|
346
|
-
- **Transport concerns** — connection info, rate limiting, the tenant header. Those are properties of the HTTP hop, not of the procedure; faking them here would only assert against the fake.
|
|
373
|
+
- **Transport concerns** — connection info, rate limiting, the tenant header. Those are properties of the HTTP hop, not of the procedure; faking them here would only assert against the fake. Reach one level up for them: [`makeTestApp`](#request-level-testing-maketestapp) drives a real request through the real REST pipeline and runs the procedure through `invoke` underneath.
|
|
347
374
|
- **Undo capture and the metrics sample** — optional injections the serve entrypoint makes. Their absence changes nothing a handler can observe.
|
|
375
|
+
- **The CLI-built base services** — `Cache`, `Kv`, analytics, the outbound `HttpClient`, the aggregate registry. The CLI builds those at boot from your config, and `@voltro/testing` does not depend on the CLI, so a handler that `yield*`s one gets the ordinary "Service not found". Plugin services and your own `layers:` DO resolve — see below.
|
|
376
|
+
|
|
377
|
+
## Plugin services — mail, storage, your own
|
|
378
|
+
|
|
379
|
+
A plugin contributes an Effect **service**, not a `ctx` field: a handler writes `const mail = yield* MailService`. Register the plugin on the context and `invoke` provides its `services:` layer, exactly as the serve pipeline does — so the handler runs, and you assert on what the plugin actually recorded:
|
|
380
|
+
|
|
381
|
+
```ts
|
|
382
|
+
import { invoke, makeTestContext, user } from '@voltro/testing'
|
|
383
|
+
import { mailPlugin, readMailBuffer, clearMailBuffer } from '@voltro/plugin-mail'
|
|
384
|
+
import { sendWelcome } from '../actions/welcome.action'
|
|
385
|
+
import sendWelcomeHandler from '../actions/welcome.action.server'
|
|
386
|
+
|
|
387
|
+
beforeEach(() => { clearMailBuffer() })
|
|
388
|
+
|
|
389
|
+
test('signing up sends the welcome email', async () => {
|
|
390
|
+
const ctx = makeTestContext({
|
|
391
|
+
subject: user('u1'),
|
|
392
|
+
plugins: [mailPlugin({ provider: 'memory', from: 'app@acme.com' })],
|
|
393
|
+
})
|
|
394
|
+
|
|
395
|
+
await invoke(sendWelcome, sendWelcomeHandler, { to: 'ada@acme.com' }, ctx)
|
|
396
|
+
|
|
397
|
+
const sent = readMailBuffer()
|
|
398
|
+
expect(sent).toHaveLength(1)
|
|
399
|
+
expect(sent[0].to).toEqual(['ada@acme.com'])
|
|
400
|
+
expect(sent[0].subject).toBe('Welcome')
|
|
401
|
+
})
|
|
402
|
+
```
|
|
403
|
+
|
|
404
|
+
`provider: 'memory'` is the mail plugin's own test adapter, and `readMailBuffer()` / `clearMailBuffer()` its own assertion surface — so the whole send path runs: the default `from`, the dev allowlist, suppression, per-send idempotency, the template render. There is deliberately **no `ctx.email` mock**: a double in `@voltro/testing` would model a weaker message than `MailService` accepts, and asserting against it would be asserting against the double.
|
|
405
|
+
|
|
406
|
+
A service nobody provided still fails with **"Service not found"**, which is the honest outcome — it is what the handler would do at runtime, not something to paper over.
|
|
407
|
+
|
|
408
|
+
Your own services work the same way, through `layers:`:
|
|
409
|
+
|
|
410
|
+
```ts
|
|
411
|
+
const ctx = makeTestContext({ layers: [Layer.succeed(Pricing, { quote: () => 499 })] })
|
|
412
|
+
```
|
|
413
|
+
|
|
414
|
+
## Request-level testing — `makeTestApp`
|
|
415
|
+
|
|
416
|
+
`invoke` is the handler hop. `makeTestApp` is the one above it: an actual request through the framework's own REST pipeline — no server, no port, no docker.
|
|
417
|
+
|
|
418
|
+
```ts
|
|
419
|
+
import { makeTestApp, makeTestContext, anonymous, user } from '@voltro/testing'
|
|
420
|
+
import { listOrders } from '../queries/orders.list.query'
|
|
421
|
+
import listOrdersHandler from '../queries/orders.list.query.server'
|
|
422
|
+
|
|
423
|
+
const ctx = makeTestContext({ subject: anonymous(), store: mockStore({ orders: [{ id: 'o1', tenantId: 'acme' }] }) })
|
|
424
|
+
const app = makeTestApp({
|
|
425
|
+
ctx,
|
|
426
|
+
publicApi: [{ descriptor: listOrders, handler: listOrdersHandler }],
|
|
427
|
+
restRoutes: [healthRoute],
|
|
428
|
+
strategies: [myBearerStrategy],
|
|
429
|
+
})
|
|
430
|
+
|
|
431
|
+
// the transport gate: the publicApi annotation's scopes refuse an unscoped caller
|
|
432
|
+
expect((await app.get('/v1/orders/list')).status).toBe(403)
|
|
433
|
+
|
|
434
|
+
// acting as someone who holds the scope
|
|
435
|
+
const res = await app.actingAs(user('u1', { tenantId: 'acme', scopes: ['orders:read'] })).get('/v1/orders/list')
|
|
436
|
+
expect(res.status).toBe(200)
|
|
437
|
+
expect(res.body).toEqual({ ids: ['o1'] })
|
|
438
|
+
|
|
439
|
+
// how an identity is RESOLVED — headers, not an injected subject
|
|
440
|
+
const viaHeader = await app.withHeaders({ 'x-tenant': 'acme', authorization: 'Bearer k1' }).get('/v1/orders/list')
|
|
441
|
+
```
|
|
442
|
+
|
|
443
|
+
The two ways to say who is calling are different questions, and the harness keeps them apart:
|
|
444
|
+
|
|
445
|
+
- **`actingAs(subject)`** fills the same `resolveSubject` seam the serve pipeline fills with its auth resolver. Use it to test what a given identity *may do*.
|
|
446
|
+
- **`withHeaders({...})`** and no `actingAs` runs the request through `composeAuthStrategies` — your real strategy chain, first-match-wins, the `x-tenant` fallback, `anonymousTenantRequired`. Use it to test how an identity *is resolved*.
|
|
447
|
+
|
|
448
|
+
Both return a derived app; neither mutates the one it came from.
|
|
449
|
+
|
|
450
|
+
| Method | |
|
|
451
|
+
|---|---|
|
|
452
|
+
| `app.get(path, opts?)` | Query string goes in the path: `app.get('/v1/orders?limit=2')`. |
|
|
453
|
+
| `app.post(path, body?, opts?)` | `put` / `patch` / `delete` likewise. `body` is JSON-encoded. |
|
|
454
|
+
| `app.request(method, path, opts?)` | The general form. |
|
|
455
|
+
| `app.actingAs(subject)` / `app.withHeaders(h)` | Derived views. |
|
|
456
|
+
| `app.paths` | Every mounted path, in mount order. |
|
|
457
|
+
|
|
458
|
+
A `TestResponse` is `{ status, headers, text, body, stream }` — `body` is the parsed JSON when the route answered with a JSON content-type.
|
|
459
|
+
|
|
460
|
+
What it runs is the framework's own code, not a re-implementation: `restRoutesToHttpRoutes` (method gate, sunset gate, `{ query, params, body }` assembly, input decode, guards, HTTP idempotency, output encode), `collectPublicApiRoutes` (the descriptor → REST projection, `scopes` → guards), `dispatchSharedPath` (the per-path dispatcher that lets a GET and a POST share one route), `composeAuthStrategies`, and `invoke` beneath a `publicApi` route — so the procedure's own guards, decode, transaction and interceptors still apply under the transport.
|
|
461
|
+
|
|
462
|
+
Two things it deliberately does not do. An auth strategy that **refuses** (`anonymousTenantRequired` with no tenant) makes the request-call REJECT rather than return a status: mapping an auth refusal onto an HTTP code is the server's job, and inventing a number here would be a number the harness made up. And there is no WebSocket / live-subscription lifecycle and no `POST /rpc` wire — those are dispatcher concerns the CLI owns; a `publicApi:` annotation is how a procedure gets an HTTP surface this harness can reach.
|
|
348
463
|
|
|
349
464
|
## Completing a fixture row — `fixtureRow`
|
|
350
465
|
|
|
@@ -372,6 +487,39 @@ What it fills and what it leaves alone:
|
|
|
372
487
|
|
|
373
488
|
There is no flag to turn the validation off — a test store that accepts rows production rejects is a fake testing itself. `fixtureRow` is a runtime filler for the loose `store.insert(name, row)` path. For **compile-time** payload typing (a missing required column caught as a type error at the call), use [`insertRow` / `upsertRow`](/docs/data/mutations) from `@voltro/database`, which check against `InferInsertRow<T>`.
|
|
374
489
|
|
|
490
|
+
### Factories — `defineFactory`
|
|
491
|
+
|
|
492
|
+
`fixtureRow` makes ONE row valid. What it cannot do is the part a fixture actually costs you: the **parent rows**. It fills a `reference()` column with a placeholder string, which satisfies the validator and points at nothing — invisible in the in-memory store, a constraint violation against a real database, and an eager `.with({ author: true })` that resolves to nothing either way.
|
|
493
|
+
|
|
494
|
+
`defineFactory` adds defaults, traits and associations on top:
|
|
495
|
+
|
|
496
|
+
```ts
|
|
497
|
+
import { defineFactory, makeTestContext } from '@voltro/testing'
|
|
498
|
+
import { users, posts } from '../schema'
|
|
499
|
+
|
|
500
|
+
const userFactory = defineFactory(users, {
|
|
501
|
+
defaults: { name: 'Ada', email: (seq) => `user-${seq}@test.local` },
|
|
502
|
+
traits: { admin: { role: 'admin' } },
|
|
503
|
+
})
|
|
504
|
+
const postFactory = defineFactory(posts, {
|
|
505
|
+
defaults: { title: 'A post', views: 0 },
|
|
506
|
+
traits: { popular: { views: 10_000 } },
|
|
507
|
+
associations: { authorId: userFactory },
|
|
508
|
+
})
|
|
509
|
+
|
|
510
|
+
const ctx = makeTestContext({ subject })
|
|
511
|
+
|
|
512
|
+
const post = await postFactory.create(ctx.store) // an author IS created
|
|
513
|
+
const byAdmin = await postFactory.create(ctx.store, { authorId: (await userFactory.with('admin').create(ctx.store)).id })
|
|
514
|
+
const top3 = await postFactory.with('popular').createList(ctx.store, 3)
|
|
515
|
+
const built = postFactory.build() // pure — writes nothing
|
|
516
|
+
```
|
|
517
|
+
|
|
518
|
+
- **Defaults** take a value or a `(seq) => value` function; `seq` is a process-monotonic counter shared with `fixtureRow`'s unique filler, so two sources of "unique enough" values can't collide.
|
|
519
|
+
- **Traits** are named override bundles, composed left to right (`with('a', 'b')` — `b` wins). An undeclared trait **throws** rather than quietly building the base row.
|
|
520
|
+
- **Associations** apply to `create` only. An ancestor is created only for a required `reference()` column your overrides leave unset — pass `{ authorId: existing.id }` and nothing extra is written. A column with no declared factory still gets its parent, built with a plain `fixtureRow`. A **cyclic** reference is refused, naming the path and the column to pass by hand.
|
|
521
|
+
- `build` / `buildList` are pure: they write nothing, so a `reference()` column gets `fixtureRow`'s placeholder. Use `create` when the relations matter.
|
|
522
|
+
|
|
375
523
|
## Subscribers — `makeSubscribeContext` + the change constructors
|
|
376
524
|
|
|
377
525
|
A `*.subscribe.ts` handler does not receive an `AppContext`. It receives a
|
|
@@ -512,18 +660,67 @@ ctx.clock.now() // current instant in ms
|
|
|
512
660
|
ctx.clock.date() // current instant as a Date
|
|
513
661
|
ctx.clock.advance('15m') // jump forward — ms | s | m | h | d, or a number of ms
|
|
514
662
|
ctx.clock.advance(900_000) // same, in milliseconds
|
|
663
|
+
ctx.clock.set('2026-03-01T12:00:00Z') // jump to an ABSOLUTE instant
|
|
664
|
+
```
|
|
665
|
+
|
|
666
|
+
#### Freezing GLOBAL time — `withFrozenTime` / `frozenTime`
|
|
667
|
+
|
|
668
|
+
On its own a `MockClock` is a value you read: `Date.now()` and `new Date()` are
|
|
669
|
+
untouched. That matters more than it sounds, because almost nothing stamps a
|
|
670
|
+
timestamp through the clock you passed it — `MockWebhooks.emittedAt`, the mail
|
|
671
|
+
plugin's memory provider, every `createdAt` default and most of your own code
|
|
672
|
+
all call `new Date()`. Advance the mock clock, assert on one of those stamps,
|
|
673
|
+
and you are comparing two unrelated clocks: the assertion passes by measuring
|
|
674
|
+
the machine.
|
|
675
|
+
|
|
676
|
+
`withFrozenTime` takes over the realm's `Date` for the body and restores it
|
|
677
|
+
afterwards — the Rails `travel_to` / Laravel `Carbon::setTestNow()` move:
|
|
678
|
+
|
|
679
|
+
```ts
|
|
680
|
+
import { withFrozenTime } from '@voltro/testing'
|
|
681
|
+
|
|
682
|
+
await withFrozenTime('2026-03-01T12:00:00Z', async (clock) => {
|
|
683
|
+
expect(new Date().toISOString()).toBe('2026-03-01T12:00:00.000Z')
|
|
684
|
+
await invoke(createTodo, { title: 'x' }, ctx) // its createdAt is that instant
|
|
685
|
+
clock.advance('1h') // global time moves with it
|
|
686
|
+
expect(Date.now()).toBe(Date.parse('2026-03-01T13:00:00Z'))
|
|
687
|
+
})
|
|
515
688
|
```
|
|
516
689
|
|
|
517
|
-
|
|
690
|
+
It restores whether the body returns, throws, resolves or rejects — an async
|
|
691
|
+
body is awaited *before* the clock goes back, so a `finally` cannot put the real
|
|
692
|
+
clock back underneath a still-running test.
|
|
518
693
|
|
|
519
|
-
|
|
694
|
+
The Effect-native form freezes for a **scope** and releases on success, failure
|
|
695
|
+
and interruption alike:
|
|
520
696
|
|
|
521
697
|
```ts
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
698
|
+
import { frozenTime } from '@voltro/testing'
|
|
699
|
+
|
|
700
|
+
Effect.scoped(Effect.gen(function* () {
|
|
701
|
+
const clock = yield* frozenTime('2026-03-01T12:00:00Z')
|
|
702
|
+
yield* somethingThatStampsNow
|
|
703
|
+
clock.advance('1d')
|
|
704
|
+
}))
|
|
525
705
|
```
|
|
526
706
|
|
|
707
|
+
`clock.install()` is the manual escape hatch and hands back the uninstall. Four
|
|
708
|
+
things worth knowing before you reach for it:
|
|
709
|
+
|
|
710
|
+
- **Only the zero-argument readings change.** `new Date(0)`, `new Date('2020-05-05')`
|
|
711
|
+
and `new Date(2020, 0, 2)` mean exactly what they say; `Date.parse` and
|
|
712
|
+
`Date.UTC` are untouched.
|
|
713
|
+
- **A second install throws.** Nesting two would make the inner uninstall restore
|
|
714
|
+
the *outer fake*, leaving the realm frozen with nothing pointing at why.
|
|
715
|
+
- **Timers are not faked.** `performance.now()` and `setTimeout` are unaffected —
|
|
716
|
+
use vitest's `vi.useFakeTimers()` for the timer wheel.
|
|
717
|
+
- **A module that captured `Date` into a local before the install keeps the real
|
|
718
|
+
one.** That is inherent to any time fake.
|
|
719
|
+
|
|
720
|
+
### Email is not a double — see [Plugin services](#plugin-services-mail-storage-your-own)
|
|
721
|
+
|
|
722
|
+
There is no `ctx.email`. Mail is an Effect service (`yield* MailService`), so the assertion surface is the mail plugin's own memory provider — register `mailPlugin({ provider: 'memory' })` on the context and read `readMailBuffer()`.
|
|
723
|
+
|
|
527
724
|
### `ctx.webhooks` — `MockWebhooks`
|
|
528
725
|
|
|
529
726
|
Records outgoing webhook emissions. Every context derived from this one — the transaction context `invoke` builds for a mutation, a `withSubject` / `withTenant` block — shares the same recorder, so what you assert on IS what the handler emitted.
|