@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.
- package/CHANGELOG.md +20 -0
- package/bin/voltro.mjs +15 -6
- package/dist/apiBuild-CSM74oEZ.js +2 -0
- package/dist/{apiBuild-BxY44VGj.js → apiBuild-deIjJKJm.js} +21 -20
- package/dist/bin.js +2 -2
- package/dist/{commands-My_YlUJV.js → commands-BHdc71DG.js} +2323 -2946
- package/dist/dev-BYfrYwrT.js +2 -0
- package/dist/dev-Bqyq62Ak.js +6492 -0
- package/dist/index.js +1 -1
- package/dist/inspectMetrics-BLUBAWLb.js +3711 -0
- package/dist/{serveCommand-C-XQ89NJ.js → serveCommand-Dn6--0Bv.js} +209 -208
- package/dist/serveEntry.js +3 -2
- package/dist/start-CWZW_4Et.js +941 -0
- package/dist/startEntry.d.ts +5 -0
- package/dist/startEntry.js +3 -0
- package/package.json +17 -17
- package/templates/agent-docs/authentication.md +104 -9
- package/templates/agent-docs/cli.md +59 -2
- package/templates/agent-docs/data.md +44 -0
- package/templates/agent-docs/database/schema.md +1 -1
- package/templates/agent-docs/testing.md +58 -0
- package/templates/apps/api-ai/package.json +7 -7
- package/templates/apps/api-auth/package.json +8 -8
- package/templates/apps/api-backend/package.json +7 -7
- package/templates/apps/api-backend-deactivation/package.json +7 -7
- package/templates/apps/api-backend-mail/package.json +8 -8
- package/templates/apps/api-backend-mariadb/package.json +9 -9
- package/templates/apps/api-backend-storage/package.json +8 -8
- package/templates/apps/api-data-advanced/package.json +8 -8
- package/templates/apps/api-durable/package.json +8 -8
- package/templates/apps/api-feature-flags/package.json +9 -9
- package/templates/apps/api-governance/package.json +8 -8
- package/templates/apps/api-kv/package.json +8 -8
- package/templates/apps/api-moderation/package.json +8 -8
- package/templates/apps/api-observability/package.json +8 -8
- package/templates/apps/api-ratelimit/package.json +8 -8
- package/templates/apps/api-rbac/package.json +8 -8
- package/templates/apps/api-rest/package.json +7 -7
- package/templates/apps/api-saas/package.json +11 -11
- package/templates/apps/api-search/package.json +8 -8
- package/templates/apps/api-versioning/package.json +8 -8
- package/templates/apps/api-webhooks/package.json +8 -8
- package/templates/apps/changelog/package.json +6 -6
- package/templates/apps/edge-functions/package.json +2 -2
- package/templates/apps/frontend-admin/package.json +8 -8
- package/templates/apps/frontend-app/package.json +8 -8
- package/templates/apps/frontend-blank/package.json +7 -7
- package/templates/apps/frontend-contact/package.json +7 -7
- package/templates/apps/frontend-dashboard/package.json +7 -7
- package/templates/apps/frontend-docs/package.json +7 -7
- package/templates/apps/frontend-i18n/package.json +6 -6
- package/templates/apps/frontend-landing/package.json +7 -7
- package/templates/apps/frontend-spa/package.json +7 -7
- package/templates/apps/frontend-ssr/package.json +7 -7
- package/templates/apps/frontend-ssr-api/package.json +8 -8
- package/templates/apps/frontend-static-blog/package.json +6 -6
- package/dist/apiBuild-Bk4JBt-i.js +0 -2
- package/dist/dev-Bjq-nIvH.js +0 -10186
- package/dist/dev-D6b74iXI.js +0 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@voltro/cli",
|
|
3
|
-
"version": "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.
|
|
61
|
-
"@voltro/cache": "0.
|
|
62
|
-
"@voltro/data-transfer": "0.
|
|
63
|
-
"@voltro/database": "0.
|
|
64
|
-
"@voltro/env": "0.
|
|
65
|
-
"@voltro/kv": "0.
|
|
66
|
-
"@voltro/logger": "0.
|
|
67
|
-
"@voltro/plugin-auth": "0.
|
|
68
|
-
"@voltro/plugin-broadcast": "0.
|
|
69
|
-
"@voltro/plugin-mail": "0.
|
|
70
|
-
"@voltro/plugin-storage": "0.
|
|
71
|
-
"@voltro/plugin-webhooks": "0.
|
|
72
|
-
"@voltro/protocol": "0.
|
|
73
|
-
"@voltro/runtime": "0.
|
|
74
|
-
"@voltro/serverless": "0.
|
|
75
|
-
"@voltro/workflow": "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
|
-
##
|
|
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
|
-
|
|
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
|
-
|
|
1986
|
-
|
|
1987
|
-
|
|
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
|
|
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
|
|
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.
|
|
15
|
-
"@voltro/cli": "0.
|
|
16
|
-
"@voltro/database": "0.
|
|
17
|
-
"@voltro/env": "0.
|
|
18
|
-
"@voltro/protocol": "0.
|
|
19
|
-
"@voltro/runtime": "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.
|
|
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.
|
|
16
|
-
"@voltro/database": "0.
|
|
17
|
-
"@voltro/env": "0.
|
|
18
|
-
"@voltro/plugin-auth": "0.
|
|
19
|
-
"@voltro/protocol": "0.
|
|
20
|
-
"@voltro/runtime": "0.
|
|
21
|
-
"@voltro/sql-postgres": "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.
|
|
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.
|
|
16
|
-
"@voltro/database": "0.
|
|
17
|
-
"@voltro/env": "0.
|
|
18
|
-
"@voltro/plugin-multitenancy": "0.
|
|
19
|
-
"@voltro/protocol": "0.
|
|
20
|
-
"@voltro/runtime": "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.
|
|
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.
|
|
16
|
-
"@voltro/database": "0.
|
|
17
|
-
"@voltro/env": "0.
|
|
18
|
-
"@voltro/plugin-deactivation": "0.
|
|
19
|
-
"@voltro/protocol": "0.
|
|
20
|
-
"@voltro/runtime": "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.
|
|
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.
|
|
16
|
-
"@voltro/database": "0.
|
|
17
|
-
"@voltro/env": "0.
|
|
18
|
-
"@voltro/plugin-mail": "0.
|
|
19
|
-
"@voltro/plugin-multitenancy": "0.
|
|
20
|
-
"@voltro/protocol": "0.
|
|
21
|
-
"@voltro/runtime": "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.
|
|
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.
|
|
16
|
-
"@voltro/database": "0.
|
|
17
|
-
"@voltro/env": "0.
|
|
18
|
-
"@voltro/plugin-multitenancy": "0.
|
|
19
|
-
"@voltro/plugin-storage": "0.
|
|
20
|
-
"@voltro/protocol": "0.
|
|
21
|
-
"@voltro/runtime": "0.
|
|
22
|
-
"@voltro/sql-mysql": "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.
|
|
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.
|
|
14
|
-
"@voltro/database": "0.
|
|
15
|
-
"@voltro/env": "0.
|
|
16
|
-
"@voltro/plugin-multitenancy": "0.
|
|
17
|
-
"@voltro/plugin-storage": "0.
|
|
18
|
-
"@voltro/protocol": "0.
|
|
19
|
-
"@voltro/runtime": "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.
|
|
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.
|
|
15
|
-
"@voltro/database": "0.
|
|
16
|
-
"@voltro/env": "0.
|
|
17
|
-
"@voltro/plugin-governance": "0.
|
|
18
|
-
"@voltro/plugin-multitenancy": "0.
|
|
19
|
-
"@voltro/protocol": "0.
|
|
20
|
-
"@voltro/runtime": "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.
|
|
24
|
+
"@voltro/testing": "0.7.0",
|
|
25
25
|
"typescript": "^5.7.0",
|
|
26
26
|
"vitest": "^3.0.0"
|
|
27
27
|
}
|