@voltro/cli 0.53.0 → 0.54.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 (181) hide show
  1. package/CHANGELOG.md +195 -0
  2. package/dist/{agentsMd-0l980yhL.js → agentsMd-BaLC10Na.js} +110 -82
  3. package/dist/agentsMd-DCY1RSs8.js +2 -0
  4. package/dist/apiBuild-CeUN55uk.js +2 -0
  5. package/dist/{apiBuild-CaPfoWku.js → apiBuild-DTWp0S_q.js} +2 -2
  6. package/dist/bin.js +1 -1
  7. package/dist/{build-D-OnvNMf.js → build-D4ygSbnV.js} +114 -114
  8. package/dist/{checkCommand-C5elt0tW.js → checkCommand-Dg1G7Gwd.js} +6 -6
  9. package/dist/{checkCommand-D2ZduVlh.js → checkCommand-L7DTlpIF.js} +1 -1
  10. package/dist/{cloudCmd-QUXh-b5w.js → cloudCmd-DzKcSYuy.js} +1 -1
  11. package/dist/{codegen-FEk8AZHb.js → codegen-DSLM8Su9.js} +2 -2
  12. package/dist/codegen-DjgxEOnD.js +2 -0
  13. package/dist/codegenCommand-CG_Vx4lc.js +41 -0
  14. package/dist/{codemodRunner-BjtB2lq6.js → codemodRunner-Cd4xkC6u.js} +9 -9
  15. package/dist/{commands-DyxAmhP0.js → commands-6Kzi92Np.js} +96 -73
  16. package/dist/{dashboardCommand-BdKTyT13.js → dashboardCommand-Cq1PWvI1.js} +3 -3
  17. package/dist/{dataCommand-Bab9X7s8.js → dataCommand-DYzW8vkv.js} +3 -3
  18. package/dist/{dbCommand-06O2finM.js → dbCommand-B4NWZtGL.js} +3 -3
  19. package/dist/dbCommand-CSFWs9ev.js +2 -0
  20. package/dist/{dev-C6LGF4iY.js → dev-CmuvUKRq.js} +2236 -2219
  21. package/dist/{dev-GjJWAYo2.js → dev-cKUiZZsB.js} +1 -1
  22. package/dist/{doctorCommand-etMkflRc.js → doctorCommand-DCiFVMtZ.js} +21 -21
  23. package/dist/doctorCommand-J3qu4E0Y.js +2 -0
  24. package/dist/{dormancyCommand-UwZ1AZzB.js → dormancyCommand-w1TrmgYP.js} +1 -1
  25. package/dist/{embeddingsCommand-C70zWHwo.js → embeddingsCommand-CMgPyRTr.js} +1 -1
  26. package/dist/{envCommand-dSyKvRkM.js → envCommand-Cyynmcfa.js} +15 -15
  27. package/dist/{evolveCommand-CG0_ebO5.js → evolveCommand-BwvQ8dVH.js} +2 -2
  28. package/dist/fileConventions-l-RIXbx8.js +36 -0
  29. package/dist/{fileTaxonomy-B7uxipWS.js → fileTaxonomy-CbyMQYx_.js} +37 -37
  30. package/dist/{frameworkTableAssembly-DKx3ba3S.js → frameworkTableAssembly-D7LJuALW.js} +5 -5
  31. package/dist/frameworkTableAssembly-IPD1pUnZ.js +2 -0
  32. package/dist/index.js +2 -2
  33. package/dist/{infoCommand-_53iOc_j.js → infoCommand-DlYlUPqs.js} +1 -1
  34. package/dist/{metaCommands-Cn2oboG4.js → metaCommands-x7RCi2AF.js} +2 -2
  35. package/dist/{migrate-Cko9rswM.js → migrate-BK_Bbx-_.js} +2 -2
  36. package/dist/mobileCommand-D9O6iq3D.js +428 -0
  37. package/dist/mobileCommand-DAum7tsG.js +2 -0
  38. package/dist/{pageConvention-C938S8oC.js → pageConvention-CMpfDN6r.js} +1 -1
  39. package/dist/{privacyCommand-DWTQMC6R.js → privacyCommand-BCa2OoZG.js} +2 -2
  40. package/dist/{probeCommand-DkGGLknv.js → probeCommand-_C0YU207.js} +1 -1
  41. package/dist/{projectScaffold-EzlErR4E.js → projectScaffold-CJfP-xbT.js} +1 -1
  42. package/dist/{projectScaffold-B4dmTlwT.js → projectScaffold-CSN0OzBV.js} +2 -2
  43. package/dist/renderModeScan-43yQ2opo.js +147 -0
  44. package/dist/{renderProfile-CskIgAfn.js → renderProfile-DvrhVJHa.js} +2 -2
  45. package/dist/{runtimeTrace-c0APJz7E.js → runtimeTrace-CGWx1Q6l.js} +1 -1
  46. package/dist/{sdkgen-BiQCgIEr.js → sdkgen-CDGHQUFj.js} +1 -1
  47. package/dist/serveCommand-BiPe8BJm.js +2 -0
  48. package/dist/{serveCommand-CueKQgzl.js → serveCommand-Bje09q1v.js} +708 -708
  49. package/dist/serveEntry.js +1 -1
  50. package/dist/start-B0bnJgxI.js +3 -0
  51. package/dist/{start-ekPan8BT.js → start-Clz-1BHB.js} +511 -504
  52. package/dist/startEntry.js +1 -1
  53. package/dist/{staticCommand-xlSL-IWk.js → staticCommand-ey0kYmOT.js} +1 -1
  54. package/dist/{subcommandNames-DpYs3DXr.js → subcommandNames-CDzfEtKV.js} +3 -3
  55. package/dist/{templates-BR-fb4SP.js → templates-BTWZkJJT.js} +41 -9
  56. package/dist/{test-BWPQcRoB.js → test-D_kW4KMj.js} +1 -1
  57. package/dist/updateCommand-CIoVDKnj.js +2 -0
  58. package/dist/{updateCommand-C_8I8Rzo.js → updateCommand-CRJlAOaM.js} +1 -1
  59. package/dist/{webDev-oczpugbx.js → webDev-DSI9SOhs.js} +1127 -1090
  60. package/dist/{webDev-C7jWJ5dX.js → webDev-DlvZO30c.js} +1 -1
  61. package/dist/{webhooksCommand-4SVPDjKg.js → webhooksCommand-BvzXNHji.js} +1 -1
  62. package/dist/workspaceDeps-RKEkX92S.js +45 -0
  63. package/package.json +19 -19
  64. package/templates/AGENTS.core.md +2 -0
  65. package/templates/AGENTS.md +4 -2
  66. package/templates/agent-docs/_index.md +2 -2
  67. package/templates/agent-docs/_manifest.json +1 -1
  68. package/templates/agent-docs/ai.md +4 -4
  69. package/templates/agent-docs/authentication.md +72 -0
  70. package/templates/agent-docs/cli.md +4 -1
  71. package/templates/agent-docs/data.md +61 -12
  72. package/templates/agent-docs/database/advancedqueries.md +1 -1
  73. package/templates/agent-docs/database/migrations.md +1 -1
  74. package/templates/agent-docs/database/seedsdialects.md +64 -2
  75. package/templates/agent-docs/internationalization.md +2 -0
  76. package/templates/agent-docs/introduction.md +25 -0
  77. package/templates/agent-docs/local-first-mobile.md +139 -41
  78. package/templates/agent-docs/observability.md +4 -2
  79. package/templates/agent-docs/plugins/atlassian.md +2 -2
  80. package/templates/agent-docs/plugins/audit.md +2 -2
  81. package/templates/agent-docs/plugins/billing.md +1 -1
  82. package/templates/agent-docs/plugins/cdc-out.md +8 -3
  83. package/templates/agent-docs/plugins/comments.md +22 -0
  84. package/templates/agent-docs/plugins/presence.md +32 -3
  85. package/templates/agent-docs/plugins/prometheus.md +2 -0
  86. package/templates/agent-docs/plugins/queue.md +47 -4
  87. package/templates/agent-docs/plugins.md +6 -6
  88. package/templates/agent-docs/reference.md +20 -1
  89. package/templates/agent-docs/routing.md +63 -9
  90. package/templates/agent-docs/scheduling.md +1 -1
  91. package/templates/agent-docs/schema-driven-ui.md +12 -1
  92. package/templates/agent-docs/templates/appshells.md +36 -4
  93. package/templates/agent-docs/whats-new.md +109 -135
  94. package/templates/apps/api-ai/package.json +6 -6
  95. package/templates/apps/api-auth/package.json +8 -8
  96. package/templates/apps/api-backend/package.json +7 -7
  97. package/templates/apps/api-backend-deactivation/package.json +7 -7
  98. package/templates/apps/api-backend-mail/package.json +8 -8
  99. package/templates/apps/api-backend-mariadb/package.json +9 -9
  100. package/templates/apps/api-backend-sqlite/package.json +8 -8
  101. package/templates/apps/api-backend-storage/package.json +8 -8
  102. package/templates/apps/api-cms/package.json +9 -9
  103. package/templates/apps/api-collab/README.md +3 -3
  104. package/templates/apps/api-collab/app.config.ts +1 -1
  105. package/templates/apps/api-collab/database/schema.ts +12 -8
  106. package/templates/apps/api-collab/mutations/documents.create.mutation.server.ts +1 -1
  107. package/templates/apps/api-collab/mutations/documents.setBody.mutation.server.ts +1 -1
  108. package/templates/apps/api-collab/mutations/documents.setBody.mutation.ts +1 -1
  109. package/templates/apps/api-collab/package.json +8 -8
  110. package/templates/apps/api-collab/template.json +1 -1
  111. package/templates/apps/api-collab/tests/documents.setBody.test.ts +10 -2
  112. package/templates/apps/api-data-advanced/package.json +8 -8
  113. package/templates/apps/api-durable/package.json +8 -8
  114. package/templates/apps/api-feature-flags/package.json +9 -9
  115. package/templates/apps/api-governance/package.json +8 -8
  116. package/templates/apps/api-kv/package.json +8 -8
  117. package/templates/apps/api-moderation/package.json +8 -8
  118. package/templates/apps/api-observability/package.json +8 -8
  119. package/templates/apps/api-ratelimit/package.json +8 -8
  120. package/templates/apps/api-rbac/package.json +8 -8
  121. package/templates/apps/api-rest/package.json +7 -7
  122. package/templates/apps/api-row-history/package.json +8 -8
  123. package/templates/apps/api-saas/package.json +11 -10
  124. package/templates/apps/api-saas-starter/package.json +10 -10
  125. package/templates/apps/api-search/package.json +8 -8
  126. package/templates/apps/api-status/package.json +8 -8
  127. package/templates/apps/api-webhooks/package.json +9 -9
  128. package/templates/apps/changelog/package.json +7 -6
  129. package/templates/apps/edge-functions/package.json +2 -2
  130. package/templates/apps/frontend-admin/package.json +8 -8
  131. package/templates/apps/frontend-app/package.json +9 -9
  132. package/templates/apps/frontend-auth/package.json +8 -8
  133. package/templates/apps/frontend-blank/package.json +7 -7
  134. package/templates/apps/frontend-cms/package.json +9 -9
  135. package/templates/apps/frontend-collab/README.md +43 -24
  136. package/templates/apps/frontend-collab/app.config.ts +3 -3
  137. package/templates/apps/frontend-collab/package.json +14 -10
  138. package/templates/apps/frontend-collab/src/locales/de.ts +1 -2
  139. package/templates/apps/frontend-collab/src/locales/en.ts +1 -2
  140. package/templates/apps/frontend-collab/src/pages/page.test.tsx +72 -76
  141. package/templates/apps/frontend-collab/src/pages/page.tsx +45 -22
  142. package/templates/apps/frontend-collab/template.json +2 -2
  143. package/templates/apps/frontend-contact/package.json +7 -7
  144. package/templates/apps/frontend-dashboard/package.json +7 -7
  145. package/templates/apps/frontend-docs/package.json +8 -7
  146. package/templates/apps/frontend-i18n/package.json +6 -6
  147. package/templates/apps/frontend-landing/README.md +48 -0
  148. package/templates/apps/frontend-landing/app.config.ts +28 -0
  149. package/templates/apps/frontend-landing/package.json +7 -6
  150. package/templates/apps/frontend-landing/src/assets/hero.jpg +0 -0
  151. package/templates/apps/frontend-landing/src/fonts/Geist-Variable.woff2 +0 -0
  152. package/templates/apps/frontend-landing/src/fonts/LICENSE-Geist.txt +92 -0
  153. package/templates/apps/frontend-landing/src/globals.css +15 -0
  154. package/templates/apps/frontend-landing/src/globals.d.ts +17 -0
  155. package/templates/apps/frontend-landing/src/locales/de.ts +3 -2
  156. package/templates/apps/frontend-landing/src/locales/en.ts +5 -2
  157. package/templates/apps/frontend-landing/src/pages/page.test.tsx +79 -0
  158. package/templates/apps/frontend-landing/src/pages/page.tsx +26 -3
  159. package/templates/apps/frontend-landing/template.json +2 -2
  160. package/templates/apps/frontend-portal/package.json +8 -8
  161. package/templates/apps/frontend-saas/package.json +8 -8
  162. package/templates/apps/frontend-spa/package.json +7 -7
  163. package/templates/apps/frontend-ssr/package.json +7 -7
  164. package/templates/apps/frontend-ssr-api/package.json +8 -8
  165. package/templates/apps/frontend-static-blog/package.json +8 -7
  166. package/templates/apps/frontend-status/package.json +8 -8
  167. package/templates/apps/mobile-app/package.json +12 -11
  168. package/templates/apps/mobile-app/src/lib/deeplinks.ts +29 -17
  169. package/templates/apps/mobile-app/tests/deeplinks.test.ts +16 -0
  170. package/dist/agentsMd-SDDSkyl4.js +0 -2
  171. package/dist/apiBuild-DHtLXYx9.js +0 -2
  172. package/dist/codegen-BWpt3VgF.js +0 -2
  173. package/dist/codegenCommand-BOiWQ5hz.js +0 -137
  174. package/dist/dbCommand-B1EXBC6f.js +0 -2
  175. package/dist/doctorCommand-B0hX0tdz.js +0 -2
  176. package/dist/fileConventions-DASGEmj-.js +0 -35
  177. package/dist/frameworkTableAssembly-C_7Z-rMs.js +0 -2
  178. package/dist/renderModeScan-CUbOeOAg.js +0 -122
  179. package/dist/serveCommand-DsnrVN3U.js +0 -2
  180. package/dist/start-BJzZLbt8.js +0 -3
  181. package/dist/updateCommand-Bqql_rsQ.js +0 -2
