@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.
- package/CHANGELOG.md +50 -0
- package/dist/{apiBuild-DdgYydVJ.js → apiBuild-CZugTK46.js} +2 -5
- package/dist/apiBuild-D-iBYKa3.js +2 -0
- package/dist/bin.js +2 -2
- package/dist/{commands-CXESev-z.js → commands-CJheDVOr.js} +2435 -2000
- package/dist/{dev-x_VqbV_8.js → dev-B71ICpXJ.js} +1993 -1823
- package/dist/dev-DNUetyG2.js +2 -0
- package/dist/index.js +1 -1
- package/dist/serveCommand-C0WFA396.js +1084 -0
- package/dist/serveEntry.js +2 -2
- package/package.json +17 -17
- package/templates/AGENTS.core.md +131 -0
- package/templates/AGENTS.md +131 -0
- package/templates/agent-docs/_manifest.json +4 -4
- package/templates/agent-docs/ai.md +34 -2
- package/templates/agent-docs/authentication.md +74 -2
- package/templates/agent-docs/cli.md +115 -0
- package/templates/agent-docs/data.md +302 -7
- package/templates/agent-docs/database/advancedqueries.md +25 -2
- package/templates/agent-docs/database/querying.md +17 -5
- package/templates/agent-docs/database/schema.md +5 -2
- 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 +110 -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/agent-docs/workflows.md +11 -8
- 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
|
@@ -30,6 +30,7 @@ The dispatcher routes `voltro <command> [args]` to the matching subcommand and p
|
|
|
30
30
|
| Ops / infra | `cache` (`status` / `flush` / `invalidate`), `add` (`redis`), `baseline` (`list` / `status` / `set`), `schedule-manifest`, [`storage`](/docs/plugins/storage) (`doctor` / `cors`) |
|
|
31
31
|
| AI / data | `embeddings backfill <table> --text <field> --vector <field>` — (re)embed rows the `vectorEmbedding()` mixin missed (pre-existing rows / a model change); `--dry-run` to preview |
|
|
32
32
|
| [Inspect & debug](/docs/cli/inspect) | `inspect`, `logs`, `traces`, `workflows`, `cluster`, `check` |
|
|
33
|
+
| [Health & surface](/docs/cli/build-and-start) | [`doctor`](/docs/cli/build-and-start) — serve preflight + the hand-roll detector (names the shipped primitive at the spot you're rebuilding it); [`capabilities`](/docs/cli/build-and-start) (`--json`) — the export surface read from your installed `@voltro/*`, so it can be verified instead of recalled |
|
|
33
34
|
| Harness | `test`, `e2e` |
|
|
34
35
|
| Cloud | `cloud` (`login` / `whoami` / `projects` / `env` / `import`) |
|
|
35
36
|
| Secrets | `secret` (`generate [purpose]` — the right var+format per secret; `generate` alone → a generic secret; `list`) |
|
|
@@ -722,6 +723,98 @@ For WebSocket connections to land on the same backend (required for in-process s
|
|
|
722
723
|
- Reverse proxy: `lb_policy ip_hash` (Caddy) / `ip_hash` (nginx).
|
|
723
724
|
- Or use `@voltro/plugin-cluster` to share subscription state across instances → any-load-balancer-works.
|
|
724
725
|
|
|
726
|
+
## `voltro doctor` — preflight a production serve
|
|
727
|
+
|
|
728
|
+
Production `voltro serve` for an **API** app boots ONLY from the precompiled serve
|
|
729
|
+
bundle and is **fatal if it's missing** — a hand-rolled Dockerfile that runs
|
|
730
|
+
`voltro serve` without a prior `voltro build` breaks at deploy. `voltro doctor`
|
|
731
|
+
(or `voltro serve --preflight`) catches that at BUILD time instead of cold-start:
|
|
732
|
+
|
|
733
|
+
```bash
|
|
734
|
+
voltro doctor . # check the serve bundle exists; print the fix if not
|
|
735
|
+
voltro serve --preflight . # same check, then exit — never boots
|
|
736
|
+
```
|
|
737
|
+
|
|
738
|
+
It exits **1** when the serve bundle is missing on an API app (so it fails a CI /
|
|
739
|
+
Docker step) and prints the exact remedy: add a `voltro build .` step before
|
|
740
|
+
`voltro serve .`. Drop it into your image build right after `voltro build` to
|
|
741
|
+
guarantee the artefact is present before the image ships.
|
|
742
|
+
|
|
743
|
+
### The hand-roll detector
|
|
744
|
+
|
|
745
|
+
`voltro doctor` also scans your source for shapes the framework already has a
|
|
746
|
+
primitive for, and names the primitive at the spot the hand-roll lives. This is
|
|
747
|
+
**advisory and never blocking** — it prints, it does not fail your build.
|
|
748
|
+
|
|
749
|
+
```bash
|
|
750
|
+
voltro doctor .
|
|
751
|
+
```
|
|
752
|
+
|
|
753
|
+
```text
|
|
754
|
+
• Shipped primitives you may be hand-rolling:
|
|
755
|
+
[server]
|
|
756
|
+
hand-written not-found branch on rows[0] — 12 file(s): queries/team.get.ts, …
|
|
757
|
+
→ .one() — fails with the typed NoRowFound on zero rows AND on more than one
|
|
758
|
+
[client]
|
|
759
|
+
per-field useState + a submit flag (hand-rolled form) — 4 file(s): src/create-dialog.tsx, …
|
|
760
|
+
→ useFormBinding — fields + validation from the mutation input Schema
|
|
761
|
+
```
|
|
762
|
+
|
|
763
|
+
It covers both halves of the stack:
|
|
764
|
+
|
|
765
|
+
| Scope | It notices | Reach for |
|
|
766
|
+
|---|---|---|
|
|
767
|
+
| server | `if (!rows[0]) throw …` | `.one()` / `.first()` |
|
|
768
|
+
| server | 3+ sequential `store.query` in one handler | `relations()` + `.with()` |
|
|
769
|
+
| server | `Effect.promise(() => ctx.store.…)` | `yield* EffectStore` |
|
|
770
|
+
| server | `requireScope(...)` at the top of an executor | `guards:` on the descriptor |
|
|
771
|
+
| server | a `token` / `secret` / `password` column with no encryption | `.encrypted()` |
|
|
772
|
+
| server | a notify / webhook helper called at a mutation's tail | `defineSubscriber` / `defineReaction` |
|
|
773
|
+
| server | `hasMore` + `limit + 1` | `paginateById` |
|
|
774
|
+
| client | per-field `useState` + a submit flag | `useFormBinding` |
|
|
775
|
+
| client | a table with local sort/filter state | `useDataTable` |
|
|
776
|
+
| client | `FileReader` / `readAsDataURL` | `useUpload` |
|
|
777
|
+
| client | `setTimeout` debounce in a `useEffect` | `useDebounced` |
|
|
778
|
+
| client | `useMemo` fanning in several subscriptions | `useDerived` |
|
|
779
|
+
| client | a local Next.js compat shim | the native `@voltro/web` exports |
|
|
780
|
+
| client | a hand-rolled presence heartbeat | `@voltro/plugin-presence` |
|
|
781
|
+
|
|
782
|
+
The rules are deliberately conservative — a detector that cries wolf trains you
|
|
783
|
+
to ignore it. A column that already carries `.encrypted()`, or a handler that
|
|
784
|
+
already uses `.one()`, stays silent.
|
|
785
|
+
|
|
786
|
+
## `voltro capabilities` — what the framework actually exports
|
|
787
|
+
|
|
788
|
+
Asked "what does this framework export", a language model will produce a
|
|
789
|
+
confident answer whether or not it knows. This command replaces that guess with
|
|
790
|
+
a reading of the `.d.ts` files in your own `node_modules`:
|
|
791
|
+
|
|
792
|
+
```bash
|
|
793
|
+
voltro capabilities # human summary, grouped by package
|
|
794
|
+
voltro capabilities --json # the full machine-readable surface
|
|
795
|
+
```
|
|
796
|
+
|
|
797
|
+
```text
|
|
798
|
+
voltro capabilities — 2517 exported symbols across 36 packages
|
|
799
|
+
@voltro/runtime@0.4.0 — 9 primitives, 41 values, 118 types
|
|
800
|
+
defineAggregate, defineReaction, defineSubscriber, defineResourcePolicy, …
|
|
801
|
+
@voltro/client@0.4.0 — 47 hooks, 12 components, 60 types
|
|
802
|
+
useSubscription, useMutation, useFormBinding, useDataTable, useUpload, …
|
|
803
|
+
|
|
804
|
+
* 8 primitive(s)/hook(s) appear nowhere in this project's agent guide:
|
|
805
|
+
@voltro/plugin-mail: defineEmail
|
|
806
|
+
…
|
|
807
|
+
```
|
|
808
|
+
|
|
809
|
+
Every symbol reported was read out of an installed package a moment ago, so an
|
|
810
|
+
agent can **verify** the surface instead of recalling it. The `--json` form is
|
|
811
|
+
stable and locale-independent — the same tree produces byte-identical output on
|
|
812
|
+
every machine, so you can diff it across upgrades.
|
|
813
|
+
|
|
814
|
+
Symbols marked `*` ship but appear nowhere in this project's seeded `AGENTS.md`
|
|
815
|
+
/ `CLAUDE.md`. Refresh the guide with `voltro agents-md --force`, or read that
|
|
816
|
+
package's README.
|
|
817
|
+
|
|
725
818
|
## Anti-patterns
|
|
726
819
|
|
|
727
820
|
- **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 +1765,30 @@ voltro update --to 0.4.0 # pin an explicit target version
|
|
|
1672
1765
|
voltro update --dry-run # preview the bump + which codemods would run — writes nothing
|
|
1673
1766
|
voltro update --force # allow a dirty working tree (not recommended)
|
|
1674
1767
|
voltro update --exact # pin exact versions (drop the ^ / ~ range prefix)
|
|
1768
|
+
|
|
1769
|
+
# Recover the codemods after a MANUAL version bump (no bump, no install):
|
|
1770
|
+
voltro update --codemods-only --from 0.3.0 # re-run codemods 0.3.0 → installed
|
|
1771
|
+
voltro update --codemods-only --from 0.3.0 --to 0.4.0 # explicit delta
|
|
1772
|
+
```
|
|
1773
|
+
|
|
1774
|
+
## Already bumped by hand? Recover the codemods
|
|
1775
|
+
|
|
1776
|
+
If you bump `@voltro/*` versions in `package.json` yourself and install first, a
|
|
1777
|
+
plain `voltro update` sees the installed version already equals the target and
|
|
1778
|
+
reports **"already on X — nothing to do"** — skipping the codemods AND the
|
|
1779
|
+
printed manual steps for the delta you actually crossed. To re-apply them without
|
|
1780
|
+
touching `package.json` again:
|
|
1781
|
+
|
|
1782
|
+
```bash
|
|
1783
|
+
voltro update --codemods-only --from <version-you-came-from>
|
|
1675
1784
|
```
|
|
1676
1785
|
|
|
1786
|
+
`--codemods-only` (alias `--run-codemods`) runs the codemods + manual notes for
|
|
1787
|
+
`[from, to]` against the already-installed tree — no version bump, no install.
|
|
1788
|
+
`--to` defaults to the installed version; pass it to pin an explicit delta.
|
|
1789
|
+
`--from` also works on a normal `voltro update` to override the auto-detected
|
|
1790
|
+
source version.
|
|
1791
|
+
|
|
1677
1792
|
## The clean-tree guard
|
|
1678
1793
|
|
|
1679
1794
|
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
|
|
|
@@ -374,12 +408,14 @@ export default async (
|
|
|
374
408
|
tenantId: ctx.request.subject.tenantId,
|
|
375
409
|
})
|
|
376
410
|
|
|
377
|
-
return { id: inserted['id']
|
|
411
|
+
return { id: String(inserted['id']) }
|
|
378
412
|
}
|
|
379
413
|
```
|
|
380
414
|
|
|
381
415
|
The descriptor is the wire contract. The `.mutation.server.ts` file is the server-only implementation.
|
|
382
416
|
|
|
417
|
+
`insert` returns the post-image as an untyped `Row` (`Readonly<Record<string, unknown>>`), so narrow the field you need (`String(...)`) instead of asserting it with `as string` — an assertion silences the compiler without checking anything. To read a row back, use the fluent terminals: `ctx.store.select('notes').where('id', id).one()` fails with the typed `NoRowFound` when the row is missing (or when more than one matches), so you never need a hand-written not-found branch; `.first()` / `.maybeOne()` return `null` instead.
|
|
418
|
+
|
|
383
419
|
## What The Runtime Does
|
|
384
420
|
|
|
385
421
|
1. Decode `input` with the descriptor schema.
|
|
@@ -432,6 +468,35 @@ export default function NewNote() {
|
|
|
432
468
|
|
|
433
469
|
`useMutation` returns `mutate`, `pending`, `error`, `data`, plus the chainable optimistic helpers.
|
|
434
470
|
|
|
471
|
+
### Handling the result — `onSuccess` / `onError` / `notify`
|
|
472
|
+
|
|
473
|
+
Pass a result handler to `mutate` instead of wrapping every call in
|
|
474
|
+
`try/catch/finally` + toasts. `pending` already replaces the `finally`:
|
|
475
|
+
|
|
476
|
+
```ts
|
|
477
|
+
const create = useMutation('app', 'teams.create')
|
|
478
|
+
|
|
479
|
+
await create.mutate(input, {
|
|
480
|
+
onSuccess: (team) => setOpen(false),
|
|
481
|
+
notify: { success: t('teams.created'), error: (e) => messageFor(e) },
|
|
482
|
+
})
|
|
483
|
+
```
|
|
484
|
+
|
|
485
|
+
**The load-bearing rule:** supplying an error handler (`onError` **or**
|
|
486
|
+
`notify.error`) marks the failure **handled** — `mutate` then resolves with
|
|
487
|
+
`undefined` instead of rejecting, which is what removes the `try/catch`. With no
|
|
488
|
+
error handler it rejects exactly as before, so an unhandled failure stays loud.
|
|
489
|
+
You opt in per call.
|
|
490
|
+
|
|
491
|
+
`notify` routes to an app-wide sink you register once — the framework is not
|
|
492
|
+
bound to any toast library:
|
|
493
|
+
|
|
494
|
+
```ts
|
|
495
|
+
import { setMutationNotifier } from '@voltro/client'
|
|
496
|
+
setMutationNotifier({ success: (m) => toast.success(m), error: (m) => toast.error(m) })
|
|
497
|
+
```
|
|
498
|
+
|
|
499
|
+
|
|
435
500
|
## Auto-Optimistic
|
|
436
501
|
|
|
437
502
|
The default path is declarative:
|
|
@@ -492,6 +557,30 @@ target: {
|
|
|
492
557
|
- `op: 'delete'` filters the item out by its key.
|
|
493
558
|
- `by` overrides the item-key field (default `'id'`).
|
|
494
559
|
|
|
560
|
+
**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:
|
|
561
|
+
|
|
562
|
+
```ts
|
|
563
|
+
target: {
|
|
564
|
+
table: 'projectRoadmaps', op: 'update', path: 'snapshot.projects',
|
|
565
|
+
identify: (input) => input.projectId,
|
|
566
|
+
shapeItem: (input, current) => ({ ...current, startDate: input.startDate }), // `current` IS the item
|
|
567
|
+
}
|
|
568
|
+
```
|
|
569
|
+
|
|
570
|
+
(The flat `shape` stays bound to the output row — a single field can't be both, so the nested shaper is its own.)
|
|
571
|
+
|
|
572
|
+
**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:
|
|
573
|
+
|
|
574
|
+
```ts
|
|
575
|
+
target: {
|
|
576
|
+
table: 'projectRoadmaps', op: 'update', path: 'snapshot.projects',
|
|
577
|
+
identify: (input) => input.projectIds, // ← ARRAY: patch them all
|
|
578
|
+
shapeItem: (input, current) => ({ ...current, shiftedBy: input.delta }), // each keeps its own key
|
|
579
|
+
}
|
|
580
|
+
```
|
|
581
|
+
|
|
582
|
+
(This works for flat top-level targets too — `identify` returning an array patches/deletes every matching row.)
|
|
583
|
+
|
|
495
584
|
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
585
|
|
|
497
586
|
```ts
|
|
@@ -502,7 +591,7 @@ target: {
|
|
|
502
591
|
}
|
|
503
592
|
```
|
|
504
593
|
|
|
505
|
-
`path`, `by`, and `
|
|
594
|
+
`path`, `by`, `match`, and `shapeItem` are browser-safe descriptor data (a dot-path string + pure functions) — the same discipline as `identify`/`shape`.
|
|
506
595
|
|
|
507
596
|
## Typed Errors
|
|
508
597
|
|
|
@@ -524,6 +613,42 @@ export const createNote = defineMutation({
|
|
|
524
613
|
|
|
525
614
|
Throw a matching error from the server file; the client can narrow on `_tag`.
|
|
526
615
|
|
|
616
|
+
### Matching typed errors on the client
|
|
617
|
+
|
|
618
|
+
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.
|
|
619
|
+
|
|
620
|
+
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:
|
|
621
|
+
|
|
622
|
+
```ts
|
|
623
|
+
import { errorTag } from '@voltro/client'
|
|
624
|
+
|
|
625
|
+
try {
|
|
626
|
+
await createNote(input)
|
|
627
|
+
} catch (err) {
|
|
628
|
+
if (errorTag(err) === 'NoteQuotaExceeded') {
|
|
629
|
+
// err.limit is the declared field — read it directly, no regex
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
```
|
|
633
|
+
|
|
634
|
+
**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:
|
|
635
|
+
|
|
636
|
+
```ts
|
|
637
|
+
const traceId = (err as { __voltroTraceId?: string }).__voltroTraceId
|
|
638
|
+
// → `voltro logs --trace <traceId>` to see the server-side span
|
|
639
|
+
```
|
|
640
|
+
|
|
641
|
+
**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):
|
|
642
|
+
|
|
643
|
+
```ts
|
|
644
|
+
import { matchError } from './rpcGroup.generated'
|
|
645
|
+
|
|
646
|
+
const message = matchError(err, {
|
|
647
|
+
NoteQuotaExceeded: (e) => `Limit ${e.limit} reached`, // e is typed
|
|
648
|
+
ScopeError: (e) => `Missing ${e.required}`,
|
|
649
|
+
}, () => 'Something went wrong')
|
|
650
|
+
```
|
|
651
|
+
|
|
527
652
|
## When Not To Use A Mutation
|
|
528
653
|
|
|
529
654
|
- **External I/O.** Use an action or workflow.
|
|
@@ -1087,9 +1212,9 @@ export default defineAggregate({
|
|
|
1087
1212
|
.descriptor,
|
|
1088
1213
|
)
|
|
1089
1214
|
return players.map((p, i) => ({
|
|
1090
|
-
playerId: p.id
|
|
1215
|
+
playerId: p.id,
|
|
1091
1216
|
rank: i + 1,
|
|
1092
|
-
kd: p.kdRatio
|
|
1217
|
+
kd: p.kdRatio,
|
|
1093
1218
|
}))
|
|
1094
1219
|
},
|
|
1095
1220
|
})
|
|
@@ -1116,6 +1241,7 @@ The reading API is **explicit + namespace-only** — `useAggregate(def).read(...
|
|
|
1116
1241
|
|
|
1117
1242
|
```ts
|
|
1118
1243
|
handle.read({
|
|
1244
|
+
where: { teamId: 't1' }, // filter the materialised rows (see below)
|
|
1119
1245
|
limit: 10, // pagination
|
|
1120
1246
|
offset: 20,
|
|
1121
1247
|
orderBy: 'rank', // any column in the output schema
|
|
@@ -1124,7 +1250,34 @@ handle.read({
|
|
|
1124
1250
|
})
|
|
1125
1251
|
```
|
|
1126
1252
|
|
|
1127
|
-
|
|
1253
|
+
### Parameterised reads — `where`
|
|
1254
|
+
|
|
1255
|
+
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.
|
|
1256
|
+
|
|
1257
|
+
```ts
|
|
1258
|
+
// one team's rows, for one year
|
|
1259
|
+
const rows = yield* handle.read({ where: { teamId: 'team_7', year: 2026 } })
|
|
1260
|
+
|
|
1261
|
+
// an array is an IN set — status is 'open' OR 'blocked'
|
|
1262
|
+
const active = yield* handle.read({ where: { status: ['open', 'blocked'] } })
|
|
1263
|
+
|
|
1264
|
+
// composes with the other read options
|
|
1265
|
+
const top = yield* handle.read({
|
|
1266
|
+
where: { teamId: 'team_7', status: ['open', 'blocked'] },
|
|
1267
|
+
orderBy: 'rank',
|
|
1268
|
+
limit: 10,
|
|
1269
|
+
})
|
|
1270
|
+
```
|
|
1271
|
+
|
|
1272
|
+
The semantics, exactly:
|
|
1273
|
+
|
|
1274
|
+
- **Entries are ANDed** — a row matches only when it satisfies *every* entry.
|
|
1275
|
+
- **A scalar value means strict equality** (`===`) against that field on the row.
|
|
1276
|
+
- **An array value means IN** — the row's value must be one of the array's entries.
|
|
1277
|
+
- An omitted `where`, or an empty `{}`, filters nothing.
|
|
1278
|
+
- Filtering happens **before** `orderBy` and `limit` / `offset`, so pagination paginates the filtered set.
|
|
1279
|
+
|
|
1280
|
+
`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
1281
|
|
|
1129
1282
|
### Metadata
|
|
1130
1283
|
|
|
@@ -1291,11 +1444,11 @@ Mental model: `'replace'` is "snapshot at time T"; `'merge'` is "incremental del
|
|
|
1291
1444
|
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
1445
|
|
|
1293
1446
|
1. **Hidden staleness.** `database.topPlayers.where(...)` looks like a live query. Readers can't tell it's stale data.
|
|
1294
|
-
2. **Computation drift.**
|
|
1447
|
+
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
1448
|
3. **Misleading expectations.** Users would reflexively try `database.topPlayers.insert(...)`. Framework would either silently do nothing or error with a cryptic message.
|
|
1296
1449
|
4. **Cross-timeline joins.** Joining an aggregate with a live table mixes two timelines (refresh-time + now). Mostly a footgun.
|
|
1297
1450
|
|
|
1298
|
-
The explicit namespace (`useAggregate(def).read(...)`) makes the materialisation explicit.
|
|
1451
|
+
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
1452
|
|
|
1300
1453
|
## Decision: aggregate vs subscriber vs cron
|
|
1301
1454
|
|
|
@@ -1621,6 +1774,148 @@ into the agent's prompt.
|
|
|
1621
1774
|
|
|
1622
1775
|
|
|
1623
1776
|
|
|
1777
|
+
---
|
|
1778
|
+
|
|
1779
|
+
<!-- source: en/data/outbox.md -->
|
|
1780
|
+
## Transactional outbox
|
|
1781
|
+
|
|
1782
|
+
_ctx.outbox.enqueue — a reliable external side effect from a mutation, committed in the same transaction as the write that caused it._
|
|
1783
|
+
|
|
1784
|
+
A mutation must not do external I/O. It runs in a transaction, and an HTTP call
|
|
1785
|
+
cannot be rolled back — if the request succeeds and the transaction then fails,
|
|
1786
|
+
you have charged a card for an order that does not exist.
|
|
1787
|
+
|
|
1788
|
+
So "write this row **and** sync it to Jira" has no correct one-step form. The
|
|
1789
|
+
usual workaround is to build one: a deliveries table written inside the
|
|
1790
|
+
transaction, a cron that drains it, and a worker with backoff and a dead-letter.
|
|
1791
|
+
That is a real subsystem, and every integration app rebuilds it.
|
|
1792
|
+
|
|
1793
|
+
`ctx.outbox` is that subsystem, as a one-liner.
|
|
1794
|
+
|
|
1795
|
+
```ts
|
|
1796
|
+
// apps/api/mutations/ticket.create.server.ts
|
|
1797
|
+
export default async (input: { title: string }, ctx: AppContext) => {
|
|
1798
|
+
const ticket = await ctx.store.insert('tickets', { title: input.title })
|
|
1799
|
+
|
|
1800
|
+
await ctx.outbox.enqueue('jira.sync', { ticketId: ticket.id }, {
|
|
1801
|
+
idempotencyKey: `jira.sync:${ticket.id}`,
|
|
1802
|
+
})
|
|
1803
|
+
|
|
1804
|
+
return { id: ticket.id }
|
|
1805
|
+
}
|
|
1806
|
+
```
|
|
1807
|
+
|
|
1808
|
+
## Why this is correct, not just convenient
|
|
1809
|
+
|
|
1810
|
+
`enqueue` writes through `ctx.store` — and inside a mutation, `ctx.store` **is
|
|
1811
|
+
the transactional view**. The outbox row commits in the same transaction as the
|
|
1812
|
+
domain write, or neither does.
|
|
1813
|
+
|
|
1814
|
+
That is the whole guarantee. There is no window in which the ticket exists and
|
|
1815
|
+
the intent to sync it was lost, because losing the intent means the ticket was
|
|
1816
|
+
rolled back too.
|
|
1817
|
+
|
|
1818
|
+
This is what separates it from reacting to a change *after* commit. A
|
|
1819
|
+
post-commit tap — including `@voltro/plugin-cdc-out`, which says so plainly —
|
|
1820
|
+
is at-least-once **from enqueue**: a crash between the commit and the tap loses
|
|
1821
|
+
the event. Here, enqueue cannot be lost.
|
|
1822
|
+
|
|
1823
|
+
Delivery *after* commit is still at-least-once. That is the strongest guarantee
|
|
1824
|
+
available without a distributed transaction into the target system, so:
|
|
1825
|
+
|
|
1826
|
+
**Handlers must be idempotent.** A process that dies between "the remote
|
|
1827
|
+
accepted it" and "we recorded that" will retry.
|
|
1828
|
+
|
|
1829
|
+
## Declaring the handler
|
|
1830
|
+
|
|
1831
|
+
One `*.outbox.ts` file per effect:
|
|
1832
|
+
|
|
1833
|
+
```ts
|
|
1834
|
+
// apps/api/outbox/jira.sync.outbox.ts
|
|
1835
|
+
import { defineOutboxHandler } from '@voltro/runtime'
|
|
1836
|
+
|
|
1837
|
+
export default defineOutboxHandler({
|
|
1838
|
+
effect: 'jira.sync',
|
|
1839
|
+
maxAttempts: 5,
|
|
1840
|
+
handler: async ({ payload, attempt, subjectId, traceId }) => {
|
|
1841
|
+
await jira.syncIssue(payload['ticketId'] as string)
|
|
1842
|
+
},
|
|
1843
|
+
})
|
|
1844
|
+
```
|
|
1845
|
+
|
|
1846
|
+
The handler runs **after** the enqueuing transaction committed, outside it, and
|
|
1847
|
+
may do external I/O — that is the point. It receives the payload, the attempt
|
|
1848
|
+
number, and the subject / tenant / trace of whoever enqueued it.
|
|
1849
|
+
|
|
1850
|
+
Two handlers claiming the same `effect` is refused at boot with both filenames,
|
|
1851
|
+
rather than letting whichever loaded last silently win.
|
|
1852
|
+
|
|
1853
|
+
## Retries, backoff, dead-letter
|
|
1854
|
+
|
|
1855
|
+
| | |
|
|
1856
|
+
|---|---|
|
|
1857
|
+
| Retry schedule | exponential — 1s, 2s, 4s … capped at 5 minutes |
|
|
1858
|
+
| Default attempts | 8 (`maxAttempts` on the handler, or per-enqueue) |
|
|
1859
|
+
| Exhausted | row moves to `dead`, logged at ERROR, stays in the table |
|
|
1860
|
+
| Unknown effect | left **pending**, never discarded |
|
|
1861
|
+
|
|
1862
|
+
That last row matters. The usual cause of an unknown effect is a deploy where
|
|
1863
|
+
the enqueuing code shipped ahead of its handler. Dead-lettering those would turn
|
|
1864
|
+
a rollout ordering detail into permanent loss of a side effect the app believes
|
|
1865
|
+
happened, so they wait instead.
|
|
1866
|
+
|
|
1867
|
+
A dead-lettered row is not deleted — it is queryable in `_voltro_outbox` with
|
|
1868
|
+
its `lastError`, because a dead letter is a side effect your app thinks occurred
|
|
1869
|
+
and which never will.
|
|
1870
|
+
|
|
1871
|
+
## Delivery, and why there is both a nudge and a poll
|
|
1872
|
+
|
|
1873
|
+
When the transaction commits, the worker is nudged and the effect usually goes
|
|
1874
|
+
out in milliseconds. A poll also runs every 5 seconds.
|
|
1875
|
+
|
|
1876
|
+
The nudge is an optimisation. The **poll is the contract**: it picks up rows
|
|
1877
|
+
whose nudge was lost because the process died between commit and delivery, rows
|
|
1878
|
+
enqueued by another replica, and rows waiting out a backoff. Without it the
|
|
1879
|
+
guarantee degrades to "delivered unless something went wrong" — which is the
|
|
1880
|
+
exact case a durable outbox exists for.
|
|
1881
|
+
|
|
1882
|
+
## Options
|
|
1883
|
+
|
|
1884
|
+
```ts
|
|
1885
|
+
await ctx.outbox.enqueue('mail.welcome', { userId }, {
|
|
1886
|
+
idempotencyKey: `welcome:${userId}`, // drop if an undelivered row has this key
|
|
1887
|
+
maxAttempts: 3, // override the handler's default
|
|
1888
|
+
delayMs: 60_000, // don't attempt before then
|
|
1889
|
+
})
|
|
1890
|
+
```
|
|
1891
|
+
|
|
1892
|
+
`idempotencyKey` dedupes against rows that have not yet succeeded, so a retried
|
|
1893
|
+
mutation does not produce a second side effect. A **delivered** key is
|
|
1894
|
+
deliberately not a blocker — reusing a key later means "do it again", and
|
|
1895
|
+
treating it as permanently consumed would silently swallow a legitimate request.
|
|
1896
|
+
|
|
1897
|
+
`enqueue` returns the outbox row id, which is also the delivery id: persist it
|
|
1898
|
+
alongside your row and a client can watch the effect's progress.
|
|
1899
|
+
|
|
1900
|
+
## When NOT to use it
|
|
1901
|
+
|
|
1902
|
+
- **Work that must be observable step-by-step, or that suspends** → a
|
|
1903
|
+
[workflow](/docs/workflows/overview). The outbox delivers one effect; it is
|
|
1904
|
+
not a durable multi-step process.
|
|
1905
|
+
- **Reacting to any change on a table, not to one mutation's intent** →
|
|
1906
|
+
[`defineSubscriber`](/docs/data/subscribers) or
|
|
1907
|
+
[`defineReaction`](/docs/data/reactions).
|
|
1908
|
+
- **Mirroring a table outward continuously** → `@voltro/plugin-cdc-out`, which
|
|
1909
|
+
is built for reverse-ETL with per-pipe ordering.
|
|
1910
|
+
|
|
1911
|
+
## See also
|
|
1912
|
+
|
|
1913
|
+
- [Mutations](/docs/data/mutations) — the transaction boundary this rides
|
|
1914
|
+
- [Subscribers](/docs/data/subscribers) — post-commit reactions to table changes
|
|
1915
|
+
- [Workflows](/docs/workflows/overview) — durable multi-step work
|
|
1916
|
+
|
|
1917
|
+
|
|
1918
|
+
|
|
1624
1919
|
---
|
|
1625
1920
|
|
|
1626
1921
|
<!-- source: en/data/wire-protocol.md -->
|
|
@@ -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
|
|
|
@@ -801,10 +803,31 @@ eager-load with `.with({...})` to get the per-field pre-filter.
|
|
|
801
803
|
inject a default depth limit.
|
|
802
804
|
- **No reactivity**: see above.
|
|
803
805
|
|
|
806
|
+
## When NOT to use
|
|
807
|
+
|
|
808
|
+
- **Single-level parent / child** — use a regular
|
|
809
|
+
[self-join](/docs/database/self-joins). A recursive CTE is overkill
|
|
810
|
+
at depth 1.
|
|
811
|
+
- **Performance-critical hot path with a large result set** —
|
|
812
|
+
recursive queries can explode on wide trees. Profile with
|
|
813
|
+
`EXPLAIN ANALYZE` against realistic data. If the iteration count
|
|
814
|
+
runs into the thousands, consider materializing the computed
|
|
815
|
+
hierarchy into a separate table instead.
|
|
816
|
+
- **Arbitrary graph algorithms** (shortest path, connected
|
|
817
|
+
components) — a recursive CTE can be bent into these but it gets
|
|
818
|
+
ugly fast. A graph database (Neo4j, the AGE extension) is the
|
|
819
|
+
better fit.
|
|
820
|
+
|
|
804
821
|
## See also
|
|
805
822
|
|
|
806
823
|
- [Plain CTEs](/docs/database/query-builder#ctes) — `withCte()` for
|
|
807
824
|
non-recursive named sub-queries
|
|
825
|
+
- [Self-joins](/docs/database/self-joins) — for single-level
|
|
826
|
+
parent/child queries
|
|
827
|
+
- [Sub-queries](/docs/database/sub-queries) — for non-recursive
|
|
828
|
+
"rows where a column matches another query" patterns
|
|
829
|
+
- [Set operations](/docs/database/set-operations) — `union` /
|
|
830
|
+
`unionAll`, the mechanism a recursive CTE is built on
|
|
808
831
|
- [Joins](/docs/database/joins) — relation-based traversal when the
|
|
809
832
|
graph depth is fixed (e.g. parent + immediate children)
|
|
810
833
|
- [Aggregations](/docs/database/query-builder#aggregations) —
|
|
@@ -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:
|
|
@@ -128,19 +130,29 @@ Each call adds ONE column + direction; chain for multi-column ordering:
|
|
|
128
130
|
.limit(20).offset(40)
|
|
129
131
|
```
|
|
130
132
|
|
|
131
|
-
For cursor pagination that avoids OFFSET's O(n) scan, use
|
|
133
|
+
For cursor pagination that avoids OFFSET's O(n) scan, use `paginateBy` over `ctx.store.query(...)`:
|
|
132
134
|
|
|
133
135
|
```ts
|
|
134
|
-
import {
|
|
136
|
+
import { paginateBy } from '@voltro/database'
|
|
135
137
|
|
|
136
138
|
const rows = await ctx.store.query(
|
|
137
|
-
|
|
139
|
+
paginateBy(database.notes.descriptor, 'createdAt', req.cursor, 20, 'desc'),
|
|
138
140
|
)
|
|
139
|
-
const nextCursor = rows.at(-1)?.
|
|
141
|
+
const nextCursor = rows.at(-1)?.createdAt ?? null
|
|
140
142
|
return { rows, nextCursor }
|
|
141
143
|
```
|
|
142
144
|
|
|
143
|
-
`
|
|
145
|
+
`paginateBy(descriptor, column, cursor, limit, direction?)` adds the keyset predicate, sets the ORDER BY, and preserves any existing `where`.
|
|
146
|
+
|
|
147
|
+
The `direction` argument controls **both** the comparison and the sort — a `desc` feed pages with `<`, not `>`. That pairing is the classic keyset bug: an ascending comparison under a descending sort returns the same first page forever.
|
|
148
|
+
|
|
149
|
+
The cursor column must be unique, or monotonic enough that ties don't straddle a page boundary. For a timestamp with collisions, order by a tie-breaker and paginate on that:
|
|
150
|
+
|
|
151
|
+
```ts
|
|
152
|
+
paginateBy(database.notes.orderBy('createdAt', 'desc').descriptor, 'id', req.cursor, 20)
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
`paginateById(descriptor, cursor, limit)` is the `id`-column shorthand — literally `paginateBy(descriptor, 'id', cursor, limit)`. It works for any sortable id scheme (TypeID, ULID, Snowflake, Numeric). Note it sets the order to `id asc`, so passing a descriptor that already carries `.orderBy('createdAt', 'desc')` does **not** page by `createdAt` — use `paginateBy` when the sort column is the thing you want to page on.
|
|
144
156
|
|
|
145
157
|
## Aggregates
|
|
146
158
|
|
|
@@ -716,8 +716,11 @@ const row = await ctx.store.insert('todos', { title, done: false })
|
|
|
716
716
|
// Test — deterministic id.
|
|
717
717
|
await ctx.store.insert('todos', { id: 'todo_pinned_for_test', title, done: false })
|
|
718
718
|
|
|
719
|
-
// Signup self-stamping —
|
|
720
|
-
|
|
719
|
+
// Signup self-stamping — the actor IS the row. Generate the id up front
|
|
720
|
+
// so createdBy can point at the row being created, in the same insert.
|
|
721
|
+
import { typeid } from 'typeid-js'
|
|
722
|
+
const userId = typeid('user').toString()
|
|
723
|
+
await ctx.store.insert('users', { id: userId, email, createdBy: userId })
|
|
721
724
|
```
|
|
722
725
|
|
|
723
726
|
## Branded TypeScript types
|