@voltro/cli 0.3.0 → 0.5.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 (72) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/dist/{apiBuild-DdgYydVJ.js → apiBuild-CZugTK46.js} +2 -5
  3. package/dist/apiBuild-D-iBYKa3.js +2 -0
  4. package/dist/bin.js +2 -2
  5. package/dist/{commands-CXESev-z.js → commands-CJheDVOr.js} +2435 -2000
  6. package/dist/{dev-x_VqbV_8.js → dev-B71ICpXJ.js} +1993 -1823
  7. package/dist/dev-DNUetyG2.js +2 -0
  8. package/dist/index.js +1 -1
  9. package/dist/serveCommand-C0WFA396.js +1084 -0
  10. package/dist/serveEntry.js +2 -2
  11. package/package.json +17 -17
  12. package/templates/AGENTS.core.md +131 -0
  13. package/templates/AGENTS.md +131 -0
  14. package/templates/agent-docs/_manifest.json +4 -4
  15. package/templates/agent-docs/ai.md +34 -2
  16. package/templates/agent-docs/authentication.md +74 -2
  17. package/templates/agent-docs/cli.md +115 -0
  18. package/templates/agent-docs/data.md +302 -7
  19. package/templates/agent-docs/database/advancedqueries.md +25 -2
  20. package/templates/agent-docs/database/querying.md +17 -5
  21. package/templates/agent-docs/database/schema.md +5 -2
  22. package/templates/agent-docs/internationalization.md +237 -2
  23. package/templates/agent-docs/observability.md +9 -0
  24. package/templates/agent-docs/plugins.md +2 -2
  25. package/templates/agent-docs/reference.md +110 -0
  26. package/templates/agent-docs/schema-driven-ui.md +551 -2
  27. package/templates/agent-docs/templates/apibackends.md +10 -8
  28. package/templates/agent-docs/templates/overview.md +1 -1
  29. package/templates/agent-docs/testing.md +177 -3
  30. package/templates/agent-docs/workflows.md +11 -8
  31. package/templates/apps/api-ai/package.json +7 -7
  32. package/templates/apps/api-auth/package.json +8 -8
  33. package/templates/apps/api-backend/package.json +7 -7
  34. package/templates/apps/api-backend-deactivation/package.json +7 -7
  35. package/templates/apps/api-backend-mail/package.json +8 -8
  36. package/templates/apps/api-backend-mariadb/package.json +9 -9
  37. package/templates/apps/api-backend-storage/package.json +8 -8
  38. package/templates/apps/api-data-advanced/package.json +8 -8
  39. package/templates/apps/api-durable/package.json +8 -8
  40. package/templates/apps/api-feature-flags/package.json +9 -9
  41. package/templates/apps/api-governance/package.json +8 -8
  42. package/templates/apps/api-kv/package.json +8 -8
  43. package/templates/apps/api-moderation/package.json +8 -8
  44. package/templates/apps/api-observability/package.json +8 -8
  45. package/templates/apps/api-ratelimit/package.json +8 -8
  46. package/templates/apps/api-rbac/README.md +9 -4
  47. package/templates/apps/api-rbac/mutations/notes.create.mutation.server.ts +1 -1
  48. package/templates/apps/api-rbac/mutations/notes.create.mutation.ts +6 -6
  49. package/templates/apps/api-rbac/package.json +8 -8
  50. package/templates/apps/api-rbac/tests/notes.create.test.ts +8 -8
  51. package/templates/apps/api-rest/package.json +7 -7
  52. package/templates/apps/api-saas/package.json +11 -11
  53. package/templates/apps/api-search/package.json +8 -8
  54. package/templates/apps/api-versioning/package.json +8 -8
  55. package/templates/apps/api-webhooks/package.json +8 -8
  56. package/templates/apps/changelog/package.json +6 -6
  57. package/templates/apps/edge-functions/package.json +2 -2
  58. package/templates/apps/frontend-admin/package.json +8 -8
  59. package/templates/apps/frontend-app/package.json +8 -8
  60. package/templates/apps/frontend-blank/package.json +7 -7
  61. package/templates/apps/frontend-contact/package.json +7 -7
  62. package/templates/apps/frontend-dashboard/package.json +7 -7
  63. package/templates/apps/frontend-docs/package.json +7 -7
  64. package/templates/apps/frontend-i18n/package.json +6 -6
  65. package/templates/apps/frontend-landing/package.json +7 -7
  66. package/templates/apps/frontend-spa/package.json +7 -7
  67. package/templates/apps/frontend-ssr/package.json +7 -7
  68. package/templates/apps/frontend-ssr-api/package.json +8 -8
  69. package/templates/apps/frontend-static-blog/package.json +6 -6
  70. package/dist/apiBuild-cadmH8ca.js +0 -2
  71. package/dist/dev-DemiMSSl.js +0 -2
  72. package/dist/serveCommand-Dttqe5Ms.js +0 -1077
@@ -1,4 +1,4 @@
1
- import { At as e, nt as t } from "./dev-x_VqbV_8.js";
1
+ import { Nt as e, at as t } from "./dev-B71ICpXJ.js";
2
2
  import { a as n } from "./startupRunner-DhlX9nqd.js";
