@voltro/cli 0.17.0 → 0.19.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 (63) hide show
  1. package/CHANGELOG.md +423 -0
  2. package/bin/voltro.mjs +17 -1
  3. package/dist/apiBuild-9NXH53Sd.js +2 -0
  4. package/dist/{apiBuild-ChdlLqGv.js → apiBuild-CY5pEwwq.js} +2 -2
  5. package/dist/bin.js +2 -2
  6. package/dist/{commands-DYQbv-DG.js → commands-BvvoQL0v.js} +2126 -1720
  7. package/dist/{dev-DB7pbLob.js → dev-B6rfgKfo.js} +1513 -1418
  8. package/dist/{dev-BCmoJfBm.js → dev-C_E1XxNF.js} +1 -1
  9. package/dist/index.js +1 -1
  10. package/dist/{inspectMetrics-DWh56Qas.js → inspectMetrics-D1DmLeJs.js} +507 -494
  11. package/dist/serveCommand-vPF5ucXC.js +1136 -0
  12. package/dist/serveEntry.js +2 -2
  13. package/dist/{start-ksY0wMZG.js → start-Clvz4IJb.js} +1 -1
  14. package/dist/startEntry.js +2 -2
  15. package/package.json +17 -17
  16. package/templates/AGENTS.core.md +60 -6
  17. package/templates/AGENTS.md +61 -7
  18. package/templates/agent-docs/_index.md +1 -1
  19. package/templates/agent-docs/authentication.md +57 -0
  20. package/templates/agent-docs/cli.md +9 -5
  21. package/templates/agent-docs/database/misc.md +26 -3
  22. package/templates/agent-docs/database/querying.md +8 -2
  23. package/templates/agent-docs/introduction.md +15 -1
  24. package/templates/agent-docs/routing.md +2 -0
  25. package/templates/agent-docs/schema-driven-ui.md +6 -3
  26. package/templates/agent-docs/whats-new.md +121 -31
  27. package/templates/apps/api-ai/package.json +7 -7
  28. package/templates/apps/api-auth/package.json +8 -8
  29. package/templates/apps/api-backend/package.json +7 -7
  30. package/templates/apps/api-backend-deactivation/package.json +7 -7
  31. package/templates/apps/api-backend-mail/package.json +8 -8
  32. package/templates/apps/api-backend-mariadb/package.json +9 -9
  33. package/templates/apps/api-backend-storage/package.json +8 -8
  34. package/templates/apps/api-data-advanced/package.json +8 -8
  35. package/templates/apps/api-durable/package.json +8 -8
  36. package/templates/apps/api-feature-flags/package.json +9 -9
  37. package/templates/apps/api-governance/package.json +8 -8
  38. package/templates/apps/api-kv/package.json +8 -8
  39. package/templates/apps/api-moderation/package.json +8 -8
  40. package/templates/apps/api-observability/package.json +8 -8
  41. package/templates/apps/api-ratelimit/package.json +8 -8
  42. package/templates/apps/api-rbac/package.json +8 -8
  43. package/templates/apps/api-rest/package.json +7 -7
  44. package/templates/apps/api-saas/package.json +11 -11
  45. package/templates/apps/api-search/package.json +8 -8
  46. package/templates/apps/api-versioning/package.json +8 -8
  47. package/templates/apps/api-webhooks/package.json +9 -9
  48. package/templates/apps/changelog/package.json +6 -6
  49. package/templates/apps/edge-functions/package.json +2 -2
  50. package/templates/apps/frontend-admin/package.json +8 -8
  51. package/templates/apps/frontend-app/package.json +8 -8
  52. package/templates/apps/frontend-blank/package.json +7 -7
  53. package/templates/apps/frontend-contact/package.json +7 -7
  54. package/templates/apps/frontend-dashboard/package.json +7 -7
  55. package/templates/apps/frontend-docs/package.json +7 -7
  56. package/templates/apps/frontend-i18n/package.json +6 -6
  57. package/templates/apps/frontend-landing/package.json +7 -7
  58. package/templates/apps/frontend-spa/package.json +7 -7
  59. package/templates/apps/frontend-ssr/package.json +7 -7
  60. package/templates/apps/frontend-ssr-api/package.json +8 -8
  61. package/templates/apps/frontend-static-blog/package.json +6 -6
  62. package/dist/apiBuild-D5WB119b.js +0 -2
  63. package/dist/serveCommand-DO6qr5Ok.js +0 -1129
@@ -1,5 +1,5 @@
1
- import { Q as e } from "./inspectMetrics-DWh56Qas.js";
1
+ import { Q as e } from "./inspectMetrics-D1DmLeJs.js";
2
2
  import { c as t } from "./seedRunner-D6eu-u5U.js";
3
3
  import { r as n } from "./appModuleLoader-C9r9mxZt.js";
4
- import { t as r } from "./serveCommand-DO6qr5Ok.js";
4
+ import { t as r } from "./serveCommand-vPF5ucXC.js";
5
5
  export { e as loadDotEnv, n as registerAppModules, t as registerDriver, r as runServe };
@@ -1,4 +1,4 @@
1
- import { A as e, B as t, C as n, E as r, G as i, I as a, K as o, N as s, O as c, U as l, W as ee, X as u, Y as d, _ as f, a as p, b as m, c as h, et as g, f as _, g as te, h as v, i as ne, it as y, j as b, k as x, m as S, nt as C, o as re, p as ie, q as w, r as ae, rt as T, s as E, st as D, t as O, tt as k, ut as A, v as j, w as oe, y as M } from "./inspectMetrics-DWh56Qas.js";
1
+ import { A as e, B as t, C as n, E as r, G as i, I as a, K as o, N as s, O as c, U as l, W as ee, X as u, Y as d, _ as f, a as p, b as m, c as h, et as g, f as _, g as te, h as v, i as ne, it as y, j as b, k as x, m as S, nt as C, o as re, p as ie, q as w, r as ae, rt as T, s as E, st as D, t as O, tt as k, ut as A, v as j, w as oe, y as M } from "./inspectMetrics-D1DmLeJs.js";
2
2
  import { D as se, E as ce, T as le, a as N, p as ue, w as de } from "./inspect-Dwx0_tUj.js";
3
3
  import { t as fe } from "./bootTiming-BdyP9nYw.js";
4
4
  import { dirname as pe, extname as P, join as F, resolve as I } from "node:path";
@@ -1,3 +1,3 @@
1
- import { Q as e } from "./inspectMetrics-DWh56Qas.js";
2
- import { t } from "./start-ksY0wMZG.js";
1
+ import { Q as e } from "./inspectMetrics-D1DmLeJs.js";
2
+ import { t } from "./start-Clvz4IJb.js";
3
3
  export { e as loadDotEnv, t as runStartCommand };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@voltro/cli",
3
- "version": "0.17.0",
3
+ "version": "0.19.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",
@@ -62,22 +62,22 @@
62
62
  "@effect/platform-node": "^0.107.0",
63
63
  "@effect/sql": "^0.51.1",
64
64
  "@effect/workflow": "^0.18.2",
65
- "@voltro/ai": "0.17.0",
66
- "@voltro/cache": "0.17.0",
67
- "@voltro/data-transfer": "0.17.0",
68
- "@voltro/database": "0.17.0",
69
- "@voltro/env": "0.17.0",
70
- "@voltro/kv": "0.17.0",
71
- "@voltro/logger": "0.17.0",
72
- "@voltro/plugin-auth": "0.17.0",
73
- "@voltro/plugin-broadcast": "0.17.0",
74
- "@voltro/plugin-mail": "0.17.0",
75
- "@voltro/plugin-storage": "0.17.0",
76
- "@voltro/plugin-webhooks": "0.17.0",
77
- "@voltro/protocol": "0.17.0",
78
- "@voltro/runtime": "0.17.0",
79
- "@voltro/serverless": "0.17.0",
80
- "@voltro/workflow": "0.17.0",
65
+ "@voltro/ai": "0.19.0",
66
+ "@voltro/cache": "0.19.0",
67
+ "@voltro/data-transfer": "0.19.0",
68
+ "@voltro/database": "0.19.0",
69
+ "@voltro/env": "0.19.0",
70
+ "@voltro/kv": "0.19.0",
71
+ "@voltro/logger": "0.19.0",
72
+ "@voltro/plugin-auth": "0.19.0",
73
+ "@voltro/plugin-broadcast": "0.19.0",
74
+ "@voltro/plugin-mail": "0.19.0",
75
+ "@voltro/plugin-storage": "0.19.0",
76
+ "@voltro/plugin-webhooks": "0.19.0",
77
+ "@voltro/protocol": "0.19.0",
78
+ "@voltro/runtime": "0.19.0",
79
+ "@voltro/serverless": "0.19.0",
80
+ "@voltro/workflow": "0.19.0",
81
81
  "chokidar": "^5.0.0",
82
82
  "ioredis": "^5.11.1",
83
83
  "tinyglobby": "^0.2.17",
@@ -103,30 +103,41 @@ line below replaces something real apps write by hand hundreds of times.
103
103
  in-handler for checks that need LOADED data. A hand-written `requireScope(...)`
104
104
  at the top of every executor — or a hand-kept map from rpc tag to policy rule,
105
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()`**
106
+ 6. **Which ROWS may the caller see** (not: may they make the call)? →
107
+ **`setRowFilter`** — a predicate derived from the Subject, AND-merged into
108
+ EVERY read of that table, including every subscription delivery. `guards:`
109
+ and this answer different questions and the pair is easy to conflate:
110
+ - `guards:` → *may I call this procedure?* → a typed `ScopeError`.
111
+ - `setRowFilter` → *which rows may I see?* → the rows are simply absent.
112
+ A `WHERE ownerId = me` in the list handler covers the query and **NOT** the
113
+ stream, which is the failure this deletes. Register it from a
114
+ `*.startup.tsx`; its `load` must read through an UNFILTERED store (the boot
115
+ store / the `database` handle) — reading through the filtered one applies the
116
+ filter to itself and blows the stack. Depth → **`authentication`**.
117
+ 7. **Storing a token, secret, or credential in a column?** → **`.encrypted()`**
107
118
  on the column. Boot fails loudly if no cipher is configured, so an
108
119
  `.encrypted()` column can never silently persist plaintext.
109
- 7. **Writing an Effect-form handler?** → `const store = yield* EffectStore` —
120
+ 8. **Writing an Effect-form handler?** → `const store = yield* EffectStore` —
110
121
  its failures land on the typed error channel. `Effect.promise(() =>
111
122
  ctx.store.query(…))` throws that channel away and turns a store failure into
112
123
  a defect.
113
- 8. **Cursor pagination?** → **`paginateBy(descriptor, column, cursor, limit,
124
+ 9. **Cursor pagination?** → **`paginateBy(descriptor, column, cursor, limit,
114
125
  direction?)`** (or **`paginateById`**, its `id`-column shorthand). Not a
115
126
  hand-rolled limit+1 / slice / `hasMore` triple. `direction` flips the
116
127
  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
128
+ 10. **Assembling data whose SHAPE depends on the data** (a tree walk where each
118
129
  level's ids come from the level above)? → **`ctx.load` / `ctx.loadMany`** —
119
130
  same-tick reads of one table coalesce into one `WHERE id IN (...)`, so the
120
131
  walk costs one query per LEVEL. Use `relations()` + `.with()` whenever the
121
132
  shape IS static; this is the fallback, not the default.
122
- 10. **A mutation must cause an EXTERNAL side effect** (webhook, Jira sync,
133
+ 11. **A mutation must cause an EXTERNAL side effect** (webhook, Jira sync,
123
134
  payment)? → **`ctx.outbox.enqueue(effect, payload)`** + a
124
135
  **`defineOutboxHandler`** in a `*.outbox.ts`. The enqueue writes through the
125
136
  mutation's TRANSACTION, so the intent commits with the write or not at all;
126
137
  delivery happens after commit, with backoff and a dead-letter. Do NOT call
127
138
  the remote from the mutation (not transactional), and do not hand-build a
128
139
  deliveries table + drain cron — that IS this primitive.
129
- 11. **Calling a third party ON BEHALF OF A USER** (their Jira token, their Slack
140
+ 12. **Calling a third party ON BEHALF OF A USER** (their Jira token, their Slack
130
141
  grant)? → **`defineConnection({ id, kind: 'oauth2' | 'pat' })`** in a
131
142
  `*.connection.ts`, then **`ctx.connections.get(id)`**. One declaration yields
132
143
  the encrypted per-user token store, the OAuth authorize/callback pair,
@@ -140,6 +151,7 @@ line below replaces something real apps write by hand hundreds of times.
140
151
  | `const rows = …; if (!rows[0]) throw new NotFound()` | `.one()` |
141
152
  | 3+ sequential `store.query` to assemble related data | `relations()` + `.with()` |
142
153
  | a notify/webhook helper called at the end of a mutation | `defineSubscriber` / `defineReaction` |
154
+ | `WHERE ownerId = me` in every list handler | `setRowFilter` (covers the SUBSCRIPTION too) |
143
155
  | a counter recomputed by scanning rows on every read | `defineAggregate` (+ `incremental:`) |
144
156
  | `requireScope(...)` as the first line of every executor | `guards:` on the descriptor |
145
157
  | a token/secret column written as plain text | `.encrypted()` |
@@ -449,6 +461,48 @@ aggregations, FTS, vectors/RAG, migrations, dialect parity → the **`database/*
449
461
  docs (see index). Async-vs-Effect handlers, `EffectStore`, typed store errors →
450
462
  **`data.md`**.
451
463
 
464
+ ## What may cross the wire (three ORTHOGONAL markers — do not substitute one for another)
465
+
466
+ A column is not "protected" or "unprotected". Three separate questions get three
467
+ separate markers, and using one to answer another's question is the mistake:
468
+
469
+ | Marker | Answers | Enforced by |
470
+ |---|---|---|
471
+ | `.serverOnly()` | may this value leave the server AT ALL? | **`voltro serve` fails to boot; `voltro doctor` exits non-zero.** `voltro dev` only warns |
472
+ | `.sensitive('class')` / `.safe()` | may it appear in a `voltro data export`? | the export's masking profile, **fail-closed** |
473
+ | `.encrypted()` | is it encrypted AT REST? | the store's codec |
474
+
475
+ ```ts
476
+ export const users = table('users', {
477
+ id: id(),
478
+ email: text().sensitive('pii'), // exportable only through a profile
479
+ pinHash: text().serverOnly(), // never reaches a client, ever
480
+ ssn: text().encrypted().serverOnly(), // both — they are not the same claim
481
+ })
482
+ ```
483
+
484
+ - **`.serverOnly()` is the one that decides "leak / no leak".** A wire-reachable
485
+ query that declares such a column in its OUTPUT does not reach production:
486
+ `voltro serve` refuses to boot and `voltro doctor` exits non-zero, so the
487
+ failure happens at deploy time rather than in a bundle. `voltro dev` only
488
+ WARNS — mid-edit a refused boot is worse than the bug — so do not read a green
489
+ dev boot as a clean audit. `VOLTRO_SERVER_ONLY=strict` makes dev fail too;
490
+ put `voltro doctor` in CI if you want one gate that covers both.
491
+ - **`.encrypted()` is NOT an exposure marker.** It says the bytes at rest are
492
+ encrypted; the runtime decrypts them for a handler, so an encrypted column
493
+ flows to the client exactly like any other unless it is ALSO `.serverOnly()`.
494
+ Reading `.encrypted()` as "safe to expose" is a category error, and a plausible
495
+ one — say both when you mean both.
496
+ - **Masking is fail-closed**: an unclassified column blocks the export rather
497
+ than passing through, so `.sensitive()` / `.safe()` is a decision you make once
498
+ per column, not a filter you remember to apply.
499
+ - `crud.list` / `getById` / `create` / `update` are **redacted by construction** —
500
+ they strip `serverOnly` columns for you. Hand-rolling the same CRUD is where
501
+ that stripping gets forgotten.
502
+
503
+ Depth (classes, profiles, the export flow) → **`database/sensitivity`**; the
504
+ redacted CRUD surface → **`data/crud`**.
505
+
452
506
  ## Naming / RPC tags
453
507
 
454
508
  - **camelCase** for vars, files, schemas, and rpc tags. The tag IS the wire
@@ -103,30 +103,41 @@ line below replaces something real apps write by hand hundreds of times.
103
103
  in-handler for checks that need LOADED data. A hand-written `requireScope(...)`
104
104
  at the top of every executor — or a hand-kept map from rpc tag to policy rule,
105
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()`**
106
+ 6. **Which ROWS may the caller see** (not: may they make the call)? →
107
+ **`setRowFilter`** — a predicate derived from the Subject, AND-merged into
108
+ EVERY read of that table, including every subscription delivery. `guards:`
109
+ and this answer different questions and the pair is easy to conflate:
110
+ - `guards:` → *may I call this procedure?* → a typed `ScopeError`.
111
+ - `setRowFilter` → *which rows may I see?* → the rows are simply absent.
112
+ A `WHERE ownerId = me` in the list handler covers the query and **NOT** the
113
+ stream, which is the failure this deletes. Register it from a
114
+ `*.startup.tsx`; its `load` must read through an UNFILTERED store (the boot
115
+ store / the `database` handle) — reading through the filtered one applies the
116
+ filter to itself and blows the stack. Depth → **`authentication`**.
117
+ 7. **Storing a token, secret, or credential in a column?** → **`.encrypted()`**
107
118
  on the column. Boot fails loudly if no cipher is configured, so an
108
119
  `.encrypted()` column can never silently persist plaintext.
109
- 7. **Writing an Effect-form handler?** → `const store = yield* EffectStore` —
120
+ 8. **Writing an Effect-form handler?** → `const store = yield* EffectStore` —
110
121
  its failures land on the typed error channel. `Effect.promise(() =>
111
122
  ctx.store.query(…))` throws that channel away and turns a store failure into
112
123
  a defect.
113
- 8. **Cursor pagination?** → **`paginateBy(descriptor, column, cursor, limit,
124
+ 9. **Cursor pagination?** → **`paginateBy(descriptor, column, cursor, limit,
114
125
  direction?)`** (or **`paginateById`**, its `id`-column shorthand). Not a
115
126
  hand-rolled limit+1 / slice / `hasMore` triple. `direction` flips the
116
127
  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
128
+ 10. **Assembling data whose SHAPE depends on the data** (a tree walk where each
118
129
  level's ids come from the level above)? → **`ctx.load` / `ctx.loadMany`** —
119
130
  same-tick reads of one table coalesce into one `WHERE id IN (...)`, so the
120
131
  walk costs one query per LEVEL. Use `relations()` + `.with()` whenever the
121
132
  shape IS static; this is the fallback, not the default.
122
- 10. **A mutation must cause an EXTERNAL side effect** (webhook, Jira sync,
133
+ 11. **A mutation must cause an EXTERNAL side effect** (webhook, Jira sync,
123
134
  payment)? → **`ctx.outbox.enqueue(effect, payload)`** + a
124
135
  **`defineOutboxHandler`** in a `*.outbox.ts`. The enqueue writes through the
125
136
  mutation's TRANSACTION, so the intent commits with the write or not at all;
126
137
  delivery happens after commit, with backoff and a dead-letter. Do NOT call
127
138
  the remote from the mutation (not transactional), and do not hand-build a
128
139
  deliveries table + drain cron — that IS this primitive.
129
- 11. **Calling a third party ON BEHALF OF A USER** (their Jira token, their Slack
140
+ 12. **Calling a third party ON BEHALF OF A USER** (their Jira token, their Slack
130
141
  grant)? → **`defineConnection({ id, kind: 'oauth2' | 'pat' })`** in a
131
142
  `*.connection.ts`, then **`ctx.connections.get(id)`**. One declaration yields
132
143
  the encrypted per-user token store, the OAuth authorize/callback pair,
@@ -140,6 +151,7 @@ line below replaces something real apps write by hand hundreds of times.
140
151
  | `const rows = …; if (!rows[0]) throw new NotFound()` | `.one()` |
141
152
  | 3+ sequential `store.query` to assemble related data | `relations()` + `.with()` |
142
153
  | a notify/webhook helper called at the end of a mutation | `defineSubscriber` / `defineReaction` |
154
+ | `WHERE ownerId = me` in every list handler | `setRowFilter` (covers the SUBSCRIPTION too) |
143
155
  | a counter recomputed by scanning rows on every read | `defineAggregate` (+ `incremental:`) |
144
156
  | `requireScope(...)` as the first line of every executor | `guards:` on the descriptor |
145
157
  | a token/secret column written as plain text | `.encrypted()` |
@@ -449,6 +461,48 @@ aggregations, FTS, vectors/RAG, migrations, dialect parity → the **`database/*
449
461
  docs (see index). Async-vs-Effect handlers, `EffectStore`, typed store errors →
450
462
  **`data.md`**.
451
463
 
464
+ ## What may cross the wire (three ORTHOGONAL markers — do not substitute one for another)
465
+
466
+ A column is not "protected" or "unprotected". Three separate questions get three
467
+ separate markers, and using one to answer another's question is the mistake:
468
+
469
+ | Marker | Answers | Enforced by |
470
+ |---|---|---|
471
+ | `.serverOnly()` | may this value leave the server AT ALL? | **`voltro serve` fails to boot; `voltro doctor` exits non-zero.** `voltro dev` only warns |
472
+ | `.sensitive('class')` / `.safe()` | may it appear in a `voltro data export`? | the export's masking profile, **fail-closed** |
473
+ | `.encrypted()` | is it encrypted AT REST? | the store's codec |
474
+
475
+ ```ts
476
+ export const users = table('users', {
477
+ id: id(),
478
+ email: text().sensitive('pii'), // exportable only through a profile
479
+ pinHash: text().serverOnly(), // never reaches a client, ever
480
+ ssn: text().encrypted().serverOnly(), // both — they are not the same claim
481
+ })
482
+ ```
483
+
484
+ - **`.serverOnly()` is the one that decides "leak / no leak".** A wire-reachable
485
+ query that declares such a column in its OUTPUT does not reach production:
486
+ `voltro serve` refuses to boot and `voltro doctor` exits non-zero, so the
487
+ failure happens at deploy time rather than in a bundle. `voltro dev` only
488
+ WARNS — mid-edit a refused boot is worse than the bug — so do not read a green
489
+ dev boot as a clean audit. `VOLTRO_SERVER_ONLY=strict` makes dev fail too;
490
+ put `voltro doctor` in CI if you want one gate that covers both.
491
+ - **`.encrypted()` is NOT an exposure marker.** It says the bytes at rest are
492
+ encrypted; the runtime decrypts them for a handler, so an encrypted column
493
+ flows to the client exactly like any other unless it is ALSO `.serverOnly()`.
494
+ Reading `.encrypted()` as "safe to expose" is a category error, and a plausible
495
+ one — say both when you mean both.
496
+ - **Masking is fail-closed**: an unclassified column blocks the export rather
497
+ than passing through, so `.sensitive()` / `.safe()` is a decision you make once
498
+ per column, not a filter you remember to apply.
499
+ - `crud.list` / `getById` / `create` / `update` are **redacted by construction** —
500
+ they strip `serverOnly` columns for you. Hand-rolling the same CRUD is where
501
+ that stripping gets forgotten.
502
+
503
+ Depth (classes, profiles, the export flow) → **`database/sensitivity`**; the
504
+ redacted CRUD surface → **`data/crud`**.
505
+
452
506
  ## Naming / RPC tags
453
507
 
454
508
  - **camelCase** for vars, files, schemas, and rpc tags. The tag IS the wire
@@ -541,7 +595,7 @@ each plugin's own README.
541
595
 
542
596
  | Topic | Open | Summary |
543
597
  |---|---|---|
544
- | **What's new in 0.16.0** | `node_modules/@voltro/cli/templates/agent-docs/whats-new.md` | Everything that changed in this version. Read it before hand-rolling something the framework may now ship. |
598
+ | **What's new in 0.19.0** | `node_modules/@voltro/cli/templates/agent-docs/whats-new.md` | Everything that changed in this version. Read it before hand-rolling something the framework may now ship. |
545
599
  | AI | `node_modules/@voltro/cli/templates/agent-docs/ai.md` | How Voltro treats AI — agents, tools, streaming, RAG — all primitives over the same WebSocket as the rest of the framework. |
546
600
  | Authentication | `node_modules/@voltro/cli/templates/agent-docs/authentication.md` | How @voltro/plugin-auth wires password + session-cookie auth across api + web, plus the pluggable identity-strategy protocol. |
547
601
  | Caching | `node_modules/@voltro/cli/templates/agent-docs/caching.md` | Voltro's caching layer (@voltro/cache) — an always-on memory default, swappable Redis-compatible backends, a low-level wrap primitive, and automatic query-result invalidation. |
@@ -9,7 +9,7 @@ each plugin's own README.
9
9
 
10
10
  | Topic | Open | Summary |
11
11
  |---|---|---|
12
- | **What's new in 0.16.0** | `node_modules/@voltro/cli/templates/agent-docs/whats-new.md` | Everything that changed in this version. Read it before hand-rolling something the framework may now ship. |
12
+ | **What's new in 0.19.0** | `node_modules/@voltro/cli/templates/agent-docs/whats-new.md` | Everything that changed in this version. Read it before hand-rolling something the framework may now ship. |
13
13
  | AI | `node_modules/@voltro/cli/templates/agent-docs/ai.md` | How Voltro treats AI — agents, tools, streaming, RAG — all primitives over the same WebSocket as the rest of the framework. |
14
14
  | Authentication | `node_modules/@voltro/cli/templates/agent-docs/authentication.md` | How @voltro/plugin-auth wires password + session-cookie auth across api + web, plus the pluggable identity-strategy protocol. |
15
15
  | Caching | `node_modules/@voltro/cli/templates/agent-docs/caching.md` | Voltro's caching layer (@voltro/cache) — an always-on memory default, swappable Redis-compatible backends, a low-level wrap primitive, and automatic query-result invalidation. |
@@ -569,6 +569,63 @@ Read users, sessions, keys. A strategy that runs domain writes while deciding
569
569
  who the caller is has the two jobs the wrong way round; nothing in the type
570
570
  stops you, and it is still wrong.
571
571
 
572
+ #### What the boot store carries, and what it does not
573
+
574
+ The line is **everything that does not need a Subject** — not "less than
575
+ `ctx.store`":
576
+
577
+ | | Boot store (`input.store`, `req.store`) | Request store (`ctx.store`) |
578
+ |---|---|---|
579
+ | `.encrypted()` columns decrypt / encrypt | ✓ | ✓ |
580
+ | Array columns round-trip on non-native dialects | ✓ | ✓ |
581
+ | Tenant scope | — | ✓ |
582
+ | Soft-delete filter | — | ✓ |
583
+ | Audit-column stamping | — | ✓ |
584
+ | Row-level security | — | ✓ |
585
+
586
+ The right-hand four need a resolved Subject, and a strategy runs *before* one
587
+ exists — so a read of tenant-owned rows here must derive and apply that scope
588
+ itself. The first two do not, and getting them wrong is silent: a `.encrypted()`
589
+ column read raw hands back the string `enc:v1:…`, which compares, concatenates,
590
+ renders and logs perfectly well, and simply never matches the token you compare
591
+ it to.
592
+
593
+ **The soft-delete row is the one to read twice if you are porting raw SQL onto
594
+ this store.** A read here behaves the way your SQL did and returns tombstones —
595
+ the store does *not* start appending `deletedAt IS NULL` behind you. So a lookup
596
+ that must see a soft-deleted row (a login that revives a returning user, say)
597
+ needs no opt-out and no `.withDeleted()`; that opt-out belongs to `ctx.store`,
598
+ which *does* apply the filter. Assuming the filter is present is the more
599
+ expensive mistake of the two: it turns a working login into a "not found →
600
+ insert → unique violation on email", and nothing about the code says so.
601
+
602
+ This is also what changes when you move a read **off** hand-written SQL and onto
603
+ the store. Raw SQL sees ciphertext and you decrypt it yourself — `decryptField`
604
+ from `@voltro/runtime` is the escape hatch for exactly that. Through either
605
+ store you get plaintext, so a hand-rolled `decryptField` on the way out will now
606
+ be handed a plaintext value; `decryptField` passes a non-ciphertext value
607
+ through unchanged, so the double call is harmless, but the manual step is no
608
+ longer doing anything.
609
+
610
+ #### Reading a plugin's own tables
611
+
612
+ A plugin's tables are declared through `extendSchema` like any others, so they
613
+ are in the same registry and the same store reads them. A public route that
614
+ needs a row a plugin wrote — a storage reference for an avatar proxy, say —
615
+ reads it directly:
616
+
617
+ ```ts
618
+ const [ref] = await req.store.query(
619
+ queryFor(storageObjects).where(eq('id', objectId)).descriptor,
620
+ )
621
+ ```
622
+
623
+ Two things to keep in mind. The table is the plugin's contract with itself, not
624
+ with you, so it can change shape in any release — pin the version if you depend
625
+ on it. And this store applies no tenant scope, so a route reading a
626
+ tenant-owned plugin table must filter by tenant itself, from something the
627
+ request proves rather than something it claims.
628
+
572
629
  ## Composing the chain
573
630
 
574
631
  `composeAuthStrategies` turns an ordered list of strategies into a single resolver. First `matched` wins; first `failed` short-circuits to anonymous.
@@ -600,19 +600,23 @@ export default {
600
600
  }
601
601
  ```
602
602
 
603
- #### Inspect token (token-gated deploys)
603
+ #### Inspect token
604
604
 
605
- The overlay's webhooks / traces / indexes panels poll each api's `/_voltro/inspect/*` endpoints. Under `voltro dev` that surface is open, so no auth is needed. A token-gated deploy (`voltro start` with `VOLTRO_INSPECT_TOKEN` set) requires the same `Authorization: Bearer <token>` the CLI sends otherwise the panels 401 to their empty state.
605
+ The overlay's webhooks / traces / indexes panels poll each api's `/_voltro/inspect/*` endpoints, and that surface is **fail-closed everywhere**: with no `VOLTRO_INSPECT_TOKEN` configured, nobody is authorised`voltro dev` included.
606
606
 
607
- The overlay reads that token from `VITE_VOLTRO_INSPECT_TOKEN` (only `VITE_`-prefixed vars reach the browser bundle). A foreign-host mount can also pass it explicitly:
607
+ **Under `voltro dev` you configure nothing.** The dev server mints a token per project and its proxy attaches the `Authorization: Bearer` header server-side, on the `/_voltro/api/<name>` route the panels fetch through. The token stays in the dev server's process; the browser never holds it.
608
+
609
+ That is deliberate rather than convenient. A token compiled into the client bundle is a live credential published to everyone who loads the page, so there is no env-var channel for it — `voltro dev` and `voltro build` set vite's `envPrefix` to a sentinel precisely so nothing leaks through `import.meta.env`.
610
+
611
+ For an api the dev proxy does not front — a `voltro start` deploy with `VOLTRO_INSPECT_TOKEN` set, say — pass the token explicitly, and note that whatever you pass ships in the bundle:
608
612
 
609
613
  ```tsx
610
614
  import { VoltroDevtools } from '@voltro/devtools'
611
615
 
612
- <VoltroDevtools inspectToken={import.meta.env.VITE_VOLTRO_INSPECT_TOKEN} />
616
+ <VoltroDevtools inspectToken={myToken} />
613
617
  ```
614
618
 
615
- When neither the prop nor the env var is set, no `Authorization` header is sent local dev is unaffected. (The indexes panel's live SSE stream can't carry a header; a token-gated deploy falls back to token-carrying HTTP polling for that panel.)
619
+ Without the prop the overlay sends no `Authorization` header of its own, which is correct: under `voltro dev` the proxy has already added one. (The indexes panel's live SSE stream can't carry a header at all; against an api reached without the proxy it falls back to token-carrying HTTP polling.)
616
620
 
617
621
  #### Overriding the overlay's labels
618
622
 
@@ -248,9 +248,32 @@ Enforcement: the [`crud.*` read helpers](/docs/data/crud) strip `.serverOnly()`
248
248
  columns from every returned row **automatically** — you declare the exposure
249
249
  policy once at the schema and can't forget it on a handler. For a hand-written
250
250
  query, omit the column from the `output` schema (and don't put it in the returned
251
- object) — and `voltro dev` **warns at boot**, naming the query and column, if a
252
- wire-reachable query's output declares a `.serverOnly()` column of its `source`
253
- table, so a secret can't ship on the wire by omission-mistake.
251
+ object).
252
+
253
+ A hand-written output is the case `crud.*` cannot cover, so an audit checks it:
254
+ a wire-reachable query whose `source` table carries a `.serverOnly()` column that
255
+ its `output` **declares**. What that costs is different per command, on purpose:
256
+
257
+ | Command | On a leak |
258
+ |---|---|
259
+ | `voltro serve` | **the boot fails** |
260
+ | `voltro doctor` | **exits non-zero** — put it in CI |
261
+ | `voltro dev` | warns, naming the query and column |
262
+
263
+ Dev only warns because a refused boot between two keystrokes is worse than the
264
+ bug; production is the opposite, so that is where the gate is. **Do not read a
265
+ green `voltro dev` boot as a clean audit** — the warning sits in the boot log
266
+ among everything else. `voltro doctor` is the check to automate.
267
+
268
+ `VOLTRO_SERVER_ONLY` moves the line in both directions: `strict` makes `voltro
269
+ dev` fail too, `warn` downgrades `voltro serve` to a warning, `off` silences it
270
+ entirely. The downgrades are documented rather than hidden because the
271
+ alternative to a stated escape hatch is deleting the marker — and a check whose
272
+ only way out is to disable it gets disabled.
273
+
274
+ (`voltro check` does **not** run this audit. It has a live-api mode that has no
275
+ access to your table definitions, and a rule that fires in one of its two modes
276
+ would be worse than one that fires in neither.)
254
277
 
255
278
  Distinct from `.encrypted()` on purpose: encryption at rest says nothing about
256
279
  who may receive the plaintext — a private note you decrypt *for its owner* is a
@@ -105,11 +105,17 @@ ctx.store.select('notes')
105
105
  .where('col', '>', value)
106
106
  .where('col', '>=', value)
107
107
  .where('col', 'in', [a, b, c])
108
- .where('col', 'like', 'abc%')
109
- .where('col', 'contains', 'needle') // case-insensitive substring (ILIKE '%…%')
108
+ .where('col', 'contains', 'needle') // case-INsensitive substring (ILIKE '%…%')
109
+ .where('col', 'startsWith', 'awb_') // case-SENSITIVE prefix (LIKE 'awb\_%')
110
110
  .where('col', 'fts', 'query string') // full-text fallback (LIKE-based here)
111
111
  ```
112
112
 
113
+ `contains` folds case because it is a search primitive — a human typing into a box means `hello` to find `Hello`. `startsWith` does not, because a prefix is a namespace: `awb_` and `AWB_` are two different key spaces, and quietly merging them is a bug. `startsWith` is also the only one of the two a database can answer from an index — `LIKE 'literal%'` is a btree range scan, `%…%` is not. `%` and `_` inside either value are escaped, so they match literally.
114
+
115
+ There is no `'like'`. It used to be here, and it was a lie: it mapped to `contains`, so `.where('path', 'like', '/api/%')` matched only rows literally containing the characters `/api/%` and the wildcard you wrote did nothing. An operator named after SQL's must honour your wildcards or not exist.
116
+
117
+ > **Writing an in-memory store for tests? Do not reimplement these.** `evaluatePredicate` is exported from `@voltro/database` and is the same function the memory store runs, so a test double built on it agrees with a real database by construction. Hand-rolling the switch is where a stub quietly diverges — a `case 'contains'` written as `String(a).includes(b)` is case-SENSITIVE where the real one folds case, and coerces a number where the real one returns `false`. That stub passes queries a live dialect fails, which is the worst direction for a test double to be wrong in.
118
+
113
119
  These are the only operators the ergonomic `.where(col, op, value)` form accepts. For `IS NULL` / `NOT IN` / `IS NOT NULL`, pass a predicate built with the `@voltro/database` helpers:
114
120
 
115
121
  ```ts
@@ -473,7 +473,7 @@ If yes, the promise belongs in the name — you cannot see a contract before you
473
473
 
474
474
  | Suffix | Promise | Enforced by |
475
475
  |---|---|---|
476
- | `*.component.tsx` | exactly one component (+ types) | `component/one-per-file`, `component/no-hook-export` |
476
+ | `*.component.tsx` | exactly one component | `component/one-per-file`, `component/no-hook-export` |
477
477
  | `*.component.ui.tsx` | one component, **reads only** | `ui/no-write`, `ui/orphaned`, `ui/unlinked` |
478
478
  | `*.hook.ts` | exactly one `use*` hook (+ types) | `hook/one-per-file`, `hook/no-component-export` |
479
479
  | `*.types.ts` | zero runtime exports | `types/runtime-export` |
@@ -483,6 +483,10 @@ If yes, the promise belongs in the name — you cannot see a contract before you
483
483
  | `*.client.ts` | it and its imports are browser-safe | boot-time import walk, `client/not-browser-safe` |
484
484
  | `*.store.ts` | exactly one `defineStore`, no server state | `store/one-per-file`, `store/mirrors-server-state` |
485
485
 
486
+ A `*.component.tsx` promises exactly ONE component. It does not promise to export nothing else: types, and plain module-local values a `const COLUMNS = […]` beside the table that renders them, are fine and always were. What the rule counts is components — a declaration that renders — so an object, an array, a string or a `new` beside your component is not a second one, and neither is `export default Card` next to `export const Card`.
487
+
488
+ The BOUNDARY rules (`internal/foreign-import`, `fixture/production-import`, `ui/unlinked`) are assertions about your import graph, so it is worth knowing which edges they follow: relative specifiers, your tsconfig `paths` aliases (read from the nearest `tsconfig.json`, so a per-app `@/*` works when you run `voltro doctor` at the repo root), `export … from` re-exports, and dynamic `import()`. A package import is a leaf — the walk stops at the edge of your app.
489
+
486
490
  ## `*.component.ui.tsx` — reads, never writes
487
491
 
488
492
  ```tsx
@@ -536,6 +540,16 @@ A component then wires it up with `useTracking(checkoutTracking, props, sink)`
536
540
 
537
541
  The payoff is not tidiness. "What do we send to third parties" becomes a file listing instead of an archaeology project — which is the only form in which that question can be answered on demand when someone asks about personal data.
538
542
 
543
+ ## `convention/missing-test` — why a shallow test is still worth writing
544
+
545
+ Every suffix that declares a runtime contract also expects a test beside it, named mechanically: `Card.component.tsx` → `Card.component.test.tsx`. It is an advisory, not an error.
546
+
547
+ The usual objection is that a per-component test at any real size is low value, and for *assertions* that is often true. That is not what the rule buys. What it buys is that something **mounts** the component — and a render loop, a crashing effect, a missing provider or a broken context is invisible until something does.
548
+
549
+ That is not hypothetical. One app adopting the taxonomy wrote 251 of these, deliberately shallow (it mounts, it performs no domain write, it renders no raw catalogue key). The first run found a page whose breadcrumb effect rebuilt a fresh array literal on every render — effect → context state → re-render → new literal, without end. That one test took 423 seconds and exhausted the heap. Ten sibling pages memoised; exactly one did not, and in a browser the screen had looked usable. After the fix the whole web suite went from 645 s to 57 s.
550
+
551
+ So write them shallow if you like. The mount is the point.
552
+
539
553
  ## What deliberately has NO suffix
540
554
 
541
555
  A generic "one component per file" rule would be worth enforcing everywhere, so tying it to a rename would make it opt-in — less coverage for more cost. The shape rules above fire only on files that *declared* the contract, because declaring it is what makes the promise mean something.
@@ -262,6 +262,8 @@ No naming trick is needed to keep something out of the router: the absence of th
262
262
 
263
263
  The canonical form is **no trailing slash** — always link with `<Link to="/about">`, not `<Link to="/about/">`.
264
264
 
265
+ `Link` forwards every prop it does not consume itself to the underlying `<a>`, `ref` included — so it drops straight into a polymorphic slot (`<Button component={Link} to={url}>`) without a wrapper.
266
+
265
267
  The framework does NOT emit a trailing-slash redirect on its own. If you need `/about/` → `/about` normalisation (for SEO), configure a 301 redirect at your reverse proxy.
266
268
 
267
269
  ## What pages CAN'T do
@@ -903,9 +903,12 @@ that affordance read-only rather than binding to a tag that does not resolve. Th
903
903
  [`useCan`](/docs/ui/client-utilities/use-can); map them to your app's real RBAC
904
904
  scopes.
905
905
 
906
- The inspect surface is open in dev. When a deploy sets an inspect token the
907
- manifest GET is bearer-gated, so an admin UI pointed at a locked-down api has to
908
- supply that token a deployment concern, not something this hook handles.
906
+ The manifest GET is bearer-gated wherever it runs `/_voltro/inspect/*` is
907
+ fail-closed, so no configured `VOLTRO_INSPECT_TOKEN` means `401`, not "everyone".
908
+ Under `voltro dev` that is handled for you (the dev server mints a token and its
909
+ proxy attaches it server-side). An admin UI pointed at a deployed api has to
910
+ supply the token itself — a deployment concern, not something this hook
911
+ handles.
909
912
 
910
913
 
911
914