@voltro/cli 0.11.3 → 0.11.4

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 (64) hide show
  1. package/CHANGELOG.md +173 -0
  2. package/THIRD-PARTY-NOTICES.md +4 -4
  3. package/dist/apiBuild-D9MtlD02.js +2 -0
  4. package/dist/{apiBuild-zFDv0u8y.js → apiBuild-DHHuiwXF.js} +4 -4
  5. package/dist/bin.js +13 -6
  6. package/dist/{commands-BDGYrBhK.js → commands-BwbMqR-1.js} +1982 -1854
  7. package/dist/dev-BsBsIv93.js +2 -0
  8. package/dist/{dev-CGt0PP1f.js → dev-Cvojvb_J.js} +1696 -1654
  9. package/dist/{devActivity-1WtIVyHc.js → devActivity-BhIu6ncs.js} +7 -1
  10. package/dist/devActivity.js +1 -1
  11. package/dist/index.d.ts +3 -0
  12. package/dist/index.js +1 -1
  13. package/dist/{inspectMetrics-SRtv8KDy.js → inspectMetrics-D4EJ8LTi.js} +331 -323
  14. package/dist/{serveCommand-p6e5Ahgx.js → serveCommand-ARqSs73o.js} +397 -350
  15. package/dist/serveEntry.js +3 -3
  16. package/dist/{start-Crl39M38.js → start-DyJE6w1R.js} +281 -273
  17. package/dist/startEntry.js +2 -2
  18. package/package.json +17 -17
  19. package/templates/agent-docs/_manifest.json +1 -1
  20. package/templates/agent-docs/cli.md +86 -2
  21. package/templates/agent-docs/data.md +104 -1
  22. package/templates/agent-docs/database/misc.md +32 -0
  23. package/templates/agent-docs/database/querying.md +14 -1
  24. package/templates/agent-docs/internationalization.md +3 -8
  25. package/templates/agent-docs/reference.md +35 -0
  26. package/templates/agent-docs/workflows.md +31 -0
  27. package/templates/apps/api-ai/package.json +7 -7
  28. package/templates/apps/api-auth/package.json +8 -8
  29. package/templates/apps/api-backend/package.json +7 -7
  30. package/templates/apps/api-backend-deactivation/package.json +7 -7
  31. package/templates/apps/api-backend-mail/package.json +8 -8
  32. package/templates/apps/api-backend-mariadb/package.json +9 -9
  33. package/templates/apps/api-backend-storage/package.json +8 -8
  34. package/templates/apps/api-data-advanced/package.json +8 -8
  35. package/templates/apps/api-durable/package.json +8 -8
  36. package/templates/apps/api-feature-flags/package.json +9 -9
  37. package/templates/apps/api-governance/package.json +8 -8
  38. package/templates/apps/api-kv/package.json +8 -8
  39. package/templates/apps/api-moderation/package.json +8 -8
  40. package/templates/apps/api-observability/package.json +8 -8
  41. package/templates/apps/api-ratelimit/package.json +8 -8
  42. package/templates/apps/api-rbac/package.json +8 -8
  43. package/templates/apps/api-rest/package.json +7 -7
  44. package/templates/apps/api-saas/package.json +11 -11
  45. package/templates/apps/api-search/package.json +8 -8
  46. package/templates/apps/api-versioning/package.json +8 -8
  47. package/templates/apps/api-webhooks/README.md +3 -1
  48. package/templates/apps/api-webhooks/package.json +8 -8
  49. package/templates/apps/changelog/package.json +6 -6
  50. package/templates/apps/edge-functions/package.json +2 -2
  51. package/templates/apps/frontend-admin/package.json +8 -8
  52. package/templates/apps/frontend-app/package.json +8 -8
  53. package/templates/apps/frontend-blank/package.json +7 -7
  54. package/templates/apps/frontend-contact/package.json +7 -7
  55. package/templates/apps/frontend-dashboard/package.json +7 -7
  56. package/templates/apps/frontend-docs/package.json +7 -7
  57. package/templates/apps/frontend-i18n/package.json +6 -6
  58. package/templates/apps/frontend-landing/package.json +7 -7
  59. package/templates/apps/frontend-spa/package.json +7 -7
  60. package/templates/apps/frontend-ssr/package.json +7 -7
  61. package/templates/apps/frontend-ssr-api/package.json +8 -8
  62. package/templates/apps/frontend-static-blog/package.json +6 -6
  63. package/dist/apiBuild-BmPdmhgi.js +0 -2
  64. package/dist/dev-D_PGP9Kx.js +0 -2
@@ -1,3 +1,3 @@
1
- import { bt as e } from "./inspectMetrics-SRtv8KDy.js";
2
- import { t } from "./start-Crl39M38.js";
1
+ import { xt as e } from "./inspectMetrics-D4EJ8LTi.js";
2
+ import { t } from "./start-DyJE6w1R.js";
3
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.11.3",
3
+ "version": "0.11.4",
4
4
  "description": "The `voltro` CLI — dev server, codegen, migrations, project scaffolding, agent-docs seeding, and production serve.",
