@voltro/cli 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/dist/{apiBuild-DdgYydVJ.js → apiBuild-CZugTK46.js} +2 -5
  3. package/dist/apiBuild-D-iBYKa3.js +2 -0
  4. package/dist/bin.js +2 -2
  5. package/dist/{commands-CXESev-z.js → commands-CJheDVOr.js} +2435 -2000
  6. package/dist/{dev-x_VqbV_8.js → dev-B71ICpXJ.js} +1993 -1823
  7. package/dist/dev-DNUetyG2.js +2 -0
  8. package/dist/index.js +1 -1
  9. package/dist/serveCommand-C0WFA396.js +1084 -0
  10. package/dist/serveEntry.js +2 -2
  11. package/package.json +17 -17
  12. package/templates/AGENTS.core.md +131 -0
  13. package/templates/AGENTS.md +131 -0
  14. package/templates/agent-docs/_manifest.json +4 -4
  15. package/templates/agent-docs/ai.md +34 -2
  16. package/templates/agent-docs/authentication.md +74 -2
  17. package/templates/agent-docs/cli.md +115 -0
  18. package/templates/agent-docs/data.md +302 -7
  19. package/templates/agent-docs/database/advancedqueries.md +25 -2
  20. package/templates/agent-docs/database/querying.md +17 -5
  21. package/templates/agent-docs/database/schema.md +5 -2
  22. package/templates/agent-docs/internationalization.md +237 -2
  23. package/templates/agent-docs/observability.md +9 -0
  24. package/templates/agent-docs/plugins.md +2 -2
  25. package/templates/agent-docs/reference.md +110 -0
  26. package/templates/agent-docs/schema-driven-ui.md +551 -2
  27. package/templates/agent-docs/templates/apibackends.md +10 -8
  28. package/templates/agent-docs/templates/overview.md +1 -1
  29. package/templates/agent-docs/testing.md +177 -3
  30. package/templates/agent-docs/workflows.md +11 -8
  31. package/templates/apps/api-ai/package.json +7 -7
  32. package/templates/apps/api-auth/package.json +8 -8
  33. package/templates/apps/api-backend/package.json +7 -7
  34. package/templates/apps/api-backend-deactivation/package.json +7 -7
  35. package/templates/apps/api-backend-mail/package.json +8 -8
  36. package/templates/apps/api-backend-mariadb/package.json +9 -9
  37. package/templates/apps/api-backend-storage/package.json +8 -8
  38. package/templates/apps/api-data-advanced/package.json +8 -8
  39. package/templates/apps/api-durable/package.json +8 -8
  40. package/templates/apps/api-feature-flags/package.json +9 -9
  41. package/templates/apps/api-governance/package.json +8 -8
  42. package/templates/apps/api-kv/package.json +8 -8
  43. package/templates/apps/api-moderation/package.json +8 -8
  44. package/templates/apps/api-observability/package.json +8 -8
  45. package/templates/apps/api-ratelimit/package.json +8 -8
  46. package/templates/apps/api-rbac/README.md +9 -4
  47. package/templates/apps/api-rbac/mutations/notes.create.mutation.server.ts +1 -1
  48. package/templates/apps/api-rbac/mutations/notes.create.mutation.ts +6 -6
  49. package/templates/apps/api-rbac/package.json +8 -8
  50. package/templates/apps/api-rbac/tests/notes.create.test.ts +8 -8
  51. package/templates/apps/api-rest/package.json +7 -7
  52. package/templates/apps/api-saas/package.json +11 -11
  53. package/templates/apps/api-search/package.json +8 -8
  54. package/templates/apps/api-versioning/package.json +8 -8
  55. package/templates/apps/api-webhooks/package.json +8 -8
  56. package/templates/apps/changelog/package.json +6 -6
  57. package/templates/apps/edge-functions/package.json +2 -2
  58. package/templates/apps/frontend-admin/package.json +8 -8
  59. package/templates/apps/frontend-app/package.json +8 -8
  60. package/templates/apps/frontend-blank/package.json +7 -7
  61. package/templates/apps/frontend-contact/package.json +7 -7
  62. package/templates/apps/frontend-dashboard/package.json +7 -7
  63. package/templates/apps/frontend-docs/package.json +7 -7
  64. package/templates/apps/frontend-i18n/package.json +6 -6
  65. package/templates/apps/frontend-landing/package.json +7 -7
  66. package/templates/apps/frontend-spa/package.json +7 -7
  67. package/templates/apps/frontend-ssr/package.json +7 -7
  68. package/templates/apps/frontend-ssr-api/package.json +8 -8
  69. package/templates/apps/frontend-static-blog/package.json +6 -6
  70. package/dist/apiBuild-cadmH8ca.js +0 -2
  71. package/dist/dev-DemiMSSl.js +0 -2
  72. package/dist/serveCommand-Dttqe5Ms.js +0 -1077
@@ -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'] as string }
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 `match` are browser-safe descriptor data (a dot-path string + a pure predicate) — the same discipline as `identify`/`shape`.
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 as string,
1215
+ playerId: p.id,
1091
1216
  rank: i + 1,
1092
- kd: p.kdRatio as number,
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
- There's deliberately no `filter` / `where` / `join`. If you need a different cut of the data, define another aggregate.
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.** Adding `WHERE region='EU'` shifts the filter from refresh-time to read-time — the materialisation point IS the query; don't re-query it.
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. 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.
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 | 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
 
@@ -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 the standalone `paginateById` helper over `ctx.store.query(...)`:
133
+ For cursor pagination that avoids OFFSET's O(n) scan, use `paginateBy` over `ctx.store.query(...)`:
132
134
 
133
135
  ```ts
134
- import { paginateById } from '@voltro/database'
136
+ import { paginateBy } from '@voltro/database'
135
137
 
136
138
  const rows = await ctx.store.query(
137
- paginateById(database.notes.orderBy('createdAt', 'desc').descriptor, req.cursor, 20),
139
+ paginateBy(database.notes.descriptor, 'createdAt', req.cursor, 20, 'desc'),
138
140
  )
139
- const nextCursor = rows.at(-1)?.id ?? null
141
+ const nextCursor = rows.at(-1)?.createdAt ?? null
140
142
  return { rows, nextCursor }
141
143
  ```
142
144
 
143
- `paginateById(descriptor, cursor, limit)` adds a `WHERE id > cursor ORDER BY id LIMIT n` clause and preserves any existing predicate. Works for any sortable id scheme (TypeID, ULID, Snowflake, Numeric).
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 — pass createdBy: userId in the same insert.
720
- await ctx.store.insert('users', { id: userId, createdBy: userId, email })
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