@voltro/cli 0.6.0 → 0.7.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.
Files changed (59) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/bin/voltro.mjs +15 -6
  3. package/dist/apiBuild-CSM74oEZ.js +2 -0
  4. package/dist/{apiBuild-BxY44VGj.js → apiBuild-deIjJKJm.js} +21 -20
  5. package/dist/bin.js +2 -2
  6. package/dist/{commands-My_YlUJV.js → commands-BHdc71DG.js} +2323 -2946
  7. package/dist/dev-BYfrYwrT.js +2 -0
  8. package/dist/dev-Bqyq62Ak.js +6492 -0
  9. package/dist/index.js +1 -1
  10. package/dist/inspectMetrics-BLUBAWLb.js +3711 -0
  11. package/dist/{serveCommand-C-XQ89NJ.js → serveCommand-Dn6--0Bv.js} +209 -208
  12. package/dist/serveEntry.js +3 -2
  13. package/dist/start-CWZW_4Et.js +941 -0
  14. package/dist/startEntry.d.ts +5 -0
  15. package/dist/startEntry.js +3 -0
  16. package/package.json +17 -17
  17. package/templates/agent-docs/authentication.md +104 -9
  18. package/templates/agent-docs/cli.md +59 -2
  19. package/templates/agent-docs/data.md +44 -0
  20. package/templates/agent-docs/database/schema.md +1 -1
  21. package/templates/agent-docs/testing.md +58 -0
  22. package/templates/apps/api-ai/package.json +7 -7
  23. package/templates/apps/api-auth/package.json +8 -8
  24. package/templates/apps/api-backend/package.json +7 -7
  25. package/templates/apps/api-backend-deactivation/package.json +7 -7
  26. package/templates/apps/api-backend-mail/package.json +8 -8
  27. package/templates/apps/api-backend-mariadb/package.json +9 -9
  28. package/templates/apps/api-backend-storage/package.json +8 -8
  29. package/templates/apps/api-data-advanced/package.json +8 -8
  30. package/templates/apps/api-durable/package.json +8 -8
  31. package/templates/apps/api-feature-flags/package.json +9 -9
  32. package/templates/apps/api-governance/package.json +8 -8
  33. package/templates/apps/api-kv/package.json +8 -8
  34. package/templates/apps/api-moderation/package.json +8 -8
  35. package/templates/apps/api-observability/package.json +8 -8
  36. package/templates/apps/api-ratelimit/package.json +8 -8
  37. package/templates/apps/api-rbac/package.json +8 -8
  38. package/templates/apps/api-rest/package.json +7 -7
  39. package/templates/apps/api-saas/package.json +11 -11
  40. package/templates/apps/api-search/package.json +8 -8
  41. package/templates/apps/api-versioning/package.json +8 -8
  42. package/templates/apps/api-webhooks/package.json +8 -8
  43. package/templates/apps/changelog/package.json +6 -6
  44. package/templates/apps/edge-functions/package.json +2 -2
  45. package/templates/apps/frontend-admin/package.json +8 -8
  46. package/templates/apps/frontend-app/package.json +8 -8
  47. package/templates/apps/frontend-blank/package.json +7 -7
  48. package/templates/apps/frontend-contact/package.json +7 -7
  49. package/templates/apps/frontend-dashboard/package.json +7 -7
  50. package/templates/apps/frontend-docs/package.json +7 -7
  51. package/templates/apps/frontend-i18n/package.json +6 -6
  52. package/templates/apps/frontend-landing/package.json +7 -7
  53. package/templates/apps/frontend-spa/package.json +7 -7
  54. package/templates/apps/frontend-ssr/package.json +7 -7
  55. package/templates/apps/frontend-ssr-api/package.json +8 -8
  56. package/templates/apps/frontend-static-blog/package.json +6 -6
  57. package/dist/apiBuild-Bk4JBt-i.js +0 -2
  58. package/dist/dev-Bjq-nIvH.js +0 -10186
  59. package/dist/dev-D6b74iXI.js +0 -2
@@ -0,0 +1,5 @@
1
+ export declare const loadDotEnv: (argv: ReadonlyArray<string>) => void;
2
+
3
+ export declare const runStartCommand: (args: ReadonlyArray<string>) => Promise<number>;
4
+
5
+ export { }
@@ -0,0 +1,3 @@
1
+ import { k as e } from "./inspectMetrics-BLUBAWLb.js";
2
+ import { t } from "./start-CWZW_4Et.js";
3
+ export { e as loadDotEnv, t as runStartCommand };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@voltro/cli",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "The `voltro` CLI — dev server, codegen, migrations, project scaffolding, agent-docs seeding, and production serve.",
5
5
  "keywords": [
6
6
  "voltro",
@@ -57,22 +57,22 @@
57
57
  "@effect/platform-node": "^0.107.0",
58
58
  "@effect/sql": "^0.51.1",
59
59
  "@effect/workflow": "^0.18.2",
60
- "@voltro/ai": "0.6.0",
61
- "@voltro/cache": "0.6.0",
62
- "@voltro/data-transfer": "0.6.0",
63
- "@voltro/database": "0.6.0",
64
- "@voltro/env": "0.6.0",
65
- "@voltro/kv": "0.6.0",
66
- "@voltro/logger": "0.6.0",
67
- "@voltro/plugin-auth": "0.6.0",
68
- "@voltro/plugin-broadcast": "0.6.0",
69
- "@voltro/plugin-mail": "0.6.0",
70
- "@voltro/plugin-storage": "0.6.0",
71
- "@voltro/plugin-webhooks": "0.6.0",
72
- "@voltro/protocol": "0.6.0",
73
- "@voltro/runtime": "0.6.0",
74
- "@voltro/serverless": "0.6.0",
75
- "@voltro/workflow": "0.6.0",
60
+ "@voltro/ai": "0.7.0",
61
+ "@voltro/cache": "0.7.0",
62
+ "@voltro/data-transfer": "0.7.0",
63
+ "@voltro/database": "0.7.0",
64
+ "@voltro/env": "0.7.0",
65
+ "@voltro/kv": "0.7.0",
66
+ "@voltro/logger": "0.7.0",
67
+ "@voltro/plugin-auth": "0.7.0",
68
+ "@voltro/plugin-broadcast": "0.7.0",
69
+ "@voltro/plugin-mail": "0.7.0",
70
+ "@voltro/plugin-storage": "0.7.0",
71
+ "@voltro/plugin-webhooks": "0.7.0",
72
+ "@voltro/protocol": "0.7.0",
73
+ "@voltro/runtime": "0.7.0",
74
+ "@voltro/serverless": "0.7.0",
75
+ "@voltro/workflow": "0.7.0",
76
76
  "chokidar": "^5.0.0",
77
77
  "ioredis": "^5.11.1",
78
78
  "ts-morph": "^28.0.0"
@@ -351,6 +351,29 @@ if (ctx.subject.type === 'anonymous') {
351
351
  }
352
352
  ```
353
353
 
354
+ ## App metadata — `subjectFromUser(user, { metadata })`
355
+
356
+ `metadata` is the free-form slot the framework itself never reads. It is where a provider credential captured at login belongs — a plugin's `credentialsResolver` reads it back per request (`@voltro/plugin-atlassian` looks for `subject.metadata.jiraToken`, say), so nothing has to be re-fetched or stored server-side per call.
357
+
358
+ Pass it when you build the Subject:
359
+
360
+ ```ts
361
+ import { subjectFromUser } from '@voltro/plugin-auth'
362
+
363
+ const subject = subjectFromUser(user, {
364
+ memberships, // → metadata.memberships
365
+ metadata: { jiraToken: atlassianPat }, // → metadata.jiraToken
366
+ })
367
+ ```
368
+
369
+ The two merge — neither clobbers the other. **When a `memberships` key appears in both,** the dedicated `memberships` option wins: it is the typed input, and it is the one projected into the `{ tenantId, role }` shape `subjectMemberships()` and the tenant switcher read. Without the option, a `memberships` key inside `metadata` passes through unchanged. A Subject built with neither option has no `metadata` key at all.
370
+
371
+ Keys set this way survive the login paths: the sign-in / sign-up / magic-link / passkey handlers merge `sessionId` onto the existing slot, and the password strategy merges `provider` — they add, they don't replace. The one exception is naming a key `sessionId` or `provider` yourself; those two are overwritten by design.
372
+
373
+ **It survives a tenant switch too.** A switch rebuilds the Subject from the user record, so the built-in `/switch-tenant` route passes the caller's current `subject.metadata` through to `handleSwitchTenant` — a credential parked here keeps working after the user changes tenant. Calling `handleSwitchTenant` yourself? Pass `metadata` or the credential is dropped, and the symptom is unpleasant to diagnose: the user stays signed in while every call to the provider starts failing. `memberships` is deliberately *not* carried — it is re-derived for the target tenant, and a carried copy would report a role the user does not hold there.
374
+
375
+ Two things not to put here. **Anything the caller could benefit from changing** — the slot rides the signed session cookie, so it is tamper-evident, but it is also stale by design: it reflects the moment of sign-in, not the current database. And **anything large** — it is re-serialised into every session cookie.
376
+
354
377
  ## Resolution
355
378
 
356
379
  `AuthMiddleware` resolves a `Subject` on every request by running the [strategy chain](/docs/authentication/strategies) — `composeAuthStrategies` evaluates each strategy in order, first `matched` wins, first `failed` short-circuits to anonymous. A typical chain resolves, in order:
@@ -1975,16 +1998,88 @@ await ctx.store.query(tickets.descriptor) // filtered
1975
1998
  await ctx.store.select('tickets').all() // filtered
1976
1999
  ```
1977
2000
 
1978
- ## A failing `load` denies
2001
+ ## When `load` fails
2002
+
2003
+ `load` reads your store — for relational visibility it *must* — which makes it
2004
+ exactly the kind of call that blips. Two separate questions follow from a
2005
+ failure, and the answers are deliberately different.
2006
+
2007
+ ### First: is the failure even real? (`retry`)
2008
+
2009
+ A transient failure must never reach the decision below, because once it gets
2010
+ there it is indistinguishable from an authorization answer. So `load` runs under
2011
+ a bounded retry before anything is concluded from it:
2012
+
2013
+ ```ts no-check
2014
+ import { Schedule } from 'effect'
2015
+
2016
+ setRowFilter({
2017
+ load,
2018
+ predicate,
2019
+ retry: Schedule.recurs(5), // your own schedule
2020
+ // retry: false, // exactly one attempt
2021
+ })
2022
+ ```
2023
+
2024
+ The default is `DEFAULT_ROW_FILTER_RETRY` (exported from `@voltro/runtime`):
2025
+ **three attempts, backing off exponentially from 20ms** — about 60ms of added
2026
+ latency in the worst case. It is sized for a blip (a connection reaped from the
2027
+ pool, a failover flap), not for an outage. A `load` still failing after that is
2028
+ not having a bad moment, and stretching the schedule only turns a fast honest
2029
+ error into a slow one while holding the request open.
2030
+
2031
+ ### Then: what does a real failure mean? (`onLoadError`)
2032
+
2033
+ Not "you may see nothing". **We cannot tell what you may see.** Those are
2034
+ different facts and only one of them is a fact — so the default raises a typed
2035
+ error:
2036
+
2037
+ ```ts no-check
2038
+ setRowFilter({
2039
+ load,
2040
+ predicate,
2041
+ onLoadError: 'fail', // default — raises RowFilterUnavailable
2042
+ // onLoadError: 'deny', // degrade to zero rows instead
2043
+ })
2044
+ ```
2045
+
2046
+ - **`'fail'` (default)** — the request fails with the typed
2047
+ `RowFilterUnavailable`. Handle it in your UI as an error state, the same as any
2048
+ other failed request.
2049
+ - **`'deny'`** — refusal is expressed as a predicate matching nothing, so the
2050
+ read returns an empty result. Choose this only if you have looked at the screen
2051
+ and are content for it to render empty during an outage. Your `onError`
2052
+ reporter still fires, so the failure stays findable in logs even though the
2053
+ response is a 200.
2054
+
2055
+ The default changed *to* `'fail'`, and the reasoning is worth stating plainly
2056
+ because the old default looked defensible: an empty result for an infrastructure
2057
+ failure is byte-identical to legitimate emptiness. The user reads "you have no
2058
+ tickets". The operator reads a healthy 200. The outage is invisible to both —
2059
+ the most misleading outcome on offer. Every constrained page **is** broken when
2060
+ this happens, and saying so is the only outcome either party can act on.
2061
+
2062
+ ### There is no fail-open option
2063
+
2064
+ A frequent request, and a deliberate refusal: there is no policy that serves
2065
+ **unfiltered** rows when the filter is unavailable, falling back to whatever
2066
+ check the handler carries.
2067
+
2068
+ Failing open on an authorization filter leaks data precisely when the system is
2069
+ under stress and nobody is reading dashboards. And it is only safe if every
2070
+ handler still carries its own row-level check — which is the entire thing a row
2071
+ filter exists to remove. A codebase where fail-open is safe is a codebase that
2072
+ did not need `setRowFilter`.
2073
+
2074
+ Both policies above are fail-**closed**: neither can ever produce an unfiltered
2075
+ read.
1979
2076
 
1980
- If `load` fails, every constrained read is **refused** rather than silently
1981
- running unfiltered. A row filter that degrades to "no filter" under load failure
1982
- is worse than no filter at all, because the system keeps serving and nothing
1983
- looks wrong.
2077
+ ### Subscriptions
1984
2078
 
1985
- The refusal is expressed as a predicate that matches nothing, not as an error —
1986
- so a failure surfaces as an **empty result** rather than a 500 on every page. The
1987
- underlying cause is reported separately, so it cannot pass unnoticed.
2079
+ A resolution failure mid-stream **revokes** the subscription and emits a typed
2080
+ error frame, rather than delivering an empty snapshot an empty snapshot on a
2081
+ live subscription reads to a client as "every row you could see was just
2082
+ deleted". Make sure your subscription error handling surfaces it.
1988
2083
 
1989
2084
  ## What does *not* bypass it
1990
2085
 
@@ -2022,7 +2117,7 @@ They answer different questions, and a complete policy usually wants both:
2022
2117
  | | Question | Failure |
2023
2118
  |---|---|---|
2024
2119
  | [`guards:`](/docs/authentication/authorization#enforce-it-declaratively-guards) | May you call this procedure? | typed `ScopeError`, before the executor runs |
2025
- | `setRowFilter` | Which rows may you see? | the rows are simply absent |
2120
+ | `setRowFilter` | Which rows may you see? | the rows are simply absent — unless the filter itself could not load, which is a typed `RowFilterUnavailable` |
2026
2121
 
2027
2122
  A guard is the right tool for "may this caller edit *this* ticket". A row filter
2028
2123
  is the right tool for "which tickets appear in the list at all" — a question a
@@ -1822,7 +1822,7 @@ _voltro update — bump the framework to the latest version and run the codemods
1822
1822
  `voltro update` upgrades an app to the latest framework release. It does three things in order:
1823
1823
 
1824
1824
  1. **Bump** every `@voltro/*` dependency in `package.json` to the target version.
1825
- 2. **Install** with your package manager (detected from the lockfile pnpm / npm / yarn / bun).
1825
+ 2. **Install** with your project's package manager — see [Which package manager](#which-package-manager) below.
1826
1826
  3. **Run the codemods** shipped with the target version — automatic source rewrites for any breaking API change, plus printed manual steps for anything that can't be automated.
1827
1827
 
1828
1828
  ```bash
@@ -1831,12 +1831,47 @@ voltro update --to 0.4.0 # pin an explicit target version
1831
1831
  voltro update --dry-run # preview the bump + which codemods would run — writes nothing
1832
1832
  voltro update --force # allow a dirty working tree (not recommended)
1833
1833
  voltro update --exact # pin exact versions (drop the ^ / ~ range prefix)
1834
+ voltro update --help # every flag — always answered, even on a dirty tree
1834
1835
 
1835
1836
  # Recover the codemods after a MANUAL version bump (no bump, no install):
1836
1837
  voltro update --codemods-only --from 0.3.0 # re-run codemods 0.3.0 → installed
1837
1838
  voltro update --codemods-only --from 0.3.0 --to 0.4.0 # explicit delta
1838
1839
  ```
1839
1840
 
1841
+ ## In a workspace, the whole workspace moves
1842
+
1843
+ Run `voltro update` anywhere inside a workspace — a `pnpm-workspace.yaml`, or a
1844
+ `workspaces` field in an ancestor `package.json` — and **every member
1845
+ `package.json` that declares `@voltro/*` is bumped to the same version**, with
1846
+ the install running **once at the workspace root**.
1847
+
1848
+ This is not a convenience. Your api and your web app share generated types (the
1849
+ rpcGroup) and a session cookie shape; if the api moves to 0.6.0 while
1850
+ `apps/web` and `packages/ui-*` stay on 0.5.0, the mismatch shows up as a runtime
1851
+ decode error in the browser, not as a build failure. Half-upgraded is the worst
1852
+ state to be in, so `voltro update` never leaves you there.
1853
+
1854
+ The plan output — and `--dry-run` — lists every file it will touch:
1855
+
1856
+ ```text
1857
+ voltro update: 0.5.0 → 0.6.0
1858
+ workspace: /repo (4 package.json with @voltro/* deps)
1859
+ package.json
1860
+ @voltro/cli: ^0.5.0 → ^0.6.0
1861
+ apps/api/package.json
1862
+ @voltro/cli: ^0.5.0 → ^0.6.0
1863
+ @voltro/database: ^0.5.0 → ^0.6.0
1864
+ apps/web/package.json
1865
+ @voltro/client: ^0.5.0 → ^0.6.0
1866
+ packages/ui-admin/package.json
1867
+ @voltro/web: ~0.5.0 → ~0.6.0
1868
+ package manager: pnpm
1869
+ install runs in: /repo
1870
+ ```
1871
+
1872
+ A standalone (non-workspace) project is unaffected: its own `package.json`, its
1873
+ own install, in place.
1874
+
1840
1875
  ## Already bumped by hand? Recover the codemods
1841
1876
 
1842
1877
  If you bump `@voltro/*` versions in `package.json` yourself and install first, a
@@ -1859,9 +1894,31 @@ source version.
1859
1894
 
1860
1895
  Codemods **rewrite your source**, so you need a clean diff to review afterwards. `voltro update` refuses to run on a dirty git working tree — commit or stash first. Use `--dry-run` to preview without touching anything, or `--force` to override the guard (you accept a mixed diff).
1861
1896
 
1897
+ `--help` / `-h` is answered *before* the guard, so `voltro update --help` prints the flag list even on a dirty tree. The same holds for `voltro doctor --help`.
1898
+
1899
+ ## If the install fails
1900
+
1901
+ The bump is written before the install runs, so a failed install leaves your `package.json` on the target version — and **no codemods applied**. `voltro update` says so explicitly, because the codemods for a jump ship *inside* the target version: a failed install never put them on disk, so there is nothing that could have run them. Fix the install, run it, then apply the codemods you are missing with the command the failure message prints for you:
1902
+
1903
+ ```bash
1904
+ voltro update --codemods-only --from 0.5.0 --to 0.6.0
1905
+ ```
1906
+
1862
1907
  ## What gets bumped
1863
1908
 
1864
- Every `@voltro/*` entry in `dependencies` and `devDependencies`, with the range style preserved (`^0.3.0` stays caret, `~0.3.0` stays tilde) unless you pass `--exact`. Non-registry specs (`workspace:*`, `catalog:`, `link:`, …) are left untouched — they're already resolved by your monorepo or catalog.
1909
+ Every `@voltro/*` entry in `dependencies` and `devDependencies` — in every workspace member, see above — with the range style preserved (`^0.3.0` stays caret, `~0.3.0` stays tilde) unless you pass `--exact`. Non-registry specs (`workspace:*`, `catalog:`, `link:`, …) are left untouched — they're already resolved by your monorepo or catalog.
1910
+
1911
+ ## Which package manager
1912
+
1913
+ `voltro update` never assumes npm. It resolves your project's package manager in this order, starting in the app directory and walking **up to the repo root**:
1914
+
1915
+ 1. The **`packageManager` field** in a `package.json` (the corepack standard) — authoritative, wins over any lockfile.
1916
+ 2. A **lockfile** at that level — `pnpm-lock.yaml`, `yarn.lock`, `bun.lock` / `bun.lockb`, `package-lock.json`.
1917
+ 3. **npm**, only when nothing declares one.
1918
+
1919
+ Walking up matters in a workspace: a scaffolded Voltro project keeps its lockfile at the monorepo root, so running `voltro update` from `apps/api` still finds `pnpm` rather than falling back to npm and running `npm install` against a pnpm workspace.
1920
+
1921
+ The same resolved manager is used for the **registry lookup** of the latest version (`pnpm view`, `yarn npm info`, `bun pm view`), so a private or scoped registry configured in your `.npmrc` / `.yarnrc.yml` is honored. `npm view` is only a last-resort fallback.
1865
1922
 
1866
1923
  ## Codemods
1867
1924
 
@@ -379,6 +379,50 @@ A `.nullable()` column wraps its mapped type, so a null timestamp round-trips as
379
379
  `null` rather than becoming epoch 0 (which would render as a plausible
380
380
  `1970-01-01` instead of "never").
381
381
 
382
+ ### Computed outputs — `timestampMs`
383
+
384
+ `rowSchema(table)` only helps a handler that returns a **raw, full table row**.
385
+ Most handlers don't: they return a struct assembled by hand from several tables
386
+ — `{ id, name, slug, addedAt, jiraProjectKey }` — where there is no single table
387
+ to derive from. That is exactly where the hand-written `Date → epoch` converters
388
+ pile up.
389
+
390
+ For those, use the field-level exports directly in your own `Schema.Struct`:
391
+
392
+ ```ts
393
+ // apps/api/queries/projects.list.query.ts
394
+ import { defineQuery } from '@voltro/protocol'
395
+ import { timestampMs, timestampMsOrNull } from '@voltro/database'
396
+ import { Schema } from 'effect'
397
+
398
+ export const listProjects = defineQuery({
399
+ name: 'projects.list',
400
+ source: 'projects',
401
+ input: Schema.Struct({}),
402
+ output: Schema.Array(
403
+ Schema.Struct({
404
+ id: Schema.String,
405
+ name: Schema.String,
406
+ jiraProjectKey: Schema.String,
407
+ addedAt: timestampMs, // Date in the handler, epoch ms on the wire
408
+ archivedAt: timestampMsOrNull, // for a nullable timestamp column
409
+ seenAt: Schema.optional(timestampMs),
410
+ }),
411
+ ),
412
+ })
413
+ ```
414
+
415
+ Same semantics as a `timestamp()` column inside `rowSchema` — literally the same
416
+ schema, which `columnSchema` reads from, so the two can never drift into
417
+ different wire representations.
418
+
419
+ - `timestampMs` — `Date` in the handler, `number` (epoch ms) on the wire.
420
+ - `timestampMsOrNull` — for a `.nullable()` timestamp. Use this rather than
421
+ converting a null by hand: `new Date(null)` is `1970-01-01`, so "never
422
+ archived" renders as a plausible date instead of as nothing.
423
+ - An **optional** field is `Schema.optional(timestampMs)` — there is no third
424
+ export for it.
425
+
382
426
  ### Keeping a column off the wire — `omit`
383
427
 
384
428
  ```ts
@@ -423,7 +423,7 @@ the framework ships richer types for specific use cases:
423
423
  - **`timestamp` without `tz`.** Use `timestamp()` which is `timestamptz`.
424
424
  - **Storing JSON blobs as `text`.** Use `json<T>()` for type-safety + JSONB performance.
425
425
  - **`references('table')`.** The constructor is `reference(() => table)` — singular, thunk-arg.
426
- - **Declaring a `timestamp()` column as `Schema.Number` in a descriptor's `output`.** That describes the *wire* type, not the domain type, so the schema has nothing left to convert and the handler ends up doing it by hand. Derive the row schema instead — [`rowSchema(table)`](/docs/data/queries#output-is-the-serializer-rowschema-table).
426
+ - **Declaring a `timestamp()` column as `Schema.Number` in a descriptor's `output`.** That describes the *wire* type, not the domain type, so the schema has nothing left to convert and the handler ends up doing it by hand. Derive the row schema instead — [`rowSchema(table)`](/docs/data/queries#output-is-the-serializer-rowschema-table) for a whole row, or [`timestampMs` / `timestampMsOrNull`](/docs/data/queries#computed-outputs-timestampms) for one field of a hand-written struct.
427
427
 
428
428
 
429
429
 
@@ -116,6 +116,8 @@ test('searchDocs returns matching rows', async () => {
116
116
  | `ai` | — | Injected AI mock (a `mockAi({...})` value). |
117
117
  | `llmResponses` | `[]` | Queued responses for the bundled `ctx.llm` (`MockLLM`). |
118
118
  | `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). |
119
+ | `relations` | — | `relations()` specs to register for this context — the boot sweep's stand-in. See [Eager loads under test](#eager-loads-under-test). |
120
+ | `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). |
119
121
 
120
122
  The returned `TestContext` carries `{ clock, email, llm, ai?, request, cache, store, withSubject, withTenant }` — read the acting subject at `ctx.request.subject` and the in-memory cache at `ctx.cache`.
121
123
 
@@ -137,6 +139,31 @@ const note = await invoke(createNote, createNoteHandler, { title: 'hi' }, ctx)
137
139
  await expect(invoke(createNote, createNoteHandler, { title: 42 }, ctx)).rejects.toThrow()
138
140
  ```
139
141
 
142
+ ### Effect-mode handlers run too
143
+
144
+ A handler may be written `async` **or** as an `Effect` — the framework's contract is "your choice, per handler", and the dispatcher runs both. `invoke` makes the same test, so an `Effect`-returning executor is *executed*, and `invoke` resolves to its success value (typed as that value, not as the `Effect`):
145
+
146
+ ```ts
147
+ export const publishNote = (input: { id: string }, ctx: AppContext) =>
148
+ Effect.gen(function* () {
149
+ const store = yield* EffectStore
150
+ yield* store.update('notes', input.id, { published: true })
151
+ return 'published'
152
+ })
153
+
154
+ const out = await invoke(publish, publishNote, { id: 'n1' }, ctx)
155
+ expect(out).toBe('published') // the value — not an un-run Effect
156
+ ```
157
+
158
+ A failure on the typed error channel rejects with **that error**, exactly as an async handler's `throw` does — so the same assertion works for either mode:
159
+
160
+ ```ts
161
+ await expect(invoke(publish, publishNote, { id: 'gone' }, ctx))
162
+ .rejects.toMatchObject({ _tag: 'NoteNotFound' })
163
+ ```
164
+
165
+ `EffectStore` and `SubjectService` are provided over the context the handler is actually given — inside a mutation that is the *transactional* one, so an Effect handler's writes roll back with everything else. Guards, the input decode, the transaction, the deadlock replay, `afterCommit` and the plugin interceptors all wrap the Effect form identically. An app's own `layers:` and the aggregate registry are **not** provided: those are boot injections the harness has no access to.
166
+
140
167
  ### Guards are enforced
141
168
 
142
169
  An unauthorized caller is refused with the typed `ScopeError` — the same error a client would receive — before the handler runs:
@@ -325,6 +352,37 @@ const raw = await ctx.store.select('notes').unscoped().withDeleted().all()
325
352
  expect(raw[0]?.deletedAt).not.toBeNull() // …but still there, tombstoned
326
353
  ```
327
354
 
355
+ ## Eager loads under test
356
+
357
+ `relations()` is **pure** — it returns a spec, it does not register one. In production `voltro dev` discovers every `*.relations.ts` and registers what it exports; a unit test runs no boot, so importing the module registers nothing and the first `.with({ … })` fails with *"no relations registered"*. Hand the specs to the context instead:
358
+
359
+ ```ts
360
+ import { teamRelations } from '../db/teams.relations'
361
+
362
+ const ctx = makeTestContext({
363
+ relations: [teamRelations],
364
+ store: mockStore({ teams: [{ id: 't1' }], members: [{ id: 'm1', teamId: 't1' }] }),
365
+ })
366
+
367
+ const rows = await ctx.store.select('teams').with({ members: true }).all()
368
+ expect(rows[0].members).toHaveLength(1)
369
+ ```
370
+
371
+ The relations registry is **process-global**, so the option *replaces* it with exactly the specs you pass rather than adding to it. That is what keeps two `makeTestContext({ relations: [...] })` calls in one file independent — additive registration would throw `duplicate relation` on a re-registered spec and would carry the first test's relations into the second. Omitting the option leaves the registry untouched.
372
+
373
+ ## Row-level security under test
374
+
375
+ `ctx.store` applies the app's [row filter](/docs/authentication/row-level-security) for the context's subject: registered with `setRowFilter(...)`, resolved once per context, AND-merged into every read. Both read paths are covered (the fluent builders and `store.query(descriptor)`), `.unscoped()` does **not** bypass it — that opts out of tenant isolation, not of authorization — and a `system` subject bypasses it, exactly as at runtime.
376
+
377
+ ```ts
378
+ const ctx = makeTestContext({ subject: alice, store: mockStore({ tickets: seed }), rowFilter: ownTickets })
379
+
380
+ const rows = await ctx.store.select('tickets').all()
381
+ expect(rows.map((r) => r.id)).not.toContain('bobs-ticket') // the rule, asserted
382
+ ```
383
+
384
+ Pass `rowFilter:` — as above — to scope a filter to **this context only**. `setRowFilter` is process-global: registered in one test it silently constrains every later test in the same worker, and a forgotten `afterEach` surfaces as a failure in an unrelated file. Either way the resolution is the runtime's own, so the retry schedule, the system bypass and the `onLoadError` policy behave identically: a filter whose `load` fails refuses the read (with `RowFilterUnavailable`, or zero rows under `onLoadError: 'deny'`) rather than quietly returning everything.
385
+
328
386
  ## The deterministic mocks
329
387
 
330
388
  ### `ctx.clock` — `MockClock`
@@ -11,16 +11,16 @@
11
11
  "dependencies": {
12
12
  "@effect/platform": "^0.96.1",
13
13
  "@effect/rpc": "^0.75.1",
14
- "@voltro/ai": "0.6.0",
15
- "@voltro/cli": "0.6.0",
16
- "@voltro/database": "0.6.0",
17
- "@voltro/env": "0.6.0",
18
- "@voltro/protocol": "0.6.0",
19
- "@voltro/runtime": "0.6.0",
14
+ "@voltro/ai": "0.7.0",
15
+ "@voltro/cli": "0.7.0",
16
+ "@voltro/database": "0.7.0",
17
+ "@voltro/env": "0.7.0",
18
+ "@voltro/protocol": "0.7.0",
19
+ "@voltro/runtime": "0.7.0",
20
20
  "effect": "^3.21.2"
21
21
  },
22
22
  "devDependencies": {
23
- "@voltro/testing": "0.6.0",
23
+ "@voltro/testing": "0.7.0",
24
24
  "typescript": "^5.7.0",
25
25
  "vitest": "^3.0.0"
26
26
  }
@@ -12,17 +12,17 @@
12
12
  "dependencies": {
13
13
  "@effect/platform": "^0.96.1",
14
14
  "@effect/rpc": "^0.75.1",
15
- "@voltro/cli": "0.6.0",
16
- "@voltro/database": "0.6.0",
17
- "@voltro/env": "0.6.0",
18
- "@voltro/plugin-auth": "0.6.0",
19
- "@voltro/protocol": "0.6.0",
20
- "@voltro/runtime": "0.6.0",
21
- "@voltro/sql-postgres": "0.6.0",
15
+ "@voltro/cli": "0.7.0",
16
+ "@voltro/database": "0.7.0",
17
+ "@voltro/env": "0.7.0",
18
+ "@voltro/plugin-auth": "0.7.0",
19
+ "@voltro/protocol": "0.7.0",
20
+ "@voltro/runtime": "0.7.0",
21
+ "@voltro/sql-postgres": "0.7.0",
22
22
  "effect": "^3.21.2"
23
23
  },
24
24
  "devDependencies": {
25
- "@voltro/testing": "0.6.0",
25
+ "@voltro/testing": "0.7.0",
26
26
  "typescript": "^5.7.0",
27
27
  "vitest": "^3.0.0"
28
28
  }
@@ -12,16 +12,16 @@
12
12
  "dependencies": {
13
13
  "@effect/platform": "^0.96.1",
14
14
  "@effect/rpc": "^0.75.1",
15
- "@voltro/cli": "0.6.0",
16
- "@voltro/database": "0.6.0",
17
- "@voltro/env": "0.6.0",
18
- "@voltro/plugin-multitenancy": "0.6.0",
19
- "@voltro/protocol": "0.6.0",
20
- "@voltro/runtime": "0.6.0",
15
+ "@voltro/cli": "0.7.0",
16
+ "@voltro/database": "0.7.0",
17
+ "@voltro/env": "0.7.0",
18
+ "@voltro/plugin-multitenancy": "0.7.0",
19
+ "@voltro/protocol": "0.7.0",
20
+ "@voltro/runtime": "0.7.0",
21
21
  "effect": "^3.21.2"
22
22
  },
23
23
  "devDependencies": {
24
- "@voltro/testing": "0.6.0",
24
+ "@voltro/testing": "0.7.0",
25
25
  "typescript": "^5.7.0",
26
26
  "vitest": "^3.0.0"
27
27
  }
@@ -12,16 +12,16 @@
12
12
  "dependencies": {
13
13
  "@effect/platform": "^0.96.1",
14
14
  "@effect/rpc": "^0.75.1",
15
- "@voltro/cli": "0.6.0",
16
- "@voltro/database": "0.6.0",
17
- "@voltro/env": "0.6.0",
18
- "@voltro/plugin-deactivation": "0.6.0",
19
- "@voltro/protocol": "0.6.0",
20
- "@voltro/runtime": "0.6.0",
15
+ "@voltro/cli": "0.7.0",
16
+ "@voltro/database": "0.7.0",
17
+ "@voltro/env": "0.7.0",
18
+ "@voltro/plugin-deactivation": "0.7.0",
19
+ "@voltro/protocol": "0.7.0",
20
+ "@voltro/runtime": "0.7.0",
21
21
  "effect": "^3.21.2"
22
22
  },
23
23
  "devDependencies": {
24
- "@voltro/testing": "0.6.0",
24
+ "@voltro/testing": "0.7.0",
25
25
  "typescript": "^5.7.0",
26
26
  "vitest": "^3.0.0"
27
27
  }
@@ -12,18 +12,18 @@
12
12
  "dependencies": {
13
13
  "@react-email/components": "^1.0.12",
14
14
  "@react-email/render": "^1.4.0",
15
- "@voltro/cli": "0.6.0",
16
- "@voltro/database": "0.6.0",
17
- "@voltro/env": "0.6.0",
18
- "@voltro/plugin-mail": "0.6.0",
19
- "@voltro/plugin-multitenancy": "0.6.0",
20
- "@voltro/protocol": "0.6.0",
21
- "@voltro/runtime": "0.6.0",
15
+ "@voltro/cli": "0.7.0",
16
+ "@voltro/database": "0.7.0",
17
+ "@voltro/env": "0.7.0",
18
+ "@voltro/plugin-mail": "0.7.0",
19
+ "@voltro/plugin-multitenancy": "0.7.0",
20
+ "@voltro/protocol": "0.7.0",
21
+ "@voltro/runtime": "0.7.0",
22
22
  "effect": "^3.21.2",
23
23
  "react": "^19.0.0"
24
24
  },
25
25
  "devDependencies": {
26
- "@voltro/testing": "0.6.0",
26
+ "@voltro/testing": "0.7.0",
27
27
  "typescript": "^5.7.0",
28
28
  "vitest": "^3.0.0"
29
29
  }
@@ -12,18 +12,18 @@
12
12
  "dependencies": {
13
13
  "@effect/platform": "^0.96.1",
14
14
  "@effect/rpc": "^0.75.1",
15
- "@voltro/cli": "0.6.0",
16
- "@voltro/database": "0.6.0",
17
- "@voltro/env": "0.6.0",
18
- "@voltro/plugin-multitenancy": "0.6.0",
19
- "@voltro/plugin-storage": "0.6.0",
20
- "@voltro/protocol": "0.6.0",
21
- "@voltro/runtime": "0.6.0",
22
- "@voltro/sql-mysql": "0.6.0",
15
+ "@voltro/cli": "0.7.0",
16
+ "@voltro/database": "0.7.0",
17
+ "@voltro/env": "0.7.0",
18
+ "@voltro/plugin-multitenancy": "0.7.0",
19
+ "@voltro/plugin-storage": "0.7.0",
20
+ "@voltro/protocol": "0.7.0",
21
+ "@voltro/runtime": "0.7.0",
22
+ "@voltro/sql-mysql": "0.7.0",
23
23
  "effect": "^3.21.2"
24
24
  },
25
25
  "devDependencies": {
26
- "@voltro/testing": "0.6.0",
26
+ "@voltro/testing": "0.7.0",
27
27
  "typescript": "^5.7.0",
28
28
  "vitest": "^3.0.0"
29
29
  }
@@ -10,17 +10,17 @@
10
10
  "test": "voltro test"
11
11
  },
12
12
  "dependencies": {
13
- "@voltro/cli": "0.6.0",
14
- "@voltro/database": "0.6.0",
15
- "@voltro/env": "0.6.0",
16
- "@voltro/plugin-multitenancy": "0.6.0",
17
- "@voltro/plugin-storage": "0.6.0",
18
- "@voltro/protocol": "0.6.0",
19
- "@voltro/runtime": "0.6.0",
13
+ "@voltro/cli": "0.7.0",
14
+ "@voltro/database": "0.7.0",
15
+ "@voltro/env": "0.7.0",
16
+ "@voltro/plugin-multitenancy": "0.7.0",
17
+ "@voltro/plugin-storage": "0.7.0",
18
+ "@voltro/protocol": "0.7.0",
19
+ "@voltro/runtime": "0.7.0",
20
20
  "effect": "^3.21.2"
21
21
  },
22
22
  "devDependencies": {
23
- "@voltro/testing": "0.6.0",
23
+ "@voltro/testing": "0.7.0",
24
24
  "typescript": "^5.7.0",
25
25
  "vitest": "^3.0.0"
26
26
  }
@@ -11,17 +11,17 @@
11
11
  "dependencies": {
12
12
  "@effect/platform": "^0.96.1",
13
13
  "@effect/rpc": "^0.75.1",
14
- "@voltro/cli": "0.6.0",
15
- "@voltro/database": "0.6.0",
16
- "@voltro/env": "0.6.0",
17
- "@voltro/plugin-governance": "0.6.0",
18
- "@voltro/plugin-multitenancy": "0.6.0",
19
- "@voltro/protocol": "0.6.0",
20
- "@voltro/runtime": "0.6.0",
14
+ "@voltro/cli": "0.7.0",
15
+ "@voltro/database": "0.7.0",
16
+ "@voltro/env": "0.7.0",
17
+ "@voltro/plugin-governance": "0.7.0",
18
+ "@voltro/plugin-multitenancy": "0.7.0",
19
+ "@voltro/protocol": "0.7.0",
20
+ "@voltro/runtime": "0.7.0",
21
21
  "effect": "^3.21.2"
22
22
  },
23
23
  "devDependencies": {
24
- "@voltro/testing": "0.6.0",
24
+ "@voltro/testing": "0.7.0",
25
25
  "typescript": "^5.7.0",
26
26
  "vitest": "^3.0.0"
27
27
  }