@voltro/cli 0.4.0 → 0.6.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 +68 -0
- package/bin/voltro.mjs +54 -2
- package/dist/apiBuild-Bk4JBt-i.js +2 -0
- package/dist/{apiBuild-CvtQeBMs.js → apiBuild-BxY44VGj.js} +20 -20
- package/dist/bin.js +2 -2
- package/dist/{commands-DhyBIs1O.js → commands-My_YlUJV.js} +2823 -2265
- package/dist/{dev-CQxbrpDz.js → dev-Bjq-nIvH.js} +2650 -2230
- package/dist/dev-D6b74iXI.js +2 -0
- package/dist/devActivity-1WtIVyHc.js +153 -0
- package/dist/devActivity.d.ts +46 -0
- package/dist/devActivity.js +2 -0
- package/dist/index.js +1 -1
- package/dist/serveCommand-C-XQ89NJ.js +1110 -0
- package/dist/serveEntry.js +2 -2
- package/package.json +22 -17
- package/templates/AGENTS.core.md +119 -0
- package/templates/AGENTS.md +119 -0
- package/templates/agent-docs/_manifest.json +3 -3
- package/templates/agent-docs/ai.md +41 -5
- package/templates/agent-docs/authentication.md +238 -0
- package/templates/agent-docs/cli.md +142 -0
- package/templates/agent-docs/configuration.md +38 -0
- package/templates/agent-docs/data.md +718 -42
- package/templates/agent-docs/database/advancedqueries.md +21 -0
- package/templates/agent-docs/database/querying.md +166 -8
- package/templates/agent-docs/database/schema.md +6 -2
- package/templates/agent-docs/deployment.md +171 -0
- package/templates/agent-docs/internationalization.md +2 -0
- package/templates/agent-docs/plugins.md +4 -4
- package/templates/agent-docs/reference.md +90 -2
- package/templates/agent-docs/templates/apibackends.md +13 -2
- package/templates/agent-docs/testing.md +157 -3
- package/templates/agent-docs/workflows.md +11 -8
- package/templates/apps/api-ai/package.json +7 -7
- package/templates/apps/api-auth/app.config.ts +20 -13
- 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/.env.example +10 -4
- 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/app.config.ts +21 -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/app.config.ts +15 -2
- 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/app.config.ts +20 -1
- 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/templates/baselines/bare/.env.example +10 -3
- package/templates/baselines/compose/.env.example +10 -2
- package/templates/baselines/compose-mariadb/.env.example +10 -2
- package/templates/baselines/helm/.env.example +10 -3
- package/dist/apiBuild-DQBNqNZ8.js +0 -2
- package/dist/dev-DYjGqPGD.js +0 -2
- package/dist/serveCommand-BZzUJIyo.js +0 -1077
- package/templates/apps/api-auth/.env +0 -17
- package/templates/apps/api-data-advanced/.env +0 -17
- package/templates/apps/api-governance/.env +0 -4
- package/templates/apps/api-webhooks/.env +0 -6
package/dist/serveEntry.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { Lt as e, lt as t } from "./dev-Bjq-nIvH.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-C-XQ89NJ.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.6.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",
|
|
@@ -31,6 +31,11 @@
|
|
|
31
31
|
"import": "./dist/inspectState.js",
|
|
32
32
|
"default": "./dist/inspectState.js"
|
|
33
33
|
},
|
|
34
|
+
"./devActivity": {
|
|
35
|
+
"types": "./dist/devActivity.d.ts",
|
|
36
|
+
"import": "./dist/devActivity.js",
|
|
37
|
+
"default": "./dist/devActivity.js"
|
|
38
|
+
},
|
|
34
39
|
"./startup": {
|
|
35
40
|
"types": "./dist/startup.d.ts",
|
|
36
41
|
"import": "./dist/startup.js",
|
|
@@ -52,22 +57,22 @@
|
|
|
52
57
|
"@effect/platform-node": "^0.107.0",
|
|
53
58
|
"@effect/sql": "^0.51.1",
|
|
54
59
|
"@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.
|
|
60
|
+
"@voltro/ai": "0.6.0",
|
|
61
|
+
"@voltro/cache": "0.6.0",
|
|
62
|
+
"@voltro/data-transfer": "0.6.0",
|
|
63
|
+
"@voltro/database": "0.6.0",
|
|
64
|
+
"@voltro/env": "0.6.0",
|
|
65
|
+
"@voltro/kv": "0.6.0",
|
|
66
|
+
"@voltro/logger": "0.6.0",
|
|
67
|
+
"@voltro/plugin-auth": "0.6.0",
|
|
68
|
+
"@voltro/plugin-broadcast": "0.6.0",
|
|
69
|
+
"@voltro/plugin-mail": "0.6.0",
|
|
70
|
+
"@voltro/plugin-storage": "0.6.0",
|
|
71
|
+
"@voltro/plugin-webhooks": "0.6.0",
|
|
72
|
+
"@voltro/protocol": "0.6.0",
|
|
73
|
+
"@voltro/runtime": "0.6.0",
|
|
74
|
+
"@voltro/serverless": "0.6.0",
|
|
75
|
+
"@voltro/workflow": "0.6.0",
|
|
71
76
|
"chokidar": "^5.0.0",
|
|
72
77
|
"ioredis": "^5.11.1",
|
|
73
78
|
"ts-morph": "^28.0.0"
|
package/templates/AGENTS.core.md
CHANGED
|
@@ -61,6 +61,100 @@ 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
|
+
11. **Calling a third party ON BEHALF OF A USER** (their Jira token, their Slack
|
|
130
|
+
grant)? → **`defineConnection({ id, kind: 'oauth2' | 'pat' })`** in a
|
|
131
|
+
`*.connection.ts`, then **`ctx.connections.get(id)`**. One declaration yields
|
|
132
|
+
the encrypted per-user token store, the OAuth authorize/callback pair,
|
|
133
|
+
refresh-before-use, and the `useConnection` + `<ConnectAccount>` connect UI.
|
|
134
|
+
The credential is bound to the REQUEST's subject — there is no parameter for
|
|
135
|
+
another subject. Do NOT hand-build a per-user token table, an OAuth action
|
|
136
|
+
pair, or a "credentials resolver" over your own schema.
|
|
137
|
+
|
|
138
|
+
| You're about to write | Reach for instead |
|
|
139
|
+
|---|---|
|
|
140
|
+
| `const rows = …; if (!rows[0]) throw new NotFound()` | `.one()` |
|
|
141
|
+
| 3+ sequential `store.query` to assemble related data | `relations()` + `.with()` |
|
|
142
|
+
| a notify/webhook helper called at the end of a mutation | `defineSubscriber` / `defineReaction` |
|
|
143
|
+
| a counter recomputed by scanning rows on every read | `defineAggregate` (+ `incremental:`) |
|
|
144
|
+
| `requireScope(...)` as the first line of every executor | `guards:` on the descriptor |
|
|
145
|
+
| a token/secret column written as plain text | `.encrypted()` |
|
|
146
|
+
| `Effect.promise(() => ctx.store.query(...))` in an Effect handler | `yield* EffectStore` |
|
|
147
|
+
| hand-rolled limit+1 / `hasMore` cursor paging | `paginateBy` / `paginateById` |
|
|
148
|
+
| a loop issuing one `store.query` per node of a walk | `ctx.load` / `ctx.loadMany` |
|
|
149
|
+
| a deliveries table + drain cron + retry worker | `ctx.outbox.enqueue` + `defineOutboxHandler` |
|
|
150
|
+
| a per-user `integrationTokens` table + OAuth actions + a refresh check | `defineConnection` + `ctx.connections.get(id)` |
|
|
151
|
+
| `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 |
|
|
152
|
+
|
|
153
|
+
Deep dive: **`data.md`** (`.one()`, subscribers, reactions, aggregates),
|
|
154
|
+
**`database/querying.md`** (relations + `.with`), **`authentication.md`**
|
|
155
|
+
(`guards:`, resource policies), **`plugins/governance.md`** (`.encrypted()`),
|
|
156
|
+
**`data.md`** (`defineConnection`, the credentials vault).
|
|
157
|
+
|
|
64
158
|
### Pick the CLIENT primitive (decision rubric)
|
|
65
159
|
|
|
66
160
|
`useSubscription` / `useMutation` / `useAction` are the transport — they are NOT
|
|
@@ -180,6 +274,31 @@ export default { type: 'web' as const, name: 'myWeb',
|
|
|
180
274
|
Env, secrets, auth strategies, layers, plugins config → **`configuration.md`**.
|
|
181
275
|
`store: 'memory'` loses data on restart — default to `postgres` for real work.
|
|
182
276
|
|
|
277
|
+
**NEVER hardcode a secret, not even as a fallback.** Not
|
|
278
|
+
`process.env.X ?? 'dev-only-change-me'`, not a value committed to `.env`. A
|
|
279
|
+
hardcoded fallback means every environment that forgot to configure the
|
|
280
|
+
variable shares one key that is readable by anyone with the repo — and it looks
|
|
281
|
+
completely healthy until someone forges a session. Declare it instead:
|
|
282
|
+
|
|
283
|
+
```ts
|
|
284
|
+
env: defineEnv({
|
|
285
|
+
VOLTRO_SESSION_SECRET: envVar.secret({ generate: 'base64url' }), // ours → minted
|
|
286
|
+
STRIPE_SECRET_KEY: envVar.secret({ minLength: 20 }), // theirs → must be set
|
|
287
|
+
})
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
`generate` makes `voltro dev` mint a unique per-project value into a gitignored
|
|
291
|
+
`.env.local` on first boot, so local development needs no placeholder at all.
|
|
292
|
+
Omit it for third-party credentials — an invented API key authenticates nobody,
|
|
293
|
+
and failing the boot gate is the useful outcome. Deployment values come from
|
|
294
|
+
`voltro secret generate <purpose>`; `voltro serve` refuses to start without them.
|
|
295
|
+
|
|
296
|
+
Also: don't read `process.env` for a secret at config time to pass it to a
|
|
297
|
+
plugin. `app.config.ts` is evaluated BEFORE the env gate and before minting, so
|
|
298
|
+
that read is what pushes you into writing a fallback. `authRoutesPlugin` and
|
|
299
|
+
`voltroPasswordStrategy` resolve `VOLTRO_SESSION_SECRET` themselves — omit
|
|
300
|
+
`secret:` entirely.
|
|
301
|
+
|
|
183
302
|
## The browser/server boundary (load-bearing — verify when you touch it)
|
|
184
303
|
|
|
185
304
|
Codegen pulls every **descriptor** (`*.query.ts` / `*.mutation.ts` /
|
package/templates/AGENTS.md
CHANGED
|
@@ -61,6 +61,100 @@ 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
|
+
11. **Calling a third party ON BEHALF OF A USER** (their Jira token, their Slack
|
|
130
|
+
grant)? → **`defineConnection({ id, kind: 'oauth2' | 'pat' })`** in a
|
|
131
|
+
`*.connection.ts`, then **`ctx.connections.get(id)`**. One declaration yields
|
|
132
|
+
the encrypted per-user token store, the OAuth authorize/callback pair,
|
|
133
|
+
refresh-before-use, and the `useConnection` + `<ConnectAccount>` connect UI.
|
|
134
|
+
The credential is bound to the REQUEST's subject — there is no parameter for
|
|
135
|
+
another subject. Do NOT hand-build a per-user token table, an OAuth action
|
|
136
|
+
pair, or a "credentials resolver" over your own schema.
|
|
137
|
+
|
|
138
|
+
| You're about to write | Reach for instead |
|
|
139
|
+
|---|---|
|
|
140
|
+
| `const rows = …; if (!rows[0]) throw new NotFound()` | `.one()` |
|
|
141
|
+
| 3+ sequential `store.query` to assemble related data | `relations()` + `.with()` |
|
|
142
|
+
| a notify/webhook helper called at the end of a mutation | `defineSubscriber` / `defineReaction` |
|
|
143
|
+
| a counter recomputed by scanning rows on every read | `defineAggregate` (+ `incremental:`) |
|
|
144
|
+
| `requireScope(...)` as the first line of every executor | `guards:` on the descriptor |
|
|
145
|
+
| a token/secret column written as plain text | `.encrypted()` |
|
|
146
|
+
| `Effect.promise(() => ctx.store.query(...))` in an Effect handler | `yield* EffectStore` |
|
|
147
|
+
| hand-rolled limit+1 / `hasMore` cursor paging | `paginateBy` / `paginateById` |
|
|
148
|
+
| a loop issuing one `store.query` per node of a walk | `ctx.load` / `ctx.loadMany` |
|
|
149
|
+
| a deliveries table + drain cron + retry worker | `ctx.outbox.enqueue` + `defineOutboxHandler` |
|
|
150
|
+
| a per-user `integrationTokens` table + OAuth actions + a refresh check | `defineConnection` + `ctx.connections.get(id)` |
|
|
151
|
+
| `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 |
|
|
152
|
+
|
|
153
|
+
Deep dive: **`data.md`** (`.one()`, subscribers, reactions, aggregates),
|
|
154
|
+
**`database/querying.md`** (relations + `.with`), **`authentication.md`**
|
|
155
|
+
(`guards:`, resource policies), **`plugins/governance.md`** (`.encrypted()`),
|
|
156
|
+
**`data.md`** (`defineConnection`, the credentials vault).
|
|
157
|
+
|
|
64
158
|
### Pick the CLIENT primitive (decision rubric)
|
|
65
159
|
|
|
66
160
|
`useSubscription` / `useMutation` / `useAction` are the transport — they are NOT
|
|
@@ -180,6 +274,31 @@ export default { type: 'web' as const, name: 'myWeb',
|
|
|
180
274
|
Env, secrets, auth strategies, layers, plugins config → **`configuration.md`**.
|
|
181
275
|
`store: 'memory'` loses data on restart — default to `postgres` for real work.
|
|
182
276
|
|
|
277
|
+
**NEVER hardcode a secret, not even as a fallback.** Not
|
|
278
|
+
`process.env.X ?? 'dev-only-change-me'`, not a value committed to `.env`. A
|
|
279
|
+
hardcoded fallback means every environment that forgot to configure the
|
|
280
|
+
variable shares one key that is readable by anyone with the repo — and it looks
|
|
281
|
+
completely healthy until someone forges a session. Declare it instead:
|
|
282
|
+
|
|
283
|
+
```ts
|
|
284
|
+
env: defineEnv({
|
|
285
|
+
VOLTRO_SESSION_SECRET: envVar.secret({ generate: 'base64url' }), // ours → minted
|
|
286
|
+
STRIPE_SECRET_KEY: envVar.secret({ minLength: 20 }), // theirs → must be set
|
|
287
|
+
})
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
`generate` makes `voltro dev` mint a unique per-project value into a gitignored
|
|
291
|
+
`.env.local` on first boot, so local development needs no placeholder at all.
|
|
292
|
+
Omit it for third-party credentials — an invented API key authenticates nobody,
|
|
293
|
+
and failing the boot gate is the useful outcome. Deployment values come from
|
|
294
|
+
`voltro secret generate <purpose>`; `voltro serve` refuses to start without them.
|
|
295
|
+
|
|
296
|
+
Also: don't read `process.env` for a secret at config time to pass it to a
|
|
297
|
+
plugin. `app.config.ts` is evaluated BEFORE the env gate and before minting, so
|
|
298
|
+
that read is what pushes you into writing a fallback. `authRoutesPlugin` and
|
|
299
|
+
`voltroPasswordStrategy` resolve `VOLTRO_SESSION_SECRET` themselves — omit
|
|
300
|
+
`secret:` entirely.
|
|
301
|
+
|
|
183
302
|
## The browser/server boundary (load-bearing — verify when you touch it)
|
|
184
303
|
|
|
185
304
|
Codegen pulls every **descriptor** (`*.query.ts` / `*.mutation.ts` /
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
"group": null,
|
|
18
18
|
"description": "How @voltro/plugin-auth wires password + session-cookie auth across api + web, plus the pluggable identity-strategy protocol.",
|
|
19
19
|
"path": "agent-docs/authentication.md",
|
|
20
|
-
"files":
|
|
20
|
+
"files": 12
|
|
21
21
|
},
|
|
22
22
|
{
|
|
23
23
|
"id": "caching",
|
|
@@ -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": 15
|
|
57
57
|
},
|
|
58
58
|
{
|
|
59
59
|
"id": "database/advancedqueries",
|
|
@@ -161,7 +161,7 @@
|
|
|
161
161
|
"group": null,
|
|
162
162
|
"description": "Voltro Cloud (coming soon) — the managed runtime for your Voltro project. Today the Free control plane registers + observes your self-hosted apps.",
|
|
163
163
|
"path": "agent-docs/deployment.md",
|
|
164
|
-
"files":
|
|
164
|
+
"files": 7
|
|
165
165
|
},
|
|
166
166
|
{
|
|
167
167
|
"id": "internationalization",
|
|
@@ -438,7 +438,18 @@ So one agent can run GPT-5.5 on a dedicated key while another inherits the env d
|
|
|
438
438
|
**Model picker / routing** — the input selects among server-owned configs (UI picker, cost/size routing, plan tiers):
|
|
439
439
|
|
|
440
440
|
```tsx
|
|
441
|
-
//
|
|
441
|
+
// support.agent.tsx — DESCRIPTOR: the picker is constrained to server-known tiers
|
|
442
|
+
import { defineAgent } from '@voltro/ai/agent'
|
|
443
|
+
import { Schema } from 'effect'
|
|
444
|
+
|
|
445
|
+
export const support = defineAgent({
|
|
446
|
+
name: 'support',
|
|
447
|
+
input: Schema.Struct({ prompt: Schema.String, tier: Schema.Literal('fast', 'smart') }),
|
|
448
|
+
})
|
|
449
|
+
|
|
450
|
+
// support.agent.server.tsx — EXECUTOR: map the tier onto server-owned configs
|
|
451
|
+
import { defineAgentExecutor } from '@voltro/ai'
|
|
452
|
+
|
|
442
453
|
export default defineAgentExecutor(support, {
|
|
443
454
|
model: (input) => input.tier === 'smart'
|
|
444
455
|
? { name: 'openai', model: 'gpt-5.5' }
|
|
@@ -449,7 +460,18 @@ export default defineAgentExecutor(support, {
|
|
|
449
460
|
**BYOK (bring your own key)** — the caller supplies their OWN key; you fold it into the returned config:
|
|
450
461
|
|
|
451
462
|
```tsx
|
|
452
|
-
//
|
|
463
|
+
// support.agent.tsx — DESCRIPTOR: the caller's own key is part of the input
|
|
464
|
+
import { defineAgent } from '@voltro/ai/agent'
|
|
465
|
+
import { Schema } from 'effect'
|
|
466
|
+
|
|
467
|
+
export const support = defineAgent({
|
|
468
|
+
name: 'support',
|
|
469
|
+
input: Schema.Struct({ prompt: Schema.String, apiKey: Schema.String }),
|
|
470
|
+
})
|
|
471
|
+
|
|
472
|
+
// support.agent.server.tsx — EXECUTOR: fold the caller's key into the config
|
|
473
|
+
import { defineAgentExecutor } from '@voltro/ai'
|
|
474
|
+
|
|
453
475
|
export default defineAgentExecutor(support, {
|
|
454
476
|
model: (input) => ({ name: 'openai', model: 'gpt-5.5', apiKey: input.apiKey }),
|
|
455
477
|
})
|
|
@@ -505,10 +527,20 @@ System prompts go in the agent definition's `system` field, NOT in the messages
|
|
|
505
527
|
The `system` field lives on the **executor**. Template it with per-call values (locale, persona, plan-specific instructions) — the function receives the input typed from the descriptor's schema:
|
|
506
528
|
|
|
507
529
|
```tsx
|
|
530
|
+
// support.agent.tsx — DESCRIPTOR: every field `system` reads must be declared here
|
|
531
|
+
import { defineAgent } from '@voltro/ai/agent'
|
|
532
|
+
import { Schema } from 'effect'
|
|
533
|
+
|
|
534
|
+
export const support = defineAgent({
|
|
535
|
+
name: 'support',
|
|
536
|
+
input: Schema.Struct({ prompt: Schema.String, plan: Schema.optional(Schema.String) }),
|
|
537
|
+
})
|
|
538
|
+
|
|
508
539
|
// support.agent.server.tsx — `plan` comes from the descriptor's input schema
|
|
540
|
+
import { defineAgentExecutor } from '@voltro/ai'
|
|
541
|
+
|
|
509
542
|
export default defineAgentExecutor(support, {
|
|
510
543
|
system: (input) => `You are speaking with a user on the ${input.plan ?? 'free'} plan.`,
|
|
511
|
-
// …
|
|
512
544
|
})
|
|
513
545
|
```
|
|
514
546
|
|
|
@@ -1717,6 +1749,7 @@ export const ask = defineAction({
|
|
|
1717
1749
|
```ts
|
|
1718
1750
|
// copilot.ask.action.server.ts — the server executor (imports @voltro/ai)
|
|
1719
1751
|
import { Effect } from 'effect'
|
|
1752
|
+
import { EffectStore } from '@voltro/runtime'
|
|
1720
1753
|
import { runDataCopilot, generateObject, CopilotProposalSchema, type CopilotSchema } from '@voltro/ai'
|
|
1721
1754
|
|
|
1722
1755
|
// The manifest the model is constrained to — only these tables/columns exist.
|
|
@@ -1729,6 +1762,7 @@ const schema: CopilotSchema = {
|
|
|
1729
1762
|
|
|
1730
1763
|
const execute = (input: { question: string }, ctx: AppContext) =>
|
|
1731
1764
|
Effect.gen(function* () {
|
|
1765
|
+
const store = yield* EffectStore
|
|
1732
1766
|
const v = yield* Effect.promise(() =>
|
|
1733
1767
|
runDataCopilot(input.question, schema, {
|
|
1734
1768
|
propose: ({ system, prompt }) =>
|
|
@@ -1739,13 +1773,15 @@ const execute = (input: { question: string }, ctx: AppContext) =>
|
|
|
1739
1773
|
)
|
|
1740
1774
|
if (!v.ok) return { ok: false as const, reason: v.rejection.reason }
|
|
1741
1775
|
// The validated SELECT runs AS the subject → tenant + row scope apply.
|
|
1742
|
-
const rows = yield*
|
|
1743
|
-
return { ok: true as const, rows
|
|
1776
|
+
const rows = yield* store.query(v.descriptor)
|
|
1777
|
+
return { ok: true as const, rows }
|
|
1744
1778
|
})
|
|
1745
1779
|
|
|
1746
1780
|
export default execute
|
|
1747
1781
|
```
|
|
1748
1782
|
|
|
1783
|
+
`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.
|
|
1784
|
+
|
|
1749
1785
|
## Client: `useDataCopilot` + `<DataCopilot>`
|
|
1750
1786
|
|
|
1751
1787
|
The hook is a thin binding over `useAction` — it imports **nothing** from
|