@@ -1,2 +1,2 @@
1
- import { p as e, x as t, y as n } from "./webDev-oczpugbx.js";
1
+ import { p as e, x as t, y as n } from "./webDev-DSI9SOhs.js";
2
2
  export { e as loadConfig, n as tryRunWebServe, t as walkPagesTree };
@@ -222,7 +222,7 @@ createVerifier({ secret: [process.env.WEBHOOK_SECRET, process.env.WEBHOOK_SECRET
222
222
  ...t === void 0 ? {} : { payload: t }
223
223
  };
224
224
  }, S = u({ scope: "voltro:webhooks" }), C = ["--out", "--name"], w = async (e) => {
225
- let { walk: t, loadDiscovered: n } = await import("./dev-GjJWAYo2.js"), { outgoingFromEvents: r } = await import("./webhookDiscovery-il9ti-HE.js");
225
+ let { walk: t, loadDiscovered: n } = await import("./dev-cKUiZZsB.js"), { outgoingFromEvents: r } = await import("./webhookDiscovery-il9ti-HE.js");
226
226
  return r((await n(await t(e))).events.map((e) => ({
227
227
  file: e.file,
228
228
  descriptor: e.descriptor
@@ -0,0 +1,45 @@
1
+ import { dirname as e, join as t } from "node:path";
2
+ import { promises as n } from "node:fs";
3
+ //#region src/workspaceDeps.ts
4
+ var r = async (r, i) => {
5
+ let a = r;
6
+ for (;;) {
7
+ let r = t(a, "node_modules", i);
8
+ try {
9
+ if ((await n.stat(r)).isDirectory()) {
10
+ let e = await n.realpath(r);
11
+ return e.includes("/node_modules/") ? null : e;
12
+ }
13
+ } catch {}
14
+ let o = e(a);
15
+ if (o === a) return null;
16
+ a = o;
17
+ }
18
+ }, i = async (e) => {
19
+ let i;
20
+ try {
21
+ let r = await n.readFile(t(e, "package.json"), "utf8"), a = JSON.parse(r);
22
+ i = Object.keys({
23
+ ...a.dependencies,
24
+ ...a.devDependencies
25
+ });
26
+ } catch {
27
+ return [];
28
+ }
29
+ return (await Promise.all(i.map(async (i) => {
30
+ let a = await r(e, i);
31
+ if (a === null) return null;
32
+ let o = t(a, "src");
33
+ try {
34
+ if (!(await n.stat(o)).isDirectory()) return null;
35
+ } catch {
36
+ return null;
37
+ }
38
+ return {
39
+ pkg: i,
40
+ dir: o
41
+ };
42
+ }))).filter((e) => e !== null);
43
+ };
44
+ //#endregion
45
+ export { i as n, r as t };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@voltro/cli",
3
- "version": "0.53.0",
3
+ "version": "0.54.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",
@@ -836,24 +836,24 @@
836
836
  "@effect/platform-node": "^0.108.0",
837
837
  "@effect/sql": "^0.52.0",
838
838
  "@effect/workflow": "^0.19.0",
839
- "@voltro/ai": "0.53.0",
840
- "@voltro/cache": "0.53.0",
841
- "@voltro/client": "0.53.0",
842
- "@voltro/content": "0.53.0",
843
- "@voltro/data-transfer": "0.53.0",
844
- "@voltro/database": "0.53.0",
845
- "@voltro/env": "0.53.0",
846
- "@voltro/kv": "0.53.0",
847
- "@voltro/logger": "0.53.0",
848
- "@voltro/plugin-auth": "0.53.0",
849
- "@voltro/plugin-broadcast": "0.53.0",
850
- "@voltro/plugin-mail": "0.53.0",
851
- "@voltro/plugin-storage": "0.53.0",
852
- "@voltro/plugin-webhooks": "0.53.0",
853
- "@voltro/protocol": "0.53.0",
854
- "@voltro/runtime": "0.53.0",
855
- "@voltro/serverless": "0.53.0",
856
- "@voltro/workflow": "0.53.0",
839
+ "@voltro/ai": "0.54.0",
840
+ "@voltro/cache": "0.54.0",
841
+ "@voltro/client": "0.54.0",
842
+ "@voltro/content": "0.54.0",
843
+ "@voltro/data-transfer": "0.54.0",
844
+ "@voltro/database": "0.54.0",
845
+ "@voltro/env": "0.54.0",
846
+ "@voltro/kv": "0.54.0",
847
+ "@voltro/logger": "0.54.0",
848
+ "@voltro/plugin-auth": "0.54.0",
849
+ "@voltro/plugin-broadcast": "0.54.0",
850
+ "@voltro/plugin-mail": "0.54.0",
851
+ "@voltro/plugin-storage": "0.54.0",
852
+ "@voltro/plugin-webhooks": "0.54.0",
853
+ "@voltro/protocol": "0.54.0",
854
+ "@voltro/runtime": "0.54.0",
855
+ "@voltro/serverless": "0.54.0",
856
+ "@voltro/workflow": "0.54.0",
857
857
  "chokidar": "^5.0.0",
858
858
  "ioredis": "^5.11.1",
859
859
  "tinyglobby": "^0.2.17",
@@ -368,6 +368,8 @@ public REST routes, declared via `restRoutes` in `app.config.ts`).
368
368
  | `*.agent.tsx` + `*.agent.server.tsx` | server-side LLM chat |
369
369
  | `*.tool.tsx` | tool an agent can call |
370
370
  | `*.entity.ts` / `*.schema.ts` / `schema.ts` | one table per file |
371
+ | `*.collection.ts` | content collection (`defineCollection`) — schema-typed markdown/JSON |
372
+ | `content/<name>/**` | that collection's files (markdown + frontmatter, or `.json`) |
371
373
  | `page.tsx` (under `src/pages/`) | the route its DIRECTORY serves + `page.test.tsx` |
372
374
  | `*.component.tsx` | exactly ONE component (+ types) |
373
375
  | `*.component.ui.tsx` | presentational: one component, READS only — never writes |
@@ -368,6 +368,8 @@ public REST routes, declared via `restRoutes` in `app.config.ts`).
368
368
  | `*.agent.tsx` + `*.agent.server.tsx` | server-side LLM chat |
369
369
  | `*.tool.tsx` | tool an agent can call |
370
370
  | `*.entity.ts` / `*.schema.ts` / `schema.ts` | one table per file |
371
+ | `*.collection.ts` | content collection (`defineCollection`) — schema-typed markdown/JSON |
372
+ | `content/<name>/**` | that collection's files (markdown + frontmatter, or `.json`) |
371
373
  | `page.tsx` (under `src/pages/`) | the route its DIRECTORY serves + `page.test.tsx` |
372
374
  | `*.component.tsx` | exactly ONE component (+ types) |
373
375
  | `*.component.ui.tsx` | presentational: one component, READS only — never writes |
@@ -728,7 +730,7 @@ each plugin's own README.
728
730
 
729
731
  | Topic | Open | Summary |
730
732
  |---|---|---|
731
- | **What's new in 0.53.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. |
733
+ | **What's new in 0.54.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. |
732
734
  | 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. |
733
735
  | 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. |
734
736
  | 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. |
@@ -786,7 +788,7 @@ each plugin's own README.
786
788
  | auth-workos | `node_modules/@voltro/cli/templates/agent-docs/plugins/auth-workos.md` (or `node_modules/@voltro/plugin-auth-workos/README.md`) | WorkOS AuthStrategy — verifies WorkOS AuthKit / SSO JWTs via JWKS (no API key) and maps org_id → tenantId. |
787
789
  | billing | `node_modules/@voltro/cli/templates/agent-docs/plugins/billing.md` (or `node_modules/@voltro/plugin-billing/README.md`) | Subscriptions, plans, entitlements, and usage metering over a pluggable provider (Stripe + mock). Money is integer minor units. |
788
790
  | broadcast | `node_modules/@voltro/cli/templates/agent-docs/plugins/broadcast.md` (or `node_modules/@voltro/plugin-broadcast/README.md`) | Cross-replica reactivity over a pub/sub bus (Redis / NATS) for non-postgres dialects — closes the single-instance gap so a write on one pod surfaces on another. |
789
- | cdc-out | `node_modules/@voltro/cli/templates/agent-docs/plugins/cdc-out.md` (or `node_modules/@voltro/plugin-cdc-out/README.md`) | Declaratively mirror table changes outward to external sinks (webhook, plus a CdcSink interface for custom sinks) through a durable outbox — ordered per pipe, at-least-once from enqueue, retried with backoff, dead-lettered. |
791
+ | cdc-out | `node_modules/@voltro/cli/templates/agent-docs/plugins/cdc-out.md` (or `node_modules/@voltro/plugin-cdc-out/README.md`) | Declaratively mirror table changes outward to external sinks (webhook, Kafka, plus a CdcSink interface for custom sinks) through a durable outbox — ordered per pipe, at-least-once from enqueue, retried with backoff, dead-lettered. |
790
792
  | clickhouse | `node_modules/@voltro/cli/templates/agent-docs/plugins/clickhouse.md` (or `node_modules/@voltro/plugin-clickhouse/README.md`) | Production-grade OLAP AnalyticsSink over ClickHouse — self-hosted or ClickHouse Cloud — for billions of events with millisecond aggregates. |
791
793
  | comments | `node_modules/@voltro/cli/templates/agent-docs/plugins/comments.md` (or `node_modules/@voltro/plugin-comments/README.md`) | Comment threads on any app entity — replies, resolve/reopen, @-mentions with notifications, reactions, unread counters — live over the reactive engine, with an ejectable thread UI. |
792
794
  | datadog | `node_modules/@voltro/cli/templates/agent-docs/plugins/datadog.md` (or `node_modules/@voltro/plugin-datadog/README.md`) | Agentless Datadog metrics exporter — pushes the unified Metrics-API to Datadog's /api/v2/series HTTP intake. |
@@ -9,7 +9,7 @@ each plugin's own README.
9
9
 
10
10
  | Topic | Open | Summary |
11
11
  |---|---|---|
12
- | **What's new in 0.53.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.54.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. |
@@ -67,7 +67,7 @@ each plugin's own README.
67
67
  | auth-workos | `node_modules/@voltro/cli/templates/agent-docs/plugins/auth-workos.md` (or `node_modules/@voltro/plugin-auth-workos/README.md`) | WorkOS AuthStrategy — verifies WorkOS AuthKit / SSO JWTs via JWKS (no API key) and maps org_id → tenantId. |
68
68
  | billing | `node_modules/@voltro/cli/templates/agent-docs/plugins/billing.md` (or `node_modules/@voltro/plugin-billing/README.md`) | Subscriptions, plans, entitlements, and usage metering over a pluggable provider (Stripe + mock). Money is integer minor units. |
69
69
  | broadcast | `node_modules/@voltro/cli/templates/agent-docs/plugins/broadcast.md` (or `node_modules/@voltro/plugin-broadcast/README.md`) | Cross-replica reactivity over a pub/sub bus (Redis / NATS) for non-postgres dialects — closes the single-instance gap so a write on one pod surfaces on another. |
70
- | cdc-out | `node_modules/@voltro/cli/templates/agent-docs/plugins/cdc-out.md` (or `node_modules/@voltro/plugin-cdc-out/README.md`) | Declaratively mirror table changes outward to external sinks (webhook, plus a CdcSink interface for custom sinks) through a durable outbox — ordered per pipe, at-least-once from enqueue, retried with backoff, dead-lettered. |
70
+ | cdc-out | `node_modules/@voltro/cli/templates/agent-docs/plugins/cdc-out.md` (or `node_modules/@voltro/plugin-cdc-out/README.md`) | Declaratively mirror table changes outward to external sinks (webhook, Kafka, plus a CdcSink interface for custom sinks) through a durable outbox — ordered per pipe, at-least-once from enqueue, retried with backoff, dead-lettered. |
71
71
  | clickhouse | `node_modules/@voltro/cli/templates/agent-docs/plugins/clickhouse.md` (or `node_modules/@voltro/plugin-clickhouse/README.md`) | Production-grade OLAP AnalyticsSink over ClickHouse — self-hosted or ClickHouse Cloud — for billions of events with millisecond aggregates. |
72
72
  | comments | `node_modules/@voltro/cli/templates/agent-docs/plugins/comments.md` (or `node_modules/@voltro/plugin-comments/README.md`) | Comment threads on any app entity — replies, resolve/reopen, @-mentions with notifications, reactions, unread counters — live over the reactive engine, with an ejectable thread UI. |
73
73
  | datadog | `node_modules/@voltro/cli/templates/agent-docs/plugins/datadog.md` (or `node_modules/@voltro/plugin-datadog/README.md`) | Agentless Datadog metrics exporter — pushes the unified Metrics-API to Datadog's /api/v2/series HTTP intake. |
@@ -460,7 +460,7 @@
460
460
  {
461
461
  "slug": "cdc-out",
462
462
  "title": "CDC-out (reverse-ETL)",
463
- "description": "Declaratively mirror table changes outward to external sinks (webhook, plus a CdcSink interface for custom sinks) through a durable outbox — ordered per pipe, at-least-once from enqueue, retried with backoff, dead-lettered.",
463
+ "description": "Declaratively mirror table changes outward to external sinks (webhook, Kafka, plus a CdcSink interface for custom sinks) through a durable outbox — ordered per pipe, at-least-once from enqueue, retried with backoff, dead-lettered.",
464
464
  "pkg": "@voltro/plugin-cdc-out",
465
465
  "doc": "plugins/cdc-out.md",
466
466
  "module": "agent-docs/plugins/cdc-out.md"
@@ -116,7 +116,7 @@ Env vars `providerFromEnv()` reads:
116
116
  | `AI_PROVIDER` | `mock` | `mock` \| `anthropic` \| `openai` \| `gateway`. |
117
117
  | `AI_MODEL` | per provider (see below) | Override the model. Defaults: `claude-opus-4-8` (anthropic), `gpt-5.5` (openai), `mock` (mock). **REQUIRED for `gateway`** — a `creator/model` id; boot throws if unset. |
118
118
 
119
- Provider API keys are read by the underlying `@ai-sdk/*` packages from their standard env vars — `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `AI_GATEWAY_API_KEY`. The framework doesn't read a separate `AI_API_KEY`. (To give a SINGLE agent its own key from code instead of env, see [Per-config key + base URL](#per-config-key--base-url) below.)
119
+ Provider API keys are read by the underlying `@ai-sdk/*` packages from their standard env vars — `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `AI_GATEWAY_API_KEY`. The framework doesn't read a separate `AI_API_KEY`. (To give a SINGLE agent its own key from code instead of env, see [Per-config key + base URL](#per-config-key-base-url) below.)
120
120
 
121
121
  ## Anthropic
122
122
 
@@ -141,7 +141,7 @@ AI_MODEL=gpt-5.5
141
141
  OPENAI_API_KEY=sk-…
142
142
  ```
143
143
 
144
- Backed by `@ai-sdk/openai`. `AI_MODEL` is a plain OpenAI model id (`gpt-5.5`, `gpt-4o-mini`, …). For a self-hosted / Azure-style / proxy endpoint, set `baseURL` on a `ProviderConfig` (see [Per-config key + base URL](#per-config-key--base-url)) rather than an env var.
144
+ Backed by `@ai-sdk/openai`. `AI_MODEL` is a plain OpenAI model id (`gpt-5.5`, `gpt-4o-mini`, …). For a self-hosted / Azure-style / proxy endpoint, set `baseURL` on a `ProviderConfig` (see [Per-config key + base URL](#per-config-key-base-url)) rather than an env var.
145
145
 
146
146
  ## Vercel AI Gateway
147
147
 
@@ -239,7 +239,7 @@ export default (input: { prompt: string }) =>
239
239
  })
240
240
  ```
241
241
 
242
- `GenerateTextOptions` is `{ prompt, system?, provider?, fallbacks?, maxTokens? }`. There is no `messages`/`effort` shape — the prompt is a single string the SDK wraps as the user turn; `system` steers it. `fallbacks` is a [provider fallback chain](#fallback-chain--survive-a-provider-outage).
242
+ `GenerateTextOptions` is `{ prompt, system?, provider?, fallbacks?, maxTokens? }`. There is no `messages`/`effort` shape — the prompt is a single string the SDK wraps as the user turn; `system` steers it. `fallbacks` is a [provider fallback chain](#fallback-chain-survive-a-provider-outage).
243
243
 
244
244
  Structured output:
245
245
 
@@ -276,7 +276,7 @@ type ProviderConfig =
276
276
  | { name: 'gateway'; model: `${string}/${string}`; apiKey?: string; baseURL?: string } // creator/model
277
277
  ```
278
278
 
279
- The mock-only `mockText` / `script` can't appear on a real provider (the type rejects it), the gateway's `model` is a `creator/model`-typed string (a bare `'gpt-5.5'` is a compile error, not a boot crash), and the per-provider model-id types (`AnthropicModel` / `OpenAIModel`) are **open unions** — known ids autocomplete, but any string the provider ships tomorrow still type-checks. Use `provider` for per-request model selection (e.g. a cheaper model on a fallback path), or to pin a specific key/endpoint (see [Per-config key + base URL](#per-config-key--base-url)).
279
+ The mock-only `mockText` / `script` can't appear on a real provider (the type rejects it), the gateway's `model` is a `creator/model`-typed string (a bare `'gpt-5.5'` is a compile error, not a boot crash), and the per-provider model-id types (`AnthropicModel` / `OpenAIModel`) are **open unions** — known ids autocomplete, but any string the provider ships tomorrow still type-checks. Use `provider` for per-request model selection (e.g. a cheaper model on a fallback path), or to pin a specific key/endpoint (see [Per-config key + base URL](#per-config-key-base-url)).
280
280
 
281
281
  For runtime provider switching across a whole layer, bind an `AiServiceImpl` to the `AiService` Context tag at boot and read it with `yield* AiService` — `defaultAiService` (backed by `providerFromEnv`) is the default.
282
282
 
@@ -2602,6 +2602,78 @@ A membership that ends mid-subscription therefore stops serving rows — the
2602
2602
  caller's open ticket list drops the rows they can no longer see, without a
2603
2603
  refresh and without the subscription having to be torn down.
2604
2604
 
2605
+ **On every transport, and that list is complete: the WebSocket, an [SSE
2606
+ stream](/docs/data/rest-routes#live-updates-over-http-stream-sse), and a
2607
+ [gRPC](/docs/data/grpc) server-streaming rpc.** All three open their
2608
+ subscription through the same code and resolve the filter per delivery, so
2609
+ "stays open for hours" never becomes a way to hold a stale predicate. The same
2610
+ delivery also re-checks the query's `guards:`; a filter resolution that FAILS
2611
+ revokes the subscription on all three rather than falling back to an unfiltered
2612
+ or empty read.
2613
+
2614
+ ## Declare which tables it narrows — `tables:`
2615
+
2616
+ Optional, one line, and it buys back a feature the filter otherwise switches
2617
+ off for the whole app:
2618
+
2619
+ ```ts
2620
+ setRowFilter({
2621
+ load,
2622
+ predicate,
2623
+ tables: ['bookmarks', 'recentSearches', 'todoSchedules', 'todoTags'],
2624
+ })
2625
+ ```
2626
+
2627
+ **What it buys.** [Delta-resume](/docs/data/wire-protocol#reconnect-delta-resume)
2628
+ is excluded for a subscription whose row set is re-resolved per delivery —
2629
+ replaying deltas could serve rows the subject has since lost. Without a
2630
+ declaration the framework cannot tell which tables your predicate may reach, so
2631
+ it excludes them all: one registration disables cheap reconnects for every
2632
+ subscription in the process. A deployment measured a filter over 4 tables
2633
+ costing it on all 173 of their query descriptors, 55 of whose source tables the
2634
+ filter never touches. With the declaration, only subscriptions on the listed
2635
+ tables are excluded.
2636
+
2637
+ **Why a declaration and not a probe.** Resolving the scope at subscribe time
2638
+ and treating "returns `undefined` for this table" as safe is cheaper and
2639
+ unsound: your predicate is a function of freshly loaded context, so a table it
2640
+ does not narrow now may be narrowed on the next delivery — which is the entire
2641
+ reason the filter is re-resolved per delivery. A static list is a promise about
2642
+ every future resolution.
2643
+
2644
+ **It is verified, not trusted.** Returning a predicate for a table outside the
2645
+ list raises `RowFilterDeclarationViolated` at the read that did it — the
2646
+ request fails and a subscription is revoked. A declaration nobody checks is a
2647
+ comment, and this one is load-bearing: the resume grant is issued on its
2648
+ strength. Omit `tables:` entirely and nothing is verified and nothing resumes —
2649
+ the conservative default.
2650
+
2651
+ ## Eager loads are refused, not silently unfiltered
2652
+
2653
+ A relation pulled in with `.with(...)` is resolved **below** the seam that
2654
+ applies the filter: the stores expand the eager tree themselves (the memory
2655
+ store recurses through its own raw read; the SQL stores fold the relation into
2656
+ one join). So a filtered table reached through a relation would come back
2657
+ unfiltered.
2658
+
2659
+ Rather than serve those rows, the read **refuses**, naming the table and both
2660
+ ways out:
2661
+
2662
+ ```
2663
+ row filter: 'readers' is reached through an eager load on 'notes', and an eager
2664
+ relation is resolved BELOW the row filter — its rows would come back
2665
+ unfiltered. Refusing the read rather than serving it.
2666
+ → read 'readers' as its own query (it is filtered there), or
2667
+ → drop 'readers' from this .with(...) if the relation does not need
2668
+ row-level narrowing.
2669
+ ```
2670
+
2671
+ Only relations reaching a table your filter actually narrows are affected —
2672
+ every other eager load is untouched. Applying the filter inside eager
2673
+ compilation is the real fix and it is a per-dialect change; until then this is
2674
+ a refusal rather than a leak, for the same reason the module refuses to
2675
+ fail open.
2676
+
2605
2677
  ## Row filters vs. guards
2606
2678
 
2607
2679
  They answer different questions, and a complete policy usually wants both:
@@ -32,6 +32,7 @@ The dispatcher routes `voltro <command> [args]` to the matching subcommand and p
32
32
  | Integrate | [`webhooks`](/docs/plugins/webhooks#voltro-webhooks-consumer-the-package-your-subscribers-install) (`consumer` / `events`) — generate the ZERO-dependency Standard-Webhooks verification package your subscribers install, from your own declared events (`--out` / `--name`); list the events a subscriber can register for (`--json`) |
33
33
  | [Inspect & debug](/docs/cli/inspect) | `inspect`, `logs`, `traces`, `workflows`, `cluster`, `check` |
34
34
  | [Health & surface](/docs/cli/build-and-start) | [`doctor`](/docs/cli/build-and-start) — serve preflight + the hand-roll detector (names the shipped primitive at the spot you're rebuilding it); [`capabilities`](/docs/cli/build-and-start) (`--json`) — the export surface read from your installed `@voltro/*`, so it can be verified instead of recalled; `info` (`--json`) — CLI / node / package-manager / dialect + every installed `@voltro/*` version, flagging lockstep skew (exits 1 on skew) |
35
+ | Mobile | `mobile` (`codegen` / `links`) — the Expo app's build-time steps: the typed rpc client + deep-link table, and the `apple-app-site-association` / `assetlinks.json` a universal link needs. `voltro codegen` inside a mobile app runs the same generators. |
35
36
  | Harness | `test`, `e2e` |
36
37
  | Cloud | `cloud` (`login` / `whoami` / `projects` / `env` / `import`); `login` is a top-level alias of `cloud login` |
37
38
  | Secrets | `secret` (`generate [purpose]` — the right var+format per secret; `generate` alone → a generic secret; `list`) |
@@ -143,6 +144,8 @@ config value always winning:
143
144
  | `VOLTRO_HSTS` | `Strict-Transport-Security` value. `off` drops just this one. |
144
145
  | `VOLTRO_MAX_RPC_BODY_BYTES` | Cap on the buffered `POST /rpc` JSON body (default 8 MiB) — an oversized body is refused `413` and never buffered past the cap. File uploads ride plugin routes with their own limits. |
145
146
  | `VOLTRO_MAX_BODY_BYTES` | Cap on every OTHER body read — plugin HTTP routes, REST routes, incoming webhooks (default 8 MiB, matching the rpc cap). The config-file spelling is `http.maxBodyBytes` in `app.config.ts`; per-route overrides (`defineRestRoute({ maxBodyBytes })`, a webhook handler's `maxBodyBytes`) win over both. Oversize is `413` for `Content-Length` and chunked alike. |
147
+ | `VOLTRO_CRDT_COMPACT_MAX_BYTES` | Size above which a merged `crdtText()` / `crdtDoc()` blob is soft-compacted (default 512 KiB, `0` disables). The config-file spelling is `crdt.compactMaxBytes` in `app.config.ts`; this variable wins over it. See [local-first](/docs/local-first/overview#rich-text-crdtdoc-usecrdtdoc-usecrdteditor). |
148
+ | `VOLTRO_GRPC_DRAIN_MS` | How long the [gRPC surface](/docs/data/grpc) lets open calls finish on shutdown before force-closing them (default 5000, `0` forces immediately). The config-file spelling is `grpc.drainMs`; this variable wins over it. Keep it below your orchestrator's termination grace. |
146
149
 
147
150
  Response compression for the buffered non-rpc surfaces (and `voltro start`'s
148
151
  HTML) is configured in the same `http:` block — `http.compression.{enabled,minBytes}`
@@ -2031,7 +2034,7 @@ package's README.
2031
2034
 
2032
2035
  _voltro migrate — apply the declared schema through the declarative differ (an alias of voltro db apply)._
2033
2036
 
2034
- `voltro migrate` applies your declared schema (`*.entity.ts` / `*.schema.ts` / `schema.ts`) to the configured database. It is an **alias of [`voltro db apply`](#the-declarative-workflow)**: it diffs the declared schema against the live database and emits the ALTERs, so a changed column or a new index actually lands.
2037
+ `voltro migrate` applies your declared schema (`*.entity.ts` / `*.schema.ts` / `schema.ts`) to the configured database. It is an **alias of [`voltro db apply`](#the-declarative-diff-workflow-voltro-db)**: it diffs the declared schema against the live database and emits the ALTERs, so a changed column or a new index actually lands.
2035
2038
 
2036
2039
  > Before 0.11.4 this command was a create-only apply (`CREATE TABLE IF NOT EXISTS`, no diffing), which meant a column or type change reported success having applied **nothing**. If you need that bootstrap-only behaviour for a brand-new database, it is now `voltro migrate --create-only`.
2037
2040
 
@@ -1717,7 +1717,7 @@ created by every migration and diffed on every boot.
1717
1717
 
1718
1718
  The other tempting option is to point `source:` at a name that resolves to
1719
1719
  nothing. That is worse than the empty table: the [stale-`source` boot
1720
- warning](#fan-out--how-many-subscribers-may-one-change-wake) is the only signal
1720
+ warning](#fan-out-how-many-subscribers-may-one-change-wake) is the only signal
1721
1721
  for a subscription that has gone permanently quiet, and an exemption for a name
1722
1722
  you invented disables it for the one case it was built for.
1723
1723
 
@@ -1847,7 +1847,7 @@ Two consequences worth knowing:
1847
1847
  - **Not free per subscriber.** A publish wakes every subscriber of that channel
1848
1848
  and re-runs each one's executor; the channel is one routing key, so
1849
1849
  subscribers looking at different slices of the state are woken too. Publish on
1850
- a real change, not on a timer — see [Fan-out](#fan-out--how-many-subscribers-may-one-change-wake).
1850
+ a real change, not on a timer — see [Fan-out](#fan-out-how-many-subscribers-may-one-change-wake).
1851
1851
 
1852
1852
  ## Query Executor
1853
1853
 
@@ -2067,7 +2067,7 @@ materialised revision and the stream continues on the same revision line, so a
2067
2067
  short offline gap costs a handful of patches instead of every row. Outside the
2068
2068
  window, for computed queries, for row-filtered apps, or whenever anything is in
2069
2069
  doubt, the query answers with a fresh snapshot — the delta-resume wire contract
2070
- lives in [the wire protocol](/docs/data/wire-protocol#reconnect--delta-resume).
2070
+ lives in [the wire protocol](/docs/data/wire-protocol#reconnect-delta-resume).
2071
2071
 
2072
2072
  **What is on screen while that happens is your last-known-good data, not a
2073
2073
  skeleton.** The replacement cache is seeded from the one it retires, so `data`
@@ -2172,6 +2172,15 @@ subscriber on every delivery, on purpose — a role revoked or a share withdrawn
2172
2172
  to end the stream on the very NEXT delivery, not whenever a cache happens to
2173
2173
  expire — and each of them can be a database round-trip.
2174
2174
 
2175
+ **On every transport.** A live query can leave the server three ways — the
2176
+ WebSocket the browser client uses, an [SSE
2177
+ stream](/docs/data/rest-routes#live-updates-over-http-stream-sse), and a
2178
+ [gRPC](/docs/data/grpc) server-streaming rpc — and all three resolve per
2179
+ delivery through the same code: guards re-checked before each frame, row
2180
+ visibility re-derived from the unfiltered base descriptor for each frame, and a
2181
+ revoked scope ending the stream. The transport decides how the frame is
2182
+ framed, never what the subject may see.
2183
+
2175
2184
  So deliveries run **concurrently, up to a bound**. The default is 8 in flight.
2176
2185
  Measured with 50 subscribers behind a 5 ms guard: 517 ms to serve all of them
2177
2186
  serially, 72 ms at 8 lanes.
@@ -3205,7 +3214,9 @@ es.addEventListener('delta', (e) => applyDelta(JSON.parse(e.data)))
3205
3214
 
3206
3215
  Each event's `_tag` becomes the SSE `event:` name, so a client listens per kind instead of switching on a payload field. The framing handles the details that bite otherwise: embedded newlines are split across `data:` lines (a raw `\n` would truncate the event), a `retry:` hint is sent, and a keep-alive comment goes out every 15s so proxies don't drop an idle stream.
3207
3216
 
3208
- Same guarantees as the WebSocket path, because it is the same code: the declarative `guards:`, the row filter and tenant scoping all run before anything is emitted, and the client's disconnect tears the subscription down (including a disconnect *during* setup). A guard denial arrives as one `error` event rather than an HTTP status — by then the response headers are already sent.
3217
+ Same guarantees as the WebSocket path, because it is the same code: the declarative `guards:`, the [row filter](/docs/authentication/row-level-security) and tenant scoping all run before anything is emitted, and the client's disconnect tears the subscription down (including a disconnect *during* setup). A guard denial arrives as one `error` event rather than an HTTP status — by then the response headers are already sent.
3218
+
3219
+ And they run before **every** event, not only before the first one: the guards are re-checked and the subject's row visibility is re-resolved from the unfiltered base descriptor per delivery, so a scope revoked while the `EventSource` is open ends the stream on the next event, and a membership that ends stops carrying those rows in the next `delta`. An open SSE stream is not a cheaper read path than a fresh `GET`.
3209
3220
 
3210
3221
  `stream: 'sse'` on a mutation or action is ignored: there is nothing to subscribe to.
3211
3222
 
@@ -4999,10 +5010,22 @@ A resume is declined — always with a fresh snapshot — when:
4999
5010
  - the resuming caller is a different subject or tenant (a login, logout or
5000
5011
  tenant switch between disconnect and resume) — the retained history is keyed
5001
5012
  by subject AND tenant, so a changed identity simply never finds it;
5002
- - the app registers a row filter (`setRowFilter`), or the query is a
5003
- **computed** query both are excluded from resume by design: a row-filtered
5004
- subscription's visible row set exists only per delivery, and a computed query
5005
- re-runs a handler with no delta chain to replay. They reconnect with a fresh
5013
+ - the query is a **computed** query — it re-runs a handler, so there is no
5014
+ delta chain to replay;
5015
+ - a registered row filter (`setRowFilter`) can narrow THIS subscription's
5016
+ source table, or the query declares an eager `.with(...)`. A row-filtered
5017
+ subscription's visible row set exists only per delivery, so replaying it
5018
+ could serve rows the subject has since lost.
5019
+
5020
+ **This is per table, not per app.** A filter that declares
5021
+ `tables: [...]` (see [row-level security](/docs/authentication/row-level-security))
5022
+ keeps delta-resume on every subscription whose source is not in that set —
5023
+ the common case, since most filters narrow a handful of tables. Without the
5024
+ declaration the framework cannot know which tables the predicate may reach
5025
+ and excludes them all, which is what a deployment measured as one filter over
5026
+ 4 tables costing the feature on all 173 of their queries. Eager loads are
5027
+ excluded wholesale because a relation resolves below the seam that narrows.
5028
+ They reconnect with a fresh
5006
5029
  snapshot, exactly as before.
5007
5030
 
5008
5031
  **Author a live-subscribed getter to return, not throw.** A subscription is a
@@ -6101,6 +6124,8 @@ export default {
6101
6124
  port: 50051,
6102
6125
  procedures: ['orders.get', 'orders.list', 'orders.create'],
6103
6126
  // tls: { certPath, keyPath, caPath? } — plaintext without it (dev / mesh).
6127
+ // drainMs: 5000, // shutdown drain budget — see below
6128
+ // maxMessageBytes, maxMetadataBytes — grpc-js frame limits
6104
6129
  },
6105
6130
  }
6106
6131
  ```
@@ -6112,6 +6137,21 @@ works out of the box). The gRPC packages ship as script-free optional
6112
6137
  dependencies of `@voltro/cli`; a configured `grpc:` block with them missing
6113
6138
  refuses the boot by name.
6114
6139
 
6140
+ ## Shutdown drains, then forces — `drainMs`
6141
+
6142
+ On SIGTERM the surface flips its health status to `NOT_SERVING` (so a load
6143
+ balancer stops sending it work) and gives open calls **`drainMs`** to finish
6144
+ before force-closing them. Default `5000`; `0` forces immediately; the env
6145
+ override is `VOLTRO_GRPC_DRAIN_MS`.
6146
+
6147
+ Pick it from two numbers only you have. Keep it **below** your orchestrator's
6148
+ termination grace (`terminationGracePeriodSeconds`, `docker stop -t`) — past
6149
+ that point SIGKILL arrives and the drain never completes, so a larger budget
6150
+ buys nothing. Keep it **above** your longest legitimately in-flight unary
6151
+ call, or every rolling deploy force-closes work that would have finished. When
6152
+ the budget is exceeded the surface says so in a warning naming the budget,
6153
+ rather than leaking the port into the next boot.
6154
+
6115
6155
  ## Field numbers are managed — `grpc.manifest.json`
6116
6156
 
6117
6157
  Field numbers are the proto wire identity, so they may never depend on
@@ -6169,10 +6209,19 @@ sleeping action whose post-sleep write never lands).
6169
6209
  A `query` becomes a **server-streaming** rpc: each frame is the CURRENT full
6170
6210
  snapshot, re-pushed live when the query's `source:` changes — subscribe,
6171
6211
  mutate from anywhere, and the open stream receives the new frame with no
6172
- re-request. The per-delivery guard re-check applies (a revoked scope ends
6173
- the stream with the mapped status), and slow consumers are handled through
6174
- grpc-js write backpressure frames coalesce to the latest snapshot rather
6175
- than buffering unboundedly.
6212
+ re-request.
6213
+
6214
+ **Authorization is re-derived per FRAME, not frozen at open.** Before every
6215
+ delivery the framework re-runs the query's `guards:` and re-resolves the
6216
+ subject's [row-level visibility](/docs/authentication/row-level-security)
6217
+ from the unfiltered base descriptor. A revoked scope ends the stream with the
6218
+ mapped status; a membership that ends mid-stream stops carrying those rows in
6219
+ the next frame, with the stream itself untouched. This is the same code the
6220
+ WebSocket and SSE transports run — an open gRPC stream is not a cheaper read
6221
+ path than a fresh call.
6222
+
6223
+ Slow consumers are handled through grpc-js write backpressure — frames
6224
+ coalesce to the latest snapshot rather than buffering unboundedly.
6176
6225
 
6177
6226
  ## Declared limits (v1)
6178
6227
 
@@ -627,7 +627,7 @@ await ctx.store.update('notes', id, {
627
627
 
628
628
  Two layers of validation apply:
629
629
 
630
- 1. **JSON validity** — that the stored bytes are well-formed JSON — is enforced automatically by the database on every dialect (see [Storage + validation per dialect](#storage--validation-per-dialect)). You don't declare anything.
630
+ 1. **JSON validity** — that the stored bytes are well-formed JSON — is enforced automatically by the database on every dialect (see [Storage + validation per dialect](#storage-validation-per-dialect)). You don't declare anything.
631
631
  2. **JSON *shape*** — that the value matches your expected structure — is up to you: enforce it at the table level with `table().validate(Schema)`:
632
632
 
633
633
  ```ts
@@ -700,7 +700,7 @@ A handler that a `voltro dev` session or a test actually ran is reported with wh
700
700
 
701
701
  ### What the codemod does per kind
702
702
 
703
- - **`rename-column`** gets a real `transform`: it renames the field in the `*.entity.ts` AND chains **`.renamedFrom('old')`** (so the differ plans a catalog RENAME, not the lossy drop+create described [above](#renamedfromoldname)), then annotates the handler sites the blast radius found.
703
+ - **`rename-column`** gets a real `transform`: it renames the field in the `*.entity.ts` AND chains **`.renamedFrom('old')`** (so the differ plans a catalog RENAME, not the lossy drop+create described [above](#renamedfrom-oldname)), then annotates the handler sites the blast radius found.
704
704
  - **`retype-column` / `split-column` / `drop-column` / `rename-table`** are reshaping changes with no single mechanical rewrite, so they get a **`manual`** codemod: a generated, numbered checklist of the edits + the annotation to add, printed for you to apply.
705
705
 
706
706
  `voltro evolve` produces the plan; it does not apply the schema change. **`voltro check` is the gate on the result**, and `voltro db apply` lands it — after `--write`, review the annotated handlers, then run those two.
@@ -391,7 +391,7 @@ What the framework hides for you vs what's worth knowing. Per-dialect pages dril
391
391
  | `RETURNING *` on DELETE | yes | no | yes (10.0+) | OUTPUT DELETED.* | yes | yes |
392
392
  | Parameterized `LIMIT ?` | yes | no — integer-literal inlined | yes | no — integer-literal inlined | yes | yes |
393
393
  | `LIMIT N OFFSET N` syntax | yes | yes | yes | no — `OFFSET … ROWS FETCH NEXT … ROWS ONLY` | yes | yes |
394
- | DEFAULT on TEXT columns | yes | **NO** — auto-uses VARCHAR(255) | yes | yes (NVARCHAR(MAX)) | yes | yes |
394
+ | DEFAULT on TEXT columns | yes | **NO** — framework emits VARCHAR(255) | yes — framework still emits VARCHAR(255) (engine parity) | yes — framework emits NVARCHAR(450) (indexable) | yes | yes |
395
395
  | Native JSON column type | JSONB | JSON | JSON | NVARCHAR(MAX) | TEXT | TEXT |
396
396
  | JSON columns returned as objects | yes | yes | yes | **no — strings** — framework auto-parses | **no — strings** — framework auto-parses | **no — strings** — framework auto-parses |
397
397
  | Booleans | proper booleans | 0/1 (TINYINT) | 0/1 | BIT (proper bool) | 0/1 (INTEGER) | 0/1 (INTEGER) |
@@ -796,11 +796,48 @@ The framework's DDL emitter detects the case and switches to `VARCHAR(255)`:
796
796
  ```typescript
797
797
  text().default('json') // → VARCHAR(255) DEFAULT 'json'
798
798
  text().oneOf(['a', 'b', 'c']).default('a') // → VARCHAR(255) DEFAULT 'a' CHECK (col IN ('a','b','c'))
799
- text().nullable() // → TEXT (unchanged — no default to trip up)
799
+ text().nullable() // → LONGTEXT (unchanged — no default to trip up)
800
800
  ```
801
801
 
802
802
  VARCHAR(255) is the framework's heuristic — enough for typical enum-like values, short status strings, format identifiers. If you need longer defaulted text, declare the column as `text().nullable()` + handle the missing-default case in application code, OR drop down to `unsafe()`.
803
803
 
804
+ ### Adding a default to an existing column
805
+
806
+ The rule holds for a migration too, not only for `CREATE TABLE`. Adding
807
+ `.default(…)` to a `text()` column that already exists **reshapes** the column
808
+ rather than setting a default on it:
809
+
810
+ ```sql
811
+ ALTER TABLE tickets MODIFY COLUMN `status` VARCHAR(255) NOT NULL DEFAULT 'active'
812
+ ```
813
+
814
+ That is deliberate, and it is what makes the change appliable at all: a plain
815
+ `ALTER TABLE … ALTER COLUMN status SET DEFAULT 'active'` is answered by MySQL
816
+ with `BLOB, TEXT, GEOMETRY or JSON column 'status' can't have a default value`,
817
+ so the migration would stop half-applied. Reshaping means the column has the same
818
+ type whether the default was declared before or after the table existed.
819
+
820
+ Two consequences worth knowing before you run it:
821
+
822
+ - **It is a narrowing.** If a row already holds more than 255 characters, the
823
+ ALTER fails (`Data too long for column 'status'`) and the migration stops
824
+ before it. Check first, and pick the width yourself with
825
+ `text().maxLength(n).default(…)` if 255 is too small:
826
+
827
+ ```sql
828
+ SELECT COUNT(*) FROM tickets WHERE CHAR_LENGTH(status) > 255
829
+ ```
830
+
831
+ - **Removing a default does not reshape back.** `DROP DEFAULT` is legal on any
832
+ mysql type, and widening a `VARCHAR(255)` back to `LONGTEXT` would fail for an
833
+ indexed column — so the column keeps its bounded type. Declare
834
+ `text().maxLength(255)` if you want that to be visible in the schema.
835
+
836
+ SQL Server does the same thing for its own reason (an `NVARCHAR(MAX)` column
837
+ cannot be indexed, so a defaulted text column is `NVARCHAR(450)`). On postgres a
838
+ `TEXT` column takes a `DEFAULT` directly, and SQLite rebuilds the table to the
839
+ declared shape — neither reshapes anything.
840
+
804
841
  ## JSON columns
805
842
 
806
843
  `json()` columns emit `JSON` (mysql's native binary JSON type since 5.7+). The driver auto-parses on read; same shape as postgres. No coercion overhead.
@@ -1187,6 +1224,31 @@ When the caller didn't supply an `orderBy` but did set `skip` (uncommon but lega
1187
1224
 
1188
1225
  The compiler inlines integer literals for TOP/OFFSET/FETCH NEXT values rather than parameter binding. Same rationale as MySQL — tedious has bind-as-INT issues with large or unexpected-typed numeric params.
1189
1226
 
1227
+ ## Text columns with a DEFAULT — NVARCHAR(450)
1228
+
1229
+ A plain `text()` column is `NVARCHAR(MAX)`, which SQL Server cannot index. A text
1230
+ column that carries a literal default or a closed value set is therefore emitted
1231
+ bounded, at `NVARCHAR(450)` — under the 900-byte single-column key limit, so it
1232
+ stays indexable:
1233
+
1234
+ ```typescript
1235
+ text().default('open') // → NVARCHAR(450) + a DEFAULT constraint
1236
+ text().oneOf(['open', 'closed']) // → NVARCHAR(450) + a CHECK constraint
1237
+ text().nullable() // → NVARCHAR(MAX)
1238
+ ```
1239
+
1240
+ This holds for migrations as well as for `CREATE TABLE`: adding `.default(…)` to
1241
+ an existing text column retypes it to `NVARCHAR(450)` and then adds the default
1242
+ constraint, so the column has the same type whether the default was declared
1243
+ before or after the table existed. It is a narrowing — if a row already holds
1244
+ more than 450 characters, the `ALTER COLUMN` fails ("String or binary data would
1245
+ be truncated") and the migration stops there. Check first, and use
1246
+ `text().maxLength(n).default(…)` to choose a different width:
1247
+
1248
+ ```sql
1249
+ SELECT COUNT(*) FROM tickets WHERE LEN(status) > 450
1250
+ ```
1251
+
1190
1252
  ## JSON columns — NVARCHAR(MAX) + auto-parse
1191
1253
 
1192
1254
  `json()` columns emit `NVARCHAR(MAX)` in DDL — mssql has no native JSON type pre-2025. Validation goes through `ISJSON(col) = 1` CHECK constraints; serialization is application-side.
@@ -78,6 +78,8 @@ Server-side, the active locale is determined by, in priority order:
78
78
  2. **`Accept-Language` header** — the browser/OS preference, q-weighted and sorted per RFC 4647.
79
79
  3. **`defaultLocale`** — last-resort fallback.
80
80
 
81
+ **One resolver decides, and everything the server renders for that request reads its answer** — the page render and its `<I18nProvider>`, the `<html lang>` attribute, the ISR cache key (so a language switch cannot re-serve the previous locale's cached HTML), and the validation errors an [`<AutoForm>` renders on the no-JavaScript form-POST path](/docs/ui/forms-and-tables#forms-without-javascript). That last one is worth naming because a server has no `<html lang>` to read yet at the time it validates; deriving the locale a second way there would answer `en` for every request.
82
+
81
83
  The resolved locale is **guaranteed** to be one of the codes in `locales`. Any unsupported value (a cookie pointing at a code you no longer ship, a browser asking for `xx-YY`) falls through to the next signal. RFC 4647 lookup strips subtags one segment at a time — `de-CH-1996` → `de-CH` → `de` — so a `de` catalog serves a `de-CH` browser.
82
84
 
83
85
  The client **adopts what the server resolved**, reading it from the `<html lang>` attribute the server render sets, then falling back to the cookie and the default. `Accept-Language` is never read in the browser: `navigator.languages` can diverge from what the server saw.