5
5
  "keywords": [
6
6
  "voltro",
@@ -62,22 +62,22 @@
62
62
  "@effect/platform-node": "^0.107.0",
63
63
  "@effect/sql": "^0.51.1",
64
64
  "@effect/workflow": "^0.18.2",
65
- "@voltro/ai": "0.11.3",
66
- "@voltro/cache": "0.11.3",
67
- "@voltro/data-transfer": "0.11.3",
68
- "@voltro/database": "0.11.3",
69
- "@voltro/env": "0.11.3",
70
- "@voltro/kv": "0.11.3",
71
- "@voltro/logger": "0.11.3",
72
- "@voltro/plugin-auth": "0.11.3",
73
- "@voltro/plugin-broadcast": "0.11.3",
74
- "@voltro/plugin-mail": "0.11.3",
75
- "@voltro/plugin-storage": "0.11.3",
76
- "@voltro/plugin-webhooks": "0.11.3",
77
- "@voltro/protocol": "0.11.3",
78
- "@voltro/runtime": "0.11.3",
79
- "@voltro/serverless": "0.11.3",
80
- "@voltro/workflow": "0.11.3",
65
+ "@voltro/ai": "0.11.4",
66
+ "@voltro/cache": "0.11.4",
67
+ "@voltro/data-transfer": "0.11.4",
68
+ "@voltro/database": "0.11.4",
69
+ "@voltro/env": "0.11.4",
70
+ "@voltro/kv": "0.11.4",
71
+ "@voltro/logger": "0.11.4",
72
+ "@voltro/plugin-auth": "0.11.4",
73
+ "@voltro/plugin-broadcast": "0.11.4",
74
+ "@voltro/plugin-mail": "0.11.4",
75
+ "@voltro/plugin-storage": "0.11.4",
76
+ "@voltro/plugin-webhooks": "0.11.4",
77
+ "@voltro/protocol": "0.11.4",
78
+ "@voltro/runtime": "0.11.4",
79
+ "@voltro/serverless": "0.11.4",
80
+ "@voltro/workflow": "0.11.4",
81
81
  "chokidar": "^5.0.0",
82
82
  "ioredis": "^5.11.1",
83
83
  "tinyglobby": "^0.2.17",
@@ -35,7 +35,7 @@
35
35
  "group": null,
36
36
  "description": "The voltro CLI — every command, grouped by purpose, with the flags that actually matter.",
37
37
  "path": "agent-docs/cli.md",
38
- "files": 9
38
+ "files": 10
39
39
  },
