@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.
Files changed (84) hide show
  1. package/CHANGELOG.md +68 -0
  2. package/bin/voltro.mjs +54 -2
  3. package/dist/apiBuild-Bk4JBt-i.js +2 -0
  4. package/dist/{apiBuild-CvtQeBMs.js → apiBuild-BxY44VGj.js} +20 -20
  5. package/dist/bin.js +2 -2
  6. package/dist/{commands-DhyBIs1O.js → commands-My_YlUJV.js} +2823 -2265
  7. package/dist/{dev-CQxbrpDz.js → dev-Bjq-nIvH.js} +2650 -2230
  8. package/dist/dev-D6b74iXI.js +2 -0
  9. package/dist/devActivity-1WtIVyHc.js +153 -0
  10. package/dist/devActivity.d.ts +46 -0
  11. package/dist/devActivity.js +2 -0
  12. package/dist/index.js +1 -1
  13. package/dist/serveCommand-C-XQ89NJ.js +1110 -0
  14. package/dist/serveEntry.js +2 -2
  15. package/package.json +22 -17
  16. package/templates/AGENTS.core.md +119 -0
  17. package/templates/AGENTS.md +119 -0
  18. package/templates/agent-docs/_manifest.json +3 -3
  19. package/templates/agent-docs/ai.md +41 -5
  20. package/templates/agent-docs/authentication.md +238 -0
  21. package/templates/agent-docs/cli.md +142 -0
  22. package/templates/agent-docs/configuration.md +38 -0
  23. package/templates/agent-docs/data.md +718 -42
  24. package/templates/agent-docs/database/advancedqueries.md +21 -0
  25. package/templates/agent-docs/database/querying.md +166 -8
  26. package/templates/agent-docs/database/schema.md +6 -2
  27. package/templates/agent-docs/deployment.md +171 -0
  28. package/templates/agent-docs/internationalization.md +2 -0
  29. package/templates/agent-docs/plugins.md +4 -4
  30. package/templates/agent-docs/reference.md +90 -2
  31. package/templates/agent-docs/templates/apibackends.md +13 -2
  32. package/templates/agent-docs/testing.md +157 -3
  33. package/templates/agent-docs/workflows.md +11 -8
  34. package/templates/apps/api-ai/package.json +7 -7
  35. package/templates/apps/api-auth/app.config.ts +20 -13
  36. package/templates/apps/api-auth/package.json +8 -8
  37. package/templates/apps/api-backend/package.json +7 -7
  38. package/templates/apps/api-backend-deactivation/package.json +7 -7
  39. package/templates/apps/api-backend-mail/package.json +8 -8
  40. package/templates/apps/api-backend-mariadb/.env.example +10 -4
  41. package/templates/apps/api-backend-mariadb/package.json +9 -9
  42. package/templates/apps/api-backend-storage/package.json +8 -8
  43. package/templates/apps/api-data-advanced/app.config.ts +21 -8
  44. package/templates/apps/api-data-advanced/package.json +8 -8
  45. package/templates/apps/api-durable/package.json +8 -8
  46. package/templates/apps/api-feature-flags/package.json +9 -9
  47. package/templates/apps/api-governance/app.config.ts +15 -2
  48. package/templates/apps/api-governance/package.json +8 -8
  49. package/templates/apps/api-kv/package.json +8 -8
  50. package/templates/apps/api-moderation/package.json +8 -8
  51. package/templates/apps/api-observability/package.json +8 -8
  52. package/templates/apps/api-ratelimit/package.json +8 -8
  53. package/templates/apps/api-rbac/package.json +8 -8
  54. package/templates/apps/api-rest/package.json +7 -7
  55. package/templates/apps/api-saas/package.json +11 -11
  56. package/templates/apps/api-search/package.json +8 -8
  57. package/templates/apps/api-versioning/package.json +8 -8
  58. package/templates/apps/api-webhooks/app.config.ts +20 -1
  59. package/templates/apps/api-webhooks/package.json +8 -8
  60. package/templates/apps/changelog/package.json +6 -6
  61. package/templates/apps/edge-functions/package.json +2 -2
  62. package/templates/apps/frontend-admin/package.json +8 -8
  63. package/templates/apps/frontend-app/package.json +8 -8
  64. package/templates/apps/frontend-blank/package.json +7 -7
  65. package/templates/apps/frontend-contact/package.json +7 -7
  66. package/templates/apps/frontend-dashboard/package.json +7 -7
  67. package/templates/apps/frontend-docs/package.json +7 -7
  68. package/templates/apps/frontend-i18n/package.json +6 -6
  69. package/templates/apps/frontend-landing/package.json +7 -7
  70. package/templates/apps/frontend-spa/package.json +7 -7
  71. package/templates/apps/frontend-ssr/package.json +7 -7
  72. package/templates/apps/frontend-ssr-api/package.json +8 -8
  73. package/templates/apps/frontend-static-blog/package.json +6 -6
  74. package/templates/baselines/bare/.env.example +10 -3
  75. package/templates/baselines/compose/.env.example +10 -2
  76. package/templates/baselines/compose-mariadb/.env.example +10 -2
  77. package/templates/baselines/helm/.env.example +10 -3
  78. package/dist/apiBuild-DQBNqNZ8.js +0 -2
  79. package/dist/dev-DYjGqPGD.js +0 -2
  80. package/dist/serveCommand-BZzUJIyo.js +0 -1077
  81. package/templates/apps/api-auth/.env +0 -17
  82. package/templates/apps/api-data-advanced/.env +0 -17
  83. package/templates/apps/api-governance/.env +0 -4
  84. package/templates/apps/api-webhooks/.env +0 -6
@@ -1,4 +1,4 @@
1
- import { jt as e, rt as t } from "./dev-CQxbrpDz.js";
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-BZzUJIyo.js";
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.4.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.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",
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"
@@ -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` /
@@ -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": 11
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": 13
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": 6
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
- // descriptor: input: Schema.Struct({ prompt: Schema.String, tier: Schema.Literal('fast', 'smart') })
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
- // descriptor: input: Schema.Struct({ prompt: Schema.String, apiKey: Schema.String })
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* Effect.promise(() => ctx.store.query(v.descriptor as never))
1743
- return { ok: true as const, rows: rows as ReadonlyArray<Record<string, unknown>> }
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