@voltro/cli 0.3.0 → 0.4.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 (70) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/dist/{apiBuild-DdgYydVJ.js → apiBuild-CvtQeBMs.js} +2 -5
  3. package/dist/apiBuild-DQBNqNZ8.js +2 -0
  4. package/dist/bin.js +2 -2
  5. package/dist/{commands-CXESev-z.js → commands-DhyBIs1O.js} +2182 -1971
  6. package/dist/{dev-x_VqbV_8.js → dev-CQxbrpDz.js} +1043 -1003
  7. package/dist/dev-DYjGqPGD.js +2 -0
  8. package/dist/index.js +1 -1
  9. package/dist/serveCommand-BZzUJIyo.js +1077 -0
  10. package/dist/serveEntry.js +2 -2
  11. package/package.json +17 -17
  12. package/templates/AGENTS.core.md +47 -0
  13. package/templates/AGENTS.md +47 -0
  14. package/templates/agent-docs/_manifest.json +3 -3
  15. package/templates/agent-docs/ai.md +28 -0
  16. package/templates/agent-docs/authentication.md +5 -2
  17. package/templates/agent-docs/cli.md +39 -0
  18. package/templates/agent-docs/data.md +155 -4
  19. package/templates/agent-docs/database/advancedqueries.md +4 -2
  20. package/templates/agent-docs/database/querying.md +2 -0
  21. package/templates/agent-docs/internationalization.md +237 -2
  22. package/templates/agent-docs/observability.md +9 -0
  23. package/templates/agent-docs/plugins.md +2 -2
  24. package/templates/agent-docs/reference.md +76 -0
  25. package/templates/agent-docs/schema-driven-ui.md +551 -2
  26. package/templates/agent-docs/templates/apibackends.md +10 -8
  27. package/templates/agent-docs/templates/overview.md +1 -1
  28. package/templates/agent-docs/testing.md +177 -3
  29. package/templates/apps/api-ai/package.json +7 -7
  30. package/templates/apps/api-auth/package.json +8 -8
  31. package/templates/apps/api-backend/package.json +7 -7
  32. package/templates/apps/api-backend-deactivation/package.json +7 -7
  33. package/templates/apps/api-backend-mail/package.json +8 -8
  34. package/templates/apps/api-backend-mariadb/package.json +9 -9
  35. package/templates/apps/api-backend-storage/package.json +8 -8
  36. package/templates/apps/api-data-advanced/package.json +8 -8
  37. package/templates/apps/api-durable/package.json +8 -8
  38. package/templates/apps/api-feature-flags/package.json +9 -9
  39. package/templates/apps/api-governance/package.json +8 -8
  40. package/templates/apps/api-kv/package.json +8 -8
  41. package/templates/apps/api-moderation/package.json +8 -8
  42. package/templates/apps/api-observability/package.json +8 -8
  43. package/templates/apps/api-ratelimit/package.json +8 -8
  44. package/templates/apps/api-rbac/README.md +9 -4
  45. package/templates/apps/api-rbac/mutations/notes.create.mutation.server.ts +1 -1
  46. package/templates/apps/api-rbac/mutations/notes.create.mutation.ts +6 -6
  47. package/templates/apps/api-rbac/package.json +8 -8
  48. package/templates/apps/api-rbac/tests/notes.create.test.ts +8 -8
  49. package/templates/apps/api-rest/package.json +7 -7
  50. package/templates/apps/api-saas/package.json +11 -11
  51. package/templates/apps/api-search/package.json +8 -8
  52. package/templates/apps/api-versioning/package.json +8 -8
  53. package/templates/apps/api-webhooks/package.json +8 -8
  54. package/templates/apps/changelog/package.json +6 -6
  55. package/templates/apps/edge-functions/package.json +2 -2
  56. package/templates/apps/frontend-admin/package.json +8 -8
  57. package/templates/apps/frontend-app/package.json +8 -8
  58. package/templates/apps/frontend-blank/package.json +7 -7
  59. package/templates/apps/frontend-contact/package.json +7 -7
  60. package/templates/apps/frontend-dashboard/package.json +7 -7
  61. package/templates/apps/frontend-docs/package.json +7 -7
  62. package/templates/apps/frontend-i18n/package.json +6 -6
  63. package/templates/apps/frontend-landing/package.json +7 -7
  64. package/templates/apps/frontend-spa/package.json +7 -7
  65. package/templates/apps/frontend-ssr/package.json +7 -7
  66. package/templates/apps/frontend-ssr-api/package.json +8 -8
  67. package/templates/apps/frontend-static-blog/package.json +6 -6
  68. package/dist/apiBuild-cadmH8ca.js +0 -2
  69. package/dist/dev-DemiMSSl.js +0 -2
  70. 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 { jt as e, rt as t } from "./dev-CQxbrpDz.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-BZzUJIyo.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.4.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.4.0",