40
40
  {
41
41
  "id": "configuration",
@@ -21,9 +21,9 @@ The dispatcher routes `voltro <command> [args]` to the matching subcommand and p
21
21
  |---|---|
22
22
  | [Scaffolding](/docs/cli/scaffolding) | `create-project`, `add-app`, `list-templates` |
23
23
  | Packages | `package` (`create` / `publishable` / `private` / `status`), `create-package` |
24
- | [Dev](/docs/cli/dev) | `dev`, `codegen`, `agents-md` |
24
+ | [Dev](/docs/cli/dev) | `dev`, `codegen`, `agents-md`, [`env`](/docs/cli/env) (`check` / `sync` / `types` / `turbo`), [`generate`](/docs/cli/scaffolding) (AI app-builder), `dashboard` (serve the DevTools dashboard standalone; `--port`, `VOLTRO_DASHBOARD_APPS`) |
25
25
  | [Build & run](/docs/cli/build-and-start) | `build`, `start`, `serve` |
26
- | Deploy | `deploy` (`plan` — auto-detect the target tier per app + function), [`serverless`](/docs/deployment/serverless-functions) (`list` / `dev` / `serve` / `build` / `deploy`), [`static`](/docs/deployment/static-sites) (`hosts` / `deploy`) |
26
+ | Deploy | `deploy` (`plan` — auto-detect the target tier per app + function), [`serverless`](/docs/deployment/serverless-functions) (`list` / `dev` / `serve` / `build` / `deploy`), [`static`](/docs/deployment/static-sites) (`hosts` / `deploy`), `dormancy` (single-node scale-to-zero: fronts the app, stops it when idle, wakes on the next request; `--idle-grace-ms` / `--tick-ms`) |
27
27
  | [Database](/docs/cli/migrate) | `migrate`, `db` (`plan` / `apply` / `plans` / `drift` / `squash` / `restore-snapshot` / `migrate` / `rollback` / `status` / `seed`) |
28
28
  | [Update](/docs/cli/update) | `update` (`--to` / `--dry-run` / `--force` / `--exact`) — bump every `@voltro/*`, install, run the codemods that adapt your source to the new version |
29
29
  | [Data transfer](/docs/cli/data) | `data` (`export` / `import` / `unpack` / `inspect` / `backup` / `restore`) — directory + single-file `.vbundle` bundles, streaming assets, masking, at-rest encryption |
@@ -1125,12 +1125,44 @@ voltro inspect rpc # procedures + workflow descript
1125
1125
  voltro inspect metrics # rolling rpc latency buckets
1126
1126
  voltro inspect cache # web data-cache stats (web apps only)
1127
1127
  voltro inspect schedules # cron registrations + coordination mode
1128
+ voltro inspect schedules --failing # only broken crons — exits 1 if any (see below)
1128
1129
  voltro inspect aggregates # materialised aggregate views
1129
1130
  voltro inspect invoke --tag users.list --input '{}' # call a procedure over HTTP
1130
1131
  ```
1131
1132
 
1132
1133
  Flags on every subcommand: `--process <name>` narrows to one api; `--format pretty|json` (default `pretty`). `invoke` additionally takes `--tag <procedureTag>` and `--input <json>`. With no subcommand it prints the endpoint map + the live processes it can reach.
1133
1134
 
1135
+ ### `schedules --failing` — is any cron actually broken?
1136
+
1137
+ A schedule fires unattended: there is no user watching it fail. The plain
1138
+ `schedules` listing answers *which crons exist and when they fire next* — never
1139
+ whether they **work**. `--failing` rolls each schedule's recent runs
1140
+ (`/_voltro/inspect/schedules/runs`) into a verdict and prints only the broken
1141
+ ones:
1142
+
1143
+ ```bash
1144
+ $ voltro inspect schedules --failing
1145
+ # schedules @api
1146
+ sprint.report 0 2 * * * FAILING x87
1147
+ every recorded run failed (last 20)
1148
+ Workflow "sprint.report" was started with an invalid payload. missing required field(s): teamId
1149
+ ```
1150
+
1151
+ It **exits 1 when anything is failing**, so it works as a post-deploy gate and
1152
+ not only as something someone remembers to run:
1153
+
1154
+ ```bash
1155
+ voltro inspect schedules --failing || echo "broken cron — do not promote"
1156
+ ```
1157
+
1158
+ A trailing success ends a streak (a recovered job is not reported), and
1159
+ `skipped` / `missed` runs are ignored — those are coordination outcomes (another
1160
+ pod took the tick, the process was down), not handler verdicts. A schedule that
1161
+ has never run is not "failing".
1162
+
1163
+ Pair it with `voltro logs --level error`: a failing handler now logs at **error**
1164
+ level, so the two surfaces agree.
1165
+
1134
1166
  ## The inspect HTTP surface
1135
1167
 
1136
1168
  Every `voltro dev` / `voltro start` instance exposes a read-only introspection surface under `/_voltro/inspect/*`. The [Voltro Dashboard](/docs/observability/dashboard) consumes it to render the route sitemap, RPC list, subscription panel, workflow runs, and metrics. You can also hit the endpoints directly with `curl` (the `voltro inspect` subcommands above are the thin wrapper over exactly these).
@@ -1235,6 +1267,18 @@ voltro traces --errors --format json | jq '.traces[]'
1235
1267
 
1236
1268
  Workflow: `voltro traces --errors --format json` to find a failure, then `voltro logs --trace <id> --format json` for the full chain (frontend → api → api, in order). The full flag set (`--min-duration`, `--status`, `--process`, …) lives in [Traces & logs from the shell](/docs/observability/cli).
1237
1269
 
1270
+ ## `voltro cluster`
1271
+
1272
+ `voltro cluster status` gives a clustering snapshot of every running api — one row per instance with its `replicaId`, runner address, dialect, CDC flavour, coordination mode, and `server_id`, plus a flag for any SQL runner stuck on `localhost` (a common misconfiguration that silently breaks cross-instance work). Use it to confirm a multi-instance deployment actually formed a cluster rather than N isolated nodes.
1273
+
1274
+ ```bash
1275
+ voltro cluster status # pretty table across every running api
1276
+ voltro cluster status --json # machine-readable (also --format json)
1277
+ voltro cluster status --process api # narrow to one named process
1278
+ ```
1279
+
1280
+ Like the rest of the inspect family it reads the live `/_voltro/inspect/*` surface, so an api has to be running.
1281
+
1238
1282
  ## `voltro test`
1239
1283
 
1240
1284
  ```bash
@@ -2052,3 +2096,43 @@ Use `voltro db apply` (the declarative diff), not `voltro db migrate` (the imper
2052
2096
 
2053
2097
  - [Migrate](/docs/cli/migrate) — schema changes end-to-end
2054
2098
  - [Build & start](/docs/cli/build-and-start) — production paths
2099
+
2100
+
2101
+
2102
+ ---
2103
+
2104
+ <!-- source: en/cli/env.md -->
2105
+ ## Env
2106
+
2107
+ _voltro env — inspect and sync the typed-env manifest; emit env.generated.d.ts so CI tsc knows your env vars._
2108
+
2109
+ `voltro env` works with the **typed-env manifest** — the set of environment variables your app declares (via `envVar(...)`) and the framework requires. It's how a missing or malformed env var becomes a boot-time error with a clear message instead of a mysterious runtime failure.
2110
+
2111
+ ```bash
2112
+ voltro env # check (default) — validate the current env against the manifest
2113
+ voltro env check # explicit
2114
+ voltro env sync # write/update .env.example from the manifest
2115
+ voltro env types # emit env.generated.d.ts (typed process.env for CI tsc)
2116
+ voltro env turbo # emit the turbo globalEnv/globalPassThroughEnv list
2117
+ ```
2118
+
2119
+ ## `check` (default)
2120
+
2121
+ Validates the current environment against the declared manifest and fails (non-zero) on a missing required var or a value that doesn't parse. Run it in a pre-deploy step to catch a misconfigured environment before the app boots. Run with no subcommand and it defaults to `check`.
2122
+
2123
+ ## `sync` — keep `.env.example` honest
2124
+
2125
+ Writes `.env.example` from the manifest so the committed template always matches what the app actually reads — new `envVar(...)` declarations show up without hand-editing.
2126
+
2127
+ ## `types` — typed `process.env` for CI
2128
+
2129
+ Emits `env.generated.d.ts`, which types `process.env` to your declared vars. Commit it (or generate it in CI before `tsc`) so a typo in a `process.env.MY_VAR` reference is a type error, not a runtime `undefined`.
2130
+
2131
+ ## `turbo` — monorepo cache correctness
2132
+
2133
+ Emits the `globalEnv` / `globalPassThroughEnv` entries for `turbo.json`, so Turbo's cache invalidates when a relevant env var changes and passes the right vars through to tasks.
2134
+
2135
+ ## Related
2136
+
2137
+ - [Secrets](/docs/cli/overview#common-env-vars) — generating secret values with `voltro secret`.
2138
+ - [Configuration](/docs/configuration) — declaring env vars with `envVar(...)`.
@@ -1579,6 +1579,61 @@ This is the Stripe-style contract — the **client** opts in by sending the head
1579
1579
  - **Inbound webhooks already dedup** via [`@voltro/plugin-webhooks`](/docs/plugins/webhooks) (provider key + `_voltro_webhook_*`) — don't double-cover them.
1580
1580
  - **Atomic claim, non-atomic completion.** Two concurrent same-key requests resolve to exactly one execution (the `UNIQUE(scope,key)` insert is the arbiter). But the cached response isn't committed in the handler's own transaction — a crash between the handler committing and the record flipping to `completed` leaves the key in-flight (a retry `409`s until the TTL lapses, then re-runs). REST handlers aren't auto-transactional, so this is the honest ceiling.
1581
1581
 
1582
+ ## Projecting an existing procedure — `publicApi`
1583
+
1584
+ You often want to *offer* an API you don't consume from your own frontend. When the procedure already exists as a query / mutation / action, you don't need to rewrite it as a REST route — annotate it with `publicApi` and the framework mounts ONE HTTP route that runs the **same** handler, under the same guards:
1585
+
1586
+ ```ts
1587
+ // queries/absenceRequests.list.query.ts
1588
+ export default defineQuery({
1589
+ name: 'absenceRequests.list',
1590
+ input: Schema.Struct({ status: Schema.optional(Schema.String), limit: Schema.optional(Schema.Number) }),
1591
+ output: Schema.Array(AbsenceRequest),
1592
+ guards: [requireScope('absences:read')],
1593
+ publicApi: {}, // → GET /v1/absenceRequests/list?status=open&limit=20
1594
+ })
1595
+ ```
1596
+
1597
+ - **Method** derives from the kind: query → `GET`, mutation / action → `POST` (override with `method`).
1598
+ - **Path** derives from the tag: `/<version>/<tag-as-path>` (override with `path`; set `version`).
1599
+ - **Input binding** follows the method: for `GET` the descriptor's `input` schema is bound to the **query string**, otherwise to the JSON **body**. So filter and pagination fields are plain URL params — no separate input shape.
1600
+ - **Relations** need nothing extra: eager loading is resolved server-side by the executor, so `include` works identically over HTTP.
1601
+ - **Authorization is the same code** as the WebSocket path — the declarative `guards:`, the row filter, and tenant scoping all run before the handler. A procedure that denies on the socket denies here.
1602
+ - Also available per endpoint: `scopes` (extra API-key scopes), `rateLimit`, `idempotent`.
1603
+
1604
+ This pairs with [`crud.list`](/docs/data/crud): `filter` / `paginate` / `sort` / `include` on the executor plus `publicApi: {}` on the descriptor is a complete, filtered, paginated REST list endpoint in one declaration.
1605
+
1606
+ Use `publicApi` when the procedure already exists and the derived URL is fine; use `defineRestRoute` when you need a hand-shaped URL, path params, or a response that isn't the procedure's output.
1607
+
1608
+ ### Live updates over HTTP — `stream: 'sse'`
1609
+
1610
+ A third party that can't open your WebSocket can still follow changes: `stream: 'sse'` on a QUERY projects it as Server-Sent Events — the initial `snapshot`, then a `delta` per change, until the client disconnects.
1611
+
1612
+ ```ts
1613
+ export default defineQuery({
1614
+ name: 'orders.live',
1615
+ input: Schema.Struct({ status: Schema.optional(Schema.String) }),
1616
+ output: Schema.Array(Order),
1617
+ guards: [requireScope('orders:read')],
1618
+ publicApi: { stream: 'sse' }, // → GET /v1/orders/live?status=open (text/event-stream)
1619
+ })
1620
+ ```
1621
+
1622
+ ```js
1623
+ // any EventSource client — no Voltro SDK needed
1624
+ const es = new EventSource('/v1/orders/live?status=open')
1625
+ es.addEventListener('snapshot', (e) => setRows(JSON.parse(e.data).data))
1626
+ es.addEventListener('delta', (e) => applyDelta(JSON.parse(e.data)))
1627
+ ```
1628
+
1629
+ Each event's `_tag` becomes the SSE `event:` name, so a client listens per kind instead of switching on a payload field. The framing handles the details that bite otherwise: embedded newlines are split across `data:` lines (a raw `\n` would truncate the event), a `retry:` hint is sent, and a keep-alive comment goes out every 15s so proxies don't drop an idle stream.
1630
+
1631
+ Same guarantees as the WebSocket path, because it is the same code: the declarative `guards:`, the row filter and tenant scoping all run before anything is emitted, and the client's disconnect tears the subscription down (including a disconnect *during* setup). A guard denial arrives as one `error` event rather than an HTTP status — by then the response headers are already sent.
1632
+
1633
+ `stream: 'sse'` on a mutation or action is ignored: there is nothing to subscribe to.
1634
+
1635
+ For a hand-written `defineRestRoute`, the same machinery is available directly — return `sse((emit) => unsubscribe)` from the handler and frame events with `sseFrame(event, data)` (both from `@voltro/protocol/rest`).
1636
+
1582
1637
  ## REST route vs Action
1583
1638
 
1584
1639
  Both are unary request/response. Pick by transport + audience:
@@ -1981,7 +2036,7 @@ export default defineQuery({
1981
2036
  ## What the defaults bake in
1982
2037
 
1983
2038
  - **Tenant scope.** `crud.list` and `crud.getById` read through `ctx.store`, which auto-scopes a `tenant()` table. They never call `.unscoped()`, so a cross-tenant read is impossible through them — `payslips.list` cannot return another tenant's rows.
1984
- - **Redaction.** `redact` names columns stripped from every returned row — a credential, a token hash, a salary that a generated read must never ship. It applies to reads and to the row a `create` / `update` echoes back. Declare the same omission in the descriptor's `output` schema so the column never reaches the client at all; the helper is the runtime guarantee that it doesn't, whatever the schema says.
2039
+ - **Redaction.** A column a generated read must never ship — a credential, a token hash, a salary is stripped from every returned row (reads and the row a `create` / `update` echoes). Two sources: a column marked [`.serverOnly()`](/docs/database/sensitivity) is stripped **automatically** (declare the exposure policy once at the schema and every crud read respects it — the single-source form), plus the per-call `redact: [...]` option for anything not worth a schema marker. Declare the same omission in the descriptor's `output` schema so the column never reaches the client at all; the helper is the runtime guarantee that it doesn't, whatever the schema says.
1985
2040
  - **`getById` returns `null`, never throws.** A reactive getter that throws takes its shared-WebSocket siblings down with it. `crud.getById` resolves `null` for an absent row.
1986
2041
 
1987
2042
  ## The helpers
@@ -1993,9 +2048,57 @@ export default defineQuery({
1993
2048
  | `crud.create(table, { redact? })` | insert `input`; id/tenant/audit auto-stamped; echoes the redacted row |
1994
2049
  | `crud.update(table, { redact? })` | patch `{ id, ...patch }`; returns the updated row or `null` |
1995
2050
  | `crud.remove(table)` | delete `input.id`; returns `{ deleted }` |
2051
+ | `crud.count(table, { filter? })` | `COUNT(*)` of the filtered, tenant-scoped set — the total for page-based UIs |
1996
2052
 
1997
2053
  `redactColumns(rows, cols)` is exported standalone for a hand-written handler that isn't plain CRUD but still needs to redact declaratively.
1998
2054
 
2055
+ ## `crud.list` read ergonomics — filter, sort, paginate, include
2056
+
2057
+ A generated list isn't limited to "all rows". `crud.list` takes the ergonomics every real list view needs — all optional and additive on top of `redact`:
2058
+
2059
+ ```ts
2060
+ export default crud.list('absenceRequests', {
2061
+ filter: (input) => ({ employeeId: input.employeeId, status: input.status }), // → WHERE
2062
+ paginate: true, // ?page=3&pageSize=20 (or ?limit=20&offset=40)
2063
+ sort: [{ column: 'createdAt', direction: 'desc' }], // multi-column
2064
+ include: { employee: { with: { team: true } } }, // eager relations, nested filter/sort
2065
+ redact: ['internalNote'],
2066
+ })
2067
+ ```
2068
+
2069
+ - **`filter`** maps the request input to a `WHERE` — return a column→value map; an `undefined` field is ignored (an absent filter param is a no-op). Applied through the tenant-scoped `.where`.
2070
+ - **`paginate: true`** accepts BOTH paging styles, so a caller uses whichever its UI thinks in: **`page`** (1-based) + **`pageSize`** (default 100), or **`limit`** / **`offset`** (defaults 100 / 0). `page` wins when both are sent, and a `page` below 1 clamps to the first page rather than producing a negative offset. For the total a page-based UI needs ("page 3 of 12"), pair it with `crud.count` — see below.
2071
+ - **`maxPageSize`** caps how many rows ONE request may ask for (default **1000**). The page size is caller-controlled, so without a cap `?limit=1000000` is a one-request read of the whole table — and with `publicApi` that caller is anyone who can reach the URL. Values above the cap are clamped, not rejected; raise it deliberately for an export-style endpoint.
2072
+ - **`sort`** is a multi-column `orderBy`, applied in order.
2073
+ - **`include`** is the SAME spec [`.with(...)`](/docs/database/relations) takes, so nested relations and per-branch `where` / `orderBy` / `limit` (nested filtering and sort) all work. `getById` takes `include` too.
2074
+
2075
+
2076
+ Pass `crud.count` the **same** `filter` as the list (share the option object) so the total and the pages can't disagree about which rows they mean. It ignores paging fields on the input — it counts the whole filtered set, not the current page:
2077
+
2078
+ ```ts
2079
+ // the total, for rendering "page 3 of 12" — SAME filter as the list
2080
+ export default crud.count('absenceRequests', {
2081
+ filter: (input) => ({ employeeId: input.employeeId, status: input.status }),
2082
+ })
2083
+ ```
2084
+
2085
+ Declare the filter / pagination fields in the descriptor's `input` schema so the client can pass them; the executor reads them off `input`.
2086
+
2087
+ **You don't need a `toView` projection layer** — the `output` schema already shapes the wire result. `crud.list` returns full rows, and on encode Effect **strips every column the output schema doesn't declare** (a tight `Schema.Struct({ id, name })` ships only `id` + `name`, whatever else the row holds), and a `timestampMs` field normalizes a `Date` to epoch ms. So select fields by naming them in `output`, and normalize dates with the wire field schemas — no per-table view function. (Renames are expressible via a Schema transform if you need them.)
2088
+
2089
+ ### `columns` — don't even READ what you drop
2090
+
2091
+ The output schema stops a column reaching the client; `columns` stops it being read at all. Use it when a table carries something wide that a list view never shows — a long text body, a big `json()` blob:
2092
+
2093
+ ```ts
2094
+ export default crud.list('articles', { columns: ['id', 'title', 'createdAt'] })
2095
+ // the large `body` is never SELECTed, transferred from the DB, or decoded
2096
+ ```
2097
+
2098
+ `.serverOnly()` columns are removed from the projection automatically — they're stripped from the response anyway, so reading them is pure waste.
2099
+
2100
+ **Trap:** an eager `include` branch joins on a foreign key, so a projection that omits that FK breaks the relation. Keep the FK in `columns` when you also pass `include`.
2101
+
1999
2102
  ## What they deliberately don't do — authorization
2000
2103
 
2001
2104
  A guard runs *before* the executor, so gating lives on the **descriptor**, not the handler — an executor cannot gate itself. Keep every write descriptor guarded:
@@ -225,6 +225,38 @@ Precedence, exactly:
225
225
  3. **`.safe()`** on an encrypted column suppresses the implied secret and copies
226
226
  it verbatim — say this only when you have genuinely reviewed the plaintext.
227
227
 
228
+ ## `.serverOnly()` — never to a client
229
+
230
+ A THIRD, independent axis. `.sensitive()` / `.safe()` are about **data export
231
+ masking**; `.encrypted()` is about **storage at rest**; `.serverOnly()` is about
232
+ **wire exposure** — a column marked `.serverOnly()` is read normally by server
233
+ code but must NEVER be serialized to a client:
234
+
235
+ ```ts
236
+ keyHash: text().serverOnly(), // an auth middleware verifies it; a client never sees it
237
+ ```
238
+
239
+ The three are orthogonal — a column can carry any combination:
240
+
241
+ ```ts
242
+ keyHash: text().serverOnly(), // a hash you never ship (not secret at rest — it IS the digest)
243
+ recoveryNote: text().encrypted(), // encrypted at rest, but the owner may read it → not serverOnly
244
+ apiToken: text().encrypted().serverOnly(), // secret at rest AND never to a client
245
+ ```
246
+
247
+ Enforcement: the [`crud.*` read helpers](/docs/data/crud) strip `.serverOnly()`
248
+ columns from every returned row **automatically** — you declare the exposure
249
+ policy once at the schema and can't forget it on a handler. For a hand-written
250
+ query, omit the column from the `output` schema (and don't put it in the returned
251
+ object) — and `voltro dev` **warns at boot**, naming the query and column, if a
252
+ wire-reachable query's output declares a `.serverOnly()` column of its `source`
253
+ table, so a secret can't ship on the wire by omission-mistake.
254
+
255
+ Distinct from `.encrypted()` on purpose: encryption at rest says nothing about
256
+ who may receive the plaintext — a private note you decrypt *for its owner* is a
257
+ valid case, so treating "encrypted" as "never to a client" would be wrong. State
258
+ the exposure policy explicitly.
259
+
228
260
  ## Worked example
229
261
 
230
262
  ```ts
@@ -1058,7 +1058,20 @@ const orgIds = await ctx.store.links('org_memberships', { userId: user.id }).lis
1058
1058
 
1059
1059
  `set(targetIds)` writes only the **difference**: the missing links are inserted, the surplus deleted, and links that are already correct are left untouched — so a reactive consumer sees a change only for what actually changed, and it returns `{ added, removed }`. `add` and `remove` read first and act only on the genuine delta, so both are idempotent.
1060
1060
 
1061
- The **target column** is the junction's *other* `reference()` column — the one the anchor doesn't name. A junction with anything but exactly two reference columns is refused (write it by hand with `insertMany` / `deleteMany`). The writes go through the normal store path, so a junction that carries `tenant()` / `audit()` gets those columns stamped as usual. A junction with extra business columns (a membership `role`, a tag `order`) needs those set per row — `links` only manages the two FK columns, so insert those rows directly.
1061
+ The **target column** is the junction's *other* `reference()` column — the one the anchor doesn't name. A junction with anything but exactly two reference columns is refused (write it by hand with `insertMany` / `deleteMany`). The writes go through the normal store path, so a junction that carries `tenant()` / `audit()` gets those columns stamped as usual.
1062
+
1063
+ `set` / `add` / `remove` manage only the two FK columns. For a junction that carries **per-row payload** — a membership `role`, a `capacity` value — use `setRows`, which diffs on the (source, target) pair AND updates the payload:
1064
+
1065
+ ```ts
1066
+ await ctx.store.links('team_capacities', { teamId: team.id }).setRows([
1067
+ { projectId: 'p1', capacity: 40 },
1068
+ { projectId: 'p2', capacity: 20 },
1069
+ ])
1070
+ // added rows inserted with payload, removed deleted, a surviving row whose payload
1071
+ // CHANGED is updated, an unchanged one is left untouched → { added, removed, updated }
1072
+ ```
1073
+
1074
+ Only rows whose payload actually differs are written, so a reactive consumer sees a change exactly where the data changed — the diff-based replacement for a drop-and-reinsert on a data-carrying junction. Payload is compared by strict per-column equality (scalars like `capacity` / `role`).
1062
1075
 
1063
1076
  ## SQL shape
1064
1077
 
@@ -178,18 +178,13 @@ useT('does.not.exist') // ✗ compile error — not a catalog key
178
178
 
179
179
  It's almost pure type refinement — the runtime is the same `useT` / `useTFn` / `<T>` (plus a `.dynamic` escape, below), only the signatures narrow to your catalog. Requires the base catalog to be `as const` (so its message strings survive as literal types).
180
180
 
181
- **Scope.** Simple `{name}` and single-argument `{count, number}` placeholders are extracted and required. For a *nested* inline ICU message (`{count, plural, one {…} other {…}}` / `select`), the **top-level arg** (`count`) is required and a branch's inner text is NOT mistaken for a var so `t('duration', { count })` typechecks and renders. A REAL var nested inside a branch (`other {# blockers in {discipline}}`) is not collected, so it reads as *not required* rather than wrongly required; pass it via the loose values bag.
182
-
183
- To get that nested var **compiler-required**, split the plural into simple sibling keys and pick with a JS branch — each key is then a plain message where every var is top-level:
181
+ **Scope.** Simple `{name}` and single-argument `{count, number}` placeholders are extracted and required. A *nested* inline ICU message (`{count, plural, one {…} other {…}}` / `select`) is fully parsed: the **top-level arg** (`count`) AND a real var nested inside a branch are both required, while a branch's literal text is never mistaken for a var. So for `'{count, plural, one {# blocker in {discipline}} other {# blockers in {discipline}}}'`, `t('blockers', { count, discipline })` is required omit `discipline` and it's a compile error, not a render-time throw:
184
182
 
185
183
  ```tsx
186
- // catalog: 'blocker.one': '{count} blocker in {discipline}', 'blocker.other': '{count} blockers in {discipline}'
187
- count === 1
188
- ? t('blocker.one', { count, discipline }) // ✓ both vars required
189
- : t('blocker.other', { count, discipline })
184
+ t('blockers', { count: n, discipline }) // both required discipline lives inside the branches
190
185
  ```
191
186
 
192
- (The [`plural()` helper](/docs/i18n/formatting) only substitutes `{count}`, so it doesn't type or fill a second var — use it for count-only messages.) `<T>` gets a typed key with loose values, because its rich-text `<tag>` renderers can't be modelled by placeholder extraction.
187
+ `<T>` gets a typed key with loose values, because its rich-text `<tag>` renderers can't be modelled by placeholder extraction.
193
188
 
194
189
  ### Runtime-computed keys — `t.dynamic`
195
190
 
@@ -985,6 +985,41 @@ export default (input: { prompt: string }) =>
985
985
  })
986
986
  ```
987
987
 
988
+ ### Writing a wrapper that PROVIDES a service
989
+
990
+ A helper that provides a service must be generic over `R` and **subtract** the tag
991
+ it provides, or it silently narrows what callers may pass it. The failure mode is
992
+ confusing because it shows up at the CALL site, not in the wrapper:
993
+
994
+ ```ts
995
+ import { Context, Effect } from 'effect'
996
+
997
+ class Tenant extends Context.Tag('Tenant')<Tenant, { readonly id: string }>() {}
998
+
999
+ // WRONG — `R` defaults to `never`, so this only accepts effects that need
1000
+ // nothing else. Pass it an effect that also needs `Db` and it stops compiling.
1001
+ const withTenantBad = <A, E>(id: string, effect: Effect.Effect<A, E>) =>
1002
+ Effect.provideService(effect, Tenant, { id })
1003
+
1004
+ // RIGHT — generic over `R`, and the return type SUBTRACTS the tag it provided.
1005
+ const withTenant = <A, E, R>(
1006
+ id: string,
1007
+ effect: Effect.Effect<A, E, R>,
1008
+ ): Effect.Effect<A, E, Exclude<R, Tenant>> =>
1009
+ Effect.provideService(effect, Tenant, { id })
1010
+ ```
1011
+
1012
+ `Exclude<R, Tenant>` is what makes the wrapper composable: the caller's remaining
1013
+ requirements pass through untouched, and only the tag you actually supplied
1014
+ disappears. Without it, the wrapper's own signature dictates the caller's entire
1015
+ requirement set.
1016
+
1017
+ The same rule applies to any callback the framework takes from you (an AI tool
1018
+ body, a media generator, a workflow step): declare its `R` as `unknown` rather
1019
+ than letting it default to `never`, then let the framework's own bridge discharge
1020
+ it. A callback typed `Effect<A, E, never>` cannot use ANY service, which is
1021
+ rarely what you meant.
1022
+
988
1023
  ## Context By Primitive
989
1024
 
990
1025
  | Server file | Transaction | Typical store usage | External I/O | Returns |
@@ -1308,6 +1308,37 @@ test('notes.summarise retries the LLM step', async () => {
1308
1308
 
1309
1309
  The result includes `{ status, output, error, steps, runId }`. `runner.inspect(runId)` returns the assembled run later without re-running it.
1310
1310
 
1311
+ ## A start with the wrong payload
1312
+
1313
+ `ctx.workflows.start(name, payload)` validates the payload against the workflow's
1314
+ `payload` schema **before** the engine sees it. A mismatch throws a
1315
+ `WorkflowPayloadError` naming three things:
1316
+
1317
+ ```
1318
+ Workflow "sprint.report" was started with an invalid payload. missing required
1319
+ field(s): teamId. { readonly teamId: string } └─ ["teamId"] is missing
1320
+ ```
1321
+
1322
+ The error also carries them structurally — `workflowName`, `missingFields`,
1323
+ `_tag: 'WorkflowPayloadError'` — so a handler can branch on it.
1324
+
1325
+ This matters most where nobody is watching. A **cron** whose payload drifted from
1326
+ the workflow's schema fails on every single firing; the schedule run is recorded
1327
+ `failed` in `_voltro_schedule_runs` and the log line is an **error**, not a warn,
1328
+ so `voltro logs --level error` and any alert wired to it see it. A nightly job
1329
+ that has been dead since a refactor is the exact failure this pair of behaviours
1330
+ exists to surface.
1331
+
1332
+ An unknown workflow name lists the registered ones, so a rename reads differently
1333
+ from a deletion:
1334
+
1335
+ ```
1336
+ Unknown workflow: sprint.reports. Registered workflows: billing.run, sprint.report.
1337
+ ```
1338
+
1339
+ Validation runs on the **decoded** value, so a `Schema.Date` payload accepts a
1340
+ `Date` — passing the already-domain-shaped value is correct and is not rejected.
1341
+
1311
1342
  ## Anti-patterns
1312
1343
 
1313
1344
  - **Querying old table names.** Use `_voltro_workflow_runs`, `_voltro_workflow_run_steps`, and `_voltro_workflow_run_events`.
@@ -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.11.3",
15
- "@voltro/cli": "0.11.3",
16
- "@voltro/database": "0.11.3",
17
- "@voltro/env": "0.11.3",
18
- "@voltro/protocol": "0.11.3",
19
- "@voltro/runtime": "0.11.3",
14
+ "@voltro/ai": "0.11.4",
15
+ "@voltro/cli": "0.11.4",
16
+ "@voltro/database": "0.11.4",
17
+ "@voltro/env": "0.11.4",
18
+ "@voltro/protocol": "0.11.4",
19
+ "@voltro/runtime": "0.11.4",
20
20
  "effect": "^3.21.2"
21
21
  },
22
22
  "devDependencies": {
23
- "@voltro/testing": "0.11.3",
23
+ "@voltro/testing": "0.11.4",
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.11.3",
16
- "@voltro/database": "0.11.3",
17
- "@voltro/env": "0.11.3",
18
- "@voltro/plugin-auth": "0.11.3",
19
- "@voltro/protocol": "0.11.3",
20
- "@voltro/runtime": "0.11.3",
21
- "@voltro/sql-postgres": "0.11.3",
15
+ "@voltro/cli": "0.11.4",
16
+ "@voltro/database": "0.11.4",
17
+ "@voltro/env": "0.11.4",
18
+ "@voltro/plugin-auth": "0.11.4",
19
+ "@voltro/protocol": "0.11.4",
20
+ "@voltro/runtime": "0.11.4",
21
+ "@voltro/sql-postgres": "0.11.4",
22
22
  "effect": "^3.21.2"
23
23
  },
24
24
  "devDependencies": {
25
- "@voltro/testing": "0.11.3",
25
+ "@voltro/testing": "0.11.4",
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.11.3",
16
- "@voltro/database": "0.11.3",
17
- "@voltro/env": "0.11.3",
18
- "@voltro/plugin-multitenancy": "0.11.3",
19
- "@voltro/protocol": "0.11.3",
20
- "@voltro/runtime": "0.11.3",
15
+ "@voltro/cli": "0.11.4",
16
+ "@voltro/database": "0.11.4",
17
+ "@voltro/env": "0.11.4",
18
+ "@voltro/plugin-multitenancy": "0.11.4",
19
+ "@voltro/protocol": "0.11.4",
20
+ "@voltro/runtime": "0.11.4",
21
21
  "effect": "^3.21.2"
22
22
  },
23
23
  "devDependencies": {
24
- "@voltro/testing": "0.11.3",
24
+ "@voltro/testing": "0.11.4",
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.11.3",
16
- "@voltro/database": "0.11.3",
17
- "@voltro/env": "0.11.3",
18
- "@voltro/plugin-deactivation": "0.11.3",
19
- "@voltro/protocol": "0.11.3",
20
- "@voltro/runtime": "0.11.3",
15
+ "@voltro/cli": "0.11.4",
16
+ "@voltro/database": "0.11.4",
17
+ "@voltro/env": "0.11.4",
18
+ "@voltro/plugin-deactivation": "0.11.4",
19
+ "@voltro/protocol": "0.11.4",
20
+ "@voltro/runtime": "0.11.4",
21
21
  "effect": "^3.21.2"
22
22
  },
23
23
  "devDependencies": {
24
- "@voltro/testing": "0.11.3",
24
+ "@voltro/testing": "0.11.4",
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.11.3",
16
- "@voltro/database": "0.11.3",
17
- "@voltro/env": "0.11.3",
18
- "@voltro/plugin-mail": "0.11.3",
19
- "@voltro/plugin-multitenancy": "0.11.3",
20
- "@voltro/protocol": "0.11.3",
21
- "@voltro/runtime": "0.11.3",
15
+ "@voltro/cli": "0.11.4",
16
+ "@voltro/database": "0.11.4",
17
+ "@voltro/env": "0.11.4",
18
+ "@voltro/plugin-mail": "0.11.4",
19
+ "@voltro/plugin-multitenancy": "0.11.4",
20
+ "@voltro/protocol": "0.11.4",
21
+ "@voltro/runtime": "0.11.4",
22
22
  "effect": "^3.21.2",
23
23
  "react": "^19.0.0"
24
24
  },
25
25
  "devDependencies": {
26
- "@voltro/testing": "0.11.3",
26
+ "@voltro/testing": "0.11.4",
27
27
  "typescript": "^5.7.0",
28
28
  "vitest": "^3.0.0"
29
29
  }