@voltro/cli 0.28.0 → 0.29.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 (280) hide show
  1. package/CHANGELOG.md +278 -0
  2. package/THIRD-PARTY-NOTICES.md +88 -2
  3. package/dist/{apiBuild-eUM32r1u.js → apiBuild-BESZbTjA.js} +3 -3
  4. package/dist/apiBuild-C8d74T6g.js +2 -0
  5. package/dist/bin.js +3 -3
  6. package/dist/checkCommand-BPAtyWs8.js +2420 -0
  7. package/dist/checkCommand-DBvZvCh1.js +2 -0
  8. package/dist/cliRuntime-Oh517vCV.js +96 -0
  9. package/dist/{commands-CSkrUI1h.js → commands-82BDEktg.js} +6803 -8191
  10. package/dist/dbCommand-CaIVTp-a.js +2 -0
  11. package/dist/{dbCommand-CpYgmSw4.js → dbCommand-bCBbk6tz.js} +2 -2
  12. package/dist/{dev-CEZwJhmb.js → dev-Cg4BKLmi.js} +1801 -1612
  13. package/dist/{dev-DlBWWnJQ.js → dev-DeEJj5T4.js} +1 -1
  14. package/dist/fileConventions-Cof68_BL.js +33 -0
  15. package/dist/{frameworkTableAssembly-BwHU9Euq.js → frameworkTableAssembly-4Db40V23.js} +2 -2
  16. package/dist/frameworkTableAssembly-BTNirAAk.js +2 -0
  17. package/dist/index.js +1 -1
  18. package/dist/{inspect-CUCCzw2I.js → inspect-BMvQpwqy.js} +211 -190
  19. package/dist/inspect-DHeaMZak.js +2 -0
  20. package/dist/{inspectMetrics-BU90mvJN.js → inspectMetrics-DHYygE4s.js} +999 -961
  21. package/dist/{manifestBuild-BnzAxp2O.js → manifestBuild-C4R1EdHn.js} +1 -1
  22. package/dist/manifestBuild-Ck_90gZy.js +2 -0
  23. package/dist/sdkgen-CW5NbYF0.js +582 -0
  24. package/dist/serveCommand-BXl8mhZD.js +1489 -0
  25. package/dist/serveEntry.js +2 -2
  26. package/dist/{start-BGXIf6zT.js → start-CsCIaW4-.js} +282 -269
  27. package/dist/startEntry.js +2 -2
  28. package/package.json +17 -17
  29. package/templates/AGENTS.md +2 -1
  30. package/templates/agent-docs/_index.md +2 -1
  31. package/templates/agent-docs/_manifest.json +10 -1
  32. package/templates/agent-docs/ai.md +135 -0
  33. package/templates/agent-docs/cli.md +124 -6
  34. package/templates/agent-docs/configuration.md +32 -0
  35. package/templates/agent-docs/data.md +282 -0
  36. package/templates/agent-docs/database/migrations.md +47 -0
  37. package/templates/agent-docs/database/misc.md +57 -0
  38. package/templates/agent-docs/database/schema.md +3 -1
  39. package/templates/agent-docs/internationalization.md +161 -1
  40. package/templates/agent-docs/local-first-mobile.md +414 -0
  41. package/templates/agent-docs/routing.md +93 -2
  42. package/templates/agent-docs/schema-driven-ui.md +12 -0
  43. package/templates/agent-docs/templates/apibackends.md +1 -1
  44. package/templates/agent-docs/whats-new.md +162 -181
  45. package/templates/agent-docs/workflows.md +11 -0
  46. package/templates/apps/api-ai/package.json +7 -7
  47. package/templates/apps/api-auth/package.json +8 -8
  48. package/templates/apps/api-backend/package.json +7 -7
  49. package/templates/apps/api-backend-deactivation/package.json +7 -7
  50. package/templates/apps/api-backend-mail/package.json +8 -8
  51. package/templates/apps/api-backend-mariadb/package.json +9 -9
  52. package/templates/apps/api-backend-sqlite/.env.example +19 -0
  53. package/templates/apps/api-backend-sqlite/README.md +38 -0
  54. package/templates/apps/api-backend-sqlite/app.config.ts +35 -0
  55. package/templates/apps/api-backend-sqlite/database/schema.ts +54 -0
  56. package/templates/apps/api-backend-sqlite/mutations/notes.create.mutation.server.ts +19 -0
  57. package/templates/apps/api-backend-sqlite/mutations/notes.create.mutation.ts +37 -0
  58. package/templates/apps/api-backend-sqlite/package.json +30 -0
  59. package/templates/apps/api-backend-sqlite/queries/notes.query.server.ts +14 -0
  60. package/templates/apps/api-backend-sqlite/queries/notes.query.ts +20 -0
  61. package/templates/apps/api-backend-sqlite/template.json +6 -0
  62. package/templates/apps/api-backend-sqlite/tests/notes.create.test.ts +50 -0
  63. package/templates/apps/api-backend-sqlite/tsconfig.json +5 -0
  64. package/templates/apps/api-backend-storage/package.json +8 -8
  65. package/templates/apps/api-cms/README.md +104 -0
  66. package/templates/apps/api-cms/actions/content.get.action.server.ts +27 -0
  67. package/templates/apps/api-cms/actions/content.get.action.ts +19 -0
  68. package/templates/apps/api-cms/actions/content.types.action.server.ts +26 -0
  69. package/templates/apps/api-cms/actions/content.types.action.ts +40 -0
  70. package/templates/apps/api-cms/actions/me.action.server.ts +18 -0
  71. package/templates/apps/api-cms/actions/me.action.ts +16 -0
  72. package/templates/apps/api-cms/app.config.ts +61 -0
  73. package/templates/apps/api-cms/content/blogPost.contentType.ts +39 -0
  74. package/templates/apps/api-cms/content/index.ts +18 -0
  75. package/templates/apps/api-cms/content/page.contentType.ts +24 -0
  76. package/templates/apps/api-cms/database/schema.ts +64 -0
  77. package/templates/apps/api-cms/mutations/content.publish.mutation.server.ts +19 -0
  78. package/templates/apps/api-cms/mutations/content.publish.mutation.ts +15 -0
  79. package/templates/apps/api-cms/mutations/content.saveDraft.mutation.server.ts +36 -0
  80. package/templates/apps/api-cms/mutations/content.saveDraft.mutation.ts +32 -0
  81. package/templates/apps/api-cms/mutations/content.unpublish.mutation.server.ts +19 -0
  82. package/templates/apps/api-cms/mutations/content.unpublish.mutation.ts +11 -0
  83. package/templates/apps/api-cms/package.json +32 -0
  84. package/templates/apps/api-cms/queries/content.list.query.server.ts +0 -0
  85. package/templates/apps/api-cms/queries/content.list.query.ts +27 -0
  86. package/templates/apps/api-cms/template.json +6 -0
  87. package/templates/apps/api-cms/tests/content.descriptors.test.ts +64 -0
  88. package/templates/apps/api-cms/tests/content.write.test.ts +85 -0
  89. package/templates/apps/api-cms/tsconfig.json +5 -0
  90. package/templates/apps/api-data-advanced/package.json +8 -8
  91. package/templates/apps/api-durable/package.json +8 -8
  92. package/templates/apps/api-feature-flags/package.json +9 -9
  93. package/templates/apps/api-governance/package.json +8 -8
  94. package/templates/apps/api-kv/package.json +8 -8
  95. package/templates/apps/api-moderation/package.json +8 -8
  96. package/templates/apps/api-observability/package.json +8 -8
  97. package/templates/apps/api-ratelimit/package.json +8 -8
  98. package/templates/apps/api-rbac/package.json +8 -8
  99. package/templates/apps/api-rest/package.json +7 -7
  100. package/templates/apps/api-saas/package.json +11 -11
  101. package/templates/apps/api-saas-starter/README.md +103 -0
  102. package/templates/apps/api-saas-starter/actions/me.action.server.ts +18 -0
  103. package/templates/apps/api-saas-starter/actions/me.action.ts +20 -0
  104. package/templates/apps/api-saas-starter/app.config.ts +87 -0
  105. package/templates/apps/api-saas-starter/database/schema.ts +57 -0
  106. package/templates/apps/api-saas-starter/mutations/invites.create.mutation.server.ts +26 -0
  107. package/templates/apps/api-saas-starter/mutations/invites.create.mutation.ts +18 -0
  108. package/templates/apps/api-saas-starter/mutations/projects.create.mutation.server.ts +29 -0
  109. package/templates/apps/api-saas-starter/mutations/projects.create.mutation.ts +18 -0
  110. package/templates/apps/api-saas-starter/package.json +32 -0
  111. package/templates/apps/api-saas-starter/queries/invites.list.query.server.ts +14 -0
  112. package/templates/apps/api-saas-starter/queries/invites.list.query.ts +17 -0
  113. package/templates/apps/api-saas-starter/queries/projects.list.query.server.ts +14 -0
  114. package/templates/apps/api-saas-starter/queries/projects.list.query.ts +18 -0
  115. package/templates/apps/api-saas-starter/template.json +6 -0
  116. package/templates/apps/api-saas-starter/tests/projects.create.test.ts +62 -0
  117. package/templates/apps/api-saas-starter/tests/session.test.ts +33 -0
  118. package/templates/apps/api-saas-starter/tsconfig.json +5 -0
  119. package/templates/apps/api-search/package.json +8 -8
  120. package/templates/apps/api-status/README.md +88 -0
  121. package/templates/apps/api-status/app.config.ts +36 -0
  122. package/templates/apps/api-status/authz.ts +33 -0
  123. package/templates/apps/api-status/database/schema.ts +70 -0
  124. package/templates/apps/api-status/mutations/components.create.mutation.server.ts +19 -0
  125. package/templates/apps/api-status/mutations/components.create.mutation.ts +16 -0
  126. package/templates/apps/api-status/mutations/incidents.create.mutation.server.ts +25 -0
  127. package/templates/apps/api-status/mutations/incidents.create.mutation.ts +22 -0
  128. package/templates/apps/api-status/mutations/incidents.resolve.mutation.server.ts +24 -0
  129. package/templates/apps/api-status/mutations/incidents.resolve.mutation.ts +19 -0
  130. package/templates/apps/api-status/mutations/incidents.update.mutation.server.ts +26 -0
  131. package/templates/apps/api-status/mutations/incidents.update.mutation.ts +21 -0
  132. package/templates/apps/api-status/package.json +30 -0
  133. package/templates/apps/api-status/queries/components.list.query.server.ts +14 -0
  134. package/templates/apps/api-status/queries/components.list.query.ts +13 -0
  135. package/templates/apps/api-status/queries/incidents.live.query.server.ts +14 -0
  136. package/templates/apps/api-status/queries/incidents.live.query.ts +20 -0
  137. package/templates/apps/api-status/queries/updates.list.query.server.ts +14 -0
  138. package/templates/apps/api-status/queries/updates.list.query.ts +17 -0
  139. package/templates/apps/api-status/template.json +6 -0
  140. package/templates/apps/api-status/tests/status.test.ts +70 -0
  141. package/templates/apps/api-status/tsconfig.json +5 -0
  142. package/templates/apps/api-versioning/package.json +8 -8
  143. package/templates/apps/api-webhooks/package.json +9 -9
  144. package/templates/apps/changelog/package.json +6 -6
  145. package/templates/apps/edge-functions/package.json +2 -2
  146. package/templates/apps/frontend-admin/package.json +8 -8
  147. package/templates/apps/frontend-app/package.json +8 -8
  148. package/templates/apps/frontend-auth/README.md +78 -0
  149. package/templates/apps/frontend-auth/app.config.ts +34 -0
  150. package/templates/apps/frontend-auth/package.json +32 -0
  151. package/templates/apps/frontend-auth/src/components/AuthShell.tsx +35 -0
  152. package/templates/apps/frontend-auth/src/components/PasswordStrength.tsx +33 -0
  153. package/templates/apps/frontend-auth/src/config.ts +11 -0
  154. package/templates/apps/frontend-auth/src/globals.css +105 -0
  155. package/templates/apps/frontend-auth/src/globals.d.ts +6 -0
  156. package/templates/apps/frontend-auth/src/lib/auth.ts +34 -0
  157. package/templates/apps/frontend-auth/src/lib/redirect.test.ts +24 -0
  158. package/templates/apps/frontend-auth/src/lib/redirect.ts +29 -0
  159. package/templates/apps/frontend-auth/src/locales/de.ts +66 -0
  160. package/templates/apps/frontend-auth/src/locales/en.ts +76 -0
  161. package/templates/apps/frontend-auth/src/locales/index.ts +14 -0
  162. package/templates/apps/frontend-auth/src/pages/forgot/page.tsx +51 -0
  163. package/templates/apps/frontend-auth/src/pages/layout.tsx +12 -0
  164. package/templates/apps/frontend-auth/src/pages/login/page.test.tsx +53 -0
  165. package/templates/apps/frontend-auth/src/pages/login/page.tsx +67 -0
  166. package/templates/apps/frontend-auth/src/pages/logout/page.tsx +28 -0
  167. package/templates/apps/frontend-auth/src/pages/magic/page.tsx +51 -0
  168. package/templates/apps/frontend-auth/src/pages/page.tsx +26 -0
  169. package/templates/apps/frontend-auth/src/pages/reset/page.test.tsx +51 -0
  170. package/templates/apps/frontend-auth/src/pages/reset/page.tsx +63 -0
  171. package/templates/apps/frontend-auth/src/pages/signup/page.tsx +60 -0
  172. package/templates/apps/frontend-auth/src/pages/verify/page.tsx +50 -0
  173. package/templates/apps/frontend-auth/template.json +6 -0
  174. package/templates/apps/frontend-auth/tsconfig.json +5 -0
  175. package/templates/apps/frontend-blank/package.json +7 -7
  176. package/templates/apps/frontend-cms/README.md +47 -0
  177. package/templates/apps/frontend-cms/app.config.ts +37 -0
  178. package/templates/apps/frontend-cms/package.json +33 -0
  179. package/templates/apps/frontend-cms/src/config.ts +8 -0
  180. package/templates/apps/frontend-cms/src/globals.css +105 -0
  181. package/templates/apps/frontend-cms/src/globals.d.ts +6 -0
  182. package/templates/apps/frontend-cms/src/lib/api.ts +58 -0
  183. package/templates/apps/frontend-cms/src/locales/de.ts +50 -0
  184. package/templates/apps/frontend-cms/src/locales/en.ts +55 -0
  185. package/templates/apps/frontend-cms/src/locales/index.ts +14 -0
  186. package/templates/apps/frontend-cms/src/pages/(app)/error.tsx +18 -0
  187. package/templates/apps/frontend-cms/src/pages/(app)/layout.test.tsx +31 -0
  188. package/templates/apps/frontend-cms/src/pages/(app)/layout.tsx +62 -0
  189. package/templates/apps/frontend-cms/src/pages/(app)/not-found.tsx +14 -0
  190. package/templates/apps/frontend-cms/src/pages/(app)/page.test.tsx +110 -0
  191. package/templates/apps/frontend-cms/src/pages/(app)/page.tsx +159 -0
  192. package/templates/apps/frontend-cms/src/pages/layout.tsx +12 -0
  193. package/templates/apps/frontend-cms/src/pages/login/page.test.tsx +58 -0
  194. package/templates/apps/frontend-cms/src/pages/login/page.tsx +93 -0
  195. package/templates/apps/frontend-cms/template.json +6 -0
  196. package/templates/apps/frontend-cms/tsconfig.json +5 -0
  197. package/templates/apps/frontend-contact/package.json +7 -7
  198. package/templates/apps/frontend-dashboard/package.json +7 -7
  199. package/templates/apps/frontend-docs/package.json +7 -7
  200. package/templates/apps/frontend-i18n/package.json +6 -6
  201. package/templates/apps/frontend-landing/package.json +7 -7
  202. package/templates/apps/frontend-portal/README.md +71 -0
  203. package/templates/apps/frontend-portal/app.config.ts +37 -0
  204. package/templates/apps/frontend-portal/package.json +32 -0
  205. package/templates/apps/frontend-portal/src/config.ts +8 -0
  206. package/templates/apps/frontend-portal/src/globals.css +93 -0
  207. package/templates/apps/frontend-portal/src/globals.d.ts +6 -0
  208. package/templates/apps/frontend-portal/src/lib/api.ts +62 -0
  209. package/templates/apps/frontend-portal/src/locales/de.ts +86 -0
  210. package/templates/apps/frontend-portal/src/locales/en.ts +93 -0
  211. package/templates/apps/frontend-portal/src/locales/index.ts +14 -0
  212. package/templates/apps/frontend-portal/src/pages/(portal)/api-keys/page.test.tsx +71 -0
  213. package/templates/apps/frontend-portal/src/pages/(portal)/api-keys/page.tsx +124 -0
  214. package/templates/apps/frontend-portal/src/pages/(portal)/billing/page.test.tsx +86 -0
  215. package/templates/apps/frontend-portal/src/pages/(portal)/billing/page.tsx +97 -0
  216. package/templates/apps/frontend-portal/src/pages/(portal)/error.tsx +19 -0
  217. package/templates/apps/frontend-portal/src/pages/(portal)/invoices/page.tsx +45 -0
  218. package/templates/apps/frontend-portal/src/pages/(portal)/layout.test.tsx +31 -0
  219. package/templates/apps/frontend-portal/src/pages/(portal)/layout.tsx +63 -0
  220. package/templates/apps/frontend-portal/src/pages/(portal)/not-found.tsx +15 -0
  221. package/templates/apps/frontend-portal/src/pages/(portal)/page.tsx +39 -0
  222. package/templates/apps/frontend-portal/src/pages/(portal)/profile/page.tsx +79 -0
  223. package/templates/apps/frontend-portal/src/pages/layout.tsx +12 -0
  224. package/templates/apps/frontend-portal/src/pages/login/page.test.tsx +58 -0
  225. package/templates/apps/frontend-portal/src/pages/login/page.tsx +96 -0
  226. package/templates/apps/frontend-portal/template.json +6 -0
  227. package/templates/apps/frontend-portal/tsconfig.json +5 -0
  228. package/templates/apps/frontend-saas/README.md +73 -0
  229. package/templates/apps/frontend-saas/app.config.ts +46 -0
  230. package/templates/apps/frontend-saas/package.json +32 -0
  231. package/templates/apps/frontend-saas/src/config.ts +8 -0
  232. package/templates/apps/frontend-saas/src/globals.css +85 -0
  233. package/templates/apps/frontend-saas/src/globals.d.ts +6 -0
  234. package/templates/apps/frontend-saas/src/lib/api.ts +47 -0
  235. package/templates/apps/frontend-saas/src/locales/de.ts +72 -0
  236. package/templates/apps/frontend-saas/src/locales/en.ts +81 -0
  237. package/templates/apps/frontend-saas/src/locales/index.ts +14 -0
  238. package/templates/apps/frontend-saas/src/pages/(marketing)/layout.tsx +30 -0
  239. package/templates/apps/frontend-saas/src/pages/(marketing)/login/page.test.tsx +58 -0
  240. package/templates/apps/frontend-saas/src/pages/(marketing)/login/page.tsx +96 -0
  241. package/templates/apps/frontend-saas/src/pages/(marketing)/page.tsx +27 -0
  242. package/templates/apps/frontend-saas/src/pages/dashboard/billing/page.tsx +72 -0
  243. package/templates/apps/frontend-saas/src/pages/dashboard/error.tsx +20 -0
  244. package/templates/apps/frontend-saas/src/pages/dashboard/layout.test.tsx +32 -0
  245. package/templates/apps/frontend-saas/src/pages/dashboard/layout.tsx +69 -0
  246. package/templates/apps/frontend-saas/src/pages/dashboard/not-found.tsx +17 -0
  247. package/templates/apps/frontend-saas/src/pages/dashboard/page.test.tsx +113 -0
  248. package/templates/apps/frontend-saas/src/pages/dashboard/page.tsx +76 -0
  249. package/templates/apps/frontend-saas/src/pages/dashboard/team/page.tsx +73 -0
  250. package/templates/apps/frontend-saas/src/pages/layout.tsx +12 -0
  251. package/templates/apps/frontend-saas/template.json +6 -0
  252. package/templates/apps/frontend-saas/tsconfig.json +5 -0
  253. package/templates/apps/frontend-spa/package.json +7 -7
  254. package/templates/apps/frontend-ssr/package.json +7 -7
  255. package/templates/apps/frontend-ssr-api/package.json +8 -8
  256. package/templates/apps/frontend-static-blog/package.json +6 -6
  257. package/templates/apps/frontend-status/README.md +51 -0
  258. package/templates/apps/frontend-status/app.config.ts +39 -0
  259. package/templates/apps/frontend-status/package.json +32 -0
  260. package/templates/apps/frontend-status/src/config.ts +7 -0
  261. package/templates/apps/frontend-status/src/globals.css +70 -0
  262. package/templates/apps/frontend-status/src/globals.d.ts +6 -0
  263. package/templates/apps/frontend-status/src/lib/status.ts +81 -0
  264. package/templates/apps/frontend-status/src/locales/de.ts +41 -0
  265. package/templates/apps/frontend-status/src/locales/en.ts +45 -0
  266. package/templates/apps/frontend-status/src/locales/index.ts +13 -0
  267. package/templates/apps/frontend-status/src/pages/layout.tsx +27 -0
  268. package/templates/apps/frontend-status/src/pages/page.test.tsx +123 -0
  269. package/templates/apps/frontend-status/src/pages/page.tsx +136 -0
  270. package/templates/apps/frontend-status/template.json +6 -0
  271. package/templates/apps/frontend-status/tsconfig.json +11 -0
  272. package/templates/baselines/compose/docker-compose.prod.yml +15 -0
  273. package/templates/baselines/compose-mariadb/docker-compose.prod.yml +15 -0
  274. package/dist/apiBuild-DgBS9ayv.js +0 -2
  275. package/dist/dbCommand-DvguqlzF.js +0 -2
  276. package/dist/fileConventions-3bffWssN.js +0 -30
  277. package/dist/frameworkTableAssembly-lrjZtk0G.js +0 -2
  278. package/dist/inspect-gt8bq-Tz.js +0 -2
  279. package/dist/manifestBuild-ifczArzr.js +0 -2
  280. package/dist/serveCommand-ZTn-dPFa.js +0 -1425
@@ -1024,6 +1024,67 @@ const message = matchError(err, {
1024
1024
  }, () => 'Something went wrong')
1025
1025
  ```
1026
1026
 
1027
+ ## Cross-table business rules — `.rule()`
1028
+
1029
+ A [typed error](#typed-errors) is declared on ONE mutation. A **rule** is declared on a TABLE: an invariant the runtime enforces on EVERY mutation that writes that table, no matter which one did the write. Declare it in the schema with `.rule(name, predicate)`:
1030
+
1031
+ ```ts
1032
+ import { table, id, integer, eq } from '@voltro/database'
1033
+ import { database } from '../database/schema'
1034
+
1035
+ export const invoices = table('invoices', {
1036
+ id: id(),
1037
+ total: integer(),
1038
+ }).rule(
1039
+ 'totalMatchesLineItems',
1040
+ async (row, { store }) => {
1041
+ const items = await store.query(
1042
+ database.lineItems.where(eq('invoiceId', row.id)).descriptor,
1043
+ )
1044
+ const sum = items.reduce((acc, l) => acc + Number(l.amount), 0)
1045
+ return sum === row.total || { params: { computed: sum, declared: row.total } }
1046
+ },
1047
+ )
1048
+ ```
1049
+
1050
+ The predicate receives the **post-write row** and a `context` whose `store` is the SAME transactional store the mutation wrote through — so a cross-table read shares the write's MVCC snapshot and cannot race it. It runs INSIDE the mutation transaction, after the write and before commit, and it is dialect-neutral (the same predicate is correct on all four dialects — no per-dialect code). Return `true` (or nothing) when the invariant holds; return `false` or a `RuleViolationDetail` (`{ params?, field?, message? }`) to signal a violation for THIS row.
1051
+
1052
+ Unlike [`.check()`](/docs/database/columns#db-level-checks) — a single-row SQL `CHECK` the DATABASE enforces as DDL — a rule is a PREDICATE the runtime evaluates, so it can read OTHER tables. Use `.check()` when the database itself must guarantee a single-row constraint; reach for `.rule()` for a cross-table invariant (an invoice total matching its line items, a booking not exceeding a resource's capacity).
1053
+
1054
+ ### The violation is a typed error — automatically
1055
+
1056
+ A violated `error`-severity rule rolls the whole mutation back and fails with the typed, wire-preserved **`BusinessRuleViolation`**. You do **not** declare it on the mutation's `error:` — the runtime auto-merges it into every mutation's error union at the wire boundary, exactly like `ScopeError`. A rule declared on a SCHEMA table can fail ANY mutation that writes that table, so no single descriptor could know to declare it; the auto-merge is what keeps the violation a typed error the client decodes by `_tag` rather than an untyped defect.
1057
+
1058
+ Match it on the client the same way as any [typed error](#matching-typed-errors-on-the-client):
1059
+
1060
+ ```ts
1061
+ import { errorTag } from '@voltro/protocol'
1062
+
1063
+ try {
1064
+ await createInvoice(input)
1065
+ } catch (err) {
1066
+ if (errorTag(err) === 'BusinessRuleViolation') {
1067
+ // err.rule — the rule name; err.params — the offending values; err.field — the pointer
1068
+ }
1069
+ }
1070
+ ```
1071
+
1072
+ `BusinessRuleViolation` carries `rule` (the declared name), optional `params` (i18n params for the offending values), an optional `field` pointer, and `severity` (always `'error'` on the wire — a warning-severity rule never reaches the client as an error).
1073
+
1074
+ ### `severity: 'warning'` — log without blocking
1075
+
1076
+ ```ts
1077
+ export const invoices = table('invoices', { id: id(), total: integer() }).rule(
1078
+ 'totalMatchesLineItems',
1079
+ async (row, { store }) => true,
1080
+ { severity: 'warning' },
1081
+ )
1082
+ ```
1083
+
1084
+ A `warning`-severity rule LOGS and audits the violation but lets the write commit — useful while backfilling data that does not yet satisfy a newly-added invariant. The default is `'error'` (roll back).
1085
+
1086
+ Rules run on `voltro dev` and `voltro serve` through the same mutation runner, so the two boot paths cannot disagree about whether a rule fires. Inserts and updates on your own tables are re-validated; a delete is not (there is no post-write row to check).
1087
+
1027
1088
  ## `internal: true` — off the wire entirely
1028
1089
 
1029
1090
  Every discovered `*.mutation.ts` / `*.query.ts` / `*.action.ts` / `*.stream.ts` is
@@ -1472,6 +1533,88 @@ A subscription that was live and is then skipped goes idle — it does **not**
1472
1533
  keep serving the snapshot it still holds. Otherwise `skip: !open` would show
1473
1534
  last time's data the moment a dialog reopens.
1474
1535
 
1536
+ ## SSR preload — first paint with data
1537
+
1538
+ By default a `useSubscription` on an SSR page flashes its empty/loading state on
1539
+ mount, THEN opens the WebSocket and fetches the first snapshot — even though the
1540
+ server could have fetched that value during the render. **`usePreloadedSubscription`**
1541
+ closes that gap: it reads its first value from the SSR hydration payload, renders
1542
+ real data on the first paint, then upgrades to the live stream the instant its
1543
+ first snapshot lands.
1544
+
1545
+ Two things wire it up: the hook, and a `preload` export on the page.
1546
+
1547
+ ```tsx
1548
+ import { usePreloadedSubscription } from '@voltro/client'
1549
+
1550
+ export const preload = ['notes.list']
1551
+
1552
+ export default function NotesPage() {
1553
+ const { data } = usePreloadedSubscription<Note[]>('app', 'notes.list')
1554
+ // `data` is present on the first paint — no loading flash
1555
+ return <NotesTable notes={data} />
1556
+ }
1557
+ ```
1558
+
1559
+ `export const preload` lists the subscriptions the page's tree needs at first
1560
+ paint. During the SSR render (`voltro dev` and `voltro start`) the framework runs
1561
+ each one server-side — the SAME `ctx.query(tag, input)` a loader receives — and
1562
+ seeds the result into the hydration payload. The client's
1563
+ `usePreloadedSubscription` finds the seed by the SAME cache key `useSubscription`
1564
+ uses, so the server markup and the client hydration render read an identical
1565
+ value — there is no hydration mismatch. The live subscription still opens and
1566
+ takes over; the seed is only the first value, never the source of truth.
1567
+
1568
+ ### `preload` entries
1569
+
1570
+ An entry is either a bare tag or a tag plus an `input` derived from the route
1571
+ params:
1572
+
1573
+ ```tsx
1574
+ export const preload = [
1575
+ 'teams.list', // no input
1576
+ { tag: 'project.detail', input: (params) => ({ id: params.id }) },
1577
+ ]
1578
+ ```
1579
+
1580
+ The `input` you derive here MUST match the `input` you pass the hook — both
1581
+ address the same cache entry:
1582
+
1583
+ ```tsx
1584
+ export default function ProjectPage({ params }: { params: { id: string } }) {
1585
+ const { data } = usePreloadedSubscription('app', 'project.detail', { id: params.id })
1586
+ return <ProjectView project={data} />
1587
+ }
1588
+ ```
1589
+
1590
+ ### Falls back to `useSubscription`
1591
+
1592
+ When no seed exists for the key — a client-side SPA navigation the server never
1593
+ rendered, or the static prerender (which has no live api origin) —
1594
+ `usePreloadedSubscription` behaves EXACTLY like `useSubscription`: it loads until
1595
+ the stream answers. So it is always safe to reach for; the preload is a
1596
+ first-paint optimization, never a correctness dependency. A failed preload is
1597
+ likewise non-fatal — the live subscription still delivers the value on the
1598
+ client, the only loss is the first-paint seed.
1599
+
1600
+ ### Seeding by hand
1601
+
1602
+ `export const preload` is sugar over an explicit seed. When a loader ALREADY has
1603
+ the value — you fetched it for the `<title>`, a breadcrumb, or the row name — seed
1604
+ it directly with `seedPreloadedSubscription` (server-side only) instead of
1605
+ fetching it a second time:
1606
+
1607
+ ```ts
1608
+ import { seedPreloadedSubscription } from '@voltro/client'
1609
+
1610
+ // inside a loader / layout loader, server-side
1611
+ const notes = await ctx.query('notes.list', {})
1612
+ seedPreloadedSubscription('app', 'notes.list', {}, notes)
1613
+ ```
1614
+
1615
+ Calling it outside a server render throws — on the client the live subscription
1616
+ already provides the value, so a client-side seed would be meaningless.
1617
+
1475
1618
  ## Streams Are Different
1476
1619
 
1477
1620
  For non-database or transient element feeds, use [streams](/docs/data/streams), not subscriptions:
@@ -2697,6 +2840,145 @@ export default defineAggregate({
2697
2840
 
2698
2841
  When no analytics sink is configured the framework provides the no-op sink: the read methods fail with `AnalyticsCapabilityNotSupported({ provider: 'noop' })`, which surfaces as a refresh error (last-good rows are preserved). Provider-specific queries beyond the four-method contract (raw SQL, HyperLogLog) live outside the sink — there is no raw-client escape hatch; query the provider with your own client instance inside the build function where you need them.
2699
2842
 
2843
+ ## Standing IVM siblings — expectations, cost budgets, experiments
2844
+
2845
+ Three more primitives are built on the same engine as `defineAggregate({ incremental })` — they reduce a table to a value maintained incrementally from `store.onChange` CDC deltas (O(1) per write, no re-query). Unlike an aggregate you never `read()` a materialised table; you **observe a live signal** through a framework registry and a `useX(def)` handler-sugar. Each is discovered by its own file convention and wired into both `voltro dev` and `voltro serve`.
2846
+
2847
+ | Primitive | File | Reduces a table to | Observe with |
2848
+ |---|---|---|---|
2849
+ | `defineExpectation` | `*.expectation.ts` | a `holding`/`violated` data-quality signal | `useExpectation` / `ExpectationRegistry` |
2850
+ | `defineCostBudget` | `*.budget.ts` | per-tenant compute-cost attribution + `ok`/`warn`/`exceeded` budgets | `useCostBudget` / `CostRegistry` |
2851
+ | `defineExperiment` | `*.experiment.ts` | per-variant metric + lift (live A/B / holdout) | `useExperiment` / `ExperimentRegistry` |
2852
+
2853
+ They are **soft, observability-grade signals** — none of them ever blocks a write. Rejecting a write is a business rule's job (`table().validate(Schema)`); running an agent/workflow on a change is [`*.reaction.tsx`](/docs/data/reactions). These three only *watch* and *report*.
2854
+
2855
+ ### Data-quality expectations (`*.expectation.ts`)
2856
+
2857
+ A `defineExpectation` is a standing data-quality contract over a table. Where dbt tests / Great Expectations run in BATCH and catch bad data hours later, an expectation re-evaluates on every write and tips `holding`↔`violated` the instant an invariant breaks — tracing the violation to the write that caused it (the CDC event's `traceId` / `subjectId` / `procedure`).
2858
+
2859
+ ```ts
2860
+ // apps/api/expectations/orderFreshness.expectation.ts
2861
+ import { defineExpectation } from '@voltro/runtime'
2862
+
2863
+ export default defineExpectation({
2864
+ name: 'orders-fresh',
2865
+ on: { table: 'orders' }, // its CDC deltas drive re-evaluation
2866
+ invariant: { kind: 'freshness', column: 'createdAt', maxAgeMs: 10 * 60_000 },
2867
+ severity: 'critical', // 'info' | 'warn' (default) | 'critical' — alerting priority only
2868
+ })
2869
+ ```
2870
+
2871
+ The default export must be the return value of `defineExpectation({...})` — the cli discovers `*.expectation.ts` files by that branded shape.
2872
+
2873
+ Four invariant kinds, each reducing the table to one incrementally-maintained metric:
2874
+
2875
+ ```ts
2876
+ { kind: 'freshness', column: 'createdAt', maxAgeMs: 600_000 } // newest row no older than X (re-checked on a clock tick too)
2877
+ { kind: 'nullRate', column: 'email', maxRate: 0.01 } // ≤ 1% of rows null in `column`
2878
+ { kind: 'rowCount', min: 1, max: 100_000 } // COUNT(*) within [min, max]
2879
+ { kind: 'valueBounds', column: 'price', min: 0, max: 1000, maxViolationRate: 0.05 } // ≤ 5% of rows out of [min, max]
2880
+ ```
2881
+
2882
+ An optional `on.where` predicate narrows the population the invariant is asserted over — it runs server-side per row and never leaves the server, so it can be any predicate:
2883
+
2884
+ ```ts
2885
+ on: { table: 'orders', where: (row) => (row['status'] as string) === 'paid' }
2886
+ ```
2887
+
2888
+ Observe one expectation from a handler with `useExpectation`, or read the whole surface off `ExpectationRegistry`:
2889
+
2890
+ ```ts
2891
+ import { Effect } from 'effect'
2892
+ import { useExpectation } from '@voltro/runtime'
2893
+ import ordersFresh from '../expectations/orderFreshness.expectation'
2894
+
2895
+ export default (input, _ctx) =>
2896
+ Effect.gen(function* () {
2897
+ const state = yield* useExpectation(ordersFresh)
2898
+ // state: { status: 'holding' | 'violated' | 'unknown', metric, threshold,
2899
+ // since, lastEvaluatedAt, lastCause, ... } | null (null = not registered)
2900
+ return { degraded: state?.status === 'violated' }
2901
+ })
2902
+ ```
2903
+
2904
+ `ExpectationRegistry` exposes `snapshot()` (every expectation's state — the inspect feed), `get(name)`, `violations()` (the alerting view), and `subscribe(listener)` for `violated`/`recovered` transitions. A transition carries the causing write's provenance; a `freshness` SLA aging out with no write reports `cause: null` — the honest "no write caused this; the ABSENCE of writes did".
2905
+
2906
+ ### Cost budgets (`*.budget.ts`)
2907
+
2908
+ A `defineCostBudget` is a reactive-FinOps primitive: per-tenant / per-subscription compute-cost attribution plus a budget every tenant is held to independently. It is to reactive compute what AI's `requireAiBudget` is to USD spend — a standing per-tenant ceiling that crosses `ok`→`warn`→`exceeded` and recovers on a window rollover.
2909
+
2910
+ ```ts
2911
+ // apps/api/budgets/tenantCompute.budget.ts
2912
+ import { defineCostBudget } from '@voltro/runtime'
2913
+
2914
+ export default defineCostBudget({
2915
+ name: 'tenant-compute',
2916
+ limit: 100_000, // the per-tenant ceiling, in the budget's unit
2917
+ unit: 'recompute', // which cost unit to meter; omit ⇒ the tenant's TOTAL across every unit
2918
+ warnAt: 0.8, // fraction of `limit` at which it goes 'warn' (default 0.8; set 1 to disable)
2919
+ window: '24h', // tumbling window — the counter resets each boundary; omit ⇒ cumulative since boot
2920
+ severity: 'warn', // 'info' | 'warn' (default) | 'critical'
2921
+ })
2922
+ ```
2923
+
2924
+ Observe one budget for one tenant with `useCostBudget(def, tenantId)`:
2925
+
2926
+ ```ts
2927
+ import { Effect } from 'effect'
2928
+ import { useCostBudget } from '@voltro/runtime'
2929
+ import tenantCompute from '../budgets/tenantCompute.budget'
2930
+
2931
+ export default (input, ctx) =>
2932
+ Effect.gen(function* () {
2933
+ const state = yield* useCostBudget(tenantCompute, ctx.tenantId)
2934
+ // state: { status: 'ok' | 'warn' | 'exceeded', spent, limit, warnThreshold, ... } | null
2935
+ return { overBudget: state?.status === 'exceeded' }
2936
+ })
2937
+ ```
2938
+
2939
+ `CostRegistry` exposes `attribution()` (per-tenant chargeback/showback rows — `total` + `byUnit` + `bySubscription`), `tenant(tenantId)`, `budgets()`, `budget(name, tenantId)`, `breaches()` (the alerting view), and `subscribe(listener)` for threshold crossings.
2940
+
2941
+ > **Reactive recomputes now feed attribution automatically.** Declaring any `*.budget.ts` wires the dispatcher's `recordCost` tap in both boot paths: every time a source-row change re-runs an affected subscription and pushes it a delta, one `{ unit: 'recompute', amount: 1 }` cost event is attributed to that subscription's tenant (and traceId). So a tenant's `total`/`spent` populates from real reactive work — a `unit: 'recompute'` budget crosses `ok`→`warn`→`exceeded` as deliveries accrue, and recovers on a window rollover. The emission is **per delivered recompute** — one event per subscription the change fanned out to, on both the row-set and computed-query delivery paths. An app that declares NO cost budget wires no tap and allocates nothing on the reactive hot path.
2942
+
2943
+ ### Online experiments (`*.experiment.ts`)
2944
+
2945
+ A `defineExperiment` is a live A/B / holdout experiment expressed as IVM aggregates. Assignment is a deterministic salted hash (subject → variant, no stored assignment table, reproducible on the client); the success metric is maintained PER VARIANT from the watched table's CDC — so lift vs a baseline is real-time, with no batch pipeline. It differs from `plugin-flags` (which GATES a code path) — an experiment MEASURES the outcome.
2946
+
2947
+ ```ts
2948
+ // apps/api/experiments/checkoutColor.experiment.ts
2949
+ import { defineExperiment } from '@voltro/runtime'
2950
+
2951
+ export default defineExperiment({
2952
+ name: 'checkout-button-color',
2953
+ on: { table: 'orders' }, // its CDC deltas drive the live recompute
2954
+ subject: 'userId', // stable per-subject bucketing (a column name, or a (row) => key fn)
2955
+ variants: [{ name: 'control' }, { name: 'green', weight: 1 }], // ≥ 2 arms; `weight` skews the split (default 1)
2956
+ holdout: 0.1, // 10% held out entirely, for a clean untouched baseline
2957
+ metric: { kind: 'conversionRate', column: 'completed' }, // per-variant success metric
2958
+ baseline: 'control', // which variant lift is measured against (default: the first)
2959
+ })
2960
+ ```
2961
+
2962
+ Metric kinds: `{ kind: 'count' }`, `{ kind: 'sum', column }`, `{ kind: 'avg', column }`, and `{ kind: 'conversionRate', column, equals? }` (converted iff `row[column]` is truthy, or `=== equals` when given). `avg` / `conversionRate` are per-subject rates that a lift reads honestly; `count` / `sum` reflect exposure too, so their cross-variant comparison only means "more/less total".
2963
+
2964
+ Observe the live result with `useExperiment`, or off `ExperimentRegistry`:
2965
+
2966
+ ```ts
2967
+ import { Effect } from 'effect'
2968
+ import { useExperiment } from '@voltro/runtime'
2969
+ import checkoutColor from '../experiments/checkoutColor.experiment'
2970
+
2971
+ export default (input, _ctx) =>
2972
+ Effect.gen(function* () {
2973
+ const result = yield* useExperiment(checkoutColor)
2974
+ // result: { totalExposure, baseline, variants: [{ variant, isBaseline, isHoldout,
2975
+ // exposure, metric, lift, diff }, ...], lastUpdatedAt, ... } | null
2976
+ return { arms: result?.variants ?? [] }
2977
+ })
2978
+ ```
2979
+
2980
+ Each variant row carries its `exposure` (sample size), the maintained `metric`, and — for non-baseline arms — `lift` (`(metric − baseMetric) / baseMetric`) and `diff` (absolute). `ExperimentRegistry` exposes `snapshot()`, `get(name)`, and `subscribe(listener)` for the live recompute stream that a results view redraws from. The same-subject-same-variant assignment is a pure function (`assignVariant(def, subject)`) a client can reproduce.
2981
+
2700
2982
 
2701
2983
 
2702
2984
  ---
@@ -658,6 +658,53 @@ export const users = table('users', {
658
658
 
659
659
  The cleanup is a separate PR after the migration has rolled out. Don't mix the rollout PR with the cleanup PR — the markers ARE the migration's audit trail until it's applied everywhere.
660
660
 
661
+ ## `voltro evolve` — the schema-evolution copilot
662
+
663
+ Adding the `.renamedFrom()` marker by hand is easy for one column. The hard part of changing an EXISTING schema is the *rest*: which handlers read or write that column, whether a rename is safe or needs a backfill, and getting the annotation onto the entity AND every call site without missing one. `voltro evolve` does that reconnaissance and proposes a reviewable plan.
664
+
665
+ Given a change, it reads the **observed graph** (`app.graph.observed.generated.json`, recorded by `voltro dev` — see [`voltro check`](/docs/cli/inspect)) plus the declared manifest, enumerates the real blast radius, and prints three things: a proposed **codemod**, a branch-verified **backfill** plan, and a `voltro check` **verify** step.
666
+
667
+ ```bash
668
+ voltro evolve rename-column notes.title --to heading # dry-run: plan + codemod preview
669
+ voltro evolve rename-column notes.title --to heading --write # apply the codemod
670
+ voltro evolve retype-column orders.total --to numeric # reshape → manual codemod + steps
671
+ voltro evolve split-column users.name --into firstName,lastName
672
+ voltro evolve rename-table note --to notes
673
+ voltro evolve drop-column notes.legacy
674
+ voltro evolve rename-column notes.title --to heading --json # for CI / an agent loop
675
+ ```
676
+
677
+ It is **dry-run by default** (mirrors `voltro generate`); `--write` applies the codemod through the same `runCodemods` toolkit as `voltro update`. `--json` emits the whole plan for CI or an agent.
678
+
679
+ ### The blast radius is observed, not guessed
680
+
681
+ ```text
682
+ change: rename-column notes.title → heading
683
+
684
+ blast radius (observed + declared):
685
+ query(notes.list) — observed read
686
+ mutation(notes.update) — observed update
687
+ ⚠ 1 declared but NEVER exercised — column use UNKNOWN: notes.archive
688
+
689
+ codemod:
690
+ entity: rename 'notes.title' → 'heading' and add .renamedFrom('title') (catalog rename — data preserved)
691
+ annotate 2 handler site(s) that reference the old field
692
+
693
+ backfill (dry-runs on branch notes-pr-0):
694
+ [safe] catalog RENAME — no data movement
695
+
696
+ verify: voltro check
697
+ ```
698
+
699
+ A handler that a `voltro dev` session or a test actually ran is reported with what it did (`observed read` / `observed update`). A handler that is *declared* to touch the table but was **never exercised** is flagged `UNKNOWN` and listed separately — it is never folded into "safe", because no run proves what it does with the column. That honesty is the point: the tool tells you exactly where it cannot vouch for the change.
700
+
701
+ ### What the codemod does per kind
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.
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
+
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.
707
+
661
708
 
662
709
 
663
710
  ---
@@ -296,6 +296,63 @@ who may receive the plaintext — a private note you decrypt *for its owner* is
296
296
  valid case, so treating "encrypted" as "never to a client" would be wrong. State
297
297
  the exposure policy explicitly.
298
298
 
299
+ ## `.readableBy(...scopes)` — visible only to scoped subjects
300
+
301
+ The **graded middle of the same wire-exposure axis** as `.serverOnly()` — not a
302
+ fourth axis. A plain column is visible to everyone who can read the row;
303
+ `.serverOnly()` hides it from every client; `.readableBy(...)` sits between them —
304
+ the column reaches a subject only if it holds the scope:
305
+
306
+ ```ts
307
+ import { id, integer, text, table } from '@voltro/database'
308
+
309
+ export const invoices = table('invoices', {
310
+ id: id(),
311
+ number: text(), // visible to everyone
312
+ amountCents: integer().readableBy('billing:read'), // only billing-scoped subjects
313
+ taxNote: text().readableBy('billing:read', 'admin:pii'), // ANY of the two scopes
314
+ })
315
+ ```
316
+
317
+ **What it does:** a column marked `.readableBy(...scopes)` is stripped from the
318
+ wire OUTPUT for any subject holding NONE of the listed scopes, and present for
319
+ one holding **at least one** of them. Like `.serverOnly()` it changes nothing at
320
+ rest and nothing in the DDL — it is a wire concern only, so a server-internal
321
+ read (`ctx.store.query`) still sees the value; the strip applies only on the way
322
+ out to a client.
323
+
324
+ **Scope semantics:**
325
+
326
+ - **≥ 1 scope, OR-matched.** A subject sees the column iff it holds at least one
327
+ of the listed scopes — `.readableBy('a', 'b')` means "a OR b", not both.
328
+ - **Effective scopes.** The check is the framework's `hasEffectiveScope` — the
329
+ subject's RAW scopes **∪** its rbac role-derived scopes — so a role that grants
330
+ `billing:read` unlocks the column even when the scope is not listed directly on
331
+ the subject.
332
+ - **`admin:full` bypasses it.** A subject holding `admin:full` sees every
333
+ `.readableBy(...)` column, exactly as it satisfies every guard.
334
+ - **At least one scope is required.** `.readableBy()` with no scope would mean
335
+ "readable by nobody" — that is `.serverOnly()` — so both the empty call and a
336
+ blank scope string are rejected at declaration.
337
+
338
+ **`.serverOnly()` wins when both are present.** `.serverOnly()` is the all-hidden
339
+ end of the axis, so a column carrying both is hidden from EVERYONE — `admin:full`
340
+ included. `.readableBy()` only ever *narrows* an otherwise-visible column; it can
341
+ never re-expose a `.serverOnly()` one:
342
+
343
+ ```ts
344
+ amountCents: integer().readableBy('billing:read'), // scoped subjects see it; admin:full does too
345
+ secretKey: text().serverOnly().readableBy('billing:read'), // serverOnly wins → hidden from EVERYONE
346
+ ```
347
+
348
+ Enforcement shares the SAME chokepoint as `.serverOnly()` — the Dispatcher's read
349
+ boundary — but resolves **per subject**: it applies to a query's initial snapshot
350
+ AND every reactive subscription delta, and to the one-shot
351
+ [`publicApi`](/docs/data/rest-routes) REST GET, in both boot paths (`voltro dev`,
352
+ `voltro serve`). A table that declares no `.readableBy(...)` column pays nothing —
353
+ the strip collapses to the subject-independent `.serverOnly()` set and the
354
+ dispatcher's read/diff memo is still shared across all subscribers of a change.
355
+
299
356
  ## Worked example
300
357
 
301
358
  ```ts
@@ -280,7 +280,9 @@ Two layers enforce row invariants, and they're complementary:
280
280
 
281
281
  - **`table().validate(Schema)` (below)** — app-level `effect/Schema` validation, run before the INSERT/UPDATE. Use it for the rich stuff a portable `CHECK` can't express: regex / email / structured strings, length ranges, numeric bounds, cross-field rules with custom typed errors. It's the single validation surface — don't reach for a pile of `.min()` / `.max()` / `.pattern()` column modifiers (there aren't any, by design).
282
282
 
283
- Rule of thumb: **`.check()` when the DATABASE must guarantee it; `validate(Schema)` for everything else.** There is no `.comment()` / `.index()` column modifier indexes are table-level.
283
+ - **`table().rule(name, predicate)` (cross-table)** neither of the above can read ANOTHER table. A [business rule](/docs/data/mutations#cross-table-business-rules-rule) is a predicate the runtime evaluates inside the mutation transaction, so it can compare the written row against rows in other tables (an invoice total vs. its line items) and rolls the mutation back with a typed `BusinessRuleViolation` on failure.
284
+
285
+ Rule of thumb: **`.check()` when the DATABASE must guarantee a single-row constraint; `table().rule()` for a cross-table invariant; `validate(Schema)` for everything else.** There is no `.comment()` / `.index()` column modifier — indexes are table-level.
284
286
 
285
287
  ## Default with callback
286
288
 
@@ -51,7 +51,11 @@ Server-side, the active locale is determined by, in priority order:
51
51
 
52
52
  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.
53
53
 
54
- The client side mirrors #1 (cookie) and #3 (default) for hydration safety. `Accept-Language` is **server-only** because `navigator.languages` can diverge from what the server saw, which would cause a hydration mismatch.
54
+ 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.
55
+
56
+ > Earlier versions said the client "mirrors cookie and default for hydration safety". That was the opposite of what happened. Dropping the `Accept-Language` signal is not the same as agreeing with the server about it — on a first visit, with no cookie yet, the server negotiated `Accept-Language` while the client fell through to `defaultLocale`. An English browser on a German-default app therefore hydrated `de` over an `en` tree and React discarded the entire server render, which is exactly what SSR was enabled to avoid. It stopped as soon as anything wrote the cookie, so one language switch made it un-reproducible for that developer.
57
+
58
+ `<html lang>` carries the same resolved locale — the value the `<I18nProvider>` renders with, on the same request. That matters on its own: it is what a screen reader pronounces in, what Chrome offers to translate *from*, and what hyphenation uses.
55
59
 
56
60
  See [Catalogs](/docs/i18n/catalogs) for the type-safe catalog convention and the component hooks, [Plurals & formatting](/docs/i18n/formatting) for CLDR plural selection and the `Intl`-backed date / number / relative-time hooks, and [URL strategies](/docs/i18n/url-strategies) for cookie-only vs URL-prefix routing.
57
61
 
@@ -697,3 +701,159 @@ For dates, numbers and relative time the hooks are the blessed path; reach for `
697
701
  - **Don't pin a global `timeZone` / locale for the whole app.** The formatters resolve the active locale from the provider; a pin makes every viewer read the app in one user's settings.
698
702
  - **Don't pass a locale-formatted string to a machine consumer.** Formatted output is presentation — send ISO strings and raw numbers to APIs, `dateTime` attributes and sort keys.
699
703
  - **Don't format inside a `.map()` by constructing `Intl` objects yourself.** The hooks memoize per locale; a fresh `new Intl.NumberFormat(...)` per row is the slow path.
704
+
705
+
706
+
707
+ ---
708
+
709
+ <!-- source: en/i18n/datetime.md -->
710
+ ## Dates & timezones
711
+
712
+ _"@voltro/datetime — UTC-instant storage, timezone-aware arithmetic/formatting on TC39 Temporal, and the request-scoped timezone-context seam."_
713
+
714
+ `@voltro/datetime` is the framework's opinion on time: **store UTC instants,
715
+ render in the viewer's timezone, never do zone math by hand.** It is built on the
716
+ [TC39 Temporal](https://tc39.es/proposal-temporal/docs/) standard (via a
717
+ polyfill, so the API is identical on the server and in the browser) and ships
718
+ pure, browser-safe helpers plus a request-scoped timezone seam.
719
+
720
+ The `.` entry is browser-safe — no `effect`, no `node:*` — so a route or
721
+ component can import it directly. The Effect seam lives in a separate subpath,
722
+ `@voltro/datetime/context`, so the pure surface carries no `effect` dependency.
723
+
724
+ > **Phase 1.** This is the storage + timezone + formatting layer. Schema-DSL
725
+ > temporal column types, `interval()`, and `rrule()` are later phases; use
726
+ > `timestamp()` columns and these helpers today.
727
+
728
+ ## The storage contract: a `Date` is a UTC instant
729
+
730
+ `timestamp()` stores `TIMESTAMPTZ`, and the driver hands your app code a plain JS
731
+ `Date`. A `Date` is a bare epoch-millisecond count with no zone of its own, so
732
+ the ONLY correct reading of it is "the UTC instant it points at". These helpers
733
+ make that reading explicit and lossless in both directions:
734
+
735
+ ```ts
736
+ import { toInstant, toUTCString, isValidTimeZone } from '@voltro/datetime'
737
+
738
+ // A `Date` from a query IS a UTC instant — read it as one, losslessly.
739
+ const when = toInstant(row.createdAt) // Temporal.Instant
740
+ toUTCString(row.createdAt) // "2026-08-06T12:00:00Z" — the wire/storage form
741
+
742
+ isValidTimeZone('Europe/Berlin') // true
743
+ isValidTimeZone('Mars/Phobos') // false
744
+ ```
745
+
746
+ `toInstant` accepts a `Temporal.Instant`, a JS `Date`, or an ISO-8601 string —
747
+ but a string MUST carry an explicit offset or `Z`. A naive `2026-01-01T12:00`
748
+ denotes no instant and throws, by design: the whole point is that there is no
749
+ silent zone-guessing. Bridge back with `toDate(instant)` for storage or interop.
750
+
751
+ ## Timezone resolution — the framework convention
752
+
753
+ Which zone should a given request render in? Resolve it in priority order, each
754
+ candidate validated as an IANA name, falling back to `UTC`:
755
+
756
+ ```ts
757
+ import { resolveTimezone } from '@voltro/datetime'
758
+
759
+ const tz = resolveTimezone({
760
+ userTimeZone: user.timezone, // 1. the viewing user's profile
761
+ tenantTimeZone: tenant.timezone, // 2. the tenant / org default
762
+ browserTimeZone: 'Europe/Berlin', // 3. the browser-reported zone
763
+ }) // → a validated IANA name, else 'UTC'
764
+ ```
765
+
766
+ This mirrors `@voltro/i18n`'s `resolveLocale` in spirit — pure, framework-agnostic,
767
+ no Effect or RPC — so it runs in any request pipeline. An invalid or absent
768
+ candidate falls through to the next signal; the result is GUARANTEED valid.
769
+
770
+ ### Carrying it through a request — the context seam
771
+
772
+ The resolved zone travels through a request via an Effect context tag in
773
+ `@voltro/datetime/context`, so any route, mutation, workflow, or agent can read it:
774
+
775
+ ```ts
776
+ import { currentTimezone, withTimezone } from '@voltro/datetime/context'
777
+ import { Effect } from 'effect'
778
+
779
+ const program = Effect.gen(function* () {
780
+ const tz = yield* currentTimezone // the request's zone, or 'UTC' when none set
781
+ return tz
782
+ })
783
+
784
+ // Provide an explicit zone for a sub-computation:
785
+ program.pipe(withTimezone('Europe/Berlin'))
786
+ ```
787
+
788
+ `currentTimezone` never fails — an absent context is the documented `UTC`
789
+ default, so call sites don't handle a missing-service error. The runtime provides
790
+ the tag per request (via `resolvedTimezoneLayer`); the web layer's `useTimezone()`
791
+ hook is a thin projection over it.
792
+
793
+ ## Arithmetic — the DST split is in the names
794
+
795
+ Every operation that is ambiguous without a zone REQUIRES an IANA `timeZone`
796
+ argument. There is no implicit "system zone", so a call site cannot silently do
797
+ the wrong thing on a differently-configured box. The DST distinction is
798
+ deliberate and encoded in the method names:
799
+
800
+ ```ts
801
+ import { addDays, addHours, startOfDay, endOfDay } from '@voltro/datetime'
802
+
803
+ // WALL-CLOCK: same local time, one calendar day later in Berlin.
804
+ // Across a DST boundary the elapsed real time is 23h or 25h. Needs a zone.
805
+ addDays(when, 1, 'Europe/Berlin')
806
+
807
+ // EXACT elapsed time: 24 × 3600 seconds, DST-oblivious. No zone.
808
+ addHours(when, 24)
809
+
810
+ // Zone-relative day boundaries.
811
+ startOfDay(when, 'Europe/Berlin')
812
+ endOfDay(when, 'Europe/Berlin')
813
+ ```
814
+
815
+ `addDays` / `addMonths` are wall-clock ("same local time, N days on"); `addHours`
816
+ is exact elapsed time. Comparisons that measure absolute instants (`isAfter`,
817
+ `isBefore`) take no zone; `isSameDay` does, because "same day" is a wall-clock
818
+ question. For a calendar date with no time and no zone — a birthday, a holiday, a
819
+ due date — use `plainDate(year, month, day)` / `parseDate('2026-08-06')`.
820
+
821
+ ## Formatting — locale- and timezone-aware
822
+
823
+ Formatting is where the viewer's timezone and locale are APPLIED. Both are
824
+ explicit arguments — this layer carries no ambient locale (the web layer resolves
825
+ `useLocale()` and passes it in):
826
+
827
+ ```ts
828
+ import { formatDate, formatDateTime, formatRelativeTime } from '@voltro/datetime'
829
+
830
+ formatDate(row.createdAt, 'Europe/Berlin', { locale: 'de' }) // "6. Aug. 2026"
831
+ formatDateTime(row.createdAt, 'America/New_York', { locale: 'en' })
832
+ formatRelativeTime(row.createdAt, { locale: 'en' }) // "3 hours ago"
833
+ ```
834
+
835
+ `FormatOptions` is `{ locale? }` plus any `Intl.DateTimeFormatOptions` override,
836
+ so `formatDate(when, tz, { locale, dateStyle: 'full' })` works. `formatRelativeTime`
837
+ picks the largest unit that fits the signed distance from `now` (default: the
838
+ current instant) and is zone-independent — it measures elapsed real time, not
839
+ wall-clock days.
840
+
841
+ > These helpers are the STORAGE/TIMEZONE layer. `@voltro/i18n`'s
842
+ > [`useFormatDate` / `useRelativeTime`](/docs/i18n/formatting) hooks are the
843
+ > React binding that read the active locale from the provider; reach for those in
844
+ > components, and for `@voltro/datetime` in server code, loaders, and tests.
845
+
846
+ ## Temporal directly
847
+
848
+ The exact standard `Temporal` types are re-exported, so you can drop to the full
849
+ API when a helper doesn't cover your case:
850
+
851
+ ```ts
852
+ import { Temporal } from '@voltro/datetime'
853
+
854
+ const noon = Temporal.PlainTime.from('12:00')
855
+ ```
856
+
857
+ Import `Temporal` from `@voltro/datetime`, never from the polyfill directly — that
858
+ keeps the eventual switch to the native global (once it is universal in browsers)
859
+ a one-line change for the whole codebase.