@voltro/cli 0.4.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 (61) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/dist/{apiBuild-CvtQeBMs.js → apiBuild-CZugTK46.js} +1 -1
  3. package/dist/apiBuild-D-iBYKa3.js +2 -0
  4. package/dist/bin.js +2 -2
  5. package/dist/{commands-DhyBIs1O.js → commands-CJheDVOr.js} +1600 -1376
  6. package/dist/{dev-CQxbrpDz.js → dev-B71ICpXJ.js} +1756 -1626
  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 +84 -0
  13. package/templates/AGENTS.md +84 -0
  14. package/templates/agent-docs/_manifest.json +1 -1
  15. package/templates/agent-docs/ai.md +6 -2
  16. package/templates/agent-docs/authentication.md +69 -0
  17. package/templates/agent-docs/cli.md +76 -0
  18. package/templates/agent-docs/data.md +147 -3
  19. package/templates/agent-docs/database/advancedqueries.md +21 -0
  20. package/templates/agent-docs/database/querying.md +15 -5
  21. package/templates/agent-docs/database/schema.md +5 -2
  22. package/templates/agent-docs/reference.md +34 -0
  23. package/templates/agent-docs/workflows.md +11 -8
  24. package/templates/apps/api-ai/package.json +7 -7
  25. package/templates/apps/api-auth/package.json +8 -8
  26. package/templates/apps/api-backend/package.json +7 -7
  27. package/templates/apps/api-backend-deactivation/package.json +7 -7
  28. package/templates/apps/api-backend-mail/package.json +8 -8
  29. package/templates/apps/api-backend-mariadb/package.json +9 -9
  30. package/templates/apps/api-backend-storage/package.json +8 -8
  31. package/templates/apps/api-data-advanced/package.json +8 -8
  32. package/templates/apps/api-durable/package.json +8 -8
  33. package/templates/apps/api-feature-flags/package.json +9 -9
  34. package/templates/apps/api-governance/package.json +8 -8
  35. package/templates/apps/api-kv/package.json +8 -8
  36. package/templates/apps/api-moderation/package.json +8 -8
  37. package/templates/apps/api-observability/package.json +8 -8
  38. package/templates/apps/api-ratelimit/package.json +8 -8
  39. package/templates/apps/api-rbac/package.json +8 -8
  40. package/templates/apps/api-rest/package.json +7 -7
  41. package/templates/apps/api-saas/package.json +11 -11
  42. package/templates/apps/api-search/package.json +8 -8
  43. package/templates/apps/api-versioning/package.json +8 -8
  44. package/templates/apps/api-webhooks/package.json +8 -8
  45. package/templates/apps/changelog/package.json +6 -6
  46. package/templates/apps/edge-functions/package.json +2 -2
  47. package/templates/apps/frontend-admin/package.json +8 -8
  48. package/templates/apps/frontend-app/package.json +8 -8
  49. package/templates/apps/frontend-blank/package.json +7 -7
  50. package/templates/apps/frontend-contact/package.json +7 -7
  51. package/templates/apps/frontend-dashboard/package.json +7 -7
  52. package/templates/apps/frontend-docs/package.json +7 -7
  53. package/templates/apps/frontend-i18n/package.json +6 -6
  54. package/templates/apps/frontend-landing/package.json +7 -7
  55. package/templates/apps/frontend-spa/package.json +7 -7
  56. package/templates/apps/frontend-ssr/package.json +7 -7
  57. package/templates/apps/frontend-ssr-api/package.json +8 -8
  58. package/templates/apps/frontend-static-blog/package.json +6 -6
  59. package/dist/apiBuild-DQBNqNZ8.js +0 -2
  60. package/dist/dev-DYjGqPGD.js +0 -2
  61. package/dist/serveCommand-BZzUJIyo.js +0 -1077
@@ -1,4 +1,4 @@
1
- import { jt as e, rt as t } from "./dev-CQxbrpDz.js";
1
+ import { Nt as e, at as t } from "./dev-B71ICpXJ.js";
2
2
  import { a as n } from "./startupRunner-DhlX9nqd.js";