56
+ "@voltro/cache": "0.4.0",
57
+ "@voltro/data-transfer": "0.4.0",
58
+ "@voltro/database": "0.4.0",
59
+ "@voltro/env": "0.4.0",
60
+ "@voltro/kv": "0.4.0",
61
+ "@voltro/logger": "0.4.0",
62
+ "@voltro/plugin-auth": "0.4.0",
63
+ "@voltro/plugin-broadcast": "0.4.0",
64
+ "@voltro/plugin-mail": "0.4.0",
65
+ "@voltro/plugin-storage": "0.4.0",
66
+ "@voltro/plugin-webhooks": "0.4.0",
67
+ "@voltro/protocol": "0.4.0",
68
+ "@voltro/runtime": "0.4.0",
69
+ "@voltro/serverless": "0.4.0",
70
+ "@voltro/workflow": "0.4.0",
71
71
  "chokidar": "^5.0.0",
72
72
  "ioredis": "^5.11.1",
73
73
  "ts-morph": "^28.0.0"
@@ -61,6 +61,53 @@ 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 CLIENT primitive (decision rubric)
65
+
66
+ `useSubscription` / `useMutation` / `useAction` are the transport — they are NOT
67
+ the whole client surface. The framework ships a **headless primitive for every
68
+ common UI job**, each derived from the descriptor Schema you already wrote. Reach
69
+ for one BEFORE hand-rolling; ask in order:
70
+
71
+ 1. **A form / any typed write UI?** → **`useFormBinding`** — fields + validation
72
+ derived from the mutation's input Schema, and a server
73
+ `ValidationError({ field })` routes itself to that field. Not per-field `useState`.
74
+ 2. **A table / list with sort, filter, pagination?** → **`useDataTable`** (columns
75
+ from the query's output Schema, live rows). **`useQueryFilters`** builds filter
76
+ controls from the query's INPUT Schema; **`useQueryField`** is the query-bound picker.
77
+ 3. **A file upload?** → **`useUpload`** (progress, cancel, provider-agnostic).
78
+ Not `FileReader` → base64 → action.
79
+ 4. **Gating UI on permission?** → **`useCan`** / **`useCanAny`** (scopes, from
80
+ `@voltro/client`, fed by `<PermissionProvider scopes>`) or
81
+ **`useResourceCan`** / **`useResourceCans`** (per-resource ReBAC, reactive).
82
+ These live in the client, not behind a plugin — scopes are a framework
83
+ concept, so gating a button never requires installing rbac.
84
+ 5. **A value derived from one or more subscriptions?** → **`useDerived`**
85
+ (dependency-tracked, referentially stable). Not a hand-maintained `useMemo` dep array.
86
+ 6. **A very large list?** → **`useWindowedSubscription`** (subscribe to the visible
87
+ window only, not a million rows).
88
+ 7. **Debounced input?** → **`useDebounced`**; **`useAsyncValidation`** for checks
89
+ that need the DB (uniqueness, cross-row) — live, over a query binding.
90
+ 8. **A loading placeholder?** → **`useFormSkeleton`** / **`useTableSkeleton`** —
91
+ shaped like the REAL data, from the same Schema.
92
+ 9. **Offline writes · undo · dry-run · "why is this value here?"** → **`useOutbox`**,
93
+ **`useUndoLog`**, **`usePreview`**, **`useProvenance`**.
94
+ 10. **Global rpc-error handling?** → **`useOnRpcError`** + **`reportClientError`**.
95
+
96
+ | You're about to write | Reach for instead |
97
+ |---|---|
98
+ | per-field `useState` + `isSubmitting` + `resetForm()` | `useFormBinding` |
99
+ | shadcn `<Table>` + per-table sort/filter state | `useDataTable` |
100
+ | `FileReader` → base64 → action → `storage.put` | `useUpload` |
101
+ | `useMemo` fanning in 3+ subscriptions | `useDerived` |
102
+ | `setTimeout` debounce inside `useEffect` | `useDebounced` |
103
+ | `data === undefined ? <Skeleton/> : …` | `useFormSkeleton` / `useTableSkeleton` |
104
+ | a hand-kept page-scope check | `useCan` / `useResourceCan` |
105
+ | a hand-rolled presence roster / notification inbox / feature flag | `@voltro/plugin-presence` / `-notifications` / `-flags` |
106
+
107
+ Deep dive: **`schema-driven-ui.md`** (forms, tables, pickers, filters, skeletons),
108
+ **`reference.md`** (the complete hook list). If you are hand-writing more than ~20
109
+ lines of generic UI plumbing, stop and check those two first.
110
+
64
111
  ## Project layout
65
112
 
66
113
  One OR MORE **projects** live under `apps/`. A project (`project.json`) is the
@@ -61,6 +61,53 @@ 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 CLIENT primitive (decision rubric)
65
+
66
+ `useSubscription` / `useMutation` / `useAction` are the transport — they are NOT
67
+ the whole client surface. The framework ships a **headless primitive for every
68
+ common UI job**, each derived from the descriptor Schema you already wrote. Reach
69
+ for one BEFORE hand-rolling; ask in order:
70
+
71
+ 1. **A form / any typed write UI?** → **`useFormBinding`** — fields + validation
72
+ derived from the mutation's input Schema, and a server
73
+ `ValidationError({ field })` routes itself to that field. Not per-field `useState`.
74
+ 2. **A table / list with sort, filter, pagination?** → **`useDataTable`** (columns
75
+ from the query's output Schema, live rows). **`useQueryFilters`** builds filter
76
+ controls from the query's INPUT Schema; **`useQueryField`** is the query-bound picker.
77
+ 3. **A file upload?** → **`useUpload`** (progress, cancel, provider-agnostic).
78
+ Not `FileReader` → base64 → action.
79
+ 4. **Gating UI on permission?** → **`useCan`** / **`useCanAny`** (scopes, from
80
+ `@voltro/client`, fed by `<PermissionProvider scopes>`) or
81
+ **`useResourceCan`** / **`useResourceCans`** (per-resource ReBAC, reactive).
82
+ These live in the client, not behind a plugin — scopes are a framework
83
+ concept, so gating a button never requires installing rbac.
84
+ 5. **A value derived from one or more subscriptions?** → **`useDerived`**
85
+ (dependency-tracked, referentially stable). Not a hand-maintained `useMemo` dep array.
86
+ 6. **A very large list?** → **`useWindowedSubscription`** (subscribe to the visible
87
+ window only, not a million rows).
88
+ 7. **Debounced input?** → **`useDebounced`**; **`useAsyncValidation`** for checks
89
+ that need the DB (uniqueness, cross-row) — live, over a query binding.
90
+ 8. **A loading placeholder?** → **`useFormSkeleton`** / **`useTableSkeleton`** —
91
+ shaped like the REAL data, from the same Schema.
92
+ 9. **Offline writes · undo · dry-run · "why is this value here?"** → **`useOutbox`**,
93
+ **`useUndoLog`**, **`usePreview`**, **`useProvenance`**.
94
+ 10. **Global rpc-error handling?** → **`useOnRpcError`** + **`reportClientError`**.
95
+
96
+ | You're about to write | Reach for instead |
97
+ |---|---|
98
+ | per-field `useState` + `isSubmitting` + `resetForm()` | `useFormBinding` |
99
+ | shadcn `<Table>` + per-table sort/filter state | `useDataTable` |
100
+ | `FileReader` → base64 → action → `storage.put` | `useUpload` |
101
+ | `useMemo` fanning in 3+ subscriptions | `useDerived` |
102
+ | `setTimeout` debounce inside `useEffect` | `useDebounced` |
103
+ | `data === undefined ? <Skeleton/> : …` | `useFormSkeleton` / `useTableSkeleton` |
104
+ | a hand-kept page-scope check | `useCan` / `useResourceCan` |
105
+ | a hand-rolled presence roster / notification inbox / feature flag | `@voltro/plugin-presence` / `-notifications` / `-flags` |
106
+
107
+ Deep dive: **`schema-driven-ui.md`** (forms, tables, pickers, filters, skeletons),
108
+ **`reference.md`** (the complete hook list). If you are hand-writing more than ~20
109
+ lines of generic UI plumbing, stop and check those two first.
110
+
64
111
  ## Project layout
65
112
 
66
113
  One OR MORE **projects** live under `apps/`. A project (`project.json`) is the
@@ -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.
@@ -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.
@@ -1778,9 +1778,12 @@ the headline that builds on it.)
1778
1778
  import { useResourceCan } from '@voltro/client'
1779
1779
 
1780
1780
  const canEdit = useResourceCan('app', 'todos.can', { action: 'edit', resourceType: 'todo', resourceId: id })
1781
- // canEdit.allowed: boolean | undefined (undefined until resolved); reactive.
1781
+ // canEdit.allowed: boolean (false until the first verdict); canEdit.pending: boolean.
1782
1782
  ```
1783
1783
 
1784
+ Full API — including the batch `useResourceCans` for per-row gating — in
1785
+ [usePermissions](/docs/ui/client-utilities/use-permissions).
1786
+
1784
1787
  ## Capability map
1785
1788
 
1786
1789
  `rebacPolicyGraph()` returns every resource type, its actions, the relations each
@@ -722,6 +722,23 @@ For WebSocket connections to land on the same backend (required for in-process s
722
722
  - Reverse proxy: `lb_policy ip_hash` (Caddy) / `ip_hash` (nginx).
723
723
  - Or use `@voltro/plugin-cluster` to share subscription state across instances → any-load-balancer-works.
724
724
 
725
+ ## `voltro doctor` — preflight a production serve
726
+
727
+ Production `voltro serve` for an **API** app boots ONLY from the precompiled serve
728
+ bundle and is **fatal if it's missing** — a hand-rolled Dockerfile that runs
729
+ `voltro serve` without a prior `voltro build` breaks at deploy. `voltro doctor`
730
+ (or `voltro serve --preflight`) catches that at BUILD time instead of cold-start:
731
+
732
+ ```bash
733
+ voltro doctor . # check the serve bundle exists; print the fix if not
734
+ voltro serve --preflight . # same check, then exit — never boots
735
+ ```
736
+
737
+ It exits **1** when the serve bundle is missing on an API app (so it fails a CI /
738
+ Docker step) and prints the exact remedy: add a `voltro build .` step before
739
+ `voltro serve .`. Drop it into your image build right after `voltro build` to
740
+ guarantee the artefact is present before the image ships.
741
+
725
742
  ## Anti-patterns
726
743
 
727
744
  - **Running `voltro start` against a directory without `dist/`.** It exits 1 with a clear `no built dist found — run voltro build first` (checked against `.framework/dist/index.html` before any heavy work). Run `voltro build` first.
@@ -1672,8 +1689,30 @@ voltro update --to 0.4.0 # pin an explicit target version
1672
1689
  voltro update --dry-run # preview the bump + which codemods would run — writes nothing
1673
1690
  voltro update --force # allow a dirty working tree (not recommended)
1674
1691
  voltro update --exact # pin exact versions (drop the ^ / ~ range prefix)
1692
+
1693
+ # Recover the codemods after a MANUAL version bump (no bump, no install):
1694
+ voltro update --codemods-only --from 0.3.0 # re-run codemods 0.3.0 → installed
1695
+ voltro update --codemods-only --from 0.3.0 --to 0.4.0 # explicit delta
1675
1696
  ```
1676
1697
 
1698
+ ## Already bumped by hand? Recover the codemods
1699
+
1700
+ If you bump `@voltro/*` versions in `package.json` yourself and install first, a
1701
+ plain `voltro update` sees the installed version already equals the target and
1702
+ reports **"already on X — nothing to do"** — skipping the codemods AND the
1703
+ printed manual steps for the delta you actually crossed. To re-apply them without
1704
+ touching `package.json` again:
1705
+
1706
+ ```bash
1707
+ voltro update --codemods-only --from <version-you-came-from>
1708
+ ```
1709
+
1710
+ `--codemods-only` (alias `--run-codemods`) runs the codemods + manual notes for
1711
+ `[from, to]` against the already-installed tree — no version bump, no install.
1712
+ `--to` defaults to the installed version; pass it to pin an explicit delta.
1713
+ `--from` also works on a normal `voltro update` to override the auto-detected
1714
+ source version.
1715
+
1677
1716
  ## The clean-tree guard
1678
1717
 
1679
1718
  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).
@@ -315,6 +315,40 @@ Queries are streaming RPCs whose elements are **subscription events**: an initia
315
315
  - **Using a stream for durable data.** Streams are transient. Persist rows and expose them through a query when the UI should survive reloads or sync across tabs.
316
316
 
317
317
 
318
+ ## Loading vs empty — don't conflate them
319
+
320
+ `useSubscription` returns `loading` and `isEmpty` alongside `data`. They are
321
+ **different** states, and branching on `data === undefined` alone is what causes
322
+ a flash of empty-state before the first snapshot:
323
+
324
+ | State | Meaning | Render |
325
+ |---|---|---|
326
+ | `loading` | no snapshot has arrived yet | skeleton |
327
+ | `isEmpty` | snapshot arrived, zero rows (or a null value) | empty state |
328
+ | neither | rows present | the list |
329
+
330
+ ```tsx
331
+ const { data, loading, isEmpty } = useSubscription<Note[]>('app', 'notes.list', {})
332
+ if (loading) return <TableSkeleton/>
333
+ if (isEmpty) return <EmptyNotes/>
334
+ return <NotesTable notes={data!}/>
335
+ ```
336
+
337
+ `fallback` fills `data` while loading so a page can render its real (empty) shell
338
+ immediately — it never lies about `loading`:
339
+
340
+ ```ts
341
+ const { data, loading } = useSubscription('app', 'notes.list', {}, { fallback: [] })
342
+ // data is [] before the first snapshot; loading is still true
343
+ ```
344
+
345
+ **Errors.** `error` carries a **cold-start** stream failure (nothing ever
346
+ arrived) — check it to avoid an infinite skeleton. A failure AFTER data arrived
347
+ deliberately does NOT replace good data with an error banner (a transient
348
+ websocket hiccup would blank a working screen); those reach the api's error bus
349
+ instead — subscribe with `useOnRpcError` for connection-level UX.
350
+
351
+
318
352
 
