@voltro/cli 0.28.0 → 0.30.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 (354) hide show
  1. package/CHANGELOG.md +655 -0
  2. package/THIRD-PARTY-NOTICES.md +90 -4
  3. package/dist/{apiBuild-eUM32r1u.js → apiBuild-BceikpQf.js} +3 -3
  4. package/dist/apiBuild-CxvR1Dwt.js +2 -0
  5. package/dist/bin.js +3 -3
  6. package/dist/checkCommand-BMr-X48w.js +2427 -0
  7. package/dist/checkCommand-BOk9y264.js +2 -0
  8. package/dist/cliRuntime-Oh517vCV.js +96 -0
  9. package/dist/{commands-CSkrUI1h.js → commands-D4Y1-Nlu.js} +8575 -9366
  10. package/dist/{dbCommand-CpYgmSw4.js → dbCommand-C7hbl5pk.js} +345 -266
  11. package/dist/dbCommand-D8nhyppR.js +2 -0
  12. package/dist/{dev-DlBWWnJQ.js → dev-Cr-teYsz.js} +1 -1
  13. package/dist/{dev-CEZwJhmb.js → dev-DmnkH-wr.js} +2939 -1832
  14. package/dist/fileConventions-Cof68_BL.js +33 -0
  15. package/dist/{frameworkTableAssembly-BwHU9Euq.js → frameworkTableAssembly-BzAajuUv.js} +115 -99
  16. package/dist/frameworkTableAssembly-DU0aOxjL.js +2 -0
  17. package/dist/index.d.ts +7 -2
  18. package/dist/index.js +1 -1
  19. package/dist/{inspect-CUCCzw2I.js → inspect-BUUjt773.js} +349 -192
  20. package/dist/inspect-mmBuRXmy.js +2 -0
  21. package/dist/{inspectMetrics-BU90mvJN.js → inspectMetrics-BeJdP_3b.js} +1068 -978
  22. package/dist/{manifestBuild-BnzAxp2O.js → manifestBuild-BLrVuSlM.js} +1 -1
  23. package/dist/manifestBuild-Dj8Jjoto.js +2 -0
  24. package/dist/sdkgen-CqpOq_fy.js +582 -0
  25. package/dist/serveCommand-Ciq_wIY8.js +1577 -0
  26. package/dist/serveEntry.js +2 -2
  27. package/dist/{start-BGXIf6zT.js → start-ocqB1seX.js} +283 -270
  28. package/dist/startEntry.js +2 -2
  29. package/package.json +17 -17
  30. package/templates/AGENTS.core.md +50 -0
  31. package/templates/AGENTS.md +54 -2
  32. package/templates/agent-docs/_index.md +4 -2
  33. package/templates/agent-docs/_manifest.json +26 -5
  34. package/templates/agent-docs/ai.md +191 -0
  35. package/templates/agent-docs/cli.md +143 -6
  36. package/templates/agent-docs/configuration.md +32 -0
  37. package/templates/agent-docs/data.md +282 -0
  38. package/templates/agent-docs/database/migrations.md +47 -0
  39. package/templates/agent-docs/database/misc.md +57 -0
  40. package/templates/agent-docs/database/schema.md +3 -1
  41. package/templates/agent-docs/internationalization.md +169 -8
  42. package/templates/agent-docs/local-first-mobile.md +426 -0
  43. package/templates/agent-docs/routing.md +94 -3
  44. package/templates/agent-docs/schema-driven-ui.md +12 -0
  45. package/templates/agent-docs/security.md +17 -0
  46. package/templates/agent-docs/templates/apibackends.md +210 -1
  47. package/templates/agent-docs/templates/appshells.md +144 -1
  48. package/templates/agent-docs/templates/mobile.md +60 -0
  49. package/templates/agent-docs/templates/overview.md +17 -7
  50. package/templates/agent-docs/whats-new.md +238 -158
  51. package/templates/agent-docs/workflows.md +376 -0
  52. package/templates/apps/api-ai/package.json +7 -7
  53. package/templates/apps/api-auth/package.json +8 -8
  54. package/templates/apps/api-backend/package.json +7 -7
  55. package/templates/apps/api-backend-deactivation/package.json +7 -7
  56. package/templates/apps/api-backend-mail/package.json +8 -8
  57. package/templates/apps/api-backend-mariadb/package.json +9 -9
  58. package/templates/apps/api-backend-sqlite/.env.example +19 -0
  59. package/templates/apps/api-backend-sqlite/README.md +38 -0
  60. package/templates/apps/api-backend-sqlite/app.config.ts +35 -0
  61. package/templates/apps/api-backend-sqlite/database/schema.ts +54 -0
  62. package/templates/apps/api-backend-sqlite/mutations/notes.create.mutation.server.ts +19 -0
  63. package/templates/apps/api-backend-sqlite/mutations/notes.create.mutation.ts +37 -0
  64. package/templates/apps/api-backend-sqlite/package.json +30 -0
  65. package/templates/apps/api-backend-sqlite/queries/notes.query.server.ts +14 -0
  66. package/templates/apps/api-backend-sqlite/queries/notes.query.ts +20 -0
  67. package/templates/apps/api-backend-sqlite/template.json +6 -0
  68. package/templates/apps/api-backend-sqlite/tests/notes.create.test.ts +50 -0
  69. package/templates/apps/api-backend-sqlite/tsconfig.json +5 -0
  70. package/templates/apps/api-backend-storage/package.json +8 -8
  71. package/templates/apps/api-cms/README.md +104 -0
  72. package/templates/apps/api-cms/actions/content.get.action.server.ts +27 -0
  73. package/templates/apps/api-cms/actions/content.get.action.ts +19 -0
  74. package/templates/apps/api-cms/actions/content.types.action.server.ts +26 -0
  75. package/templates/apps/api-cms/actions/content.types.action.ts +40 -0
  76. package/templates/apps/api-cms/actions/me.action.server.ts +18 -0
  77. package/templates/apps/api-cms/actions/me.action.ts +16 -0
  78. package/templates/apps/api-cms/app.config.ts +61 -0
  79. package/templates/apps/api-cms/content/blogPost.contentType.ts +39 -0
  80. package/templates/apps/api-cms/content/index.ts +18 -0
  81. package/templates/apps/api-cms/content/page.contentType.ts +24 -0
  82. package/templates/apps/api-cms/database/schema.ts +64 -0
  83. package/templates/apps/api-cms/mutations/content.publish.mutation.server.ts +19 -0
  84. package/templates/apps/api-cms/mutations/content.publish.mutation.ts +15 -0
  85. package/templates/apps/api-cms/mutations/content.saveDraft.mutation.server.ts +36 -0
  86. package/templates/apps/api-cms/mutations/content.saveDraft.mutation.ts +32 -0
  87. package/templates/apps/api-cms/mutations/content.unpublish.mutation.server.ts +19 -0
  88. package/templates/apps/api-cms/mutations/content.unpublish.mutation.ts +11 -0
  89. package/templates/apps/api-cms/package.json +32 -0
  90. package/templates/apps/api-cms/queries/content.list.query.server.ts +0 -0
  91. package/templates/apps/api-cms/queries/content.list.query.ts +27 -0
  92. package/templates/apps/api-cms/template.json +6 -0
  93. package/templates/apps/api-cms/tests/content.descriptors.test.ts +64 -0
  94. package/templates/apps/api-cms/tests/content.write.test.ts +85 -0
  95. package/templates/apps/api-cms/tsconfig.json +5 -0
  96. package/templates/apps/api-collab/README.md +70 -0
  97. package/templates/apps/api-collab/app.config.ts +28 -0
  98. package/templates/apps/api-collab/database/schema.ts +63 -0
  99. package/templates/apps/api-collab/mutations/documents.create.mutation.server.ts +19 -0
  100. package/templates/apps/api-collab/mutations/documents.create.mutation.ts +36 -0
  101. package/templates/apps/api-collab/mutations/documents.setBody.mutation.server.ts +20 -0
  102. package/templates/apps/api-collab/mutations/documents.setBody.mutation.ts +30 -0
  103. package/templates/apps/api-collab/package.json +30 -0
  104. package/templates/apps/api-collab/queries/documents.query.server.ts +16 -0
  105. package/templates/apps/api-collab/queries/documents.query.ts +23 -0
  106. package/templates/apps/api-collab/template.json +6 -0
  107. package/templates/apps/api-collab/tests/documents.setBody.test.ts +89 -0
  108. package/templates/apps/api-collab/tsconfig.json +5 -0
  109. package/templates/apps/api-data-advanced/package.json +8 -8
  110. package/templates/apps/api-durable/package.json +8 -8
  111. package/templates/apps/api-feature-flags/package.json +9 -9
  112. package/templates/apps/api-governance/package.json +8 -8
  113. package/templates/apps/api-kv/package.json +8 -8
  114. package/templates/apps/api-moderation/package.json +8 -8
  115. package/templates/apps/api-observability/package.json +8 -8
  116. package/templates/apps/api-ratelimit/package.json +8 -8
  117. package/templates/apps/api-rbac/package.json +8 -8
  118. package/templates/apps/api-rest/package.json +7 -7
  119. package/templates/apps/api-saas/package.json +11 -11
  120. package/templates/apps/api-saas-starter/README.md +103 -0
  121. package/templates/apps/api-saas-starter/actions/me.action.server.ts +18 -0
  122. package/templates/apps/api-saas-starter/actions/me.action.ts +20 -0
  123. package/templates/apps/api-saas-starter/app.config.ts +87 -0
  124. package/templates/apps/api-saas-starter/database/schema.ts +57 -0
  125. package/templates/apps/api-saas-starter/mutations/invites.create.mutation.server.ts +26 -0
  126. package/templates/apps/api-saas-starter/mutations/invites.create.mutation.ts +18 -0
  127. package/templates/apps/api-saas-starter/mutations/projects.create.mutation.server.ts +29 -0
  128. package/templates/apps/api-saas-starter/mutations/projects.create.mutation.ts +18 -0
  129. package/templates/apps/api-saas-starter/package.json +32 -0
  130. package/templates/apps/api-saas-starter/queries/invites.list.query.server.ts +14 -0
  131. package/templates/apps/api-saas-starter/queries/invites.list.query.ts +17 -0
  132. package/templates/apps/api-saas-starter/queries/projects.list.query.server.ts +14 -0
  133. package/templates/apps/api-saas-starter/queries/projects.list.query.ts +18 -0
  134. package/templates/apps/api-saas-starter/template.json +6 -0
  135. package/templates/apps/api-saas-starter/tests/projects.create.test.ts +62 -0
  136. package/templates/apps/api-saas-starter/tests/session.test.ts +33 -0
  137. package/templates/apps/api-saas-starter/tsconfig.json +5 -0
  138. package/templates/apps/api-search/package.json +8 -8
  139. package/templates/apps/api-status/README.md +88 -0
  140. package/templates/apps/api-status/app.config.ts +36 -0
  141. package/templates/apps/api-status/authz.ts +33 -0
  142. package/templates/apps/api-status/database/schema.ts +70 -0
  143. package/templates/apps/api-status/mutations/components.create.mutation.server.ts +19 -0
  144. package/templates/apps/api-status/mutations/components.create.mutation.ts +16 -0
  145. package/templates/apps/api-status/mutations/incidents.create.mutation.server.ts +25 -0
  146. package/templates/apps/api-status/mutations/incidents.create.mutation.ts +22 -0
  147. package/templates/apps/api-status/mutations/incidents.resolve.mutation.server.ts +24 -0
  148. package/templates/apps/api-status/mutations/incidents.resolve.mutation.ts +19 -0
  149. package/templates/apps/api-status/mutations/incidents.update.mutation.server.ts +26 -0
  150. package/templates/apps/api-status/mutations/incidents.update.mutation.ts +21 -0
  151. package/templates/apps/api-status/package.json +30 -0
  152. package/templates/apps/api-status/queries/components.list.query.server.ts +14 -0
  153. package/templates/apps/api-status/queries/components.list.query.ts +13 -0
  154. package/templates/apps/api-status/queries/incidents.live.query.server.ts +14 -0
  155. package/templates/apps/api-status/queries/incidents.live.query.ts +20 -0
  156. package/templates/apps/api-status/queries/updates.list.query.server.ts +14 -0
  157. package/templates/apps/api-status/queries/updates.list.query.ts +17 -0
  158. package/templates/apps/api-status/template.json +6 -0
  159. package/templates/apps/api-status/tests/status.test.ts +70 -0
  160. package/templates/apps/api-status/tsconfig.json +5 -0
  161. package/templates/apps/api-versioning/package.json +8 -8
  162. package/templates/apps/api-webhooks/package.json +9 -9
  163. package/templates/apps/changelog/package.json +6 -6
  164. package/templates/apps/edge-functions/package.json +2 -2
  165. package/templates/apps/frontend-admin/app.config.ts +1 -1
  166. package/templates/apps/frontend-admin/package.json +8 -8
  167. package/templates/apps/frontend-admin/src/locales/index.ts +1 -1
  168. package/templates/apps/frontend-app/app.config.ts +1 -1
  169. package/templates/apps/frontend-app/package.json +8 -8
  170. package/templates/apps/frontend-app/src/locales/index.ts +1 -1
  171. package/templates/apps/frontend-app/src/pages/layout.tsx +1 -1
  172. package/templates/apps/frontend-app/src/pages/page.tsx +1 -1
  173. package/templates/apps/frontend-auth/README.md +78 -0
  174. package/templates/apps/frontend-auth/app.config.ts +34 -0
  175. package/templates/apps/frontend-auth/package.json +32 -0
  176. package/templates/apps/frontend-auth/src/components/AuthShell.tsx +35 -0
  177. package/templates/apps/frontend-auth/src/components/PasswordStrength.tsx +33 -0
  178. package/templates/apps/frontend-auth/src/config.ts +11 -0
  179. package/templates/apps/frontend-auth/src/globals.css +105 -0
  180. package/templates/apps/frontend-auth/src/globals.d.ts +6 -0
  181. package/templates/apps/frontend-auth/src/lib/auth.ts +34 -0
  182. package/templates/apps/frontend-auth/src/lib/redirect.test.ts +24 -0
  183. package/templates/apps/frontend-auth/src/lib/redirect.ts +29 -0
  184. package/templates/apps/frontend-auth/src/locales/de.ts +66 -0
  185. package/templates/apps/frontend-auth/src/locales/en.ts +76 -0
  186. package/templates/apps/frontend-auth/src/locales/index.ts +14 -0
  187. package/templates/apps/frontend-auth/src/pages/forgot/page.tsx +51 -0
  188. package/templates/apps/frontend-auth/src/pages/layout.tsx +12 -0
  189. package/templates/apps/frontend-auth/src/pages/login/page.test.tsx +53 -0
  190. package/templates/apps/frontend-auth/src/pages/login/page.tsx +67 -0
  191. package/templates/apps/frontend-auth/src/pages/logout/page.tsx +28 -0
  192. package/templates/apps/frontend-auth/src/pages/magic/page.tsx +51 -0
  193. package/templates/apps/frontend-auth/src/pages/page.tsx +26 -0
  194. package/templates/apps/frontend-auth/src/pages/reset/page.test.tsx +51 -0
  195. package/templates/apps/frontend-auth/src/pages/reset/page.tsx +63 -0
  196. package/templates/apps/frontend-auth/src/pages/signup/page.tsx +60 -0
  197. package/templates/apps/frontend-auth/src/pages/verify/page.tsx +50 -0
  198. package/templates/apps/frontend-auth/template.json +6 -0
  199. package/templates/apps/frontend-auth/tsconfig.json +5 -0
  200. package/templates/apps/frontend-blank/app.config.ts +1 -1
  201. package/templates/apps/frontend-blank/package.json +7 -7
  202. package/templates/apps/frontend-blank/src/locales/index.ts +1 -1
  203. package/templates/apps/frontend-blank/src/pages/layout.tsx +1 -1
  204. package/templates/apps/frontend-blank/src/pages/page.tsx +1 -1
  205. package/templates/apps/frontend-cms/README.md +47 -0
  206. package/templates/apps/frontend-cms/app.config.ts +37 -0
  207. package/templates/apps/frontend-cms/package.json +33 -0
  208. package/templates/apps/frontend-cms/src/config.ts +8 -0
  209. package/templates/apps/frontend-cms/src/globals.css +105 -0
  210. package/templates/apps/frontend-cms/src/globals.d.ts +6 -0
  211. package/templates/apps/frontend-cms/src/lib/api.ts +58 -0
  212. package/templates/apps/frontend-cms/src/locales/de.ts +50 -0
  213. package/templates/apps/frontend-cms/src/locales/en.ts +55 -0
  214. package/templates/apps/frontend-cms/src/locales/index.ts +14 -0
  215. package/templates/apps/frontend-cms/src/pages/(app)/error.tsx +18 -0
  216. package/templates/apps/frontend-cms/src/pages/(app)/layout.test.tsx +31 -0
  217. package/templates/apps/frontend-cms/src/pages/(app)/layout.tsx +62 -0
  218. package/templates/apps/frontend-cms/src/pages/(app)/not-found.tsx +14 -0
  219. package/templates/apps/frontend-cms/src/pages/(app)/page.test.tsx +110 -0
  220. package/templates/apps/frontend-cms/src/pages/(app)/page.tsx +159 -0
  221. package/templates/apps/frontend-cms/src/pages/layout.tsx +12 -0
  222. package/templates/apps/frontend-cms/src/pages/login/page.test.tsx +58 -0
  223. package/templates/apps/frontend-cms/src/pages/login/page.tsx +93 -0
  224. package/templates/apps/frontend-cms/template.json +6 -0
  225. package/templates/apps/frontend-cms/tsconfig.json +5 -0
  226. package/templates/apps/frontend-collab/README.md +80 -0
  227. package/templates/apps/frontend-collab/app.config.ts +38 -0
  228. package/templates/apps/frontend-collab/package.json +33 -0
  229. package/templates/apps/frontend-collab/src/locales/de.ts +29 -0
  230. package/templates/apps/frontend-collab/src/locales/en.ts +31 -0
  231. package/templates/apps/frontend-collab/src/locales/index.ts +15 -0
  232. package/templates/apps/frontend-collab/src/pages/layout.tsx +39 -0
  233. package/templates/apps/frontend-collab/src/pages/page.test.tsx +150 -0
  234. package/templates/apps/frontend-collab/src/pages/page.tsx +186 -0
  235. package/templates/apps/frontend-collab/template.json +6 -0
  236. package/templates/apps/frontend-collab/tsconfig.json +11 -0
  237. package/templates/apps/frontend-contact/package.json +7 -7
  238. package/templates/apps/frontend-dashboard/app.config.ts +1 -1
  239. package/templates/apps/frontend-dashboard/package.json +7 -7
  240. package/templates/apps/frontend-dashboard/src/locales/index.ts +1 -1
  241. package/templates/apps/frontend-docs/package.json +7 -7
  242. package/templates/apps/frontend-i18n/package.json +6 -6
  243. package/templates/apps/frontend-landing/package.json +7 -7
  244. package/templates/apps/frontend-portal/README.md +71 -0
  245. package/templates/apps/frontend-portal/app.config.ts +37 -0
  246. package/templates/apps/frontend-portal/package.json +32 -0
  247. package/templates/apps/frontend-portal/src/config.ts +8 -0
  248. package/templates/apps/frontend-portal/src/globals.css +93 -0
  249. package/templates/apps/frontend-portal/src/globals.d.ts +6 -0
  250. package/templates/apps/frontend-portal/src/lib/api.ts +62 -0
  251. package/templates/apps/frontend-portal/src/locales/de.ts +86 -0
  252. package/templates/apps/frontend-portal/src/locales/en.ts +93 -0
  253. package/templates/apps/frontend-portal/src/locales/index.ts +14 -0
  254. package/templates/apps/frontend-portal/src/pages/(portal)/api-keys/page.test.tsx +71 -0
  255. package/templates/apps/frontend-portal/src/pages/(portal)/api-keys/page.tsx +124 -0
  256. package/templates/apps/frontend-portal/src/pages/(portal)/billing/page.test.tsx +86 -0
  257. package/templates/apps/frontend-portal/src/pages/(portal)/billing/page.tsx +97 -0
  258. package/templates/apps/frontend-portal/src/pages/(portal)/error.tsx +19 -0
  259. package/templates/apps/frontend-portal/src/pages/(portal)/invoices/page.tsx +45 -0
  260. package/templates/apps/frontend-portal/src/pages/(portal)/layout.test.tsx +31 -0
  261. package/templates/apps/frontend-portal/src/pages/(portal)/layout.tsx +63 -0
  262. package/templates/apps/frontend-portal/src/pages/(portal)/not-found.tsx +15 -0
  263. package/templates/apps/frontend-portal/src/pages/(portal)/page.tsx +39 -0
  264. package/templates/apps/frontend-portal/src/pages/(portal)/profile/page.tsx +79 -0
  265. package/templates/apps/frontend-portal/src/pages/layout.tsx +12 -0
  266. package/templates/apps/frontend-portal/src/pages/login/page.test.tsx +58 -0
  267. package/templates/apps/frontend-portal/src/pages/login/page.tsx +96 -0
  268. package/templates/apps/frontend-portal/template.json +6 -0
  269. package/templates/apps/frontend-portal/tsconfig.json +5 -0
  270. package/templates/apps/frontend-saas/README.md +73 -0
  271. package/templates/apps/frontend-saas/app.config.ts +46 -0
  272. package/templates/apps/frontend-saas/package.json +32 -0
  273. package/templates/apps/frontend-saas/src/config.ts +8 -0
  274. package/templates/apps/frontend-saas/src/globals.css +85 -0
  275. package/templates/apps/frontend-saas/src/globals.d.ts +6 -0
  276. package/templates/apps/frontend-saas/src/lib/api.ts +47 -0
  277. package/templates/apps/frontend-saas/src/locales/de.ts +72 -0
  278. package/templates/apps/frontend-saas/src/locales/en.ts +81 -0
  279. package/templates/apps/frontend-saas/src/locales/index.ts +14 -0
  280. package/templates/apps/frontend-saas/src/pages/(marketing)/layout.tsx +30 -0
  281. package/templates/apps/frontend-saas/src/pages/(marketing)/login/page.test.tsx +58 -0
  282. package/templates/apps/frontend-saas/src/pages/(marketing)/login/page.tsx +96 -0
  283. package/templates/apps/frontend-saas/src/pages/(marketing)/page.tsx +27 -0
  284. package/templates/apps/frontend-saas/src/pages/dashboard/billing/page.tsx +72 -0
  285. package/templates/apps/frontend-saas/src/pages/dashboard/error.tsx +20 -0
  286. package/templates/apps/frontend-saas/src/pages/dashboard/layout.test.tsx +32 -0
  287. package/templates/apps/frontend-saas/src/pages/dashboard/layout.tsx +69 -0
  288. package/templates/apps/frontend-saas/src/pages/dashboard/not-found.tsx +17 -0
  289. package/templates/apps/frontend-saas/src/pages/dashboard/page.test.tsx +113 -0
  290. package/templates/apps/frontend-saas/src/pages/dashboard/page.tsx +76 -0
  291. package/templates/apps/frontend-saas/src/pages/dashboard/team/page.tsx +73 -0
  292. package/templates/apps/frontend-saas/src/pages/layout.tsx +12 -0
  293. package/templates/apps/frontend-saas/template.json +6 -0
  294. package/templates/apps/frontend-saas/tsconfig.json +5 -0
  295. package/templates/apps/frontend-spa/app.config.ts +1 -1
  296. package/templates/apps/frontend-spa/package.json +7 -7
  297. package/templates/apps/frontend-spa/src/locales/index.ts +1 -1
  298. package/templates/apps/frontend-spa/src/pages/layout.tsx +1 -1
  299. package/templates/apps/frontend-spa/src/pages/page.tsx +1 -1
  300. package/templates/apps/frontend-ssr/app.config.ts +1 -1
  301. package/templates/apps/frontend-ssr/package.json +7 -7
  302. package/templates/apps/frontend-ssr/src/locales/index.ts +1 -1
  303. package/templates/apps/frontend-ssr/src/pages/layout.tsx +1 -1
  304. package/templates/apps/frontend-ssr/src/pages/page.tsx +1 -1
  305. package/templates/apps/frontend-ssr-api/app.config.ts +1 -1
  306. package/templates/apps/frontend-ssr-api/package.json +8 -8
  307. package/templates/apps/frontend-ssr-api/src/locales/index.ts +1 -1
  308. package/templates/apps/frontend-ssr-api/src/pages/layout.tsx +1 -1
  309. package/templates/apps/frontend-static-blog/package.json +6 -6
  310. package/templates/apps/frontend-status/README.md +51 -0
  311. package/templates/apps/frontend-status/app.config.ts +39 -0
  312. package/templates/apps/frontend-status/package.json +32 -0
  313. package/templates/apps/frontend-status/src/config.ts +7 -0
  314. package/templates/apps/frontend-status/src/globals.css +70 -0
  315. package/templates/apps/frontend-status/src/globals.d.ts +6 -0
  316. package/templates/apps/frontend-status/src/lib/status.ts +81 -0
  317. package/templates/apps/frontend-status/src/locales/de.ts +41 -0
  318. package/templates/apps/frontend-status/src/locales/en.ts +45 -0
  319. package/templates/apps/frontend-status/src/locales/index.ts +13 -0
  320. package/templates/apps/frontend-status/src/pages/layout.tsx +27 -0
  321. package/templates/apps/frontend-status/src/pages/page.test.tsx +123 -0
  322. package/templates/apps/frontend-status/src/pages/page.tsx +136 -0
  323. package/templates/apps/frontend-status/template.json +6 -0
  324. package/templates/apps/frontend-status/tsconfig.json +11 -0
  325. package/templates/apps/mobile-app/README.md +58 -0
  326. package/templates/apps/mobile-app/app.config.ts +33 -0
  327. package/templates/apps/mobile-app/babel.config.js +5 -0
  328. package/templates/apps/mobile-app/metro.config.js +20 -0
  329. package/templates/apps/mobile-app/package.json +33 -0
  330. package/templates/apps/mobile-app/src/app/_layout.tsx +61 -0
  331. package/templates/apps/mobile-app/src/app/index.tsx +62 -0
  332. package/templates/apps/mobile-app/src/app/orders/[id].tsx +19 -0
  333. package/templates/apps/mobile-app/src/app/settings.tsx +51 -0
  334. package/templates/apps/mobile-app/src/client.ts +74 -0
  335. package/templates/apps/mobile-app/src/lib/deeplinks.ts +39 -0
  336. package/templates/apps/mobile-app/src/lib/notifications.ts +31 -0
  337. package/templates/apps/mobile-app/src/lib/sync.ts +29 -0
  338. package/templates/apps/mobile-app/src/persistence.ts +38 -0
  339. package/templates/apps/mobile-app/template.json +6 -0
  340. package/templates/apps/mobile-app/tests/deeplinks.test.ts +32 -0
  341. package/templates/apps/mobile-app/tests/notifications.test.ts +26 -0
  342. package/templates/apps/mobile-app/tests/sync.test.ts +34 -0
  343. package/templates/apps/mobile-app/tsconfig.json +10 -0
  344. package/templates/apps/mobile-app/voltro.mobile.ts +11 -0
  345. package/templates/baselines/compose/docker-compose.prod.yml +15 -0
  346. package/templates/baselines/compose-mariadb/docker-compose.prod.yml +15 -0
  347. package/templates/patches/@effect__cluster@0.60.0.patch +6 -6
  348. package/dist/apiBuild-DgBS9ayv.js +0 -2
  349. package/dist/dbCommand-DvguqlzF.js +0 -2
  350. package/dist/fileConventions-3bffWssN.js +0 -30
  351. package/dist/frameworkTableAssembly-lrjZtk0G.js +0 -2
  352. package/dist/inspect-gt8bq-Tz.js +0 -2
  353. package/dist/manifestBuild-ifczArzr.js +0 -2
  354. package/dist/serveCommand-ZTn-dPFa.js +0 -1425