3
- import { t as r } from "./serveCommand-BZzUJIyo.js";
3
+ import { t as r } from "./serveCommand-C0WFA396.js";
4
4
  export { e as loadDotEnv, n as registerAppModules, t as registerDriver, r as runServe };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@voltro/cli",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "The `voltro` CLI — dev server, codegen, migrations, project scaffolding, agent-docs seeding, and production serve.",
5
5
  "keywords": [
6
6
  "voltro",
@@ -52,22 +52,22 @@
52
52
  "@effect/platform-node": "^0.107.0",
53
53
  "@effect/sql": "^0.51.1",
54
54
  "@effect/workflow": "^0.18.2",
55
- "@voltro/ai": "0.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",
55
+ "@voltro/ai": "0.5.0",
56
+ "@voltro/cache": "0.5.0",
57
+ "@voltro/data-transfer": "0.5.0",
58
+ "@voltro/database": "0.5.0",
59
+ "@voltro/env": "0.5.0",
60
+ "@voltro/kv": "0.5.0",
61
+ "@voltro/logger": "0.5.0",
62
+ "@voltro/plugin-auth": "0.5.0",
63
+ "@voltro/plugin-broadcast": "0.5.0",
64
+ "@voltro/plugin-mail": "0.5.0",
65
+ "@voltro/plugin-storage": "0.5.0",
66
+ "@voltro/plugin-webhooks": "0.5.0",
67
+ "@voltro/protocol": "0.5.0",
68
+ "@voltro/runtime": "0.5.0",
69
+ "@voltro/serverless": "0.5.0",
70
+ "@voltro/workflow": "0.5.0",
71
71
  "chokidar": "^5.0.0",
72
72
  "ioredis": "^5.11.1",
73
73
  "ts-morph": "^28.0.0"
@@ -61,6 +61,90 @@ Ask in order; stop at the first "yes":
61
61
  Deep dive: **`data.md`** (queries/mutations/actions/streams/errors),
62
62
  **`workflows.md`**, **`scheduling.md`**.
63
63
 
64
+ ### Pick the SERVER primitive (inside a handler)
65
+
66
+ The four above answer "which FILE do I write". This answers "what do I write
67
+ INSIDE it" — the part where hand-rolled boilerplate actually accumulates. Every
68
+ line below replaces something real apps write by hand hundreds of times.
69
+
70
+ 1. **Loading one row you expect to exist?** → **`.one()`**
71
+ (`ctx.store.select('t').where('id', x).one()`). It fails with the typed
72
+ `NoRowFound` when there is no row — AND when there is more than one, so a
73
+ filter that stops being unique fails loudly instead of returning an
74
+ arbitrary row. `NoRowFound` is declarable in the descriptor's `error:` union.
75
+ Nullable variant: **`.first()`** / **`.maybeOne()`**.
76
+ Never `const rows = await …; if (!rows[0]) throw new NotFound()`.
77
+ 2. **Need related data — 2+ reads to assemble one result?** → declare
78
+ **`relations()`** (`*.relations.ts`) and eager-load with **`.with({ posts: true })`**.
79
+ That compiles to ONE JSON-aggregate query on every SQL dialect. Sequential
80
+ `store.query` calls per parent row are an N+1 you are writing on purpose.
81
+ 3. **Something must happen AFTER a mutation commits** (notify, webhook, sync)?
82
+ → **`defineSubscriber`** (`*.subscribe.ts`, runs any handler on a table
83
+ change) or **`defineReaction`** (`*.reaction.tsx`, fires a named agent or
84
+ workflow; requires a `dedupeKey`, supports `rateLimit` / `costBudgetUsd`).
85
+ Both are post-commit and receive the `old`/`new` diff. Calling a
86
+ notify-helper at the tail of the mutation body instead makes the reactivity
87
+ invisible — you can only find it by reading every executor. Both are
88
+ best-effort, so genuinely critical delivery still belongs in a workflow.
89
+ 4. **A roll-up / counter recomputed on every read?** → **`defineAggregate`**
90
+ (`*.aggregate.ts`). Add `incremental:` for `count|sum|avg|min|max` group-bys
91
+ and it is MAINTAINED on write rather than recomputed. `read({ where })`
92
+ parameterises it, so one aggregate serves per-team / per-period slices.
93
+ 5. **A permission check?** → declare **`guards:`** on the descriptor — it is
94
+ enforced before the executor (and before a mutation's transaction opens) and
95
+ fails with a typed `ScopeError`. Two kinds, same array, all must pass:
96
+ - `{ scope: 'notes:write' }` — may the caller do this AT ALL.
97
+ - `{ action, resourceType, resource: (input) => input.id }` — may they do it
98
+ to THIS ROW (relationship/ReBAC, against a `defineResourcePolicy`). Needs a
99
+ registered `setTupleSource`; **everything unanswerable DENIES** (no source,
100
+ no policy, no id, source throws).
101
+ Guards are re-checked on every subscription delivery, so a revoked grant ends
102
+ the stream instead of continuing to push rows. Only use `ctx.access`
103
+ in-handler for checks that need LOADED data. A hand-written `requireScope(...)`
104
+ at the top of every executor — or a hand-kept map from rpc tag to policy rule,
105
+ which is fail-open by omission — is what `guards:` exists to delete.
106
+ 6. **Storing a token, secret, or credential in a column?** → **`.encrypted()`**
107
+ on the column. Boot fails loudly if no cipher is configured, so an
108
+ `.encrypted()` column can never silently persist plaintext.
109
+ 7. **Writing an Effect-form handler?** → `const store = yield* EffectStore` —
110
+ its failures land on the typed error channel. `Effect.promise(() =>
111
+ ctx.store.query(…))` throws that channel away and turns a store failure into
112
+ a defect.
113
+ 8. **Cursor pagination?** → **`paginateBy(descriptor, column, cursor, limit,
114
+ direction?)`** (or **`paginateById`**, its `id`-column shorthand). Not a
115
+ hand-rolled limit+1 / slice / `hasMore` triple. `direction` flips the
116
+ COMPARISON as well as the sort — a `desc` feed pages with `<`.
117
+ 9. **Assembling data whose SHAPE depends on the data** (a tree walk where each
118
+ level's ids come from the level above)? → **`ctx.load` / `ctx.loadMany`** —
119
+ same-tick reads of one table coalesce into one `WHERE id IN (...)`, so the
120
+ walk costs one query per LEVEL. Use `relations()` + `.with()` whenever the
121
+ shape IS static; this is the fallback, not the default.
122
+ 10. **A mutation must cause an EXTERNAL side effect** (webhook, Jira sync,
123
+ payment)? → **`ctx.outbox.enqueue(effect, payload)`** + a
124
+ **`defineOutboxHandler`** in a `*.outbox.ts`. The enqueue writes through the
125
+ mutation's TRANSACTION, so the intent commits with the write or not at all;
126
+ delivery happens after commit, with backoff and a dead-letter. Do NOT call
127
+ the remote from the mutation (not transactional), and do not hand-build a
128
+ deliveries table + drain cron — that IS this primitive.
129
+
130
+ | You're about to write | Reach for instead |
131
+ |---|---|
132
+ | `const rows = …; if (!rows[0]) throw new NotFound()` | `.one()` |
133
+ | 3+ sequential `store.query` to assemble related data | `relations()` + `.with()` |
134
+ | a notify/webhook helper called at the end of a mutation | `defineSubscriber` / `defineReaction` |
135
+ | a counter recomputed by scanning rows on every read | `defineAggregate` (+ `incremental:`) |
136
+ | `requireScope(...)` as the first line of every executor | `guards:` on the descriptor |
137
+ | a token/secret column written as plain text | `.encrypted()` |
138
+ | `Effect.promise(() => ctx.store.query(...))` in an Effect handler | `yield* EffectStore` |
139
+ | hand-rolled limit+1 / `hasMore` cursor paging | `paginateBy` / `paginateById` |
140
+ | a loop issuing one `store.query` per node of a walk | `ctx.load` / `ctx.loadMany` |
141
+ | a deliveries table + drain cron + retry worker | `ctx.outbox.enqueue` + `defineOutboxHandler` |
142
+ | `row['name'] as string` on every field you read | nothing — `ctx.store.query(database.t….descriptor)` already returns the table's row type. A cast here means the descriptor was hand-built, or you are casting out of habit |
143
+
144
+ Deep dive: **`data.md`** (`.one()`, subscribers, reactions, aggregates),
145
+ **`database/querying.md`** (relations + `.with`), **`authentication.md`**
146
+ (`guards:`, resource policies), **`plugins/governance.md`** (`.encrypted()`).
147
+
64
148
  ### Pick the CLIENT primitive (decision rubric)
65
149
 
66
150
  `useSubscription` / `useMutation` / `useAction` are the transport — they are NOT
@@ -61,6 +61,90 @@ Ask in order; stop at the first "yes":
61
61
  Deep dive: **`data.md`** (queries/mutations/actions/streams/errors),
62
62
  **`workflows.md`**, **`scheduling.md`**.
63
63
 
64
+ ### Pick the SERVER primitive (inside a handler)
65
+
66
+ The four above answer "which FILE do I write". This answers "what do I write
67
+ INSIDE it" — the part where hand-rolled boilerplate actually accumulates. Every
68
+ line below replaces something real apps write by hand hundreds of times.
69
+
70
+ 1. **Loading one row you expect to exist?** → **`.one()`**
71
+ (`ctx.store.select('t').where('id', x).one()`). It fails with the typed
72
+ `NoRowFound` when there is no row — AND when there is more than one, so a
73
+ filter that stops being unique fails loudly instead of returning an
74
+ arbitrary row. `NoRowFound` is declarable in the descriptor's `error:` union.
75
+ Nullable variant: **`.first()`** / **`.maybeOne()`**.
76
+ Never `const rows = await …; if (!rows[0]) throw new NotFound()`.
77
+ 2. **Need related data — 2+ reads to assemble one result?** → declare
78
+ **`relations()`** (`*.relations.ts`) and eager-load with **`.with({ posts: true })`**.
79
+ That compiles to ONE JSON-aggregate query on every SQL dialect. Sequential
80
+ `store.query` calls per parent row are an N+1 you are writing on purpose.
81
+ 3. **Something must happen AFTER a mutation commits** (notify, webhook, sync)?
82
+ → **`defineSubscriber`** (`*.subscribe.ts`, runs any handler on a table
83
+ change) or **`defineReaction`** (`*.reaction.tsx`, fires a named agent or
84
+ workflow; requires a `dedupeKey`, supports `rateLimit` / `costBudgetUsd`).
85
+ Both are post-commit and receive the `old`/`new` diff. Calling a
86
+ notify-helper at the tail of the mutation body instead makes the reactivity
87
+ invisible — you can only find it by reading every executor. Both are
88
+ best-effort, so genuinely critical delivery still belongs in a workflow.
89
+ 4. **A roll-up / counter recomputed on every read?** → **`defineAggregate`**
90
+ (`*.aggregate.ts`). Add `incremental:` for `count|sum|avg|min|max` group-bys
91
+ and it is MAINTAINED on write rather than recomputed. `read({ where })`
92
+ parameterises it, so one aggregate serves per-team / per-period slices.
93
+ 5. **A permission check?** → declare **`guards:`** on the descriptor — it is
94
+ enforced before the executor (and before a mutation's transaction opens) and
95
+ fails with a typed `ScopeError`. Two kinds, same array, all must pass:
96
+ - `{ scope: 'notes:write' }` — may the caller do this AT ALL.
97
+ - `{ action, resourceType, resource: (input) => input.id }` — may they do it
98
+ to THIS ROW (relationship/ReBAC, against a `defineResourcePolicy`). Needs a
99
+ registered `setTupleSource`; **everything unanswerable DENIES** (no source,
100
+ no policy, no id, source throws).
101
+ Guards are re-checked on every subscription delivery, so a revoked grant ends
102
+ the stream instead of continuing to push rows. Only use `ctx.access`
103
+ in-handler for checks that need LOADED data. A hand-written `requireScope(...)`
104
+ at the top of every executor — or a hand-kept map from rpc tag to policy rule,
105
+ which is fail-open by omission — is what `guards:` exists to delete.
106
+ 6. **Storing a token, secret, or credential in a column?** → **`.encrypted()`**
107
+ on the column. Boot fails loudly if no cipher is configured, so an
108
+ `.encrypted()` column can never silently persist plaintext.
109
+ 7. **Writing an Effect-form handler?** → `const store = yield* EffectStore` —
110
+ its failures land on the typed error channel. `Effect.promise(() =>
111
+ ctx.store.query(…))` throws that channel away and turns a store failure into
112
+ a defect.
113
+ 8. **Cursor pagination?** → **`paginateBy(descriptor, column, cursor, limit,
114
+ direction?)`** (or **`paginateById`**, its `id`-column shorthand). Not a
115
+ hand-rolled limit+1 / slice / `hasMore` triple. `direction` flips the
116
+ COMPARISON as well as the sort — a `desc` feed pages with `<`.
117
+ 9. **Assembling data whose SHAPE depends on the data** (a tree walk where each
118
+ level's ids come from the level above)? → **`ctx.load` / `ctx.loadMany`** —
119
+ same-tick reads of one table coalesce into one `WHERE id IN (...)`, so the
120
+ walk costs one query per LEVEL. Use `relations()` + `.with()` whenever the
121
+ shape IS static; this is the fallback, not the default.
122
+ 10. **A mutation must cause an EXTERNAL side effect** (webhook, Jira sync,
123
+ payment)? → **`ctx.outbox.enqueue(effect, payload)`** + a
124
+ **`defineOutboxHandler`** in a `*.outbox.ts`. The enqueue writes through the
125
+ mutation's TRANSACTION, so the intent commits with the write or not at all;
126
+ delivery happens after commit, with backoff and a dead-letter. Do NOT call
127
+ the remote from the mutation (not transactional), and do not hand-build a
128
+ deliveries table + drain cron — that IS this primitive.
129
+
130
+ | You're about to write | Reach for instead |
131
+ |---|---|
132
+ | `const rows = …; if (!rows[0]) throw new NotFound()` | `.one()` |
133
+ | 3+ sequential `store.query` to assemble related data | `relations()` + `.with()` |
134
+ | a notify/webhook helper called at the end of a mutation | `defineSubscriber` / `defineReaction` |
135
+ | a counter recomputed by scanning rows on every read | `defineAggregate` (+ `incremental:`) |
136
+ | `requireScope(...)` as the first line of every executor | `guards:` on the descriptor |
137
+ | a token/secret column written as plain text | `.encrypted()` |
138
+ | `Effect.promise(() => ctx.store.query(...))` in an Effect handler | `yield* EffectStore` |
139
+ | hand-rolled limit+1 / `hasMore` cursor paging | `paginateBy` / `paginateById` |
140
+ | a loop issuing one `store.query` per node of a walk | `ctx.load` / `ctx.loadMany` |
141
+ | a deliveries table + drain cron + retry worker | `ctx.outbox.enqueue` + `defineOutboxHandler` |
142
+ | `row['name'] as string` on every field you read | nothing — `ctx.store.query(database.t….descriptor)` already returns the table's row type. A cast here means the descriptor was hand-built, or you are casting out of habit |
143
+
144
+ Deep dive: **`data.md`** (`.one()`, subscribers, reactions, aggregates),
145
+ **`database/querying.md`** (relations + `.with`), **`authentication.md`**
146
+ (`guards:`, resource policies), **`plugins/governance.md`** (`.encrypted()`).
147
+
64
148
  ### Pick the CLIENT primitive (decision rubric)
65
149
 
66
150
  `useSubscription` / `useMutation` / `useAction` are the transport — they are NOT
@@ -53,7 +53,7 @@
53
53
  "group": null,
54
54
  "description": "How Voltro's reactive data layer works — queries, mutations, actions, streams, all over one WebSocket with typed errors and tracked dependencies.",
55
55
  "path": "agent-docs/data.md",
56
- "files": 13
56
+ "files": 14
57
57
  },
58
58
  {
59
59
  "id": "database/advancedqueries",
@@ -1717,6 +1717,7 @@ export const ask = defineAction({
1717
1717
  ```ts
1718
1718
  // copilot.ask.action.server.ts — the server executor (imports @voltro/ai)
1719
1719
  import { Effect } from 'effect'
1720
+ import { EffectStore } from '@voltro/runtime'
1720
1721
  import { runDataCopilot, generateObject, CopilotProposalSchema, type CopilotSchema } from '@voltro/ai'
1721
1722
 
1722
1723
  // The manifest the model is constrained to — only these tables/columns exist.
@@ -1729,6 +1730,7 @@ const schema: CopilotSchema = {
1729
1730
 
1730
1731
  const execute = (input: { question: string }, ctx: AppContext) =>
1731
1732
  Effect.gen(function* () {
1733
+ const store = yield* EffectStore
1732
1734
  const v = yield* Effect.promise(() =>
1733
1735
  runDataCopilot(input.question, schema, {
1734
1736
  propose: ({ system, prompt }) =>
@@ -1739,13 +1741,15 @@ const execute = (input: { question: string }, ctx: AppContext) =>
1739
1741
  )
1740
1742
  if (!v.ok) return { ok: false as const, reason: v.rejection.reason }
1741
1743
  // The validated SELECT runs AS the subject → tenant + row scope apply.
1742
- const rows = yield* Effect.promise(() => ctx.store.query(v.descriptor as never))
1743
- return { ok: true as const, rows: rows as ReadonlyArray<Record<string, unknown>> }
1744
+ const rows = yield* store.query(v.descriptor)
1745
+ return { ok: true as const, rows }
1744
1746
  })
1745
1747
 
1746
1748
  export default execute
1747
1749
  ```
1748
1750
 
1751
+ `v.descriptor` is already a `QueryDescriptor`, so nothing needs casting. In an Effect-form executor read through `EffectStore` (`yield* EffectStore`) rather than lifting `ctx.store.query` with `Effect.promise` — the lift discards the typed `StoreError` channel that `store.query` gives you.
1752
+
1749
1753
  ## Client: `useDataCopilot` + `<DataCopilot>`
1750
1754
 
1751
1755
  The hook is a thin binding over `useAction` — it imports **nothing** from
@@ -1732,6 +1732,75 @@ export const todoPolicy = defineResourcePolicy({
1732
1732
  })
1733
1733
  ```
1734
1734
 
1735
+ ## Enforce it declaratively — `guards:`
1736
+
1737
+ `can` / `assertCan` above are the imperative form: you load the tuples and make
1738
+ the decision inside the handler. That works, and it is what you reach for when
1739
+ the check needs data you have already loaded.
1740
+
1741
+ For the ordinary case — "may this caller perform ACTION on the row this input
1742
+ names?" — declare it on the descriptor instead:
1743
+
1744
+ ```ts
1745
+ export const todoUpdate = defineMutation({
1746
+ name: 'todos.update',
1747
+ input: Schema.Struct({ id: Schema.String, title: Schema.String }),
1748
+ output: Schema.Void,
1749
+ guards: [{ action: 'edit', resourceType: 'todo', resource: (input) => input.id }],
1750
+ })
1751
+ ```
1752
+
1753
+ The framework resolves it BEFORE the executor runs — for a mutation, before the
1754
+ transaction opens — and fails with a typed `ScopeError` naming
1755
+ `<resourceType>:<action>`.
1756
+
1757
+ Why the declarative form is not just shorter: an in-handler check is one an
1758
+ author can forget, and a forgotten check is a silent hole rather than an error.
1759
+ The same is true of the older pattern of hand-maintaining a map from rpc tag to
1760
+ policy rule and installing it as an interceptor — that map is **fail-open by
1761
+ omission**: add an endpoint, forget the entry, and nothing anywhere tells you.
1762
+ A guard on the descriptor cannot be forgotten for an rpc that exists, because
1763
+ it is part of the rpc.
1764
+
1765
+ Scope guards and relationship guards live in the same array and ALL must pass:
1766
+
1767
+ ```ts
1768
+ guards: [
1769
+ { scope: 'todos:write' }, // may you edit todos at all
1770
+ { action: 'edit', resourceType: 'todo', resource: (i) => i.id }, // may you edit THIS one
1771
+ ]
1772
+ ```
1773
+
1774
+ ### Activate it: register a tuple source
1775
+
1776
+ A relationship guard needs to read the caller's relations. That comes from the
1777
+ registered **tuple source**:
1778
+
1779
+ ```ts
1780
+ // app.config.ts or a *.startup.ts
1781
+ import { setTupleSource, loadResourceTuples } from '@voltro/runtime'
1782
+
1783
+ setTupleSource((req) =>
1784
+ loadResourceTuples(store, req.subjectId, req.resourceType, req.resourceId))
1785
+ ```
1786
+
1787
+ `voltro dev` / `voltro serve` register exactly this default for you, reading
1788
+ `_voltro_rebac_tuples`. Register your own when your relationships already live
1789
+ in your own tables — a `teamMembers` row is a relation; you should not have to
1790
+ copy it into a framework table to authorize against it.
1791
+
1792
+ **Every unanswerable case denies.** No tuple source registered, no policy for
1793
+ that `resourceType`, an input that doesn't identify a resource, a tuple source
1794
+ that throws — each is a denial, not a pass. An authorization question nobody can
1795
+ answer is a refusal; treating it as a pass is how a policy layer ends up
1796
+ enforcing nothing while looking like it does.
1797
+
1798
+ ### Guards are re-checked on every subscription delivery
1799
+
1800
+ A subscription is a long-lived grant. Its guards — scope and relationship alike
1801
+ — are re-evaluated before each delivery, so revoking a relation mid-session ends
1802
+ the stream with the typed error instead of continuing to push rows.
1803
+
1735
1804
  ## Decide — `can` / `assertCan`
1736
1805
 
1737
1806
  `can(subject, action, resource, { policy, tuples })` is the decision; `assertCan`
@@ -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`) |
@@ -739,6 +740,81 @@ Docker step) and prints the exact remedy: add a `voltro build .` step before
739
740
  `voltro serve .`. Drop it into your image build right after `voltro build` to
740
741
  guarantee the artefact is present before the image ships.
741
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
+
742
818
  ## Anti-patterns
743
819
 
744
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.
@@ -408,12 +408,14 @@ export default async (
408
408
  tenantId: ctx.request.subject.tenantId,
409
409
  })
410
410
 
411
- return { id: inserted['id'] as string }
411
+ return { id: String(inserted['id']) }
412
412
  }
413
413
  ```
414
414
 
415
415
  The descriptor is the wire contract. The `.mutation.server.ts` file is the server-only implementation.
416
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
+
417
419
  ## What The Runtime Does
418
420
 
419
421
  1. Decode `input` with the descriptor schema.
@@ -1210,9 +1212,9 @@ export default defineAggregate({
1210
1212
  .descriptor,
1211
1213
  )
1212
1214
  return players.map((p, i) => ({
1213
- playerId: p.id as string,
1215
+ playerId: p.id,
1214
1216
  rank: i + 1,
1215
- kd: p.kdRatio as number,
1217
+ kd: p.kdRatio,
1216
1218
  }))
1217
1219
  },
1218
1220
  })
@@ -1772,6 +1774,148 @@ into the agent's prompt.
1772
1774
 
1773
1775
 
1774
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
+
1775
1919
  ---
1776
1920
 
1777
1921
  <!-- source: en/data/wire-protocol.md -->
@@ -803,10 +803,31 @@ eager-load with `.with({...})` to get the per-field pre-filter.
803
803
  inject a default depth limit.
804
804
  - **No reactivity**: see above.
805
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
+
806
821
  ## See also
807
822
 
808
823
  - [Plain CTEs](/docs/database/query-builder#ctes) — `withCte()` for
809
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
810
831
  - [Joins](/docs/database/joins) — relation-based traversal when the
811
832
  graph depth is fixed (e.g. parent + immediate children)
812
833
  - [Aggregations](/docs/database/query-builder#aggregations) —