319
353
  ---
320
354
 
@@ -432,6 +466,35 @@ export default function NewNote() {
432
466
 
433
467
  `useMutation` returns `mutate`, `pending`, `error`, `data`, plus the chainable optimistic helpers.
434
468
 
469
+ ### Handling the result — `onSuccess` / `onError` / `notify`
470
+
471
+ Pass a result handler to `mutate` instead of wrapping every call in
472
+ `try/catch/finally` + toasts. `pending` already replaces the `finally`:
473
+
474
+ ```ts
475
+ const create = useMutation('app', 'teams.create')
476
+
477
+ await create.mutate(input, {
478
+ onSuccess: (team) => setOpen(false),
479
+ notify: { success: t('teams.created'), error: (e) => messageFor(e) },
480
+ })
481
+ ```
482
+
483
+ **The load-bearing rule:** supplying an error handler (`onError` **or**
484
+ `notify.error`) marks the failure **handled** — `mutate` then resolves with
485
+ `undefined` instead of rejecting, which is what removes the `try/catch`. With no
486
+ error handler it rejects exactly as before, so an unhandled failure stays loud.
487
+ You opt in per call.
488
+
489
+ `notify` routes to an app-wide sink you register once — the framework is not
490
+ bound to any toast library:
491
+
492
+ ```ts
493
+ import { setMutationNotifier } from '@voltro/client'
494
+ setMutationNotifier({ success: (m) => toast.success(m), error: (m) => toast.error(m) })
495
+ ```
496
+
497
+
435
498
  ## Auto-Optimistic
436
499
 
437
500
  The default path is declarative:
@@ -492,6 +555,30 @@ target: {
492
555
  - `op: 'delete'` filters the item out by its key.
493
556
  - `by` overrides the item-key field (default `'id'`).
494
557
 
558
+ **Shape the item with `shapeItem` (not `shape`).** For a nested target, build/patch the item with `shapeItem` — it is typed to the **item** of the nested array, not the mutation's output, so `current` needs no cast:
559
+
560
+ ```ts
561
+ target: {
562
+ table: 'projectRoadmaps', op: 'update', path: 'snapshot.projects',
563
+ identify: (input) => input.projectId,
564
+ shapeItem: (input, current) => ({ ...current, startDate: input.startDate }), // `current` IS the item
565
+ }
566
+ ```
567
+
568
+ (The flat `shape` stays bound to the output row — a single field can't be both, so the nested shaper is its own.)
569
+
570
+ **Bulk (multi-item) patches.** `identify` may return an **array** of ids to patch or delete **many** items in one mutation — exactly the group-drag / batch-edit where per-item parallel writes used to race:
571
+
572
+ ```ts
573
+ target: {
574
+ table: 'projectRoadmaps', op: 'update', path: 'snapshot.projects',
575
+ identify: (input) => input.projectIds, // ← ARRAY: patch them all
576
+ shapeItem: (input, current) => ({ ...current, shiftedBy: input.delta }), // each keeps its own key
577
+ }
578
+ ```
579
+
580
+ (This works for flat top-level targets too — `identify` returning an array patches/deletes every matching row.)
581
+
495
582
  Add `match` to patch **only** the entries whose current value satisfies a predicate — the guard that stops a patch bleeding across sibling subscriptions sharing a source table:
496
583
 
497
584
  ```ts
@@ -502,7 +589,7 @@ target: {
502
589
  }
503
590
  ```
504
591
 
505
- `path`, `by`, and `match` are browser-safe descriptor data (a dot-path string + a pure predicate) — the same discipline as `identify`/`shape`.
592
+ `path`, `by`, `match`, and `shapeItem` are browser-safe descriptor data (a dot-path string + pure functions) — the same discipline as `identify`/`shape`.
506
593
 
507
594
  ## Typed Errors
508
595
 
@@ -524,6 +611,42 @@ export const createNote = defineMutation({
524
611
 
525
612
  Throw a matching error from the server file; the client can narrow on `_tag`.
526
613
 
614
+ ### Matching typed errors on the client
615
+
616
+ Tagged errors **round-trip structurally** over the wire — the caught value carries `_tag` plus every declared field as real properties (and `instanceof` works, same Schema class both ends). You do **not** need to parse the error message string.
617
+
618
+ Inside Effect, use `Effect.catchTag('NoteQuotaExceeded', …)`. In a React `try/catch` (outside Effect, where `catchTag` isn't available and the decoded value may be a plain object, not a class instance), match with **`errorTag(err)`** — the dependency-free tag reader `@voltro/client` ships:
619
+
620
+ ```ts
621
+ import { errorTag } from '@voltro/client'
622
+
623
+ try {
624
+ await createNote(input)
625
+ } catch (err) {
626
+ if (errorTag(err) === 'NoteQuotaExceeded') {
627
+ // err.limit is the declared field — read it directly, no regex
628
+ }
629
+ }
630
+ ```
631
+
632
+ **Trace id for debugging.** An error caught from `useMutation` / `useAction` carries a **non-enumerable `__voltroTraceId`** — the bridge to the server logs for that exact call:
633
+
634
+ ```ts
635
+ const traceId = (err as { __voltroTraceId?: string }).__voltroTraceId
636
+ // → `voltro logs --trace <traceId>` to see the server-side span
637
+ ```
638
+
639
+ **Exhaustive matching with the generated `matchError`.** Codegen emits a per-app `matchError` (plus `AppError` / `AppErrorTag`) into `rpcGroup.generated.ts`, derived by reference from every descriptor's `error:` schema + your plugins' cross-cutting errors — so there's no hand-maintained tag list to drift out of date (a dead/renamed tag is a compile error):
640
+
641
+ ```ts
642
+ import { matchError } from './rpcGroup.generated'
643
+
644
+ const message = matchError(err, {
645
+ NoteQuotaExceeded: (e) => `Limit ${e.limit} reached`, // e is typed
646
+ ScopeError: (e) => `Missing ${e.required}`,
647
+ }, () => 'Something went wrong')
648
+ ```
649
+
527
650
  ## When Not To Use A Mutation
528
651
 
529
652
  - **External I/O.** Use an action or workflow.
@@ -1116,6 +1239,7 @@ The reading API is **explicit + namespace-only** — `useAggregate(def).read(...
1116
1239
 
1117
1240
  ```ts
1118
1241
  handle.read({
1242
+ where: { teamId: 't1' }, // filter the materialised rows (see below)
1119
1243
  limit: 10, // pagination
1120
1244
  offset: 20,
1121
1245
  orderBy: 'rank', // any column in the output schema
@@ -1124,7 +1248,34 @@ handle.read({
1124
1248
  })
1125
1249
  ```
1126
1250
 
1127
- There's deliberately no `filter` / `where` / `join`. If you need a different cut of the data, define another aggregate.
1251
+ ### Parameterised reads `where`
1252
+
1253
+ Without a filter an aggregate can only ever be "the one global roll-up". Every tenant-, team- or period-scoped roll-up — which is most of the real ones — then has to read the *whole* aggregate and filter client-side: every row crosses the wire so the caller can throw most of them away. `where` moves that cut to the read.
1254
+
1255
+ ```ts
1256
+ // one team's rows, for one year
1257
+ const rows = yield* handle.read({ where: { teamId: 'team_7', year: 2026 } })
1258
+
1259
+ // an array is an IN set — status is 'open' OR 'blocked'
1260
+ const active = yield* handle.read({ where: { status: ['open', 'blocked'] } })
1261
+
1262
+ // composes with the other read options
1263
+ const top = yield* handle.read({
1264
+ where: { teamId: 'team_7', status: ['open', 'blocked'] },
1265
+ orderBy: 'rank',
1266
+ limit: 10,
1267
+ })
1268
+ ```
1269
+
1270
+ The semantics, exactly:
1271
+
1272
+ - **Entries are ANDed** — a row matches only when it satisfies *every* entry.
1273
+ - **A scalar value means strict equality** (`===`) against that field on the row.
1274
+ - **An array value means IN** — the row's value must be one of the array's entries.
1275
+ - An omitted `where`, or an empty `{}`, filters nothing.
1276
+ - Filtering happens **before** `orderBy` and `limit` / `offset`, so pagination paginates the filtered set.
1277
+
1278
+ `where` is deliberately **data, not a predicate function**. It is applied over the rows the aggregate has already materialised — the refresh still computes the full roll-up, and `where` cuts the result before it crosses the wire. Keeping it a serializable record of field → value (rather than a callback) is what leaves the door open to pushing the same filter down to the store later. A cut you can't express as equality / IN belongs in another aggregate rather than in the read.
1128
1279
 
1129
1280
  ### Metadata
1130
1281
 
@@ -1291,11 +1442,11 @@ Mental model: `'replace'` is "snapshot at time T"; `'merge'` is "incremental del
1291
1442
  The defining property of an aggregate is **the query is fixed in advance**. Treating it as a query-buildable virtual table (`database.topPlayers.where(...)`) opens four footguns:
1292
1443
 
1293
1444
  1. **Hidden staleness.** `database.topPlayers.where(...)` looks like a live query. Readers can't tell it's stale data.
1294
- 2. **Computation drift.** Adding `WHERE region='EU'` shifts the filter from refresh-time to read-time — the materialisation point IS the query; don't re-query it.
1445
+ 2. **Computation drift.** A full query builder shifts arbitrary computation from refresh-time to read-time — the materialisation point IS the query; don't re-query it. `read({ where })` is the bounded exception: an equality / IN cut of rows that are *already* materialised, not a new query.
1295
1446
  3. **Misleading expectations.** Users would reflexively try `database.topPlayers.insert(...)`. Framework would either silently do nothing or error with a cryptic message.
1296
1447
  4. **Cross-timeline joins.** Joining an aggregate with a live table mixes two timelines (refresh-time + now). Mostly a footgun.
1297
1448
 
1298
- The explicit namespace (`useAggregate(def).read(...)`) makes the materialisation explicit. Friction in the wrong direction (filtering, joining) is a feature it pushes you to either define another aggregate or do the work in app code with clear boundaries.
1449
+ The explicit namespace (`useAggregate(def).read(...)`) makes the materialisation explicit. `where` covers the one cut that genuinely belongs at read time — scoping a roll-up to a tenant, a team, a period. Everything past it (joins, aggregating over the aggregate, arbitrary predicates) keeps its friction on purpose: it pushes you to either define another aggregate or do the work in app code with clear boundaries.
1299
1450
 
1300
1451
  ## Decision: aggregate vs subscriber vs cron
1301
1452
 
@@ -242,9 +242,11 @@ Cross-dialect:
242
242
  | Dialect | Support |
243
243
  |---------------------|----------------------------------------------------------------|
244
244
  | postgres / sqlite / mssql | native partial `CREATE UNIQUE INDEX … WHERE` |
245
- | mysql / mariadb | FAILS LOUDLY at migrate no partial-index support; a full unique index would forbid re-creating a soft-deleted key. Use a generated STORED column + `.unique([...])` there. |
245
+ | mysql / mariadb | lowered automatically to a generated STORED column per key column (NULL when soft-deleted) + a UNIQUE over them NULL-distinct gives the same resurrection-safe semantics. Nothing to hand-write. |
246
246
 
247
- The predicate is emitted verbatim (ANSI double-quoted identifiers, valid on the three supported dialects).
247
+ **mysql / mariadb — how the emulation works.** Those engines have no partial index, so `.uniqueActive(['projectId', 'year'])` lowers to one `CASE WHEN <predicate> THEN CAST(<col> AS CHAR(255)) ELSE NULL END` STORED column per key column plus a `UNIQUE` over them. A soft-deleted row's generated columns are all NULL, and mysql/mariadb treat NULLs as DISTINCT in a unique index, so it never collides — re-creating the key just works, exactly like the partial index elsewhere. This round-trips through the declarative differ (the generated columns are part of the declared snapshot on those dialects, so `voltro dev` never re-plans them). You write the same `.uniqueActive([...])` on every dialect.
248
+
249
+ The predicate is emitted verbatim (ANSI double-quoted identifiers; on mysql/mariadb they are re-quoted with backticks inside the generated column).
248
250
 
249
251
  ## When NOT to index
250
252
 
@@ -100,6 +100,8 @@ ctx.store.select('notes').where(not(eq('archived', true)))
100
100
 
101
101
  `and(...)` is rarely needed because chained `.where()` calls are already AND'd; useful inside `or(...)` to nest.
102
102
 
103
+ `eq(col, val)` (and the other predicate helpers) is **callable without a row-type generic** — it defaults to a loose row shape — so in generic handler code you write `eq('teamId', id)` directly. There's no need for a `const ef = (c, v) => eq<Row, string>(c, v)` wrapper.
104
+
103
105
  ### JSON path filters
104
106
 
105
107
  For `json<T>()` columns: