@voltro/cli 0.18.0 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/CHANGELOG.md +245 -3
  2. package/bin/voltro.mjs +11 -0
  3. package/dist/apiBuild-B1FDtx1y.js +2 -0
  4. package/dist/{apiBuild-CPDTJHkH.js → apiBuild-CSCVI1wC.js} +2 -2
  5. package/dist/bin.js +2 -2
  6. package/dist/{commands-laJDMj2m.js → commands-DQ0mUwDe.js} +2074 -1771
  7. package/dist/{dev-BOFxC21E.js → dev-DnHlqqoF.js} +1607 -1566
  8. package/dist/{dev-DOK0w6ZW.js → dev-Z1E_twG9.js} +1 -1
  9. package/dist/index.js +1 -1
  10. package/dist/{inspectMetrics-D1DmLeJs.js → inspectMetrics-BL8kZOv3.js} +196 -176
  11. package/dist/serveCommand-Dh4qd49N.js +1136 -0
  12. package/dist/serveEntry.js +2 -2
  13. package/dist/{start-Clvz4IJb.js → start-IVgQe2YD.js} +368 -368
  14. package/dist/startEntry.js +2 -2
  15. package/package.json +17 -17
  16. package/templates/AGENTS.core.md +25 -9
  17. package/templates/AGENTS.md +26 -10
  18. package/templates/agent-docs/_index.md +1 -1
  19. package/templates/agent-docs/authentication.md +9 -0
  20. package/templates/agent-docs/database/migrations.md +35 -2
  21. package/templates/agent-docs/database/misc.md +42 -3
  22. package/templates/agent-docs/database/querying.md +2 -0
  23. package/templates/agent-docs/routing.md +13 -0
  24. package/templates/agent-docs/whats-new.md +44 -189
  25. package/templates/apps/api-ai/package.json +7 -7
  26. package/templates/apps/api-auth/package.json +8 -8
  27. package/templates/apps/api-backend/package.json +7 -7
  28. package/templates/apps/api-backend-deactivation/package.json +7 -7
  29. package/templates/apps/api-backend-mail/package.json +8 -8
  30. package/templates/apps/api-backend-mariadb/package.json +9 -9
  31. package/templates/apps/api-backend-storage/package.json +8 -8
  32. package/templates/apps/api-data-advanced/package.json +8 -8
  33. package/templates/apps/api-durable/package.json +8 -8
  34. package/templates/apps/api-feature-flags/package.json +9 -9
  35. package/templates/apps/api-governance/package.json +8 -8
  36. package/templates/apps/api-kv/package.json +8 -8
  37. package/templates/apps/api-moderation/package.json +8 -8
  38. package/templates/apps/api-observability/package.json +8 -8
  39. package/templates/apps/api-ratelimit/package.json +8 -8
  40. package/templates/apps/api-rbac/package.json +8 -8
  41. package/templates/apps/api-rest/package.json +7 -7
  42. package/templates/apps/api-saas/package.json +11 -11
  43. package/templates/apps/api-search/package.json +8 -8
  44. package/templates/apps/api-versioning/package.json +8 -8
  45. package/templates/apps/api-webhooks/package.json +9 -9
  46. package/templates/apps/changelog/package.json +6 -6
  47. package/templates/apps/edge-functions/package.json +2 -2
  48. package/templates/apps/frontend-admin/package.json +8 -8
  49. package/templates/apps/frontend-app/package.json +8 -8
  50. package/templates/apps/frontend-blank/package.json +7 -7
  51. package/templates/apps/frontend-contact/package.json +7 -7
  52. package/templates/apps/frontend-dashboard/package.json +7 -7
  53. package/templates/apps/frontend-docs/package.json +7 -7
  54. package/templates/apps/frontend-i18n/package.json +6 -6
  55. package/templates/apps/frontend-landing/package.json +7 -7
  56. package/templates/apps/frontend-spa/package.json +7 -7
  57. package/templates/apps/frontend-ssr/package.json +7 -7
  58. package/templates/apps/frontend-ssr-api/package.json +8 -8
  59. package/templates/apps/frontend-static-blog/package.json +6 -6
  60. package/dist/apiBuild-D1UBJ4TM.js +0 -2
  61. package/dist/serveCommand-CxcxHc9Y.js +0 -1129
@@ -1,3 +1,3 @@
1
- import { Q as e } from "./inspectMetrics-D1DmLeJs.js";
2
- import { t } from "./start-Clvz4IJb.js";
1
+ import { et as e } from "./inspectMetrics-BL8kZOv3.js";
2
+ import { t } from "./start-IVgQe2YD.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.18.0",
3
+ "version": "0.20.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.18.0",
66
- "@voltro/cache": "0.18.0",
67
- "@voltro/data-transfer": "0.18.0",
68
- "@voltro/database": "0.18.0",
69
- "@voltro/env": "0.18.0",
70
- "@voltro/kv": "0.18.0",
71
- "@voltro/logger": "0.18.0",
72
- "@voltro/plugin-auth": "0.18.0",
73
- "@voltro/plugin-broadcast": "0.18.0",
74
- "@voltro/plugin-mail": "0.18.0",
75
- "@voltro/plugin-storage": "0.18.0",
76
- "@voltro/plugin-webhooks": "0.18.0",
77
- "@voltro/protocol": "0.18.0",
78
- "@voltro/runtime": "0.18.0",
79
- "@voltro/serverless": "0.18.0",
80
- "@voltro/workflow": "0.18.0",
65
+ "@voltro/ai": "0.20.0",
66
+ "@voltro/cache": "0.20.0",
67
+ "@voltro/data-transfer": "0.20.0",
68
+ "@voltro/database": "0.20.0",
69
+ "@voltro/env": "0.20.0",
70
+ "@voltro/kv": "0.20.0",
71
+ "@voltro/logger": "0.20.0",
72
+ "@voltro/plugin-auth": "0.20.0",
73
+ "@voltro/plugin-broadcast": "0.20.0",
74
+ "@voltro/plugin-mail": "0.20.0",
75
+ "@voltro/plugin-storage": "0.20.0",
76
+ "@voltro/plugin-webhooks": "0.20.0",
77
+ "@voltro/protocol": "0.20.0",
78
+ "@voltro/runtime": "0.20.0",
79
+ "@voltro/serverless": "0.20.0",
80
+ "@voltro/workflow": "0.20.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()` |
@@ -456,7 +468,7 @@ separate markers, and using one to answer another's question is the mistake:
456
468
 
457
469
  | Marker | Answers | Enforced by |
458
470
  |---|---|---|
459
- | `.serverOnly()` | may this value leave the server AT ALL? | **the boot audit it FAILS the boot**, it does not warn |
471
+ | `.serverOnly()` | may this value leave the server AT ALL? | **`voltro serve` fails to boot; `voltro doctor` exits non-zero.** `voltro dev` only warns |
460
472
  | `.sensitive('class')` / `.safe()` | may it appear in a `voltro data export`? | the export's masking profile, **fail-closed** |
461
473
  | `.encrypted()` | is it encrypted AT REST? | the store's codec |
462
474
 
@@ -470,8 +482,12 @@ export const users = table('users', {
470
482
  ```
471
483
 
472
484
  - **`.serverOnly()` is the one that decides "leak / no leak".** A wire-reachable
473
- query that declares such a column in its OUTPUT does not start: the boot audit
474
- refuses. That is a feature the failure happens at boot, not in a bundle.
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.
475
491
  - **`.encrypted()` is NOT an exposure marker.** It says the bytes at rest are
476
492
  encrypted; the runtime decrypts them for a handler, so an encrypted column
477
493
  flows to the client exactly like any other unless it is ALSO `.serverOnly()`.
@@ -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()` |
@@ -456,7 +468,7 @@ separate markers, and using one to answer another's question is the mistake:
456
468
 
457
469
  | Marker | Answers | Enforced by |
458
470
  |---|---|---|
459
- | `.serverOnly()` | may this value leave the server AT ALL? | **the boot audit it FAILS the boot**, it does not warn |
471
+ | `.serverOnly()` | may this value leave the server AT ALL? | **`voltro serve` fails to boot; `voltro doctor` exits non-zero.** `voltro dev` only warns |
460
472
  | `.sensitive('class')` / `.safe()` | may it appear in a `voltro data export`? | the export's masking profile, **fail-closed** |
461
473
  | `.encrypted()` | is it encrypted AT REST? | the store's codec |
462
474
 
@@ -470,8 +482,12 @@ export const users = table('users', {
470
482
  ```
471
483
 
472
484
  - **`.serverOnly()` is the one that decides "leak / no leak".** A wire-reachable
473
- query that declares such a column in its OUTPUT does not start: the boot audit
474
- refuses. That is a feature the failure happens at boot, not in a bundle.
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.
475
491
  - **`.encrypted()` is NOT an exposure marker.** It says the bytes at rest are
476
492
  encrypted; the runtime decrypts them for a handler, so an encrypted column
477
493
  flows to the client exactly like any other unless it is ALSO `.serverOnly()`.
@@ -579,7 +595,7 @@ each plugin's own README.
579
595
 
580
596
  | Topic | Open | Summary |
581
597
  |---|---|---|
582
- | **What's new in 0.18.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.20.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. |
583
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. |
584
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. |
585
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.18.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.20.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. |
@@ -590,6 +590,15 @@ column read raw hands back the string `enc:v1:…`, which compares, concatenates
590
590
  renders and logs perfectly well, and simply never matches the token you compare
591
591
  it to.
592
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
+
593
602
  This is also what changes when you move a read **off** hand-written SQL and onto
594
603
  the store. Raw SQL sees ciphertext and you decrypt it yourself — `decryptField`
595
604
  from `@voltro/runtime` is the escape hatch for exactly that. Through either
@@ -1937,6 +1937,19 @@ source of truth. Just restoring the snapshot brings the data back
1937
1937
  but the declared schema still says the column shouldn't exist; the
1938
1938
  next apply would re-drop it.
1939
1939
 
1940
+ ## The differ leaves snapshots alone
1941
+
1942
+ A soft drop leaves an object in the database that no schema declares —
1943
+ that is the whole point of it. The planner treats every
1944
+ `<name>__dropped_<ts>` as framework-managed and never plans a drop for
1945
+ it, the same way it skips `_voltro_*` and the cluster engine's tables.
1946
+ This holds for columns and for tables.
1947
+
1948
+ You do not need `VOLTRO_DB_IGNORE_TABLES` for a snapshot, and you should
1949
+ not add one: that list is for YOUR unmanaged infra tables, and an entry
1950
+ there would still be in your config long after `gc-snapshots` reclaimed
1951
+ the snapshot.
1952
+
1940
1953
  ## GC
1941
1954
 
1942
1955
  Snapshot columns aren't automatically dropped. They survive until
@@ -2612,7 +2625,15 @@ export default migration({
2612
2625
  // VOLTRO_DESTRUCTIVE_OK=1 voltro db apply --note 'retiring oldUsersTable per ticket #...'
2613
2626
  ```
2614
2627
 
2615
- **Two tables this never proposes dropping:**
2628
+ **Delete the entity and drop the table in the SAME change.** The intuitive
2629
+ order — remove the code first, sort the schema out after — is the broken one:
2630
+ with `VOLTRO_AUTO_MIGRATE=1` the very next boot sees an undeclared table,
2631
+ refuses, and the app crashloops until the drop is authorised. There is nothing
2632
+ to recover from, but the app is down while you work it out. Take the entity out
2633
+ together with the `VOLTRO_DESTRUCTIVE_OK` apply that removes its table, or leave
2634
+ the entity in place until you are ready to run both.
2635
+
2636
+ **Three tables this never proposes dropping:**
2616
2637
 
2617
2638
  - **`actors`** — the framework-provided audit subject. You don't declare an
2618
2639
  `actors.entity.ts`; `db plan` / `db apply` auto-include the built-in `actors`
@@ -2633,6 +2654,10 @@ export default migration({
2633
2654
  Strapi→Voltro `_strapi_id_map` sitting in the DB won't refuse-to-boot on a
2634
2655
  `drop-table`. (The framework already self-excludes its own `_voltro_*` /
2635
2656
  `cluster_*` runtime tables; this is the user list on top of that.)
2657
+ - **Soft-drop snapshots** — a `<name>__dropped_<ts>` left behind by
2658
+ `VOLTRO_SOFT_DROP=1`. The differ treats it as framework-managed until
2659
+ `voltro db gc-snapshots` reclaims it, so do NOT add one to
2660
+ `VOLTRO_DB_IGNORE_TABLES`.
2636
2661
 
2637
2662
  ## A migrate / apply DDL statement failed — find which one
2638
2663
 
@@ -2699,7 +2724,15 @@ it now is, must have nothing left to do.
2699
2724
 
2700
2725
  **It is a framework bug, not a mistake in your schema.** The named operations
2701
2726
  emit DDL that does not take effect. Report the operation kinds plus the column
2702
- types involved. In the meantime the schema is unchanged and safe — nothing was
2727
+ types involved.
2728
+
2729
+ **Read the named operations before you trust the "no-op" wording**, though —
2730
+ the message states a CAUSE, and a cause can be wrong. One release told users
2731
+ `drop-table <name>__dropped_<ts>` was a no-op when the DDL had worked perfectly:
2732
+ `VOLTRO_SOFT_DROP=1` had renamed the table, and the planner then read its own
2733
+ snapshot as one more undeclared table and proposed dropping it again. Fixed, and
2734
+ worth knowing as the shape to look for: an operation naming an object that the
2735
+ PREVIOUS operation created or renamed is a planner blind spot, not dead DDL. In the meantime the schema is unchanged and safe — nothing was
2703
2736
  half-applied, and no fingerprint was written, so `voltro db plan` still shows you
2704
2737
  the truth.
2705
2738
 
@@ -248,9 +248,48 @@ 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
+ ### Gate CI on the audit having RUN, not on its silence
275
+
276
+ `voltro doctor` can only run this audit if it can load your descriptors. When it
277
+ cannot, it says so instead of claiming a pass:
278
+
279
+ ```
280
+ • serverOnly: NOT CHECKED — the app's descriptors could not be loaded (not a pass)
281
+ reason: Transform failed with 1 error:
282
+ src/queries/broken.query.ts:2:5: ERROR: Expected ";" but found "is"
283
+ ```
284
+
285
+ `voltro doctor --json` carries the same answer as `serverOnly: { checked, reason?, leaks? }`.
286
+ Assert on `checked` — an app that leans on `.serverOnly()` should treat a
287
+ persistent skip as a failure, because a skipped audit and a clean one look
288
+ identical from the outside.
289
+
290
+ (`voltro check` does **not** run this audit. It has a live-api mode that has no
291
+ access to your table definitions, and a rule that fires in one of its two modes
292
+ would be worse than one that fires in neither.)
254
293
 
255
294
  Distinct from `.encrypted()` on purpose: encryption at rest says nothing about
256
295
  who may receive the plaintext — a private note you decrypt *for its owner* is a
@@ -114,6 +114,8 @@ ctx.store.select('notes')
114
114
 
115
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
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
+
117
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:
118
120
 
119
121
  ```ts
@@ -258,6 +258,19 @@ No naming trick is needed to keep something out of the router: the absence of th
258
258
 
259
259
  > Before this convention, every `.tsx` under `src/pages/` became a route, so a co-located component silently got a URL. That route rendered nothing and nobody visited it in dev; the failure surfaced at the first production build. If you are upgrading, `voltro update` renames your pages for you.
260
260
 
261
+ ## Two routes, one screen
262
+
263
+ When two URLs must render the same component — a versioned path kept alive because devices in the field are configured against it, say — re-export it instead of copying it:
264
+
265
+ ```tsx
266
+ // src/pages/v2/page.tsx → /v2, rendering exactly what / renders
267
+ export { default, renderMode } from '../page'
268
+ ```
269
+
270
+ `export { default } from '…'` satisfies the page contract: the module has a default export, it just did not declare it here. Forwarding `renderMode` alongside it is what keeps the two routes from drifting apart — the build follows the forward when it computes the render profile, so `/v2` is classified the same as `/`, not silently as `static`.
271
+
272
+ `export { default as Screen } from '../page'` is the opposite: it renames the default away, leaving this module without one. That still fails the contract.
273
+
261
274
  ## Trailing slashes
262
275
 
263
276
  The canonical form is **no trailing slash** — always link with `<Link to="/about">`, not `<Link to="/about/">`.