@voltro/cli 0.53.0 → 0.55.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 (191) hide show
  1. package/CHANGELOG.md +335 -0
  2. package/dist/{agentsMd-0l980yhL.js → agentsMd-BaLC10Na.js} +110 -82
  3. package/dist/agentsMd-DCY1RSs8.js +2 -0
  4. package/dist/{apiBuild-CaPfoWku.js → apiBuild-CMvLJM_K.js} +2 -2
  5. package/dist/apiBuild-Cl0IDx8c.js +2 -0
  6. package/dist/bin.js +1 -1
  7. package/dist/{build-D-OnvNMf.js → build-S0QOzqPT.js} +115 -115
  8. package/dist/{checkCommand-D2ZduVlh.js → checkCommand-DNkY5kwF.js} +1 -1
  9. package/dist/{checkCommand-C5elt0tW.js → checkCommand-fbj9GDjN.js} +6 -6
  10. package/dist/{cloudCmd-QUXh-b5w.js → cloudCmd-DzKcSYuy.js} +1 -1
  11. package/dist/codegen-CN6vMM4J.js +2 -0
  12. package/dist/{codegen-FEk8AZHb.js → codegen-SIepQtUl.js} +76 -65
  13. package/dist/codegenCommand-3TDJezom.js +42 -0
  14. package/dist/{codemodRunner-BjtB2lq6.js → codemodRunner-C2zxZUIw.js} +64 -9
  15. package/dist/{commands-DyxAmhP0.js → commands-BBYJ7Q3B.js} +96 -73
  16. package/dist/{dashboardCommand-BdKTyT13.js → dashboardCommand-D2kmyCLL.js} +3 -3
  17. package/dist/{dataCommand-Bab9X7s8.js → dataCommand-BEPPQiTl.js} +267 -195
  18. package/dist/dbCommand-BTyBGhIA.js +2 -0
  19. package/dist/{dbCommand-06O2finM.js → dbCommand-DZTmOFT4.js} +3 -3
  20. package/dist/{dev-C6LGF4iY.js → dev-Ca_A_S9v.js} +2439 -2397
  21. package/dist/{dev-GjJWAYo2.js → dev-DfVZaoys.js} +1 -1
  22. package/dist/{doctorCommand-etMkflRc.js → doctorCommand-CGZJK_4o.js} +21 -21
  23. package/dist/doctorCommand-djmqEcDC.js +2 -0
  24. package/dist/{dormancyCommand-UwZ1AZzB.js → dormancyCommand-DY2rYpTa.js} +1 -1
  25. package/dist/{embeddingsCommand-C70zWHwo.js → embeddingsCommand-BoCqZsgp.js} +1 -1
  26. package/dist/{envCommand-dSyKvRkM.js → envCommand-Bxy2fOjc.js} +15 -15
  27. package/dist/{evolveCommand-CG0_ebO5.js → evolveCommand-BsbZ-XDg.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-Df2Ymp2f.js +2 -0
  31. package/dist/{frameworkTableAssembly-DKx3ba3S.js → frameworkTableAssembly-Do-cf6RJ.js} +96 -102
  32. package/dist/index.js +2 -2
  33. package/dist/{infoCommand-_53iOc_j.js → infoCommand-EmM3jPKD.js} +1 -1
  34. package/dist/{inspect-Bd8-9wsi.js → inspect-DCqILJ1G.js} +4 -0
  35. package/dist/inspect-DGJwpOAb.js +2 -0
  36. package/dist/interruptedReplace-CwnkBb2X.js +41 -0
  37. package/dist/interruptedReplace-qzmFI020.js +2 -0
  38. package/dist/manifestBuild-CJ2zvPvT.js +2 -0
  39. package/dist/{manifestBuild-Cqgsx2bM.js → manifestBuild-DjX5MoXy.js} +1 -1
  40. package/dist/{metaCommands-Cn2oboG4.js → metaCommands-x7RCi2AF.js} +2 -2
  41. package/dist/{migrate-Cko9rswM.js → migrate-CGFZS-1a.js} +2 -2
  42. package/dist/mobileCommand-D9O6iq3D.js +428 -0
  43. package/dist/mobileCommand-DAum7tsG.js +2 -0
  44. package/dist/{pageConvention-C938S8oC.js → pageConvention-CMpfDN6r.js} +1 -1
  45. package/dist/{privacyCommand-DWTQMC6R.js → privacyCommand-BCa2OoZG.js} +2 -2
  46. package/dist/{probeCommand-DkGGLknv.js → probeCommand-Bs3iVBSL.js} +1 -1
  47. package/dist/{projectScaffold-EzlErR4E.js → projectScaffold-CJfP-xbT.js} +1 -1
  48. package/dist/{projectScaffold-B4dmTlwT.js → projectScaffold-CSN0OzBV.js} +2 -2
  49. package/dist/renderModeScan-43yQ2opo.js +147 -0
  50. package/dist/{renderProfile-CskIgAfn.js → renderProfile-DvrhVJHa.js} +2 -2
  51. package/dist/{runtimeTrace-c0APJz7E.js → runtimeTrace-C1BTpHGQ.js} +1 -1
  52. package/dist/{sdkgen-BiQCgIEr.js → sdkgen-CXMwLg9n.js} +1 -1
  53. package/dist/{serveCommand-CueKQgzl.js → serveCommand-C7IrCD58.js} +899 -897
  54. package/dist/serveCommand-Cjt5S9hD.js +2 -0
  55. package/dist/serveEntry.js +1 -1
  56. package/dist/start-DH7cat4-.js +3 -0
  57. package/dist/{start-ekPan8BT.js → start-EOV7s1NZ.js} +544 -527
  58. package/dist/startEntry.js +1 -1
  59. package/dist/{staticCommand-xlSL-IWk.js → staticCommand-ey0kYmOT.js} +1 -1
  60. package/dist/{subcommandNames-DpYs3DXr.js → subcommandNames-CDzfEtKV.js} +3 -3
  61. package/dist/{templates-BR-fb4SP.js → templates-BTWZkJJT.js} +41 -9
  62. package/dist/{test-BWPQcRoB.js → test-DO27-x2P.js} +1 -1
  63. package/dist/{updateCommand-C_8I8Rzo.js → updateCommand-C_jN1w18.js} +1 -1
  64. package/dist/updateCommand-nnFjDbl4.js +2 -0
  65. package/dist/{webDev-C7jWJ5dX.js → webDev-1XpVnYkW.js} +1 -1
  66. package/dist/{webDev-oczpugbx.js → webDev-B7vNj4Bq.js} +1231 -1186
  67. package/dist/{webhooksCommand-4SVPDjKg.js → webhooksCommand-B1LVcyO3.js} +1 -1
  68. package/dist/workspaceDeps-RKEkX92S.js +45 -0
  69. package/package.json +31 -19
  70. package/templates/AGENTS.core.md +2 -0
  71. package/templates/AGENTS.md +4 -2
  72. package/templates/agent-docs/_index.md +2 -2
  73. package/templates/agent-docs/_manifest.json +1 -1
  74. package/templates/agent-docs/ai.md +4 -4
  75. package/templates/agent-docs/authentication.md +115 -0
  76. package/templates/agent-docs/cli.md +98 -12
  77. package/templates/agent-docs/data.md +121 -21
  78. package/templates/agent-docs/database/advancedqueries.md +1 -1
  79. package/templates/agent-docs/database/migrations.md +1 -1
  80. package/templates/agent-docs/database/seedsdialects.md +64 -2
  81. package/templates/agent-docs/internationalization.md +2 -0
  82. package/templates/agent-docs/introduction.md +25 -0
  83. package/templates/agent-docs/local-first-mobile.md +139 -41
  84. package/templates/agent-docs/observability.md +4 -2
  85. package/templates/agent-docs/plugins/atlassian.md +2 -2
  86. package/templates/agent-docs/plugins/audit.md +2 -2
  87. package/templates/agent-docs/plugins/billing.md +1 -1
  88. package/templates/agent-docs/plugins/cdc-out.md +8 -3
  89. package/templates/agent-docs/plugins/comments.md +22 -0
  90. package/templates/agent-docs/plugins/presence.md +32 -3
  91. package/templates/agent-docs/plugins/prometheus.md +2 -0
  92. package/templates/agent-docs/plugins/queue.md +47 -4
  93. package/templates/agent-docs/plugins.md +52 -14
  94. package/templates/agent-docs/reference.md +25 -4
  95. package/templates/agent-docs/routing.md +81 -9
  96. package/templates/agent-docs/scheduling.md +1 -1
  97. package/templates/agent-docs/schema-driven-ui.md +137 -1
  98. package/templates/agent-docs/templates/appshells.md +36 -4
  99. package/templates/agent-docs/whats-new.md +75 -158
  100. package/templates/apps/api-ai/package.json +6 -6
  101. package/templates/apps/api-auth/package.json +8 -8
  102. package/templates/apps/api-backend/package.json +7 -7
  103. package/templates/apps/api-backend-deactivation/package.json +7 -7
  104. package/templates/apps/api-backend-mail/package.json +8 -8
  105. package/templates/apps/api-backend-mariadb/package.json +9 -9
  106. package/templates/apps/api-backend-sqlite/package.json +8 -8
  107. package/templates/apps/api-backend-storage/package.json +8 -8
  108. package/templates/apps/api-cms/package.json +9 -9
  109. package/templates/apps/api-collab/README.md +3 -3
  110. package/templates/apps/api-collab/app.config.ts +1 -1
  111. package/templates/apps/api-collab/database/schema.ts +12 -8
  112. package/templates/apps/api-collab/mutations/documents.create.mutation.server.ts +1 -1
  113. package/templates/apps/api-collab/mutations/documents.setBody.mutation.server.ts +1 -1
  114. package/templates/apps/api-collab/mutations/documents.setBody.mutation.ts +1 -1
  115. package/templates/apps/api-collab/package.json +8 -8
  116. package/templates/apps/api-collab/template.json +1 -1
  117. package/templates/apps/api-collab/tests/documents.setBody.test.ts +10 -2
  118. package/templates/apps/api-data-advanced/package.json +8 -8
  119. package/templates/apps/api-durable/package.json +8 -8
  120. package/templates/apps/api-feature-flags/package.json +9 -9
  121. package/templates/apps/api-governance/package.json +8 -8
  122. package/templates/apps/api-kv/package.json +8 -8
  123. package/templates/apps/api-moderation/package.json +8 -8
  124. package/templates/apps/api-observability/package.json +8 -8
  125. package/templates/apps/api-ratelimit/package.json +8 -8
  126. package/templates/apps/api-rbac/package.json +8 -8
  127. package/templates/apps/api-rest/package.json +7 -7
  128. package/templates/apps/api-row-history/package.json +8 -8
  129. package/templates/apps/api-saas/package.json +11 -10
  130. package/templates/apps/api-saas-starter/package.json +10 -10
  131. package/templates/apps/api-search/package.json +8 -8
  132. package/templates/apps/api-status/package.json +8 -8
  133. package/templates/apps/api-webhooks/package.json +9 -9
  134. package/templates/apps/changelog/package.json +7 -6
  135. package/templates/apps/edge-functions/package.json +2 -2
  136. package/templates/apps/frontend-admin/package.json +8 -8
  137. package/templates/apps/frontend-app/package.json +9 -9
  138. package/templates/apps/frontend-auth/package.json +8 -8
  139. package/templates/apps/frontend-blank/package.json +7 -7
  140. package/templates/apps/frontend-cms/package.json +9 -9
  141. package/templates/apps/frontend-collab/README.md +43 -24
  142. package/templates/apps/frontend-collab/app.config.ts +3 -3
  143. package/templates/apps/frontend-collab/package.json +14 -10
  144. package/templates/apps/frontend-collab/src/locales/de.ts +1 -2
  145. package/templates/apps/frontend-collab/src/locales/en.ts +1 -2
  146. package/templates/apps/frontend-collab/src/pages/page.test.tsx +72 -76
  147. package/templates/apps/frontend-collab/src/pages/page.tsx +45 -22
  148. package/templates/apps/frontend-collab/template.json +2 -2
  149. package/templates/apps/frontend-contact/package.json +7 -7
  150. package/templates/apps/frontend-dashboard/package.json +7 -7
  151. package/templates/apps/frontend-docs/package.json +8 -7
  152. package/templates/apps/frontend-i18n/package.json +6 -6
  153. package/templates/apps/frontend-landing/README.md +48 -0
  154. package/templates/apps/frontend-landing/app.config.ts +28 -0
  155. package/templates/apps/frontend-landing/package.json +7 -6
  156. package/templates/apps/frontend-landing/src/assets/hero.jpg +0 -0
  157. package/templates/apps/frontend-landing/src/fonts/Geist-Variable.woff2 +0 -0
  158. package/templates/apps/frontend-landing/src/fonts/LICENSE-Geist.txt +92 -0
  159. package/templates/apps/frontend-landing/src/globals.css +15 -0
  160. package/templates/apps/frontend-landing/src/globals.d.ts +17 -0
  161. package/templates/apps/frontend-landing/src/locales/de.ts +3 -2
  162. package/templates/apps/frontend-landing/src/locales/en.ts +5 -2
  163. package/templates/apps/frontend-landing/src/pages/page.test.tsx +79 -0
  164. package/templates/apps/frontend-landing/src/pages/page.tsx +26 -3
  165. package/templates/apps/frontend-landing/template.json +2 -2
  166. package/templates/apps/frontend-portal/package.json +8 -8
  167. package/templates/apps/frontend-saas/package.json +8 -8
  168. package/templates/apps/frontend-spa/package.json +7 -7
  169. package/templates/apps/frontend-ssr/package.json +7 -7
  170. package/templates/apps/frontend-ssr-api/package.json +8 -8
  171. package/templates/apps/frontend-static-blog/package.json +8 -7
  172. package/templates/apps/frontend-status/package.json +8 -8
  173. package/templates/apps/mobile-app/package.json +12 -11
  174. package/templates/apps/mobile-app/src/lib/deeplinks.ts +29 -17
  175. package/templates/apps/mobile-app/tests/deeplinks.test.ts +16 -0
  176. package/dist/agentsMd-SDDSkyl4.js +0 -2
  177. package/dist/apiBuild-DHtLXYx9.js +0 -2
  178. package/dist/codegen-BWpt3VgF.js +0 -2
  179. package/dist/codegenCommand-BOiWQ5hz.js +0 -137
  180. package/dist/dbCommand-B1EXBC6f.js +0 -2
  181. package/dist/doctorCommand-B0hX0tdz.js +0 -2
  182. package/dist/fileConventions-DASGEmj-.js +0 -35
  183. package/dist/frameworkTableAssembly-C_7Z-rMs.js +0 -2
  184. package/dist/inspect-CuoDInfZ.js +0 -2
  185. package/dist/interruptedReplace-C3O3M1MM.js +0 -28
  186. package/dist/interruptedReplace-CvmiAM9K.js +0 -2
  187. package/dist/manifestBuild-C4-J1-m_.js +0 -2
  188. package/dist/renderModeScan-CUbOeOAg.js +0 -122
  189. package/dist/serveCommand-DsnrVN3U.js +0 -2
  190. package/dist/start-BJzZLbt8.js +0 -3
  191. package/dist/updateCommand-Bqql_rsQ.js +0 -2
@@ -431,6 +431,15 @@ between acting on it and learning to skim it:
431
431
  recorder resolves it through the relation registry instead, target and (for a
432
432
  many-to-many) junction alike. A write to the junction changes membership,
433
433
  which is exactly the change a user makes.
434
+ - **A `crud.*` executor is watched exactly like a hand-written one** — and it is
435
+ the case that needs it most. `crud.list('tasks', { include: { subTasks: true } })`
436
+ reads a table your own file never names, so there is nothing in front of you to
437
+ check `source:` against. The descriptor stays yours either way: `crud.*` supplies
438
+ only the executor, you write the `source:` beside it. `crud.count` counts as a
439
+ read too — it returns a number rather than rows, but an insert changes that
440
+ number, so the counted table belongs in `source:` or "page 3 of 12" stops moving.
441
+ `crud.create` / `update` / `remove` issue no read at all and never produce a
442
+ finding.
434
443
  - **A table read only to NARROW a result is not counted** — a parent reached
435
444
  through `inSubquery(...)`, or a read the framework made to resolve your row
436
445
  filter. Those decide which rows come back rather than contributing rows, and
@@ -733,9 +742,12 @@ is always present (the fallback stands in until the first snapshot) and `loading
733
742
  is a plain boolean reporting the true state. There is nothing to narrow.
734
743
 
735
744
  **Errors.** `loading` means **no data has arrived yet** — it is not a claim that
736
- the subscription is healthy. A **cold-start** failure (nothing ever arrived)
737
- leaves `loading` true *and* sets `error`, so a component that branches on
738
- `loading` alone renders a skeleton forever; check `error` to break out of it. A
745
+ the subscription is healthy. A **cold-start** failure (nothing ever arrived) is
746
+ its own state: `loading` is `false`, `failed` is `true`, and `error` is
747
+ non-optional there, so branching on `loading` alone can no longer render a
748
+ skeleton forever. (It used to leave `loading` true, and the type's own comment
749
+ predicted the consequence — the fix was to stop making `loading` mean two
750
+ things rather than to keep warning about it.) A
739
751
  failure AFTER data arrived deliberately does NOT replace good data with an error
740
752
  banner (a transient websocket hiccup would blank a working screen); those reach
741
753
  the api's error bus instead — subscribe with `useOnRpcError` for
@@ -1119,7 +1131,13 @@ export const employeesUpdate = defineMutation({
1119
1131
  target: {
1120
1132
  table: 'employees',
1121
1133
  op: 'update',
1122
- relations: { assignedStores: 'employee_assigned_stores' },
1134
+ relations: {
1135
+ assignedStores: {
1136
+ junction: 'employee_assigned_stores',
1137
+ anchorColumn: 'employeeId', // the junction reference() pointing at `employees`
1138
+ targetColumn: 'storeId', // the junction's other reference()
1139
+ },
1140
+ },
1123
1141
  },
1124
1142
  })
1125
1143
  ```
@@ -1127,9 +1145,7 @@ export const employeesUpdate = defineMutation({
1127
1145
  After the executor succeeds, `input.assignedStores` is reconciled against the
1128
1146
  junction via the diff-based link writer (`store.relationLinks`): missing rows
1129
1147
  inserted, surplus rows deleted, unchanged rows untouched — so reactive
1130
- subscriptions on the junction see one change per changed row. The anchor
1131
- column is derived from the junction's `reference()` targets; a self-junction
1132
- (both columns referencing one table) is refused by name, never guessed.
1148
+ subscriptions on the junction see one change per changed row.
1133
1149
 
1134
1150
  The semantics worth knowing: an ABSENT input field leaves the links
1135
1151
  untouched — absent is not empty; an empty array is the explicit "clear them
@@ -1137,6 +1153,34 @@ all". The row id comes from the executor's `output.id`, falling back to
1137
1153
  `input.id`. The link writes go through `ctx.store`, so undo capture and
1138
1154
  cross-table rules see them like any other write.
1139
1155
 
1156
+ ### The same declaration drives the optimistic update
1157
+
1158
+ A junction change used to reach the browser only with the server delta — so on
1159
+ one submit the renamed title flipped immediately and the assigned stores sat on
1160
+ their old value until the roundtrip landed. It does not any more: `useMutation`
1161
+ reconciles the junction rows of every subscription sourced on `junction` the
1162
+ moment the mutation is sent, against the same `input[field]` the server will
1163
+ write.
1164
+
1165
+ It is a diff, not a redraw: a surviving link keeps its own row (and its real
1166
+ id), a surplus link disappears, and only a genuinely new link is a staged
1167
+ optimistic row. The patches ride the ordinary optimistic lane — reverted if the
1168
+ mutation fails, kept after it succeeds until the server data actually moves.
1169
+ Nothing is on a timer.
1170
+
1171
+ Client-side the anchor id is `input.id`; for an `insert` it is the same
1172
+ optimistic id the new row was stamped with, since the server's `output.id` is
1173
+ not knowable before the response arrives.
1174
+
1175
+ **Why you state the two columns.** The optimistic patch runs in the BROWSER, and
1176
+ the browser cannot import your `db/` schema — `@voltro/database` is server-only
1177
+ by construction — so the junction's two `reference()` columns cannot be derived
1178
+ there. `anchorColumn` is the one pointing at the target's own table;
1179
+ `targetColumn` is the other. They are not taken on trust: before it writes, the
1180
+ server compares your declaration against the junction's real reference columns
1181
+ and refuses, naming the correct pair, if they disagree. A self-junction (both
1182
+ columns referencing one table) is still refused by name, never guessed.
1183
+
1140
1184
  ## Typed Errors
1141
1185
 
1142
1186
  ```ts
@@ -1717,7 +1761,7 @@ created by every migration and diffed on every boot.
1717
1761
 
1718
1762
  The other tempting option is to point `source:` at a name that resolves to
1719
1763
  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
1764
+ warning](#fan-out-how-many-subscribers-may-one-change-wake) is the only signal
1721
1765
  for a subscription that has gone permanently quiet, and an exemption for a name
1722
1766
  you invented disables it for the one case it was built for.
1723
1767
 
@@ -1847,7 +1891,7 @@ Two consequences worth knowing:
1847
1891
  - **Not free per subscriber.** A publish wakes every subscriber of that channel
1848
1892
  and re-runs each one's executor; the channel is one routing key, so
1849
1893
  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).
1894
+ a real change, not on a timer — see [Fan-out](#fan-out-how-many-subscribers-may-one-change-wake).
1851
1895
 
1852
1896
  ## Query Executor
1853
1897
 
@@ -2065,9 +2109,10 @@ the resume window (`reactive.resume.windowMs`, default 60 s) the server replays
2065
2109
  **only the deltas the client missed** — the re-subscribe presents the last
2066
2110
  materialised revision and the stream continues on the same revision line, so a
2067
2111
  short offline gap costs a handful of patches instead of every row. Outside the
2068
- window, for computed queries, for row-filtered apps, or whenever anything is in
2112
+ window, for computed queries, for a subscription whose source table a
2113
+ registered row filter may narrow, or whenever anything is in
2069
2114
  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).
2115
+ lives in [the wire protocol](/docs/data/wire-protocol#reconnect-delta-resume).
2071
2116
 
2072
2117
  **What is on screen while that happens is your last-known-good data, not a
2073
2118
  skeleton.** The replacement cache is seeded from the one it retires, so `data`
@@ -2172,6 +2217,15 @@ subscriber on every delivery, on purpose — a role revoked or a share withdrawn
2172
2217
  to end the stream on the very NEXT delivery, not whenever a cache happens to
2173
2218
  expire — and each of them can be a database round-trip.
2174
2219
 
2220
+ **On every transport.** A live query can leave the server three ways — the
2221
+ WebSocket the browser client uses, an [SSE
2222
+ stream](/docs/data/rest-routes#live-updates-over-http-stream-sse), and a
2223
+ [gRPC](/docs/data/grpc) server-streaming rpc — and all three resolve per
2224
+ delivery through the same code: guards re-checked before each frame, row
2225
+ visibility re-derived from the unfiltered base descriptor for each frame, and a
2226
+ revoked scope ending the stream. The transport decides how the frame is
2227
+ framed, never what the subject may see.
2228
+
2175
2229
  So deliveries run **concurrently, up to a bound**. The default is 8 in flight.
2176
2230
  Measured with 50 subscribers behind a 5 ms guard: 517 ms to serve all of them
2177
2231
  serially, 72 ms at 8 lanes.
@@ -3205,7 +3259,9 @@ es.addEventListener('delta', (e) => applyDelta(JSON.parse(e.data)))
3205
3259
 
3206
3260
  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
3261
 
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.
3262
+ 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.
3263
+
3264
+ 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
3265
 
3210
3266
  `stream: 'sse'` on a mutation or action is ignored: there is nothing to subscribe to.
3211
3267
 
@@ -4999,11 +5055,29 @@ A resume is declined — always with a fresh snapshot — when:
4999
5055
  - the resuming caller is a different subject or tenant (a login, logout or
5000
5056
  tenant switch between disconnect and resume) — the retained history is keyed
5001
5057
  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
5006
- snapshot, exactly as before.
5058
+ - the query is a **computed** query — it re-runs a handler, so there is no
5059
+ delta chain to replay;
5060
+ - a registered row filter (`setRowFilter`) can narrow THIS subscription's
5061
+ source table, or the query declares an eager `.with(...)`. A row-filtered
5062
+ subscription's visible row set exists only per delivery, so replaying it
5063
+ could serve rows the subject has since lost.
5064
+
5065
+ **This is per table, not per app.** A filter that declares
5066
+ `tables: [...]` (see [row-level security](/docs/authentication/row-level-security))
5067
+ keeps delta-resume on every subscription whose source is not in that set —
5068
+ the common case, since most filters narrow a handful of tables. Without the
5069
+ declaration the framework cannot know which tables the predicate may reach
5070
+ and excludes them all. Eager loads are excluded wholesale because a relation
5071
+ resolves below the seam that narrows. They reconnect with a fresh snapshot,
5072
+ exactly as before.
5073
+
5074
+ **Which of your queries actually got a ring** is recorded per label, since the
5075
+ excluded and the never-eligible look identical on the wire:
5076
+ `/_voltro/inspect/subscriptions` returns a `resume` array of
5077
+ `{ label, resumable, excluded }`, and `voltro dev` logs each verdict once under
5078
+ the `voltro:resume` scope. A `computed` verdict is the one worth reading first:
5079
+ it means the executor returns a value rather than a descriptor, so no row-filter
5080
+ declaration can ever change it.
5007
5081
 
5008
5082
  **Author a live-subscribed getter to return, not throw.** A subscription is a
5009
5083
  long-lived stream, so a getter that throws on every re-evaluation is a broken
@@ -6101,6 +6175,8 @@ export default {
6101
6175
  port: 50051,
6102
6176
  procedures: ['orders.get', 'orders.list', 'orders.create'],
6103
6177
  // tls: { certPath, keyPath, caPath? } — plaintext without it (dev / mesh).
6178
+ // drainMs: 5000, // shutdown drain budget — see below
6179
+ // maxMessageBytes, maxMetadataBytes — grpc-js frame limits
6104
6180
  },
6105
6181
  }
6106
6182
  ```
@@ -6112,6 +6188,21 @@ works out of the box). The gRPC packages ship as script-free optional
6112
6188
  dependencies of `@voltro/cli`; a configured `grpc:` block with them missing
6113
6189
  refuses the boot by name.
6114
6190
 
6191
+ ## Shutdown drains, then forces — `drainMs`
6192
+
6193
+ On SIGTERM the surface flips its health status to `NOT_SERVING` (so a load
6194
+ balancer stops sending it work) and gives open calls **`drainMs`** to finish
6195
+ before force-closing them. Default `5000`; `0` forces immediately; the env
6196
+ override is `VOLTRO_GRPC_DRAIN_MS`.
6197
+
6198
+ Pick it from two numbers only you have. Keep it **below** your orchestrator's
6199
+ termination grace (`terminationGracePeriodSeconds`, `docker stop -t`) — past
6200
+ that point SIGKILL arrives and the drain never completes, so a larger budget
6201
+ buys nothing. Keep it **above** your longest legitimately in-flight unary
6202
+ call, or every rolling deploy force-closes work that would have finished. When
6203
+ the budget is exceeded the surface says so in a warning naming the budget,
6204
+ rather than leaking the port into the next boot.
6205
+
6115
6206
  ## Field numbers are managed — `grpc.manifest.json`
6116
6207
 
6117
6208
  Field numbers are the proto wire identity, so they may never depend on
@@ -6169,10 +6260,19 @@ sleeping action whose post-sleep write never lands).
6169
6260
  A `query` becomes a **server-streaming** rpc: each frame is the CURRENT full
6170
6261
  snapshot, re-pushed live when the query's `source:` changes — subscribe,
6171
6262
  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.
6263
+ re-request.
6264
+
6265
+ **Authorization is re-derived per FRAME, not frozen at open.** Before every
6266
+ delivery the framework re-runs the query's `guards:` and re-resolves the
6267
+ subject's [row-level visibility](/docs/authentication/row-level-security)
6268
+ from the unfiltered base descriptor. A revoked scope ends the stream with the
6269
+ mapped status; a membership that ends mid-stream stops carrying those rows in
6270
+ the next frame, with the stream itself untouched. This is the same code the
6271
+ WebSocket and SSE transports run — an open gRPC stream is not a cheaper read
6272
+ path than a fresh call.
6273
+
6274
+ Slow consumers are handled through grpc-js write backpressure — frames
6275
+ coalesce to the latest snapshot rather than buffering unboundedly.
6176
6276
 
6177
6277
  ## Declared limits (v1)
6178
6278
 
@@ -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.
@@ -544,6 +544,16 @@ export const searchParams = Schema.Struct({
544
544
 
545
545
  - `searchParams` (an `effect/Schema` struct — every field optional or with a default) types the page's query string: `useSearchParams(searchParams)` returns the decoded shape, and links built with `withQuery` type-check against it. Details: [Pages → Query strings](/docs/routing/pages#query-strings).
546
546
 
547
+ Two more page exports change what the framework produces for a route:
548
+
549
+ ```tsx
550
+ export const ogImage = ({ params, loaderData, locale }) => ({ type: 'div', props: { /* satori JSX */ } })
551
+ export const intercept = { from: '/photos' }
552
+ ```
553
+
554
+ - `ogImage` declares the page's `og:image` as a satori JSX template. `static` pages render the PNG at BUILD time into `dist/assets/og/`; `ssr` pages render it on demand over a signed route. The `og:image` / `twitter:image` / `twitter:card` tags are injected automatically unless your own `meta` already sets them. A declared font is REQUIRED (there is no bundled default), and an `ssr` route exporting it needs `VOLTRO_OG_SECRET` — `voltro start` refuses the boot otherwise. Details: [Loaders and meta → OG images](/docs/routing/loaders-and-meta#og-images-from-a-template-ogimage).
555
+ - `intercept` makes the page an **intercepting route**: `from` names one or more ROUTE PATTERNS (`'/photos'`, `['/photos', '/albums/[id]']`), and a soft navigation arriving from one of them renders this page as an overlay above the still-mounted origin. Every hard load — and a soft navigation from anywhere else — renders it standalone. Details: [Intercepting routes](/docs/routing/intercepting-routes).
556
+
547
557
  ## Discovery in practice
548
558
 
549
559
  ```text
@@ -605,11 +615,26 @@ If yes, the promise belongs in the name — you cannot see a contract before you
605
615
  | `*.store.ts` | exactly one `defineStore`, no server state | `store/one-per-file`, `store/mirrors-server-state` |
606
616
  | `*.collection.ts` | declares content collections (`defineCollection`); frontmatter schema violations fail the build naming the file | the build's collection decode + reference validation |
607
617
  | `*.consumer.ts` | declares queue consumers (`defineQueueConsumer`, @voltro/plugin-queue); loading registers, the plugin's activation starts them | the queue runner (decode→DLQ, retry→DLQ, commit-per-message) |
618
+ | `*.ws.ts` | default-exports one raw WebSocket gateway (`defineWebSocket`), mounting its own upgrade path beside the rpc socket | boot discovery on BOTH paths (`voltro dev` and `voltro serve`); two gateways on one path refuse the boot |
608
619
 
609
620
  A `*.component.tsx` promises exactly ONE component. It does not promise to export nothing else: types, and plain module-local values a `const COLUMNS = […]` beside the table that renders them, are fine and always were. What the rule counts is components — a declaration that renders — so an object, an array, a string or a `new` beside your component is not a second one, and neither is `export default Card` next to `export const Card`.
610
621
 
611
622
  The BOUNDARY rules (`internal/foreign-import`, `fixture/production-import`, `ui/unlinked`) are assertions about your import graph, so it is worth knowing which edges they follow: relative specifiers, your tsconfig `paths` aliases (read from the nearest `tsconfig.json`, so a per-app `@/*` works when you run `voltro doctor` at the repo root), `export … from` re-exports, and dynamic `import()`. A package import is a leaf — the walk stops at the edge of your app.
612
623
 
624
+ ## Contracts that are not suffixes
625
+
626
+ The admission test above is about the PROMISE, not about the spelling — and three of the framework's conventions carry one without being a suffix on a filename. They are listed here because a reader looking for "what does the framework read out of my tree" would otherwise stop at the table:
627
+
628
+ | Convention | Promise | Read by |
629
+ |---|---|---|
630
+ | `searchParams` page export | the page's query string decodes through this `effect/Schema` struct — every field optional or with a default | `useSearchParams(searchParams)`, `withQuery` link typing, and the render-mode scan (a page declaring BOTH `renderMode: 'isr'` and `searchParams` is refused) |
631
+ | `ogImage` page export | this route's `og:image` is a satori JSX template, not a file you ship | the build (`static` → a hashed PNG in `dist/assets/og/`) and `voltro start` (`ssr` → a signed on-demand route, which needs `VOLTRO_OG_SECRET`) |
632
+ | `intercept` page export | `from` names the routes a soft navigation may arrive from for this page to render as an overlay above them | the client router; a hard load renders the page standalone regardless |
633
+ | `grpc.manifest.json` (app root) | field numbers are checked in and append-only — a deleted field goes `reserved`, never re-used | `voltro grpc proto` and the gRPC surface wiring, which derive wire identity from it rather than from declaration order |
634
+ | `content/<name>/**` | the files a `*.collection.ts` declares — markdown with frontmatter, or `.json` for a data collection | `getCollection` / `getEntry`, the build's collection artifacts, and the dev server's watcher |
635
+
636
+ The page exports are per-ROUTE and the last two are per-APP, which is the only reason they cannot be spellings: there is nothing to rename.
637
+
613
638
  ## `*.component.ui.tsx` — reads, never writes
614
639
 
615
640
  ```tsx