3
- import { t as r } from "./serveCommand-Dttqe5Ms.js";
3
+ import { t as r } from "./serveCommand-C0WFA396.js";
4
4
  export { e as loadDotEnv, n as registerAppModules, t as registerDriver, r as runServe };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@voltro/cli",
3
- "version": "0.3.0",
3
+ "version": "0.5.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",
@@ -52,22 +52,22 @@
52
52
  "@effect/platform-node": "^0.107.0",
53
53
  "@effect/sql": "^0.51.1",
54
54
  "@effect/workflow": "^0.18.2",
55
- "@voltro/ai": "0.3.0",
56
- "@voltro/cache": "0.3.0",
57
- "@voltro/data-transfer": "0.3.0",
58
- "@voltro/database": "0.3.0",
59
- "@voltro/env": "0.3.0",
60
- "@voltro/kv": "0.3.0",
61
- "@voltro/logger": "0.3.0",
62
- "@voltro/plugin-auth": "0.3.0",
63
- "@voltro/plugin-broadcast": "0.3.0",
64
- "@voltro/plugin-mail": "0.3.0",
65
- "@voltro/plugin-storage": "0.3.0",
66
- "@voltro/plugin-webhooks": "0.3.0",
67
- "@voltro/protocol": "0.3.0",
68
- "@voltro/runtime": "0.3.0",
69
- "@voltro/serverless": "0.3.0",
70
- "@voltro/workflow": "0.3.0",
55
+ "@voltro/ai": "0.5.0",
56
+ "@voltro/cache": "0.5.0",
57
+ "@voltro/data-transfer": "0.5.0",
58
+ "@voltro/database": "0.5.0",
59
+ "@voltro/env": "0.5.0",
60
+ "@voltro/kv": "0.5.0",
61
+ "@voltro/logger": "0.5.0",
62
+ "@voltro/plugin-auth": "0.5.0",
63
+ "@voltro/plugin-broadcast": "0.5.0",
64
+ "@voltro/plugin-mail": "0.5.0",
65
+ "@voltro/plugin-storage": "0.5.0",
66
+ "@voltro/plugin-webhooks": "0.5.0",
67
+ "@voltro/protocol": "0.5.0",
68
+ "@voltro/runtime": "0.5.0",
69
+ "@voltro/serverless": "0.5.0",
70
+ "@voltro/workflow": "0.5.0",
71
71
  "chokidar": "^5.0.0",
72
72
  "ioredis": "^5.11.1",
73
73
  "ts-morph": "^28.0.0"
@@ -61,6 +61,137 @@ Ask in order; stop at the first "yes":
61
61
  Deep dive: **`data.md`** (queries/mutations/actions/streams/errors),
62
62
  **`workflows.md`**, **`scheduling.md`**.
63
63
 
64
+ ### Pick the SERVER primitive (inside a handler)
65
+
66
+ The four above answer "which FILE do I write". This answers "what do I write
67
+ INSIDE it" — the part where hand-rolled boilerplate actually accumulates. Every
68
+ line below replaces something real apps write by hand hundreds of times.
69
+
70
+ 1. **Loading one row you expect to exist?** → **`.one()`**
71
+ (`ctx.store.select('t').where('id', x).one()`). It fails with the typed
72
+ `NoRowFound` when there is no row — AND when there is more than one, so a
73
+ filter that stops being unique fails loudly instead of returning an
74
+ arbitrary row. `NoRowFound` is declarable in the descriptor's `error:` union.
75
+ Nullable variant: **`.first()`** / **`.maybeOne()`**.
76
+ Never `const rows = await …; if (!rows[0]) throw new NotFound()`.
77
+ 2. **Need related data — 2+ reads to assemble one result?** → declare
78
+ **`relations()`** (`*.relations.ts`) and eager-load with **`.with({ posts: true })`**.
79
+ That compiles to ONE JSON-aggregate query on every SQL dialect. Sequential
80
+ `store.query` calls per parent row are an N+1 you are writing on purpose.
81
+ 3. **Something must happen AFTER a mutation commits** (notify, webhook, sync)?
82
+ → **`defineSubscriber`** (`*.subscribe.ts`, runs any handler on a table
83
+ change) or **`defineReaction`** (`*.reaction.tsx`, fires a named agent or
84
+ workflow; requires a `dedupeKey`, supports `rateLimit` / `costBudgetUsd`).
85
+ Both are post-commit and receive the `old`/`new` diff. Calling a
86
+ notify-helper at the tail of the mutation body instead makes the reactivity
87
+ invisible — you can only find it by reading every executor. Both are
88
+ best-effort, so genuinely critical delivery still belongs in a workflow.
89
+ 4. **A roll-up / counter recomputed on every read?** → **`defineAggregate`**
90
+ (`*.aggregate.ts`). Add `incremental:` for `count|sum|avg|min|max` group-bys
91
+ and it is MAINTAINED on write rather than recomputed. `read({ where })`
92
+ parameterises it, so one aggregate serves per-team / per-period slices.
93
+ 5. **A permission check?** → declare **`guards:`** on the descriptor — it is
94
+ enforced before the executor (and before a mutation's transaction opens) and
95
+ fails with a typed `ScopeError`. Two kinds, same array, all must pass:
96
+ - `{ scope: 'notes:write' }` — may the caller do this AT ALL.
97
+ - `{ action, resourceType, resource: (input) => input.id }` — may they do it
98
+ to THIS ROW (relationship/ReBAC, against a `defineResourcePolicy`). Needs a
99
+ registered `setTupleSource`; **everything unanswerable DENIES** (no source,
100
+ no policy, no id, source throws).
101
+ Guards are re-checked on every subscription delivery, so a revoked grant ends
102
+ the stream instead of continuing to push rows. Only use `ctx.access`
103
+ in-handler for checks that need LOADED data. A hand-written `requireScope(...)`
104
+ at the top of every executor — or a hand-kept map from rpc tag to policy rule,
105
+ which is fail-open by omission — is what `guards:` exists to delete.
106
+ 6. **Storing a token, secret, or credential in a column?** → **`.encrypted()`**
107
+ on the column. Boot fails loudly if no cipher is configured, so an
108
+ `.encrypted()` column can never silently persist plaintext.
109
+ 7. **Writing an Effect-form handler?** → `const store = yield* EffectStore` —
110
+ its failures land on the typed error channel. `Effect.promise(() =>
111
+ ctx.store.query(…))` throws that channel away and turns a store failure into
112
+ a defect.
113
+ 8. **Cursor pagination?** → **`paginateBy(descriptor, column, cursor, limit,
114
+ direction?)`** (or **`paginateById`**, its `id`-column shorthand). Not a
115
+ hand-rolled limit+1 / slice / `hasMore` triple. `direction` flips the
116
+ COMPARISON as well as the sort — a `desc` feed pages with `<`.
117
+ 9. **Assembling data whose SHAPE depends on the data** (a tree walk where each
118
+ level's ids come from the level above)? → **`ctx.load` / `ctx.loadMany`** —
119
+ same-tick reads of one table coalesce into one `WHERE id IN (...)`, so the
120
+ walk costs one query per LEVEL. Use `relations()` + `.with()` whenever the
121
+ shape IS static; this is the fallback, not the default.
122
+ 10. **A mutation must cause an EXTERNAL side effect** (webhook, Jira sync,
123
+ payment)? → **`ctx.outbox.enqueue(effect, payload)`** + a
124
+ **`defineOutboxHandler`** in a `*.outbox.ts`. The enqueue writes through the
125
+ mutation's TRANSACTION, so the intent commits with the write or not at all;
126
+ delivery happens after commit, with backoff and a dead-letter. Do NOT call
127
+ the remote from the mutation (not transactional), and do not hand-build a
128
+ deliveries table + drain cron — that IS this primitive.
129
+
130
+ | You're about to write | Reach for instead |
131
+ |---|---|
132
+ | `const rows = …; if (!rows[0]) throw new NotFound()` | `.one()` |
133
+ | 3+ sequential `store.query` to assemble related data | `relations()` + `.with()` |
134
+ | a notify/webhook helper called at the end of a mutation | `defineSubscriber` / `defineReaction` |
135
+ | a counter recomputed by scanning rows on every read | `defineAggregate` (+ `incremental:`) |
136
+ | `requireScope(...)` as the first line of every executor | `guards:` on the descriptor |
137
+ | a token/secret column written as plain text | `.encrypted()` |
138
+ | `Effect.promise(() => ctx.store.query(...))` in an Effect handler | `yield* EffectStore` |
139
+ | hand-rolled limit+1 / `hasMore` cursor paging | `paginateBy` / `paginateById` |
140
+ | a loop issuing one `store.query` per node of a walk | `ctx.load` / `ctx.loadMany` |
141
+ | a deliveries table + drain cron + retry worker | `ctx.outbox.enqueue` + `defineOutboxHandler` |
142
+ | `row['name'] as string` on every field you read | nothing — `ctx.store.query(database.t….descriptor)` already returns the table's row type. A cast here means the descriptor was hand-built, or you are casting out of habit |
143
+
144
+ Deep dive: **`data.md`** (`.one()`, subscribers, reactions, aggregates),
145
+ **`database/querying.md`** (relations + `.with`), **`authentication.md`**
146
+ (`guards:`, resource policies), **`plugins/governance.md`** (`.encrypted()`).
147
+
148
+ ### Pick the CLIENT primitive (decision rubric)
149
+
150
+ `useSubscription` / `useMutation` / `useAction` are the transport — they are NOT
151
+ the whole client surface. The framework ships a **headless primitive for every
152
+ common UI job**, each derived from the descriptor Schema you already wrote. Reach
153
+ for one BEFORE hand-rolling; ask in order:
154
+
155
+ 1. **A form / any typed write UI?** → **`useFormBinding`** — fields + validation
156
+ derived from the mutation's input Schema, and a server
157
+ `ValidationError({ field })` routes itself to that field. Not per-field `useState`.
158
+ 2. **A table / list with sort, filter, pagination?** → **`useDataTable`** (columns
159
+ from the query's output Schema, live rows). **`useQueryFilters`** builds filter
160
+ controls from the query's INPUT Schema; **`useQueryField`** is the query-bound picker.
161
+ 3. **A file upload?** → **`useUpload`** (progress, cancel, provider-agnostic).
162
+ Not `FileReader` → base64 → action.
163
+ 4. **Gating UI on permission?** → **`useCan`** / **`useCanAny`** (scopes, from
164
+ `@voltro/client`, fed by `<PermissionProvider scopes>`) or
165
+ **`useResourceCan`** / **`useResourceCans`** (per-resource ReBAC, reactive).
166
+ These live in the client, not behind a plugin — scopes are a framework
167
+ concept, so gating a button never requires installing rbac.
168
+ 5. **A value derived from one or more subscriptions?** → **`useDerived`**
169
+ (dependency-tracked, referentially stable). Not a hand-maintained `useMemo` dep array.
170
+ 6. **A very large list?** → **`useWindowedSubscription`** (subscribe to the visible
171
+ window only, not a million rows).
172
+ 7. **Debounced input?** → **`useDebounced`**; **`useAsyncValidation`** for checks
173
+ that need the DB (uniqueness, cross-row) — live, over a query binding.
174
+ 8. **A loading placeholder?** → **`useFormSkeleton`** / **`useTableSkeleton`** —
175
+ shaped like the REAL data, from the same Schema.
176
+ 9. **Offline writes · undo · dry-run · "why is this value here?"** → **`useOutbox`**,
177
+ **`useUndoLog`**, **`usePreview`**, **`useProvenance`**.
178
+ 10. **Global rpc-error handling?** → **`useOnRpcError`** + **`reportClientError`**.
179
+
180
+ | You're about to write | Reach for instead |
181
+ |---|---|
182
+ | per-field `useState` + `isSubmitting` + `resetForm()` | `useFormBinding` |
183
+ | shadcn `<Table>` + per-table sort/filter state | `useDataTable` |
184
+ | `FileReader` → base64 → action → `storage.put` | `useUpload` |
185
+ | `useMemo` fanning in 3+ subscriptions | `useDerived` |
186
+ | `setTimeout` debounce inside `useEffect` | `useDebounced` |
187
+ | `data === undefined ? <Skeleton/> : …` | `useFormSkeleton` / `useTableSkeleton` |
188
+ | a hand-kept page-scope check | `useCan` / `useResourceCan` |
189
+ | a hand-rolled presence roster / notification inbox / feature flag | `@voltro/plugin-presence` / `-notifications` / `-flags` |
190
+
191
+ Deep dive: **`schema-driven-ui.md`** (forms, tables, pickers, filters, skeletons),
192
+ **`reference.md`** (the complete hook list). If you are hand-writing more than ~20
193
+ lines of generic UI plumbing, stop and check those two first.
194
+
64
195
  ## Project layout
65
196
 
66
197
  One OR MORE **projects** live under `apps/`. A project (`project.json`) is the
@@ -61,6 +61,137 @@ Ask in order; stop at the first "yes":
61
61
  Deep dive: **`data.md`** (queries/mutations/actions/streams/errors),
62
62
  **`workflows.md`**, **`scheduling.md`**.
63
63
 
64
+ ### Pick the SERVER primitive (inside a handler)
65
+
66
+ The four above answer "which FILE do I write". This answers "what do I write
67
+ INSIDE it" — the part where hand-rolled boilerplate actually accumulates. Every
68
+ line below replaces something real apps write by hand hundreds of times.
69
+
70
+ 1. **Loading one row you expect to exist?** → **`.one()`**
71
+ (`ctx.store.select('t').where('id', x).one()`). It fails with the typed
72
+ `NoRowFound` when there is no row — AND when there is more than one, so a
73
+ filter that stops being unique fails loudly instead of returning an
74
+ arbitrary row. `NoRowFound` is declarable in the descriptor's `error:` union.
75
+ Nullable variant: **`.first()`** / **`.maybeOne()`**.
76
+ Never `const rows = await …; if (!rows[0]) throw new NotFound()`.
77
+ 2. **Need related data — 2+ reads to assemble one result?** → declare
78
+ **`relations()`** (`*.relations.ts`) and eager-load with **`.with({ posts: true })`**.
79
+ That compiles to ONE JSON-aggregate query on every SQL dialect. Sequential
80
+ `store.query` calls per parent row are an N+1 you are writing on purpose.
81
+ 3. **Something must happen AFTER a mutation commits** (notify, webhook, sync)?
82
+ → **`defineSubscriber`** (`*.subscribe.ts`, runs any handler on a table
83
+ change) or **`defineReaction`** (`*.reaction.tsx`, fires a named agent or
84
+ workflow; requires a `dedupeKey`, supports `rateLimit` / `costBudgetUsd`).
85
+ Both are post-commit and receive the `old`/`new` diff. Calling a
86
+ notify-helper at the tail of the mutation body instead makes the reactivity
87
+ invisible — you can only find it by reading every executor. Both are
88
+ best-effort, so genuinely critical delivery still belongs in a workflow.
89
+ 4. **A roll-up / counter recomputed on every read?** → **`defineAggregate`**
90
+ (`*.aggregate.ts`). Add `incremental:` for `count|sum|avg|min|max` group-bys
91
+ and it is MAINTAINED on write rather than recomputed. `read({ where })`
92
+ parameterises it, so one aggregate serves per-team / per-period slices.
93
+ 5. **A permission check?** → declare **`guards:`** on the descriptor — it is
94
+ enforced before the executor (and before a mutation's transaction opens) and
95
+ fails with a typed `ScopeError`. Two kinds, same array, all must pass:
96
+ - `{ scope: 'notes:write' }` — may the caller do this AT ALL.
97
+ - `{ action, resourceType, resource: (input) => input.id }` — may they do it
98
+ to THIS ROW (relationship/ReBAC, against a `defineResourcePolicy`). Needs a
99
+ registered `setTupleSource`; **everything unanswerable DENIES** (no source,
100
+ no policy, no id, source throws).
101
+ Guards are re-checked on every subscription delivery, so a revoked grant ends
102
+ the stream instead of continuing to push rows. Only use `ctx.access`
103
+ in-handler for checks that need LOADED data. A hand-written `requireScope(...)`
104
+ at the top of every executor — or a hand-kept map from rpc tag to policy rule,
105
+ which is fail-open by omission — is what `guards:` exists to delete.
106
+ 6. **Storing a token, secret, or credential in a column?** → **`.encrypted()`**
107
+ on the column. Boot fails loudly if no cipher is configured, so an
108
+ `.encrypted()` column can never silently persist plaintext.
109
+ 7. **Writing an Effect-form handler?** → `const store = yield* EffectStore` —
110
+ its failures land on the typed error channel. `Effect.promise(() =>
111
+ ctx.store.query(…))` throws that channel away and turns a store failure into
112
+ a defect.
113
+ 8. **Cursor pagination?** → **`paginateBy(descriptor, column, cursor, limit,
114
+ direction?)`** (or **`paginateById`**, its `id`-column shorthand). Not a
115
+ hand-rolled limit+1 / slice / `hasMore` triple. `direction` flips the
116
+ COMPARISON as well as the sort — a `desc` feed pages with `<`.
117
+ 9. **Assembling data whose SHAPE depends on the data** (a tree walk where each
118
+ level's ids come from the level above)? → **`ctx.load` / `ctx.loadMany`** —
119
+ same-tick reads of one table coalesce into one `WHERE id IN (...)`, so the
120
+ walk costs one query per LEVEL. Use `relations()` + `.with()` whenever the
121
+ shape IS static; this is the fallback, not the default.
122
+ 10. **A mutation must cause an EXTERNAL side effect** (webhook, Jira sync,
123
+ payment)? → **`ctx.outbox.enqueue(effect, payload)`** + a
124
+ **`defineOutboxHandler`** in a `*.outbox.ts`. The enqueue writes through the
125
+ mutation's TRANSACTION, so the intent commits with the write or not at all;
126
+ delivery happens after commit, with backoff and a dead-letter. Do NOT call
127
+ the remote from the mutation (not transactional), and do not hand-build a
128
+ deliveries table + drain cron — that IS this primitive.
129
+
130
+ | You're about to write | Reach for instead |
131
+ |---|---|
132
+ | `const rows = …; if (!rows[0]) throw new NotFound()` | `.one()` |
133
+ | 3+ sequential `store.query` to assemble related data | `relations()` + `.with()` |
134
+ | a notify/webhook helper called at the end of a mutation | `defineSubscriber` / `defineReaction` |
135
+ | a counter recomputed by scanning rows on every read | `defineAggregate` (+ `incremental:`) |
136
+ | `requireScope(...)` as the first line of every executor | `guards:` on the descriptor |
137
+ | a token/secret column written as plain text | `.encrypted()` |
138
+ | `Effect.promise(() => ctx.store.query(...))` in an Effect handler | `yield* EffectStore` |
139
+ | hand-rolled limit+1 / `hasMore` cursor paging | `paginateBy` / `paginateById` |
140
+ | a loop issuing one `store.query` per node of a walk | `ctx.load` / `ctx.loadMany` |
141
+ | a deliveries table + drain cron + retry worker | `ctx.outbox.enqueue` + `defineOutboxHandler` |
142
+ | `row['name'] as string` on every field you read | nothing — `ctx.store.query(database.t….descriptor)` already returns the table's row type. A cast here means the descriptor was hand-built, or you are casting out of habit |
143
+
144
+ Deep dive: **`data.md`** (`.one()`, subscribers, reactions, aggregates),
145
+ **`database/querying.md`** (relations + `.with`), **`authentication.md`**
146
+ (`guards:`, resource policies), **`plugins/governance.md`** (`.encrypted()`).
147
+
148
+ ### Pick the CLIENT primitive (decision rubric)
149
+
150
+ `useSubscription` / `useMutation` / `useAction` are the transport — they are NOT
151
+ the whole client surface. The framework ships a **headless primitive for every
152
+ common UI job**, each derived from the descriptor Schema you already wrote. Reach
153
+ for one BEFORE hand-rolling; ask in order:
154
+
155
+ 1. **A form / any typed write UI?** → **`useFormBinding`** — fields + validation
156
+ derived from the mutation's input Schema, and a server
157
+ `ValidationError({ field })` routes itself to that field. Not per-field `useState`.
158
+ 2. **A table / list with sort, filter, pagination?** → **`useDataTable`** (columns
159
+ from the query's output Schema, live rows). **`useQueryFilters`** builds filter
160
+ controls from the query's INPUT Schema; **`useQueryField`** is the query-bound picker.
161
+ 3. **A file upload?** → **`useUpload`** (progress, cancel, provider-agnostic).
162
+ Not `FileReader` → base64 → action.
163
+ 4. **Gating UI on permission?** → **`useCan`** / **`useCanAny`** (scopes, from
164
+ `@voltro/client`, fed by `<PermissionProvider scopes>`) or
165
+ **`useResourceCan`** / **`useResourceCans`** (per-resource ReBAC, reactive).
166
+ These live in the client, not behind a plugin — scopes are a framework
167
+ concept, so gating a button never requires installing rbac.
168
+ 5. **A value derived from one or more subscriptions?** → **`useDerived`**
169
+ (dependency-tracked, referentially stable). Not a hand-maintained `useMemo` dep array.
170
+ 6. **A very large list?** → **`useWindowedSubscription`** (subscribe to the visible
171
+ window only, not a million rows).
172
+ 7. **Debounced input?** → **`useDebounced`**; **`useAsyncValidation`** for checks
173
+ that need the DB (uniqueness, cross-row) — live, over a query binding.
174
+ 8. **A loading placeholder?** → **`useFormSkeleton`** / **`useTableSkeleton`** —
175
+ shaped like the REAL data, from the same Schema.
176
+ 9. **Offline writes · undo · dry-run · "why is this value here?"** → **`useOutbox`**,
177
+ **`useUndoLog`**, **`usePreview`**, **`useProvenance`**.
178
+ 10. **Global rpc-error handling?** → **`useOnRpcError`** + **`reportClientError`**.
179
+
180
+ | You're about to write | Reach for instead |
181
+ |---|---|
182
+ | per-field `useState` + `isSubmitting` + `resetForm()` | `useFormBinding` |
183
+ | shadcn `<Table>` + per-table sort/filter state | `useDataTable` |
184
+ | `FileReader` → base64 → action → `storage.put` | `useUpload` |
185
+ | `useMemo` fanning in 3+ subscriptions | `useDerived` |
186
+ | `setTimeout` debounce inside `useEffect` | `useDebounced` |
187
+ | `data === undefined ? <Skeleton/> : …` | `useFormSkeleton` / `useTableSkeleton` |
188
+ | a hand-kept page-scope check | `useCan` / `useResourceCan` |
189
+ | a hand-rolled presence roster / notification inbox / feature flag | `@voltro/plugin-presence` / `-notifications` / `-flags` |
190
+
191
+ Deep dive: **`schema-driven-ui.md`** (forms, tables, pickers, filters, skeletons),
192
+ **`reference.md`** (the complete hook list). If you are hand-writing more than ~20
193
+ lines of generic UI plumbing, stop and check those two first.
194
+
64
195
  ## Project layout
65
196
 
66
197
  One OR MORE **projects** live under `apps/`. A project (`project.json`) is the
@@ -53,7 +53,7 @@
53
53
  "group": null,
54
54
  "description": "How Voltro's reactive data layer works — queries, mutations, actions, streams, all over one WebSocket with typed errors and tracked dependencies.",
55
55
  "path": "agent-docs/data.md",
56
- "files": 13
56
+ "files": 14
57
57
  },
58
58
  {
59
59
  "id": "database/advancedqueries",
@@ -170,7 +170,7 @@
170
170
  "group": null,
171
171
  "description": "Voltro's i18n layer (@voltro/i18n) — an opinionated wrap over react-intl, auto-wired from a single app.config.ts field, with cookie + Accept-Language locale resolution.",
172
172
  "path": "agent-docs/internationalization.md",
173
- "files": 3
173
+ "files": 4
174
174
  },
175
175
  {
176
176
  "id": "introduction",
@@ -242,7 +242,7 @@
242
242
  "group": null,
243
243
  "description": "Project the typed descriptor graph into UI — forms, tables, pickers, and reactive components, all bound to a descriptor with near-zero glue.",
244
244
  "path": "agent-docs/schema-driven-ui.md",
245
- "files": 13
245
+ "files": 23
246
246
  },
247
247
  {
248
248
  "id": "security",
@@ -305,7 +305,7 @@
305
305
  "group": null,
306
306
  "description": "Voltro's test story — the @voltro/testing package, the test pyramid (unit handlers/tools → workflow runner → e2e), and the voltro test / voltro e2e CLI commands.",
307
307
  "path": "agent-docs/testing.md",
308
- "files": 4
308
+ "files": 5
309
309
  },
310
310
  {
311
311
  "id": "workflows",
@@ -1019,6 +1019,34 @@ It returns a `Stream<SeqEvent>` — each element is `{ seq, event }`. The descri
1019
1019
 
1020
1020
  On the client, `useResumableAgentStream('app', 'support.run')` does the rest: it unwraps each `SeqEvent` (so `.events` are the plain inner events), tracks the highest `seq`, and on a transport drop BEFORE the run's terminal event it auto-reconnects with `fromSeq` = the last seq it rendered (exponential backoff; the no-progress cap resets whenever a reconnect delivers a new event, so a long flaky stream survives any number of well-spaced drops). The server replays past the cursor, then continues — one seamless stream.
1021
1021
 
1022
+ The hook's own surface is small:
1023
+
1024
+ ```tsx
1025
+ import { useResumableAgentStream } from '@voltro/client'
1026
+ import type { AgentEvent } from '@voltro/ai/events'
1027
+
1028
+ const run = useResumableAgentStream<AgentEvent>('app', 'support.run')
1029
+
1030
+ // `input` MUST carry the resume key — `fromSeq` is injected by the hook.
1031
+ run.start({ streamId, message: prompt })
1032
+ run.cancel()
1033
+ ```
1034
+
1035
+ | Field | Meaning |
1036
+ |---|---|
1037
+ | `events` | The unwrapped inner events so far, in order and deduped by `seq`. |
1038
+ | `status` | `'idle'`, `'streaming'`, `'reconnecting'`, `'done'`, or `'error'`. |
1039
+ | `reconnects` | How many times the transport dropped and auto-reconnected this run. |
1040
+ | `error` | Set when `status === 'error'`. |
1041
+ | `start(input?)` | Begins a run, clearing previous events. |
1042
+ | `cancel()` | Stops the run and any pending reconnect. |
1043
+
1044
+ A third options argument tunes the reconnect policy: `maxReconnects` (default
1045
+ `6`), `backoffMs` (`400`), `maxBackoffMs` (`8000`), and `isTerminal` — which
1046
+ defaults to treating an `AgentEvent`-shaped `{ _tag: 'done' | 'error' }` as the
1047
+ end of the run. Override `isTerminal` when your element type signals completion
1048
+ some other way, or the hook will keep trying to resume a finished stream.
1049
+
1022
1050
  For multi-node deployments use `dataStoreResumableStreamStore(ctx.store)` — it persists to the framework's own database (`streamEventsTable` + `streamStateTable`, register them in your `database/index.ts`) and elects exactly ONE producer per `streamId` via an atomic claim, so only one node runs the model while every node's consumers tail the shared log. Sweep finished streams with `gcResumableStreams(store, { olderThan })`.
1023
1051
 
1024
1052
  For the fastest path, `redisResumableStreamStore(redis, { ttlSeconds })` backs the log with a Redis LIST (`RPUSH`/`LRANGE`) plus a `SET … NX` producer claim — TTL evicts finished/abandoned streams without a sweep. `@voltro/ai` takes no Redis dependency; you inject a tiny `ResumableRedis` client (five methods: `setNx` / `rpush` / `lrange` / `set` / `exists`) adapting ioredis / node-redis. All three backends satisfy the same `ResumableStreamStore` interface, so they swap without touching the producer/consumer code.
@@ -1689,6 +1717,7 @@ export const ask = defineAction({
1689
1717
  ```ts
1690
1718
  // copilot.ask.action.server.ts — the server executor (imports @voltro/ai)
1691
1719
  import { Effect } from 'effect'
1720
+ import { EffectStore } from '@voltro/runtime'
1692
1721
  import { runDataCopilot, generateObject, CopilotProposalSchema, type CopilotSchema } from '@voltro/ai'
1693
1722
 
1694
1723
  // The manifest the model is constrained to — only these tables/columns exist.
@@ -1701,6 +1730,7 @@ const schema: CopilotSchema = {
1701
1730
 
1702
1731
  const execute = (input: { question: string }, ctx: AppContext) =>
1703
1732
  Effect.gen(function* () {
1733
+ const store = yield* EffectStore
1704
1734
  const v = yield* Effect.promise(() =>
1705
1735
  runDataCopilot(input.question, schema, {
1706
1736
  propose: ({ system, prompt }) =>
@@ -1711,13 +1741,15 @@ const execute = (input: { question: string }, ctx: AppContext) =>
1711
1741
  )
1712
1742
  if (!v.ok) return { ok: false as const, reason: v.rejection.reason }
1713
1743
  // The validated SELECT runs AS the subject → tenant + row scope apply.
1714
- const rows = yield* Effect.promise(() => ctx.store.query(v.descriptor as never))
1715
- return { ok: true as const, rows: rows as ReadonlyArray<Record<string, unknown>> }
1744
+ const rows = yield* store.query(v.descriptor)
1745
+ return { ok: true as const, rows }
1716
1746
  })
1717
1747
 
1718
1748
  export default execute
1719
1749
  ```
1720
1750
 
1751
+ `v.descriptor` is already a `QueryDescriptor`, so nothing needs casting. In an Effect-form executor read through `EffectStore` (`yield* EffectStore`) rather than lifting `ctx.store.query` with `Effect.promise` — the lift discards the typed `StoreError` channel that `store.query` gives you.
1752
+
1721
1753
  ## Client: `useDataCopilot` + `<DataCopilot>`
1722
1754
 
1723
1755
  The hook is a thin binding over `useAction` — it imports **nothing** from
@@ -403,7 +403,7 @@ if (!canTrigger) throw new Unauthorised({})
403
403
  ```ts
404
404
  import { permission } from '@voltro/plugin-rbac'
405
405
 
406
- yield* permission(ctx, 'admin:full') // Effect<void, Forbidden>
406
+ yield* permission(ctx, 'admin:full') // Effect<void, ScopeError>
407
407
  ```
408
408
 
409
409
  See [the RBAC plugin](/docs/plugins/rbac) for the full model.
@@ -1732,6 +1732,75 @@ export const todoPolicy = defineResourcePolicy({
1732
1732
  })
1733
1733
  ```
1734
1734
 
1735
+ ## Enforce it declaratively — `guards:`
1736
+
1737
+ `can` / `assertCan` above are the imperative form: you load the tuples and make
1738
+ the decision inside the handler. That works, and it is what you reach for when
1739
+ the check needs data you have already loaded.
1740
+
1741
+ For the ordinary case — "may this caller perform ACTION on the row this input
1742
+ names?" — declare it on the descriptor instead:
1743
+
1744
+ ```ts
1745
+ export const todoUpdate = defineMutation({
1746
+ name: 'todos.update',
1747
+ input: Schema.Struct({ id: Schema.String, title: Schema.String }),
1748
+ output: Schema.Void,
1749
+ guards: [{ action: 'edit', resourceType: 'todo', resource: (input) => input.id }],
1750
+ })
1751
+ ```
1752
+
1753
+ The framework resolves it BEFORE the executor runs — for a mutation, before the
1754
+ transaction opens — and fails with a typed `ScopeError` naming
1755
+ `<resourceType>:<action>`.
1756
+
1757
+ Why the declarative form is not just shorter: an in-handler check is one an
1758
+ author can forget, and a forgotten check is a silent hole rather than an error.
1759
+ The same is true of the older pattern of hand-maintaining a map from rpc tag to
1760
+ policy rule and installing it as an interceptor — that map is **fail-open by
1761
+ omission**: add an endpoint, forget the entry, and nothing anywhere tells you.
1762
+ A guard on the descriptor cannot be forgotten for an rpc that exists, because
1763
+ it is part of the rpc.
1764
+
1765
+ Scope guards and relationship guards live in the same array and ALL must pass:
1766
+
1767
+ ```ts
1768
+ guards: [
1769
+ { scope: 'todos:write' }, // may you edit todos at all
1770
+ { action: 'edit', resourceType: 'todo', resource: (i) => i.id }, // may you edit THIS one
1771
+ ]
1772
+ ```
1773
+
1774
+ ### Activate it: register a tuple source
1775
+
1776
+ A relationship guard needs to read the caller's relations. That comes from the
1777
+ registered **tuple source**:
1778
+
1779
+ ```ts
1780
+ // app.config.ts or a *.startup.ts
1781
+ import { setTupleSource, loadResourceTuples } from '@voltro/runtime'
1782
+
1783
+ setTupleSource((req) =>
1784
+ loadResourceTuples(store, req.subjectId, req.resourceType, req.resourceId))
1785
+ ```
1786
+
1787
+ `voltro dev` / `voltro serve` register exactly this default for you, reading
1788
+ `_voltro_rebac_tuples`. Register your own when your relationships already live
1789
+ in your own tables — a `teamMembers` row is a relation; you should not have to
1790
+ copy it into a framework table to authorize against it.
1791
+
1792
+ **Every unanswerable case denies.** No tuple source registered, no policy for
1793
+ that `resourceType`, an input that doesn't identify a resource, a tuple source
1794
+ that throws — each is a denial, not a pass. An authorization question nobody can
1795
+ answer is a refusal; treating it as a pass is how a policy layer ends up
1796
+ enforcing nothing while looking like it does.
1797
+
1798
+ ### Guards are re-checked on every subscription delivery
1799
+
1800
+ A subscription is a long-lived grant. Its guards — scope and relationship alike
1801
+ — are re-evaluated before each delivery, so revoking a relation mid-session ends
1802
+ the stream with the typed error instead of continuing to push rows.
1803
+
1735
1804
  ## Decide — `can` / `assertCan`
1736
1805
 
1737
1806
  `can(subject, action, resource, { policy, tuples })` is the decision; `assertCan`
@@ -1778,9 +1847,12 @@ the headline that builds on it.)
1778
1847
  import { useResourceCan } from '@voltro/client'
1779
1848
 
1780
1849
  const canEdit = useResourceCan('app', 'todos.can', { action: 'edit', resourceType: 'todo', resourceId: id })
1781
- // canEdit.allowed: boolean | undefined (undefined until resolved); reactive.
1850
+ // canEdit.allowed: boolean (false until the first verdict); canEdit.pending: boolean.
1782
1851
  ```
1783
1852
 
1853
+ Full API — including the batch `useResourceCans` for per-row gating — in
1854
+ [usePermissions](/docs/ui/client-utilities/use-permissions).
1855
+
1784
1856
  ## Capability map
1785
1857
 
1786
1858
  `rebacPolicyGraph()` returns every resource type, its actions, the relations each