@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.
- package/CHANGELOG.md +29 -0
- package/dist/{apiBuild-DdgYydVJ.js → apiBuild-CvtQeBMs.js} +2 -5
- package/dist/apiBuild-DQBNqNZ8.js +2 -0
- package/dist/bin.js +2 -2
- package/dist/{commands-CXESev-z.js → commands-DhyBIs1O.js} +2182 -1971
- package/dist/{dev-x_VqbV_8.js → dev-CQxbrpDz.js} +1043 -1003
- package/dist/dev-DYjGqPGD.js +2 -0
- package/dist/index.js +1 -1
- package/dist/serveCommand-BZzUJIyo.js +1077 -0
- package/dist/serveEntry.js +2 -2
- package/package.json +17 -17
- package/templates/AGENTS.core.md +47 -0
- package/templates/AGENTS.md +47 -0
- package/templates/agent-docs/_manifest.json +3 -3
- package/templates/agent-docs/ai.md +28 -0
- package/templates/agent-docs/authentication.md +5 -2
- package/templates/agent-docs/cli.md +39 -0
- package/templates/agent-docs/data.md +155 -4
- package/templates/agent-docs/database/advancedqueries.md +4 -2
- package/templates/agent-docs/database/querying.md +2 -0
- package/templates/agent-docs/internationalization.md +237 -2
- package/templates/agent-docs/observability.md +9 -0
- package/templates/agent-docs/plugins.md +2 -2
- package/templates/agent-docs/reference.md +76 -0
- package/templates/agent-docs/schema-driven-ui.md +551 -2
- package/templates/agent-docs/templates/apibackends.md +10 -8
- package/templates/agent-docs/templates/overview.md +1 -1
- package/templates/agent-docs/testing.md +177 -3
- package/templates/apps/api-ai/package.json +7 -7
- package/templates/apps/api-auth/package.json +8 -8
- package/templates/apps/api-backend/package.json +7 -7
- package/templates/apps/api-backend-deactivation/package.json +7 -7
- package/templates/apps/api-backend-mail/package.json +8 -8
- package/templates/apps/api-backend-mariadb/package.json +9 -9
- package/templates/apps/api-backend-storage/package.json +8 -8
- package/templates/apps/api-data-advanced/package.json +8 -8
- package/templates/apps/api-durable/package.json +8 -8
- package/templates/apps/api-feature-flags/package.json +9 -9
- package/templates/apps/api-governance/package.json +8 -8
- package/templates/apps/api-kv/package.json +8 -8
- package/templates/apps/api-moderation/package.json +8 -8
- package/templates/apps/api-observability/package.json +8 -8
- package/templates/apps/api-ratelimit/package.json +8 -8
- package/templates/apps/api-rbac/README.md +9 -4
- package/templates/apps/api-rbac/mutations/notes.create.mutation.server.ts +1 -1
- package/templates/apps/api-rbac/mutations/notes.create.mutation.ts +6 -6
- package/templates/apps/api-rbac/package.json +8 -8
- package/templates/apps/api-rbac/tests/notes.create.test.ts +8 -8
- package/templates/apps/api-rest/package.json +7 -7
- package/templates/apps/api-saas/package.json +11 -11
- package/templates/apps/api-search/package.json +8 -8
- package/templates/apps/api-versioning/package.json +8 -8
- package/templates/apps/api-webhooks/package.json +8 -8
- package/templates/apps/changelog/package.json +6 -6
- package/templates/apps/edge-functions/package.json +2 -2
- package/templates/apps/frontend-admin/package.json +8 -8
- package/templates/apps/frontend-app/package.json +8 -8
- package/templates/apps/frontend-blank/package.json +7 -7
- package/templates/apps/frontend-contact/package.json +7 -7
- package/templates/apps/frontend-dashboard/package.json +7 -7
- package/templates/apps/frontend-docs/package.json +7 -7
- package/templates/apps/frontend-i18n/package.json +6 -6
- package/templates/apps/frontend-landing/package.json +7 -7
- package/templates/apps/frontend-spa/package.json +7 -7
- package/templates/apps/frontend-ssr/package.json +7 -7
- package/templates/apps/frontend-ssr-api/package.json +8 -8
- package/templates/apps/frontend-static-blog/package.json +6 -6
- package/dist/apiBuild-cadmH8ca.js +0 -2
- package/dist/dev-DemiMSSl.js +0 -2
- package/dist/serveCommand-Dttqe5Ms.js +0 -1077
package/dist/serveEntry.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
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-
|
|
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
|
+
"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.
|
|
56
|
-
"@voltro/cache": "0.
|
|
57
|
-
"@voltro/data-transfer": "0.
|
|
58
|
-
"@voltro/database": "0.
|
|
59
|
-
"@voltro/env": "0.
|
|
60
|
-
"@voltro/kv": "0.
|
|
61
|
-
"@voltro/logger": "0.
|
|
62
|
-
"@voltro/plugin-auth": "0.
|
|
63
|
-
"@voltro/plugin-broadcast": "0.
|
|
64
|
-
"@voltro/plugin-mail": "0.
|
|
65
|
-
"@voltro/plugin-storage": "0.
|
|
66
|
-
"@voltro/plugin-webhooks": "0.
|
|
67
|
-
"@voltro/protocol": "0.
|
|
68
|
-
"@voltro/runtime": "0.
|
|
69
|
-
"@voltro/serverless": "0.
|
|
70
|
-
"@voltro/workflow": "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"
|
package/templates/AGENTS.core.md
CHANGED
|
@@ -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
|
package/templates/AGENTS.md
CHANGED
|
@@ -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":
|
|
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":
|
|
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":
|
|
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,
|
|
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
|
|
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 `
|
|
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
|
-
|
|
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.**
|
|
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.
|
|
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 |
|
|
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
|
-
|
|
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:
|