@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.
- package/CHANGELOG.md +21 -0
- package/dist/{apiBuild-CvtQeBMs.js → apiBuild-CZugTK46.js} +1 -1
- package/dist/apiBuild-D-iBYKa3.js +2 -0
- package/dist/bin.js +2 -2
- package/dist/{commands-DhyBIs1O.js → commands-CJheDVOr.js} +1600 -1376
- package/dist/{dev-CQxbrpDz.js → dev-B71ICpXJ.js} +1756 -1626
- 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 +84 -0
- package/templates/AGENTS.md +84 -0
- package/templates/agent-docs/_manifest.json +1 -1
- package/templates/agent-docs/ai.md +6 -2
- package/templates/agent-docs/authentication.md +69 -0
- package/templates/agent-docs/cli.md +76 -0
- package/templates/agent-docs/data.md +147 -3
- package/templates/agent-docs/database/advancedqueries.md +21 -0
- package/templates/agent-docs/database/querying.md +15 -5
- package/templates/agent-docs/database/schema.md +5 -2
- package/templates/agent-docs/reference.md +34 -0
- 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/package.json +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-DQBNqNZ8.js +0 -2
- package/dist/dev-DYjGqPGD.js +0 -2
- package/dist/serveCommand-BZzUJIyo.js +0 -1077
package/dist/serveEntry.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
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-
|
|
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.
|
|
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.
|
|
56
|
-
"@voltro/cache": "0.
|
|
57
|
-
"@voltro/data-transfer": "0.
|
|
58
|
-
"@voltro/database": "0.
|
|
59
|
-
"@voltro/env": "0.
|
|
60
|
-
"@voltro/kv": "0.
|
|
61
|
-
"@voltro/logger": "0.
|
|
62
|
-
"@voltro/plugin-auth": "0.
|
|
63
|
-
"@voltro/plugin-broadcast": "0.
|
|
64
|
-
"@voltro/plugin-mail": "0.
|
|
65
|
-
"@voltro/plugin-storage": "0.
|
|
66
|
-
"@voltro/plugin-webhooks": "0.
|
|
67
|
-
"@voltro/protocol": "0.
|
|
68
|
-
"@voltro/runtime": "0.
|
|
69
|
-
"@voltro/serverless": "0.
|
|
70
|
-
"@voltro/workflow": "0.
|
|
55
|
+
"@voltro/ai": "0.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"
|
package/templates/AGENTS.core.md
CHANGED
|
@@ -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
|
package/templates/AGENTS.md
CHANGED
|
@@ -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":
|
|
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*
|
|
1743
|
-
return { ok: true as const, rows
|
|
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']
|
|
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
|
|
1215
|
+
playerId: p.id,
|
|
1214
1216
|
rank: i + 1,
|
|
1215
|
-
kd: p.kdRatio
|
|
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) —
|