@@ -0,0 +1,426 @@
1
+ # Local-first & Mobile
2
+
3
+ > "@voltro/local-first — CRDT text merge (crdtText/mergeCrdtStates), the offline sync-queue + SyncClient wire, presence/awareness, durable persistence, and the localFirst table mixin. Pure and browser-safe; the React hooks live behind a subpath."
4
+
5
+
6
+
7
+ ---
8
+
9
+ <!-- source: en/local-first/overview.md -->
10
+ ## Local-first & CRDTs
11
+
12
+ _"@voltro/local-first — CRDT text merge (crdtText/mergeCrdtStates), the offline sync-queue + SyncClient wire, presence/awareness, durable persistence, and the localFirst table mixin. Pure and browser-safe; the React hooks live behind a subpath."_
13
+
14
+ `@voltro/local-first` is the framework's foundation for **offline-capable,
15
+ multiplayer, convergent** apps: edit while disconnected, see other peers' cursors,
16
+ and reconcile without losing work when the network returns. The `.` entry is
17
+ **pure and browser-safe** — no `effect`, no `node:*` — so a route or component
18
+ imports it directly. The React wrappers live behind `@voltro/local-first/react`
19
+ (React is an optional peer, so the pure path never pulls it in).
20
+
21
+ > **What ships today**: the pure CRDT text merge, the offline sync-queue reducer,
22
+ > the connection-lifecycle state machine, the conflict policy, the
23
+ > [`crdtText()` database column](#the-crdttext-database-column) (with its
24
+ > authoritative server-side merge on the write path), the
25
+ > [`SyncClient`](#the-syncclient-bi-directional-wire) that drives the queue over a
26
+ > transport, [presence/awareness](#presence--awareness) via `usePresence`,
27
+ > [durable IndexedDB persistence](#durable-persistence), and the
28
+ > [`localFirst` table mixin](#the-localfirst-table-mixin). What remains is a thin
29
+ > [runtime binding](#whats-shipped-vs-a-runtime-seam) to provisioned infra
30
+ > (a broker at scale, a running app's transport) — not un-built framework code.
31
+
32
+ ## CRDT text: `crdtText` + `mergeCrdtStates`
33
+
34
+ A CRDT (Conflict-free Replicated Data Type) text field can be edited by many
35
+ peers offline and always converges to the same result. `crdtText()` builds one
36
+ (Yjs-backed behind the `CrdtBackend` abstraction), and `mergeCrdtStates()` is
37
+ the heart of the package — it converges two encoded states into one,
38
+ deterministically and order-independently.
39
+
40
+ ```ts
41
+ import { crdtText, mergeCrdtStates, decodeCrdtText } from '@voltro/local-first'
42
+
43
+ // A field, edited offline. `insert`/`delete` mutate and return the same handle.
44
+ const doc = crdtText('Hello').insert(5, ', world')
45
+ const state = doc.encode() // the wire/storage form: a CrdtState
46
+
47
+ // Converge two peers' encoded states — order-independent, no lost edits.
48
+ const remote = crdtText('Hello').insert(5, ' there').encode()
49
+ const merged = mergeCrdtStates(state, remote)
50
+ decodeCrdtText(merged) // the plain-string view; both edits survive
51
+ ```
52
+
53
+ `mergeCrdtStates` is deterministic (`decodeCrdtText(mergeCrdtStates(a, b))`
54
+ equals the same for `(b, a)`), idempotent (re-merging a contained state is a
55
+ no-op), and treats `emptyCrdtState()` as identity. The backend is a parameter on
56
+ every function (defaulting to Yjs), so a later swap to Loro touches no call site.
57
+
58
+ ## The `crdtText()` database column
59
+
60
+ `@voltro/database` ships a `crdtText()` **column type** for CRDT-managed fields.
61
+ It needs no special DDL — to the declarative differ it is an ordinary nullable
62
+ `bytes` column (BYTEA / BLOB / LONGBLOB / VARBINARY), so it plans and round-trips
63
+ on every dialect like any other:
64
+
65
+ ```ts
66
+ import { table, id, text, crdtText } from '@voltro/database'
67
+
68
+ export const documents = table('documents', {
69
+ id: id(),
70
+ title: text(),
71
+ body: crdtText(), // CRDT-managed field — stored as the encoded state (bytes)
72
+ })
73
+ ```
74
+
75
+ The row type is `Uint8Array | null` (the encoded CRDT state); decode it to a
76
+ string with `decodeCrdtText()`, and produce writes with a `crdtText()` handle's
77
+ `.encode()`. The merge is **authoritative and server-side**: the runtime folds an
78
+ incoming update into the stored state with `mergeCrdtStates` on the write path
79
+ before writing, then the reactive engine broadcasts the merged result — which is
80
+ what makes concurrent edits converge without a last-write-wins loser.
81
+
82
+ ## The `localFirst` table mixin
83
+
84
+ `localFirst()` **marks a table as local-first** — mirrored to the client, synced
85
+ bi-directionally, and (for its `crdtText()` fields) converged via CRDT merge. It
86
+ adds no column; it is a property the framework reflects on.
87
+
88
+ ```ts
89
+ import { table, id, text, crdtText, localFirst } from '@voltro/database'
90
+
91
+ export const documents = table('documents', {
92
+ id: id(),
93
+ title: text(),
94
+ body: crdtText(),
95
+ }).with(localFirst()) // opt this table into local-first sync + persistence
96
+ ```
97
+
98
+ Discovery needs no codegen change — a marker mixin rides `.with()` like any
99
+ column type. `isLocalFirst(table)` and `localFirstTables(schema)` are pure
100
+ helpers, and the runtime's schema registry reflects it as `hasLocalFirst(table)`
101
+ (beside `crdtColumns(table)`), which is the signal a client-sync-set builder
102
+ reads. A local-first table may also carry plain columns — those sync
103
+ last-write-wins via the [conflict policy](#conflict-policy-for-non-crdt-fields).
104
+
105
+ ## The `SyncClient`: bi-directional wire
106
+
107
+ `createSyncClient({ transport })` maps the offline sync queue onto a transport:
108
+ a local edit merges optimistically and queues; reconnect drains it to the server
109
+ with retry; incoming merged state folds back via the CRDT — and concurrent edits
110
+ converge. It invents **no** transport of its own — the `SyncTransport` is two
111
+ functions an app binds to its **existing** wire:
112
+
113
+ - `push` — deliver a queued CRDT write. Bound to a [`useMutation`](/docs/data/mutations)
114
+ that writes the `crdtText()` column (the server folds it authoritatively).
115
+ - `onRemoteState` — receive merged state. Bound to the reactive
116
+ [`useSubscription`](/docs/data/queries) that already streams the row.
117
+
118
+ ```ts
119
+ import { createSyncClient } from '@voltro/local-first'
120
+
121
+ const sync = createSyncClient({
122
+ transport: {
123
+ kind: 'sync-transport',
124
+ push: (write) => runMutation('documents.setBody', write.payload),
125
+ onRemoteState: (handler) =>
126
+ subscribeRow('documents', (row) =>
127
+ handler({ table: 'documents', id: row.id, column: 'body', state: row.body }),
128
+ ),
129
+ },
130
+ adapter: durablePersistence, // optional — survives a reload
131
+ })
132
+
133
+ // A local edit: merges locally at once, queues, drains when online.
134
+ sync.enqueue({ table: 'documents', id: 'd1', column: 'body', update: doc.encode() })
135
+ sync.getText({ table: 'documents', id: 'd1', column: 'body' }) // the merged view
136
+ ```
137
+
138
+ Everything below the two transport functions — the drain loop, retry/attempt
139
+ counting, optimistic local merge, durable persistence — is in the client and
140
+ tested against an in-memory dispatcher that mirrors the server's merge.
141
+
142
+ ## Presence & awareness
143
+
144
+ `usePresence(roomId, self, { channel })` publishes this peer's ephemeral state
145
+ (cursor, name, selection) and returns everyone else's — the multiplayer cursors
146
+ of a collaborative editor. Presence is **ephemeral and high-frequency**, so it
147
+ rides a pub/sub channel, never Postgres CDC or a table.
148
+
149
+ ```tsx
150
+ import { usePresence } from '@voltro/local-first/react'
151
+
152
+ function Editor({ documentId, channel }) {
153
+ const { presence, others, setPresence } = usePresence(
154
+ documentId,
155
+ { cursor: 0, name: 'Ada' },
156
+ { channel },
157
+ )
158
+ // render `others` as remote cursors; update on selection change:
159
+ const onSelect = (cursor: number) => setPresence({ cursor, name: 'Ada' })
160
+ return <Cursors others={others} />
161
+ }
162
+ ```
163
+
164
+ The `PresenceChannel` is the **same dumb string-payload pub/sub shape** as the
165
+ framework broker (`@voltro/plugin-broadcast`), so a runtime binding forwards
166
+ straight onto the app's provisioned broker — in-memory locally,
167
+ Redis/NATS at scale (both already shipped). `createInMemoryPresenceChannel()` is
168
+ the local/test transport. Join/leave, announce-back discovery, cursor
169
+ propagation, and TTL expiry live in the pure `createPresenceRoom` the hook wraps.
170
+
171
+ ## The offline sync queue
172
+
173
+ `useSyncQueue()` is a reactive view over a **pure, tested reducer**: writes made
174
+ offline are queued, and a transport drains them when connectivity returns. Use it
175
+ directly for fine-grained UI, or let the [`SyncClient`](#the-syncclient-bi-directional-wire)
176
+ drive it for you.
177
+
178
+ ```tsx
179
+ import { useSyncQueue, useConnectionStatus } from '@voltro/local-first/react'
180
+
181
+ function SaveIndicator() {
182
+ const queue = useSyncQueue()
183
+ const { status } = useConnectionStatus(queue.outstanding)
184
+ // `outstanding` counts pending + in-flight (0 means synced).
185
+ return status === 'synced' ? null : <span>Saving… ({status})</span>
186
+ }
187
+ ```
188
+
189
+ ## Connection status
190
+
191
+ `useConnectionStatus(outstanding)` observes the connection lifecycle for the
192
+ local-first layer — network up/down plus reconnect confirmation — and folds the
193
+ number of unsynced writes into a display `status` of `offline | syncing |
194
+ synced`, so `synced` means online **and** drained. It feeds the machine the two
195
+ signals a browser can observe (`navigator.onLine` + the `online`/`offline`
196
+ events); confirmed round-trips (`confirm()`/`confirmFailed()`) are left to the
197
+ caller, so the hook never invents a server ping.
198
+
199
+ > This is the local-first connection machine, distinct from `@voltro/client`'s
200
+ > RPC-error-derived [`useConnectionStatus`](/docs/ui/client-utilities/use-connection-status) —
201
+ > a different package with a different signal source.
202
+
203
+ ## Durable persistence
204
+
205
+ Local CRDT state and the offline queue should survive a reload. Everything above
206
+ storage speaks the `PersistenceAdapter` contract, so the backing swaps freely:
207
+
208
+ - `createInMemoryPersistence()` — ephemeral (lost on reload); the test/default.
209
+ - `createIndexedDbPersistence()` — **durable**, over the browser's own
210
+ IndexedDB. No WASM, no added dependency; the IDB implementation is injectable,
211
+ so it is tested against a fake backend that survives a reopen.
212
+
213
+ ```ts
214
+ import { createIndexedDbPersistence } from '@voltro/local-first'
215
+
216
+ const adapter = await createIndexedDbPersistence({ databaseName: 'my-app' })
217
+ const sync = createSyncClient({ transport, adapter }) // state now survives reload
218
+ ```
219
+
220
+ ## Conflict policy for non-CRDT fields
221
+
222
+ CRDT fields resolve themselves — the merge **is** the resolver. A plain scalar
223
+ like `title` needs a policy. `conflictPolicy()` declares one per field; the
224
+ default everywhere is last-write-wins, and any field you do not name falls back
225
+ to it, so a policy never has to enumerate every column.
226
+
227
+ ```ts
228
+ import { conflictPolicy } from '@voltro/local-first'
229
+
230
+ const policy = conflictPolicy({
231
+ title: 'lastWriteWins',
232
+ // A custom resolver MUST converge: both peers pick the same winner.
233
+ tags: (local, remote) => (remote.updatedAt >= local.updatedAt ? remote.value : local.value),
234
+ })
235
+
236
+ policy.resolveRecord(
237
+ { title: { value: 'Draft', updatedAt: 1 } },
238
+ { title: { value: 'Final', updatedAt: 2 } },
239
+ ) // → { title: 'Final' }
240
+ ```
241
+
242
+ The one property that matters is **convergence**: `lastWriteWins` breaks an
243
+ exact `updatedAt` tie on a stable, symmetric key (writer id, then the value's
244
+ string form), so two peers agree regardless of which side each calls "local".
245
+
246
+ ## What's shipped vs. a runtime seam
247
+
248
+ The framework code for local-first is built and tested end to end against
249
+ in-memory transports. What remains is not un-built framework — it is the thin
250
+ binding to **provisioned infrastructure**, sitting behind interfaces the tested
251
+ code already speaks:
252
+
253
+ | Runtime seam | What it binds | Why it's a binding, not code |
254
+ | --- | --- | --- |
255
+ | **Sync transport → a running app** | The `SyncClient`'s `push`/`onRemoteState` to one app's `useMutation` + `useSubscription`. | The two functions are app-specific glue; the drain loop, merge, retry, and persistence all ship and are tested. |
256
+ | **Presence channel → a broker at scale** | The `PresenceChannel` to a provisioned Redis/NATS broker. | It's a network hop over an already-shipped broker; the awareness logic ships and is tested over the in-memory channel. |
257
+ | **wa-sqlite / Turso adapter** *(optional)* | A SQL durable adapter for cross-tab queries, behind `PersistenceAdapter`. | IndexedDB is the durable default today; a SQL backing is a sibling factory, nothing above it changes. |
258
+
259
+
260
+
261
+ ---
262
+
263
+ <!-- source: en/react-native/overview.md -->
264
+ ## React Native
265
+
266
+ _"@voltro/react-native — the credential-free mobile plumbing: registerDevice + the _voltro_devices table, defineDeepLink + its matcher, useBackgroundSync, and offline-first client defaults + connection status."_
267
+
268
+ The React-client bindings are **import-safe** in React Native — every DOM touch
269
+ in `@voltro/client` is `typeof window`-guarded, so nothing crashes at import.
270
+ What is **not done yet** is wiring the client's runtime for RN: building the
271
+ `ApiHandle`s (runtime + subscription cache + rpc client) over a native
272
+ WebSocket, plus an RN persistence adapter and a NetInfo connection signal. That
273
+ is Phase **M0** of the mobile plan — until it lands, the reactive loop is
274
+ unproven on a device. `@voltro/react-native` ships the mobile-specific plumbing
275
+ that works **today**, limited to the parts that need **no per-tenant credentials
276
+ and no native runtime**: device registration, background-sync scheduling,
277
+ offline-first defaults, a connection-status surface, and the deep-link
278
+ declaration shape.
279
+
280
+ > **Scaffold a mobile app.** `voltro create-project acme --api=api-backend
281
+ > --mobile` (or `voltro add-app mobile --template mobile-app`) scaffolds an Expo
282
+ > app that consumes your api with the same typed hooks. Expo owns Metro
283
+ > (`expo start` / `expo run:ios`), not `voltro dev`. See
284
+ > [the `mobile-app` template](/docs/templates/mobile-app).
285
+
286
+ The package **root is RN-safe** — no `node:*`, no `@voltro/database`, and React
287
+ is reached only through the hooks (an optional peer). The `_voltro_devices` table
288
+ declaration is server-side and lives at `@voltro/react-native/schema`.
289
+
290
+ ## Device registration
291
+
292
+ A device is registered **after** the OS issues its push token (APNs on iOS, FCM
293
+ on Android, Web Push on web). `registerDevice()` normalises a raw input into a
294
+ row and upserts it through whatever transport the app already has — a generated
295
+ mutation caller or a plain `fetch` — so the package stays free of transport
296
+ coupling.
297
+
298
+ ```ts
299
+ import { registerDevice } from '@voltro/react-native'
300
+
301
+ // `userId`/`tenantId` are stamped SERVER-side from the authenticated request —
302
+ // never trusted from the client. `locale`/`timezone` default from the device.
303
+ await registerDevice(
304
+ (row) => api.mutate('registerDevice', row),
305
+ { deviceToken, platform: 'ios' },
306
+ )
307
+ ```
308
+
309
+ Registration is idempotent: the row is stored in `_voltro_devices` with a unique
310
+ key of `(platform, token)`, so re-registering the same token updates the row in
311
+ place instead of inserting a duplicate. A **rotated** token is a new
312
+ registration; reaping the stale one is the sender adapter's job (a seam), not the
313
+ client's.
314
+
315
+ ### The `_voltro_devices` table
316
+
317
+ The table declaration is a server-side entry — it imports the `@voltro/database`
318
+ column DSL, so it is deliberately off the RN-safe root. Contribute it to your
319
+ schema and it migrates like any framework table (the `_voltro_*` prefix rides the
320
+ declarative differ on `voltro dev` / `voltro db apply`, on every dialect):
321
+
322
+ ```ts
323
+ // schema/devices.ts — add the framework device table to your app's schema.
324
+ export { devicesTable } from '@voltro/react-native/schema'
325
+ ```
326
+
327
+ It carries tenant + user scope, `platform`, `token`, `locale`, `timezone`,
328
+ optional `appVersion`/`metadata`, and a `lastSeenAt` rotation clock. It is unique
329
+ on `(platform, token)` and indexed on `userId` — the hot read path for fanning a
330
+ push out to every device of a user.
331
+
332
+ ## Deep links: `defineDeepLink` + the matcher
333
+
334
+ `defineDeepLink({ pattern, handler })` is the descriptor a deep-link file
335
+ declares; its pure matcher turns `/orders/:id` + `/orders/42` into `{ id: '42' }`.
336
+ The params are inferred from the `:name` segments, so `handler` type-checks
337
+ against exactly the params the pattern declares.
338
+
339
+ ```ts
340
+ import { defineDeepLink } from '@voltro/react-native'
341
+
342
+ export default defineDeepLink({
343
+ pattern: '/orders/:id',
344
+ handler: ({ id }) => navigateTo(`/orders/${id}`),
345
+ })
346
+ ```
347
+
348
+ The matcher is pure — no navigation, no side effects — and normalises scheme +
349
+ host away, so a universal link, an App Link, and a custom-scheme URL all match
350
+ the same path-only pattern:
351
+
352
+ ```ts
353
+ import { matchDeepLink, matchFirstDeepLink } from '@voltro/react-native'
354
+ import orderLink from './orders.deepLink'
355
+
356
+ matchDeepLink('/orders/:id', '/orders/42') // → { id: '42' }
357
+ matchDeepLink('/orders/:id', '/orders/42/edit') // → null
358
+
359
+ // Until `*.deepLink.ts` file discovery lands, register links by hand —
360
+ // declaration order wins, so list more-specific patterns first.
361
+ const hit = matchFirstDeepLink([orderLink], 'myapp://orders/42')
362
+ hit?.params.id // '42'
363
+ ```
364
+
365
+ > **Seam — file discovery.** Wiring `*.deepLink.ts` into codegen (so the router
366
+ > auto-collects every declared link) is one additive file, landing after the
367
+ > current release settles. The descriptor shape above is **final**, so register
368
+ > links via `matchFirstDeepLink()` until then.
369
+
370
+ ## Background sync
371
+
372
+ `useBackgroundSync(onSync, options)` owns three triggers — an interval timer, a
373
+ "returned to foreground" subscription, and a manual `sync()` — over a pure
374
+ `shouldSync` policy (single-flight, foreground-gated, interval-gated). The OS
375
+ background-fetch **registration** itself stays the app's; this hook is only the
376
+ interval/foreground state machine.
377
+
378
+ ```tsx
379
+ import { useBackgroundSync } from '@voltro/react-native'
380
+
381
+ function SyncIndicator() {
382
+ const { status, lastSyncAt, sync } = useBackgroundSync(
383
+ () => api.refetchAll(),
384
+ { intervalMs: 60_000, syncOnForeground: true },
385
+ )
386
+
387
+ return <button onClick={sync}>Sync ({status})</button>
388
+ }
389
+ ```
390
+
391
+ `onSync` may be async — a rejection is captured into `status: 'error'` +
392
+ `lastError`, a resolution into `status: 'success'` + `lastSyncAt`.
393
+
394
+ ## Offline-first defaults + connection status
395
+
396
+ `offlineFirstDefaults` is the mobile posture as a value you spread into your
397
+ client config: local-first ON, optimistic mutations, sync-on-foreground, a
398
+ 5-minute cadence, and a retry backoff schedule. `useMobileConnectionStatus()`
399
+ surfaces a `connected | degraded | offline` status — `offline` from
400
+ `navigator.onLine`, `degraded` from failures the app reports.
401
+
402
+ ```tsx
403
+ import { offlineFirstDefaults, useMobileConnectionStatus } from '@voltro/react-native'
404
+
405
+ // Spread the mobile posture into your client config.
406
+ const config = { ...offlineFirstDefaults, url }
407
+
408
+ function ConnectionPill() {
409
+ const { status, reportFailure, reportSuccess } = useMobileConnectionStatus()
410
+ return <span data-status={status}>{status}</span>
411
+ }
412
+ ```
413
+
414
+ ## What's shipped vs. a seam
415
+
416
+ This package ships the credential-free plumbing above. The parts that need
417
+ external credentials or a native runtime are flagged as **deliberate seams** —
418
+ not built here:
419
+
420
+ | Seam | Why it is not in this package |
421
+ |---|---|
422
+ | **APNs / FCM sender adapters** | Need per-tenant Apple Developer / Firebase credentials — genuinely external, managed via provider provisioning. Registration stores the token; sending to it is the seam. |
423
+ | **Native module bindings** (camera, biometrics, secure token storage) | Need a native runtime this TS package cannot provide. |
424
+ | **Swift / Kotlin SDK generators** | **Built + golden-tested** — `voltro build api --target swift\|kotlin` emits a native SDK package. What is deferred is *compiling* the emitted package (`swiftc` / Gradle): that is a mobile-CI step, no cross-language toolchain lives in the framework repo. |
425
+ | **Universal-links / App-Links file automation** (`apple-app-site-association`, `assetlinks.json`) | A deployment-layer concern, not a client primitive. |
426
+ | **`*.deepLink.ts` codegen discovery** | One additive file after the release settles; the descriptor shape is final, so register links via `matchFirstDeepLink()` today. |
@@ -785,6 +785,38 @@ export default function NotePage(): ReactNode {
785
785
 
786
786
  `useLoaderData<T>()` returns the loader's resolved value, typed via the generic.
787
787
 
788
+ **On a page that declares a `loader`, the value is always there.** The router
789
+ never renders such a page without its data: a settled loader commits its data
790
+ and the displayed route together, a pending one shows the `Pending` skeleton
791
+ (or keeps the previous page), and one that threw renders the error subtree. You
792
+ do not need a guard, and adding one only hides a real mistake behind a `?.`.
793
+
794
+ **Calling it where no `loader` exists is an error, and it says so.** A level with
795
+ no `loader` export throws:
796
+
797
+ ```text
798
+ useLoaderData() was called at a level that declares no `loader`. Export `loader`
799
+ from this page/layout, or — if this component is shared between routes that have
800
+ one and routes that do not — read it with `useOptionalLoaderData()` …
801
+ ```
802
+
803
+ Note what is NOT an absence: an empty **result**. A loader returning
804
+ `{ items: [] }` returns exactly that. `undefined` never means "the query found
805
+ nothing" — it means "there is no loader at this level".
806
+
807
+ ### `useOptionalLoaderData()` — for a component on both kinds of route
808
+
809
+ One case needs it: a component genuinely mounted both under routes that declare
810
+ a loader and routes that do not. It returns `undefined` instead of throwing.
811
+
812
+ ```tsx
813
+ const data = useOptionalLoaderData<Data>()
814
+ const project = data?.project
815
+ ```
816
+
817
+ A loader that legitimately resolves to `undefined` is not an error — both hooks
818
+ hand that `undefined` back. Only the missing `loader` throws.
819
+
788
820
  ## When loaders run
789
821
 
790
822
  | renderMode | When loader runs |
@@ -830,6 +862,23 @@ export default function Profile() {
830
862
 
831
863
  Because the SSR markup and the hydration render read the same loader value, they match — no hydration flicker — and you don't hand-build a seed store to bridge the two. This is distinct from `fallback`, whose value never came from the server and so keeps `loading: true`; use exactly one of the two.
832
864
 
865
+ > **Seed whatever the screen checks FIRST, not whatever is most interesting.**
866
+ > A component's server render stops at its OUTERMOST unsatisfied gate, so an
867
+ > unseeded subscription in an early branch hides every seeded one below it:
868
+ >
869
+ > ```tsx
870
+ > // Seeding `roadmap` changes nothing while this branch is the first one.
871
+ > if (availableYearsIdle || availableYearsLoading) return <Spinner />
872
+ > return <Roadmap data={roadmap} />
873
+ > ```
874
+ >
875
+ > The symptom is a page that still server-renders a spinner after you seeded
876
+ > the data you care about. Walk the component's early returns from the top and
877
+ > seed each subscription they read, or move the gate below the render you want.
878
+ > The same applies to an auth gate: a `useSubscription`-backed
879
+ > `AuthenticationProvider` has no data during a server render, so every page
880
+ > under it renders its loading state until that subscription is seeded too.
881
+
833
882
  Client-side navigation is unchanged: moving to another route runs that route's
834
883
  loaders in the browser as usual. A `spa` page has no server render, so its
835
884
  loader runs on the client on first mount.
@@ -1148,18 +1197,26 @@ export const meta = ({ loaderData }: { loaderData: { note?: { title: string; bod
1148
1197
  })
1149
1198
  ```
1150
1199
 
1151
- The third context field — `locale: string` — is the active i18n locale for this render. For `[locale]/…` routes it carries the URL-prefix locale (`'de'` on `/de/notes/42`). For bare-path routes it carries the active locale from the framework's `voltro:lang` cookie when set — so **cookie-based i18n works too** (an authed dashboard with no `[locale]` URL still gets a translated `<title>` that tracks the language switch) — otherwise the app's `defaultLocale`. Use it to localise title / description / canonical / OG per locale at SSG time so search engines see translated head tags on every variant, and to give cookie-i18n pages a translated tab title:
1200
+ The third context field — `locale: string` — is the active i18n locale for this render. For `[locale]/…` routes it carries the URL-prefix locale (`'de'` on `/de/notes/42`). For bare-path routes it carries the active locale from the framework's `voltro:locale` cookie when set — so **cookie-based i18n works too** (an authed dashboard with no `[locale]` URL still gets a translated `<title>` that tracks the language switch) — otherwise the app's `defaultLocale`. Use it to localise title / description / canonical / OG per locale at SSG time so search engines see translated head tags on every variant, and to give cookie-i18n pages a translated tab title:
1152
1201
 
1153
1202
  ```tsx
1154
1203
  import { getCatalog } from '../lib/locale'
1155
- import { localeCanonicalUrl } from '../lib/seo'
1204
+ import { seoAlternates } from '@voltro/web'
1156
1205
 
1157
1206
  export const meta = ({ locale }: { locale: string }): PageMeta => {
1158
1207
  const c = getCatalog(locale)
1159
1208
  return {
1160
1209
  title: c['seo.notes.title'] as string,
1161
1210
  description: c['seo.notes.description'] as string,
1162
- canonical: localeCanonicalUrl('/notes', locale),
1211
+ // Canonical URL for THIS locale + a reciprocal `hreflang` alternate for
1212
+ // every locale (incl. `x-default`), spread straight into the meta.
1213
+ ...seoAlternates({
1214
+ siteUrl: 'https://notes.example.com',
1215
+ path: '/notes',
1216
+ locale,
1217
+ locales: ['en', 'de'],
1218
+ defaultLocale: 'en',
1219
+ }),
1163
1220
  }
1164
1221
  }
1165
1222
  ```
@@ -1168,6 +1225,40 @@ export const meta = ({ locale }: { locale: string }): PageMeta => {
1168
1225
 
1169
1226
  `loaderData` is the PAGE loader's result. Because `meta` runs server-side after the loader, the SSR'd `<head>` is already correct on first paint — no client-side title patching, no flash.
1170
1227
 
1228
+ ## Technical SEO: canonical, hreflang, sitemap & robots
1229
+
1230
+ The framework ships the cheap technical-SEO primitives so an indexable app gets them without app-level plumbing.
1231
+
1232
+ **Canonical + hreflang helpers** (`@voltro/web`) are pure functions you call from `meta`. They are browser-safe, so importing them into a `*.page.tsx` never drags a server module into the client bundle:
1233
+
1234
+ - `canonicalUrl(siteUrl, path)` — one absolute canonical URL.
1235
+ - `seoAlternates({ siteUrl, path, locale, locales, defaultLocale })` — returns `{ canonical, links }` where `canonical` is this locale's URL and `links` is one `rel="alternate"` per locale (each an **absolute** URL, as Google requires) plus `hreflang="x-default"`. The alternate set is reciprocal across every locale — exactly what Google's [hreflang rules](https://developers.google.com/search/docs/specialty/international/localized-versions) want. The locale model is URL-PREFIX routing: the default locale on the bare path (`/notes`), other locales prefixed (`/de/notes`).
1236
+
1237
+ **Keeping a page out of the index** — set `noIndex` on its meta. It emits `<meta name="robots" content="noindex, nofollow">` AND excludes the route from the generated `sitemap.xml`:
1238
+
1239
+ ```tsx
1240
+ export const meta: PageMeta = { title: 'Checkout', noIndex: true }
1241
+ ```
1242
+
1243
+ **`sitemap.xml` + `robots.txt`** are generated at `voltro build` from the prerendered routes. Turn them on with a `seo.siteUrl` in `app.config.ts`:
1244
+
1245
+ ```ts
1246
+ export default {
1247
+ type: 'web' as const,
1248
+ name: 'Notes',
1249
+ locales: ['en', 'de'],
1250
+ defaultLocale: 'en',
1251
+ seo: {
1252
+ siteUrl: 'https://notes.example.com',
1253
+ disallow: ['/admin'], // extra robots Disallow prefixes (optional)
1254
+ },
1255
+ }
1256
+ ```
1257
+
1258
+ - **`sitemap.xml`** lists every prerendered route (minus `noIndex` ones). With 2+ `locales`, each URL carries the full `xhtml:link` alternate set. Written only when `seo.siteUrl` is set — absolute URLs are required.
1259
+ - **`robots.txt`** is generated even without `siteUrl`. Production allows all and advertises the sitemap; `voltro dev` — and any build with `VOLTRO_SEO_NOINDEX=1` (staging / preview deploys) — disallows everything, so a non-production surface never gets indexed by default.
1260
+ - A user-authored `public/sitemap.xml` / `public/robots.txt` always wins — the generator never overwrites one.
1261
+
1171
1262
  ## Examples
1172
1263
 
1173
1264
  ### Authenticated dashboard with cookie-driven loader
@@ -202,6 +202,18 @@ A combined create-or-edit screen is a three-line wrapper:
202
202
  `{ fields, values, errors, isValid, pending, setValue, submit, reset }` for
203
203
  100% custom JSX — the binding stays.
204
204
 
205
+ ### Accessible by default
206
+
207
+ The built-in widgets render accessible HTML without any extra work: a `<label htmlFor>` tied to the control, `aria-required` on required fields (the visual `*` is `aria-hidden` — assistive tech learns "required" from the control, not from a spoken "star"), and, on error, `aria-invalid` + a `role="alert"` message associated via `aria-describedby`. A radio group ties its error to the whole `<fieldset>`.
208
+
209
+ Add **help text** by putting a `description` on the field's Schema — it renders as a hint and is associated to the control via `aria-describedby`, so a screen reader announces it with the label:
210
+
211
+ ```ts
212
+ Schema.Struct({
213
+ handle: Schema.String.annotations({ description: 'Shown on your public profile' }),
214
+ })
215
+ ```
216
+
205
217
  ## `<DataTable>` — bind to a QUERY
206
218
 
207
219
  Columns come from the query's output `Schema`; rows are a LIVE subscription
@@ -88,6 +88,23 @@ DNS is not resolved. A public hostname that *resolves* to a private address (DNS
88
88
  rebinding) still passes. That vector needs network-layer egress control; it is
89
89
  stated here rather than silently implied.
90
90
 
91
+ ## Auditing what your log tables actually hold
92
+
93
+ ```bash
94
+ voltro db scan-credentials
95
+ voltro db scan-credentials --table my_events:actor
96
+ ```
97
+
98
+ Counts rows whose Subject carries a credential-shaped key — `token`, `secret`, `password`, `apikey`, `credential`, `privatekey` — in `_voltro_audit_log` and `_voltro_row_history`, plus any table you name with `--table <name>[:<column>]` (the column defaults to `subject`). Exit code `1` on a hit, so CI can gate on it.
99
+
100
+ **It is a command and not a documented query on purpose.** The same check once shipped as SQL you were asked to run yourself, in its postgres spelling (`subject::text ILIKE '%token%'`). On MySQL/MariaDB the natural translation is a bare `LIKE` — and against the `utf8mb4_bin` collation the migrator emits for a `json()` column, `LIKE` is case-**sensitive**. So `'%token%'` does not match `jiraToken`, and almost every JSON key that carries a credential is camelCase. A team ran the translated query over 141 rows, got `0`, and nearly reported themselves clean; 117 of those rows held a working credential. Every dialect now casts to its own text type before lowering, in code you do not have to translate.
101
+
102
+ **A `0` here never means two things.** Each line prints the number of rows *scanned* beside the number of hits. An empty table says so in words rather than reading as clean, a missing table reports as missing rather than as zero, and a run that examined nothing exits `2`.
103
+
104
+ Run it on every environment. A development database is not a sample of production.
105
+
106
+ If it finds something: purge the rows **and** rotate the credentials — assume anything written to a log table has been read — then move the credential off the Subject entirely with `connectionCredentials(...)`, which keeps it in the framework vault.
107
+
91
108
  ## Supply-chain assurance
92
109
 
93
110
  Every release passes automated supply-chain gates in CI before a single package is published: