@voltro/cli 0.18.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 (56) hide show
  1. package/CHANGELOG.md +158 -3
  2. package/bin/voltro.mjs +11 -0
  3. package/dist/apiBuild-9NXH53Sd.js +2 -0
  4. package/dist/{apiBuild-CPDTJHkH.js → apiBuild-CY5pEwwq.js} +1 -1
  5. package/dist/bin.js +1 -1
  6. package/dist/{commands-laJDMj2m.js → commands-BvvoQL0v.js} +1410 -1203
  7. package/dist/{dev-BOFxC21E.js → dev-B6rfgKfo.js} +1424 -1392
  8. package/dist/{dev-DOK0w6ZW.js → dev-C_E1XxNF.js} +1 -1
  9. package/dist/index.js +1 -1
  10. package/dist/serveCommand-vPF5ucXC.js +1136 -0
  11. package/dist/serveEntry.js +1 -1
  12. package/package.json +17 -17
  13. package/templates/AGENTS.core.md +25 -9
  14. package/templates/AGENTS.md +26 -10
  15. package/templates/agent-docs/_index.md +1 -1
  16. package/templates/agent-docs/authentication.md +9 -0
  17. package/templates/agent-docs/database/misc.md +26 -3
  18. package/templates/agent-docs/database/querying.md +2 -0
  19. package/templates/agent-docs/whats-new.md +82 -161
  20. package/templates/apps/api-ai/package.json +7 -7
  21. package/templates/apps/api-auth/package.json +8 -8
  22. package/templates/apps/api-backend/package.json +7 -7
  23. package/templates/apps/api-backend-deactivation/package.json +7 -7
  24. package/templates/apps/api-backend-mail/package.json +8 -8
  25. package/templates/apps/api-backend-mariadb/package.json +9 -9
  26. package/templates/apps/api-backend-storage/package.json +8 -8
  27. package/templates/apps/api-data-advanced/package.json +8 -8
  28. package/templates/apps/api-durable/package.json +8 -8
  29. package/templates/apps/api-feature-flags/package.json +9 -9
  30. package/templates/apps/api-governance/package.json +8 -8
  31. package/templates/apps/api-kv/package.json +8 -8
  32. package/templates/apps/api-moderation/package.json +8 -8
  33. package/templates/apps/api-observability/package.json +8 -8
  34. package/templates/apps/api-ratelimit/package.json +8 -8
  35. package/templates/apps/api-rbac/package.json +8 -8
  36. package/templates/apps/api-rest/package.json +7 -7
  37. package/templates/apps/api-saas/package.json +11 -11
  38. package/templates/apps/api-search/package.json +8 -8
  39. package/templates/apps/api-versioning/package.json +8 -8
  40. package/templates/apps/api-webhooks/package.json +9 -9
  41. package/templates/apps/changelog/package.json +6 -6
  42. package/templates/apps/edge-functions/package.json +2 -2
  43. package/templates/apps/frontend-admin/package.json +8 -8
  44. package/templates/apps/frontend-app/package.json +8 -8
  45. package/templates/apps/frontend-blank/package.json +7 -7
  46. package/templates/apps/frontend-contact/package.json +7 -7
  47. package/templates/apps/frontend-dashboard/package.json +7 -7
  48. package/templates/apps/frontend-docs/package.json +7 -7
  49. package/templates/apps/frontend-i18n/package.json +6 -6
  50. package/templates/apps/frontend-landing/package.json +7 -7
  51. package/templates/apps/frontend-spa/package.json +7 -7
  52. package/templates/apps/frontend-ssr/package.json +7 -7
  53. package/templates/apps/frontend-ssr-api/package.json +8 -8
  54. package/templates/apps/frontend-static-blog/package.json +6 -6
  55. package/dist/apiBuild-D1UBJ4TM.js +0 -2
  56. package/dist/serveCommand-CxcxHc9Y.js +0 -1129
@@ -1,5 +1,5 @@
1
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-CxcxHc9Y.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 };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@voltro/cli",
3
- "version": "0.18.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.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.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()` |
@@ -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.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. |
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.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. |
@@ -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
@@ -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
@@ -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