@voltro/cli 0.29.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 (169) hide show
  1. package/CHANGELOG.md +377 -0
  2. package/THIRD-PARTY-NOTICES.md +2 -2
  3. package/dist/{apiBuild-BESZbTjA.js → apiBuild-BceikpQf.js} +2 -2
  4. package/dist/apiBuild-CxvR1Dwt.js +2 -0
  5. package/dist/bin.js +3 -3
  6. package/dist/{checkCommand-BPAtyWs8.js → checkCommand-BMr-X48w.js} +23 -16
  7. package/dist/checkCommand-BOk9y264.js +2 -0
  8. package/dist/{commands-82BDEktg.js → commands-D4Y1-Nlu.js} +1936 -1339
  9. package/dist/{dbCommand-bCBbk6tz.js → dbCommand-C7hbl5pk.js} +344 -265
  10. package/dist/dbCommand-D8nhyppR.js +2 -0
  11. package/dist/{dev-DeEJj5T4.js → dev-Cr-teYsz.js} +1 -1
  12. package/dist/{dev-Cg4BKLmi.js → dev-DmnkH-wr.js} +2660 -1742
  13. package/dist/{frameworkTableAssembly-4Db40V23.js → frameworkTableAssembly-BzAajuUv.js} +114 -98
  14. package/dist/frameworkTableAssembly-DU0aOxjL.js +2 -0
  15. package/dist/index.d.ts +7 -2
  16. package/dist/index.js +1 -1
  17. package/dist/{inspect-BMvQpwqy.js → inspect-BUUjt773.js} +150 -14
  18. package/dist/inspect-mmBuRXmy.js +2 -0
  19. package/dist/{inspectMetrics-DHYygE4s.js → inspectMetrics-BeJdP_3b.js} +564 -512
  20. package/dist/{manifestBuild-C4R1EdHn.js → manifestBuild-BLrVuSlM.js} +1 -1
  21. package/dist/manifestBuild-Dj8Jjoto.js +2 -0
  22. package/dist/{sdkgen-CW5NbYF0.js → sdkgen-CqpOq_fy.js} +1 -1
  23. package/dist/serveCommand-Ciq_wIY8.js +1577 -0
  24. package/dist/serveEntry.js +2 -2
  25. package/dist/{start-CsCIaW4-.js → start-ocqB1seX.js} +19 -19
  26. package/dist/startEntry.js +2 -2
  27. package/package.json +17 -17
  28. package/templates/AGENTS.core.md +50 -0
  29. package/templates/AGENTS.md +53 -2
  30. package/templates/agent-docs/_index.md +3 -2
  31. package/templates/agent-docs/_manifest.json +16 -4
  32. package/templates/agent-docs/ai.md +56 -0
  33. package/templates/agent-docs/cli.md +19 -0
  34. package/templates/agent-docs/internationalization.md +8 -7
  35. package/templates/agent-docs/local-first-mobile.md +18 -6
  36. package/templates/agent-docs/routing.md +1 -1
  37. package/templates/agent-docs/security.md +17 -0
  38. package/templates/agent-docs/templates/apibackends.md +209 -0
  39. package/templates/agent-docs/templates/appshells.md +144 -1
  40. package/templates/agent-docs/templates/mobile.md +60 -0
  41. package/templates/agent-docs/templates/overview.md +17 -7
  42. package/templates/agent-docs/whats-new.md +256 -157
  43. package/templates/agent-docs/workflows.md +365 -0
  44. package/templates/apps/api-ai/package.json +7 -7
  45. package/templates/apps/api-auth/package.json +8 -8
  46. package/templates/apps/api-backend/package.json +7 -7
  47. package/templates/apps/api-backend-deactivation/package.json +7 -7
  48. package/templates/apps/api-backend-mail/package.json +8 -8
  49. package/templates/apps/api-backend-mariadb/package.json +9 -9
  50. package/templates/apps/api-backend-sqlite/package.json +8 -8
  51. package/templates/apps/api-backend-storage/package.json +8 -8
  52. package/templates/apps/api-cms/package.json +10 -10
  53. package/templates/apps/api-collab/README.md +70 -0
  54. package/templates/apps/api-collab/app.config.ts +28 -0
  55. package/templates/apps/api-collab/database/schema.ts +63 -0
  56. package/templates/apps/api-collab/mutations/documents.create.mutation.server.ts +19 -0
  57. package/templates/apps/api-collab/mutations/documents.create.mutation.ts +36 -0
  58. package/templates/apps/api-collab/mutations/documents.setBody.mutation.server.ts +20 -0
  59. package/templates/apps/api-collab/mutations/documents.setBody.mutation.ts +30 -0
  60. package/templates/apps/api-collab/package.json +30 -0
  61. package/templates/apps/api-collab/queries/documents.query.server.ts +16 -0
  62. package/templates/apps/api-collab/queries/documents.query.ts +23 -0
  63. package/templates/apps/api-collab/template.json +6 -0
  64. package/templates/apps/api-collab/tests/documents.setBody.test.ts +89 -0
  65. package/templates/apps/api-collab/tsconfig.json +5 -0
  66. package/templates/apps/api-data-advanced/package.json +8 -8
  67. package/templates/apps/api-durable/package.json +8 -8
  68. package/templates/apps/api-feature-flags/package.json +9 -9
  69. package/templates/apps/api-governance/package.json +8 -8
  70. package/templates/apps/api-kv/package.json +8 -8
  71. package/templates/apps/api-moderation/package.json +8 -8
  72. package/templates/apps/api-observability/package.json +8 -8
  73. package/templates/apps/api-ratelimit/package.json +8 -8
  74. package/templates/apps/api-rbac/package.json +8 -8
  75. package/templates/apps/api-rest/package.json +7 -7
  76. package/templates/apps/api-saas/package.json +11 -11
  77. package/templates/apps/api-saas-starter/package.json +10 -10
  78. package/templates/apps/api-search/package.json +8 -8
  79. package/templates/apps/api-status/package.json +8 -8
  80. package/templates/apps/api-versioning/package.json +8 -8
  81. package/templates/apps/api-webhooks/package.json +9 -9
  82. package/templates/apps/changelog/package.json +6 -6
  83. package/templates/apps/edge-functions/package.json +2 -2
  84. package/templates/apps/frontend-admin/app.config.ts +1 -1
  85. package/templates/apps/frontend-admin/package.json +8 -8
  86. package/templates/apps/frontend-admin/src/locales/index.ts +1 -1
  87. package/templates/apps/frontend-app/app.config.ts +1 -1
  88. package/templates/apps/frontend-app/package.json +8 -8
  89. package/templates/apps/frontend-app/src/locales/index.ts +1 -1
  90. package/templates/apps/frontend-app/src/pages/layout.tsx +1 -1
  91. package/templates/apps/frontend-app/src/pages/page.tsx +1 -1
  92. package/templates/apps/frontend-auth/package.json +8 -8
  93. package/templates/apps/frontend-auth/src/locales/index.ts +1 -1
  94. package/templates/apps/frontend-blank/app.config.ts +1 -1
  95. package/templates/apps/frontend-blank/package.json +7 -7
  96. package/templates/apps/frontend-blank/src/locales/index.ts +1 -1
  97. package/templates/apps/frontend-blank/src/pages/layout.tsx +1 -1
  98. package/templates/apps/frontend-blank/src/pages/page.tsx +1 -1
  99. package/templates/apps/frontend-cms/package.json +9 -9
  100. package/templates/apps/frontend-cms/src/locales/index.ts +1 -1
  101. package/templates/apps/frontend-collab/README.md +80 -0
  102. package/templates/apps/frontend-collab/app.config.ts +38 -0
  103. package/templates/apps/frontend-collab/package.json +33 -0
  104. package/templates/apps/frontend-collab/src/locales/de.ts +29 -0
  105. package/templates/apps/frontend-collab/src/locales/en.ts +31 -0
  106. package/templates/apps/frontend-collab/src/locales/index.ts +15 -0
  107. package/templates/apps/frontend-collab/src/pages/layout.tsx +39 -0
  108. package/templates/apps/frontend-collab/src/pages/page.test.tsx +150 -0
  109. package/templates/apps/frontend-collab/src/pages/page.tsx +186 -0
  110. package/templates/apps/frontend-collab/template.json +6 -0
  111. package/templates/apps/frontend-collab/tsconfig.json +11 -0
  112. package/templates/apps/frontend-contact/package.json +7 -7
  113. package/templates/apps/frontend-dashboard/app.config.ts +1 -1
  114. package/templates/apps/frontend-dashboard/package.json +7 -7
  115. package/templates/apps/frontend-dashboard/src/locales/index.ts +1 -1
  116. package/templates/apps/frontend-docs/package.json +7 -7
  117. package/templates/apps/frontend-i18n/package.json +6 -6
  118. package/templates/apps/frontend-landing/package.json +7 -7
  119. package/templates/apps/frontend-portal/package.json +8 -8
  120. package/templates/apps/frontend-portal/src/locales/index.ts +1 -1
  121. package/templates/apps/frontend-saas/README.md +1 -1
  122. package/templates/apps/frontend-saas/app.config.ts +1 -1
  123. package/templates/apps/frontend-saas/package.json +8 -8
  124. package/templates/apps/frontend-saas/src/locales/index.ts +1 -1
  125. package/templates/apps/frontend-spa/app.config.ts +1 -1
  126. package/templates/apps/frontend-spa/package.json +7 -7
  127. package/templates/apps/frontend-spa/src/locales/index.ts +1 -1
  128. package/templates/apps/frontend-spa/src/pages/layout.tsx +1 -1
  129. package/templates/apps/frontend-spa/src/pages/page.tsx +1 -1
  130. package/templates/apps/frontend-ssr/app.config.ts +1 -1
  131. package/templates/apps/frontend-ssr/package.json +7 -7
  132. package/templates/apps/frontend-ssr/src/locales/index.ts +1 -1
  133. package/templates/apps/frontend-ssr/src/pages/layout.tsx +1 -1
  134. package/templates/apps/frontend-ssr/src/pages/page.tsx +1 -1
  135. package/templates/apps/frontend-ssr-api/app.config.ts +1 -1
  136. package/templates/apps/frontend-ssr-api/package.json +8 -8
  137. package/templates/apps/frontend-ssr-api/src/locales/index.ts +1 -1
  138. package/templates/apps/frontend-ssr-api/src/pages/layout.tsx +1 -1
  139. package/templates/apps/frontend-static-blog/package.json +6 -6
  140. package/templates/apps/frontend-status/package.json +8 -8
  141. package/templates/apps/frontend-status/src/locales/index.ts +1 -1
  142. package/templates/apps/mobile-app/README.md +58 -0
  143. package/templates/apps/mobile-app/app.config.ts +33 -0
  144. package/templates/apps/mobile-app/babel.config.js +5 -0
  145. package/templates/apps/mobile-app/metro.config.js +20 -0
  146. package/templates/apps/mobile-app/package.json +33 -0
  147. package/templates/apps/mobile-app/src/app/_layout.tsx +61 -0
  148. package/templates/apps/mobile-app/src/app/index.tsx +62 -0
  149. package/templates/apps/mobile-app/src/app/orders/[id].tsx +19 -0
  150. package/templates/apps/mobile-app/src/app/settings.tsx +51 -0
  151. package/templates/apps/mobile-app/src/client.ts +74 -0
  152. package/templates/apps/mobile-app/src/lib/deeplinks.ts +39 -0
  153. package/templates/apps/mobile-app/src/lib/notifications.ts +31 -0
  154. package/templates/apps/mobile-app/src/lib/sync.ts +29 -0
  155. package/templates/apps/mobile-app/src/persistence.ts +38 -0
  156. package/templates/apps/mobile-app/template.json +6 -0
  157. package/templates/apps/mobile-app/tests/deeplinks.test.ts +32 -0
  158. package/templates/apps/mobile-app/tests/notifications.test.ts +26 -0
  159. package/templates/apps/mobile-app/tests/sync.test.ts +34 -0
  160. package/templates/apps/mobile-app/tsconfig.json +10 -0
  161. package/templates/apps/mobile-app/voltro.mobile.ts +11 -0
  162. package/templates/patches/@effect__cluster@0.60.0.patch +6 -6
  163. package/dist/apiBuild-C8d74T6g.js +0 -2
  164. package/dist/checkCommand-DBvZvCh1.js +0 -2
  165. package/dist/dbCommand-CaIVTp-a.js +0 -2
  166. package/dist/frameworkTableAssembly-BTNirAAk.js +0 -2
  167. package/dist/inspect-DHeaMZak.js +0 -2
  168. package/dist/manifestBuild-Ck_90gZy.js +0 -2
  169. package/dist/serveCommand-BXl8mhZD.js +0 -1489
@@ -1,5 +1,5 @@
1
- import { et as e } from "./inspectMetrics-DHYygE4s.js";
1
+ import { et as e } from "./inspectMetrics-BeJdP_3b.js";
2
2
  import { c as t } from "./seedRunner-Bqxgp7HZ.js";
3
3
  import { r as n } from "./appModuleLoader-C9r9mxZt.js";
4
- import { t as r } from "./serveCommand-BXl8mhZD.js";
4
+ import { t as r } from "./serveCommand-Ciq_wIY8.js";
5
5
  export { e as loadDotEnv, n as registerAppModules, t as registerDriver, r as runServe };
@@ -1,5 +1,5 @@
1
- import { B as e, E as t, G as n, I as r, K as i, N as a, O as o, Q as s, S as c, T as l, U as u, W as ee, Z as d, _ as te, _t as f, a as p, at as m, b as h, c as g, f as _, ft as v, g as y, gt as b, h as x, ht as ne, i as re, it as ie, lt as ae, m as S, mt as C, nt as oe, o as se, ot as w, p as T, q as E, r as D, rt as O, s as k, t as A, v as j, x as ce, y as M } from "./inspectMetrics-DHYygE4s.js";
2
- import { D as le, E as ue, O as N, a as de, p as P, w as F } from "./inspect-BMvQpwqy.js";
1
+ import { B as e, E as t, G as n, I as r, K as i, N as a, O as o, Q as s, S as c, T as l, U as u, W as ee, Z as d, _ as te, _t as f, a as p, at as m, b as h, c as g, f as _, ft as v, g as y, gt as b, h as x, ht as ne, i as re, it as ie, lt as ae, m as S, mt as C, nt as oe, o as se, ot as w, p as T, q as E, r as D, rt as O, s as k, t as A, v as j, x as ce, y as M } from "./inspectMetrics-BeJdP_3b.js";
2
+ import { A as le, E as N, O as P, a as ue, k as F, m as de } from "./inspect-BUUjt773.js";
3
3
  import { t as fe } from "./bootTiming-BdyP9nYw.js";
4
4
  import { dirname as I, extname as pe, join as L, resolve as R } from "node:path";
5
5
  import { fileURLToPath as z, pathToFileURL as B } from "node:url";
@@ -283,7 +283,7 @@ CREATE INDEX IF NOT EXISTS ${e}_servable_until_idx ON ${e} (servable_until);
283
283
  locale: E.locale
284
284
  } : {}
285
285
  }
286
- }), t = E?.locale ?? T.cookies["voltro:lang"] ?? "en", r = (t) => ce({
286
+ }), t = E?.locale ?? T.cookies["voltro:locale"] ?? "en", r = (t) => ce({
287
287
  helpers: C,
288
288
  prepared: e,
289
289
  meta: t,
@@ -349,7 +349,7 @@ CREATE INDEX IF NOT EXISTS ${e}_servable_until_idx ON ${e} (servable_until);
349
349
  C.renderRouterStateScript(O.stateInput)
350
350
  ].filter((e) => e.length > 0).join("\n ");
351
351
  if (g === "ssr" && D === "full" && e.buffered !== !0) {
352
- let e = E?.locale ?? T.cookies["voltro:lang"] ?? "en", t = x({ shell: s });
352
+ let e = E?.locale ?? T.cookies["voltro:locale"] ?? "en", t = x({ shell: s });
353
353
  return {
354
354
  renderMode: "ssr",
355
355
  stream: (n) => y({
@@ -371,13 +371,13 @@ CREATE INDEX IF NOT EXISTS ${e}_servable_until_idx ON ${e} (servable_until);
371
371
  })
372
372
  };
373
373
  }
374
- let { html: le, meta: ue } = C.renderPageToHtml(k), N = A(ue), de = E?.locale ?? T.cookies["voltro:lang"] ?? "en", P = s.replace("<div id=\"root\"></div>", `<div id="root">${le}</div>`).replace("</head>", ` ${N}\n </head>`).replace(/<html(\s+lang="[^"]*")?/i, `<html lang="${de}"`);
375
- D === "none" && (P = Se(P));
376
- let F = g === "isr" ? w(m.revalidate) : null;
374
+ let { html: le, meta: N } = C.renderPageToHtml(k), P = A(N), ue = E?.locale ?? T.cookies["voltro:locale"] ?? "en", F = s.replace("<div id=\"root\"></div>", `<div id="root">${le}</div>`).replace("</head>", ` ${P}\n </head>`).replace(/<html(\s+lang="[^"]*")?/i, `<html lang="${ue}"`);
375
+ D === "none" && (F = Se(F));
376
+ let de = g === "isr" ? w(m.revalidate) : null;
377
377
  return {
378
- html: P,
378
+ html: F,
379
379
  renderMode: g,
380
- revalidateMs: F
380
+ revalidateMs: de
381
381
  };
382
382
  }, $ = (e) => e === "" ? "/" : e, De = async (e) => {
383
383
  let t = await import(B(e).href);
@@ -651,13 +651,13 @@ CREATE INDEX IF NOT EXISTS ${e}_servable_until_idx ON ${e} (servable_until);
651
651
  status: r.status,
652
652
  headers: {
653
653
  "content-type": r.contentType,
654
- ...F(n.origin)
654
+ ...N(n.origin)
655
655
  },
656
656
  body: r.body
657
657
  };
658
658
  }
659
659
  if (i !== "/_voltro/inspect/stream") {
660
- let e = P(t, Ne, n);
660
+ let e = de(t, Ne, n);
661
661
  if (e !== null) return {
662
662
  status: e.status,
663
663
  headers: {
@@ -859,7 +859,7 @@ CREATE INDEX IF NOT EXISTS ${e}_servable_until_idx ON ${e} (servable_until);
859
859
  return;
860
860
  }
861
861
  if (s === "OPTIONS" && o.startsWith("/_voltro/inspect")) {
862
- let e = F(a.origin);
862
+ let e = N(a.origin);
863
863
  t.writeHead(204, e), t.end();
864
864
  return;
865
865
  }
@@ -869,13 +869,13 @@ CREATE INDEX IF NOT EXISTS ${e}_servable_until_idx ON ${e} (servable_until);
869
869
  t.writeHead(400, { "content-type": "text/plain" }), t.end("missing ?target=");
870
870
  return;
871
871
  }
872
- if (!ue(n)) {
872
+ if (!P(n)) {
873
873
  t.writeHead(400, { "content-type": "text/plain" }), t.end("target not allowed: must be loopback or an origin configured in VOLTRO_DASHBOARD_APPS");
874
874
  return;
875
875
  }
876
876
  try {
877
877
  let r = { accept: a.accept ?? "application/json" }, i = a.authorization;
878
- i ? r.authorization = Array.isArray(i) ? i[0] : i : N(n) && process.env.VOLTRO_INSPECT_TOKEN && (r.authorization = `Bearer ${process.env.VOLTRO_INSPECT_TOKEN}`);
878
+ i ? r.authorization = Array.isArray(i) ? i[0] : i : le(n) && process.env.VOLTRO_INSPECT_TOKEN && (r.authorization = `Bearer ${process.env.VOLTRO_INSPECT_TOKEN}`);
879
879
  let o;
880
880
  if (s && s !== "GET" && s !== "HEAD") {
881
881
  let t = [];
@@ -905,14 +905,14 @@ CREATE INDEX IF NOT EXISTS ${e}_servable_until_idx ON ${e} (servable_until);
905
905
  t.writeHead(400, { "content-type": "text/plain" }), t.end("missing ?target=");
906
906
  return;
907
907
  }
908
- if (!ue(n)) {
908
+ if (!P(n)) {
909
909
  t.writeHead(400, { "content-type": "text/plain" }), t.end("target not allowed: must be loopback or an origin configured in VOLTRO_DASHBOARD_APPS");
910
910
  return;
911
911
  }
912
912
  let i = new AbortController();
913
913
  e.on("close", () => i.abort()), t.on("close", () => i.abort());
914
914
  try {
915
- let e = a.authorization ?? (N(n) && process.env.VOLTRO_INSPECT_TOKEN ? `Bearer ${process.env.VOLTRO_INSPECT_TOKEN}` : void 0), r = await fetch(n, {
915
+ let e = a.authorization ?? (le(n) && process.env.VOLTRO_INSPECT_TOKEN ? `Bearer ${process.env.VOLTRO_INSPECT_TOKEN}` : void 0), r = await fetch(n, {
916
916
  redirect: "manual",
917
917
  method: "GET",
918
918
  headers: {
@@ -947,15 +947,15 @@ CREATE INDEX IF NOT EXISTS ${e}_servable_until_idx ON ${e} (servable_until);
947
947
  return;
948
948
  }
949
949
  if (o === "/_voltro/inspect/stream") {
950
- let r = F(a.origin);
951
- if (le()) {
950
+ let r = N(a.origin);
951
+ if (F()) {
952
952
  t.writeHead(503, {
953
953
  "content-type": "application/json",
954
954
  ...r
955
955
  }), t.end(JSON.stringify({ error: "inspect disabled (VOLTRO_INSPECT=off)" }));
956
956
  return;
957
957
  }
958
- let i = de(a, "GET");
958
+ let i = ue(a, "GET");
959
959
  if (!i.ok) {
960
960
  t.writeHead(401, {
961
961
  "content-type": "application/json",
@@ -1,3 +1,3 @@
1
- import { et as e } from "./inspectMetrics-DHYygE4s.js";
2
- import { t } from "./start-CsCIaW4-.js";
1
+ import { et as e } from "./inspectMetrics-BeJdP_3b.js";
2
+ import { t } from "./start-ocqB1seX.js";
3
3
  export { e as loadDotEnv, t as runStartCommand };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@voltro/cli",
3
- "version": "0.29.0",
3
+ "version": "0.30.0",
4
4
  "description": "The `voltro` CLI — dev server, codegen, migrations, project scaffolding, agent-docs seeding, and production serve.",
5
5
  "keywords": [
6
6
  "voltro",
@@ -62,22 +62,22 @@
62
62
  "@effect/platform-node": "^0.108.0",
63
63
  "@effect/sql": "^0.52.0",
64
64
  "@effect/workflow": "^0.19.0",
65
- "@voltro/ai": "0.29.0",
66
- "@voltro/cache": "0.29.0",
67
- "@voltro/data-transfer": "0.29.0",
68
- "@voltro/database": "0.29.0",
69
- "@voltro/env": "0.29.0",
70
- "@voltro/kv": "0.29.0",
71
- "@voltro/logger": "0.29.0",
72
- "@voltro/plugin-auth": "0.29.0",
73
- "@voltro/plugin-broadcast": "0.29.0",
74
- "@voltro/plugin-mail": "0.29.0",
75
- "@voltro/plugin-storage": "0.29.0",
76
- "@voltro/plugin-webhooks": "0.29.0",
77
- "@voltro/protocol": "0.29.0",
78
- "@voltro/runtime": "0.29.0",
79
- "@voltro/serverless": "0.29.0",
80
- "@voltro/workflow": "0.29.0",
65
+ "@voltro/ai": "0.30.0",
66
+ "@voltro/cache": "0.30.0",
67
+ "@voltro/data-transfer": "0.30.0",
68
+ "@voltro/database": "0.30.0",
69
+ "@voltro/env": "0.30.0",
70
+ "@voltro/kv": "0.30.0",
71
+ "@voltro/logger": "0.30.0",
72
+ "@voltro/plugin-auth": "0.30.0",
73
+ "@voltro/plugin-broadcast": "0.30.0",
74
+ "@voltro/plugin-mail": "0.30.0",
75
+ "@voltro/plugin-storage": "0.30.0",
76
+ "@voltro/plugin-webhooks": "0.30.0",
77
+ "@voltro/protocol": "0.30.0",
78
+ "@voltro/runtime": "0.30.0",
79
+ "@voltro/serverless": "0.30.0",
80
+ "@voltro/workflow": "0.30.0",
81
81
  "chokidar": "^5.0.0",
82
82
  "ioredis": "^5.11.1",
83
83
  "tinyglobby": "^0.2.17",
@@ -67,6 +67,56 @@ Ask in order; stop at the first "yes":
67
67
  Deep dive: **`data.md`** (queries/mutations/actions/streams/errors),
68
68
  **`workflows.md`**, **`scheduling.md`**.
69
69
 
70
+ **Do NOT hand-roll debounce / one-at-a-time / rate limits around a workflow.**
71
+ They are fields on `workflow({...})`, enforced BEFORE a run exists:
72
+
73
+ ```ts
74
+ debounce: { key: (p) => `tour:${p.rowId}`, period: '15 minutes' }
75
+ singleton: { key: (p) => p.tenantId, mode: 'skip' | 'cancel' }
76
+ concurrency: { limit: 5, key: (p) => p.tenantId }
77
+ throttle: { limit: 100, period: '1 minute' } // queues the excess
78
+ rateLimit: { limit: 100, period: '1 minute' } // DROPS the excess
79
+ batch: { item: Item, key: (i) => i.tenantId, maxSize: 100, timeout: '30 seconds' }
80
+ timeouts: { start: '1 hour', finish: '10 minutes' }
81
+ onFailure: 'notifierWorkflowName'
82
+ cancelOn: [{ event: 'jira.issue.deleted', schema: Deleted,
83
+ match: (e, p) => e.issueKey === p.issueKey }]
84
+ ```
85
+
86
+ The hand-rolled version (an idempotency key carrying a timestamp + a re-check
87
+ loop + a round cap) costs one durable cluster entity PER START to express "one
88
+ job, latest deadline". `voltro workflows flow` shows what is queued and why.
89
+
90
+ `cancelOn` reaches a run whose fiber is not executing anything — sleeping,
91
+ suspended, or still queued — which a race inside the body cannot. `match` is
92
+ REQUIRED (the omitted case would cancel every live run); a run that started AFTER
93
+ the event is never cancelled; queued starts of the same workflow are discarded
94
+ too, or the debounced duplicate fires seconds later against the deleted row.
95
+
96
+ Stopping or re-driving MANY runs is `voltro workflows cancel-many` /
97
+ `replay-many`. Both are a DRY RUN until `--commit`, `--limit` is required (the cap
98
+ IS the blast radius; `truncated` says whether more matched), and a cancel needs a
99
+ `--reason` that lands on every affected run.
100
+
101
+ A model call inside a workflow: `aiStep` / `aiObjectStep` from
102
+ `@voltro/ai/workflow`. Pass `store: ctx.store` and the spend is recorded per step;
103
+ the prompt is journaled as a DIGEST unless you write `recordPrompt: 'full'`. Add
104
+ `offload: true` and the run SUSPENDS instead of holding a worker while the model
105
+ thinks — a dispatcher owns the call and resumes the run. Costs one suspend/resume
106
+ round trip (~250ms), so offload the slow calls and leave a fast classification
107
+ inline.
108
+
109
+ **`idempotencyKey` is the execution's IDENTITY, permanently — not a dedupe
110
+ window.** A second start with the same key replays the first run's result
111
+ forever; after it completes the key is SPENT and a genuinely new invocation is a
112
+ silent no-op. So it must vary per unit of work (`` `tour:${rowId}:${editedAt}` ``,
113
+ not `` `tour:${rowId}` ``), while a flow-control `key` — the RESOURCE runs
114
+ compete for — stays stable. Those are two different fields; conflating them is
115
+ what makes "I need to re-arm a key" feel like a missing feature.
116
+
117
+ A start can now come back `queued` / `dropped` / `skipped` with
118
+ `executionId: null`, so narrow on `handle.status` before using the id.
119
+
70
120
  ### Pick the SERVER primitive (inside a handler)
71
121
 
72
122
  The four above answer "which FILE do I write". This answers "what do I write
@@ -67,6 +67,56 @@ Ask in order; stop at the first "yes":
67
67
  Deep dive: **`data.md`** (queries/mutations/actions/streams/errors),
68
68
  **`workflows.md`**, **`scheduling.md`**.
69
69
 
70
+ **Do NOT hand-roll debounce / one-at-a-time / rate limits around a workflow.**
71
+ They are fields on `workflow({...})`, enforced BEFORE a run exists:
72
+
73
+ ```ts
74
+ debounce: { key: (p) => `tour:${p.rowId}`, period: '15 minutes' }
75
+ singleton: { key: (p) => p.tenantId, mode: 'skip' | 'cancel' }
76
+ concurrency: { limit: 5, key: (p) => p.tenantId }
77
+ throttle: { limit: 100, period: '1 minute' } // queues the excess
78
+ rateLimit: { limit: 100, period: '1 minute' } // DROPS the excess
79
+ batch: { item: Item, key: (i) => i.tenantId, maxSize: 100, timeout: '30 seconds' }
80
+ timeouts: { start: '1 hour', finish: '10 minutes' }
81
+ onFailure: 'notifierWorkflowName'
82
+ cancelOn: [{ event: 'jira.issue.deleted', schema: Deleted,
83
+ match: (e, p) => e.issueKey === p.issueKey }]
84
+ ```
85
+
86
+ The hand-rolled version (an idempotency key carrying a timestamp + a re-check
87
+ loop + a round cap) costs one durable cluster entity PER START to express "one
88
+ job, latest deadline". `voltro workflows flow` shows what is queued and why.
89
+
90
+ `cancelOn` reaches a run whose fiber is not executing anything — sleeping,
91
+ suspended, or still queued — which a race inside the body cannot. `match` is
92
+ REQUIRED (the omitted case would cancel every live run); a run that started AFTER
93
+ the event is never cancelled; queued starts of the same workflow are discarded
94
+ too, or the debounced duplicate fires seconds later against the deleted row.
95
+
96
+ Stopping or re-driving MANY runs is `voltro workflows cancel-many` /
97
+ `replay-many`. Both are a DRY RUN until `--commit`, `--limit` is required (the cap
98
+ IS the blast radius; `truncated` says whether more matched), and a cancel needs a
99
+ `--reason` that lands on every affected run.
100
+
101
+ A model call inside a workflow: `aiStep` / `aiObjectStep` from
102
+ `@voltro/ai/workflow`. Pass `store: ctx.store` and the spend is recorded per step;
103
+ the prompt is journaled as a DIGEST unless you write `recordPrompt: 'full'`. Add
104
+ `offload: true` and the run SUSPENDS instead of holding a worker while the model
105
+ thinks — a dispatcher owns the call and resumes the run. Costs one suspend/resume
106
+ round trip (~250ms), so offload the slow calls and leave a fast classification
107
+ inline.
108
+
109
+ **`idempotencyKey` is the execution's IDENTITY, permanently — not a dedupe
110
+ window.** A second start with the same key replays the first run's result
111
+ forever; after it completes the key is SPENT and a genuinely new invocation is a
112
+ silent no-op. So it must vary per unit of work (`` `tour:${rowId}:${editedAt}` ``,
113
+ not `` `tour:${rowId}` ``), while a flow-control `key` — the RESOURCE runs
114
+ compete for — stays stable. Those are two different fields; conflating them is
115
+ what makes "I need to re-arm a key" feel like a missing feature.
116
+
117
+ A start can now come back `queued` / `dropped` / `skipped` with
118
+ `executionId: null`, so narrow on `handle.status` before using the id.
119
+
70
120
  ### Pick the SERVER primitive (inside a handler)
71
121
 
72
122
  The four above answer "which FILE do I write". This answers "what do I write
@@ -602,7 +652,7 @@ each plugin's own README.
602
652
 
603
653
  | Topic | Open | Summary |
604
654
  |---|---|---|
605
- | **What's new in 0.29.0** | `node_modules/@voltro/cli/templates/agent-docs/whats-new.md` | Everything that changed in this version. Read it before hand-rolling something the framework may now ship. |
655
+ | **What's new in 0.30.0** | `node_modules/@voltro/cli/templates/agent-docs/whats-new.md` | Everything that changed in this version. Read it before hand-rolling something the framework may now ship. |
606
656
  | AI | `node_modules/@voltro/cli/templates/agent-docs/ai.md` | How Voltro treats AI — agents, tools, streaming, RAG — all primitives over the same WebSocket as the rest of the framework. |
607
657
  | Authentication | `node_modules/@voltro/cli/templates/agent-docs/authentication.md` | How @voltro/plugin-auth wires password + session-cookie auth across api + web, plus the pluggable identity-strategy protocol. |
608
658
  | Caching | `node_modules/@voltro/cli/templates/agent-docs/caching.md` | Voltro's caching layer (@voltro/cache) — an always-on memory default, swappable Redis-compatible backends, a low-level wrap primitive, and automatic query-result invalidation. |
@@ -635,7 +685,8 @@ each plugin's own README.
635
685
  | templates.apiBackends | `node_modules/@voltro/cli/templates/agent-docs/templates/apibackends.md` | The minimal Voltro backend — app.config + schema + one streaming query + one tenant-guarded mutation. Tenant-aware out of the box. |
636
686
  | templates.appShells | `node_modules/@voltro/cli/templates/agent-docs/templates/appshells.md` | A marketing landing page — hero, features, CTA. Static-rendered with zero JS on the wire by default. |
637
687
  | templates.custom | `node_modules/@voltro/cli/templates/agent-docs/templates/custom.md` | Add a template the CLI can scaffold from — a directory under voltro-templates/apps/ with a manifest and the files to copy. |
638
- | Templates | `node_modules/@voltro/cli/templates/agent-docs/templates/overview.md` | Thirty-four dogfooded starter templates ship with the frameworktwenty backend shapes, thirteen frontend shapes, and a serverless function library. Scaffold any of them with one CLI call. |
688
+ | templates.mobile | `node_modules/@voltro/cli/templates/agent-docs/templates/mobile.md` | An Expo (React Native) app that is the third consumer of your api the same typed hooks (useSubscription / useMutation), offline-first by default, typed deep links, and device registration for push. Expo owns Metro; voltro dev runs the sibling api. |
689
+ | Templates | `node_modules/@voltro/cli/templates/agent-docs/templates/overview.md` | Thirty-eight dogfooded starter templates ship with the framework — twenty-two backend shapes, fourteen frontend shapes, a serverless function library, and an Expo mobile app. Scaffold any of them with one CLI call. |
639
690
  | templates.serverless | `node_modules/@voltro/cli/templates/agent-docs/templates/serverless.md` | A library of standalone *.serverless.ts functions — pure compute, request-header/geo, outbound HTTP, Web Crypto HMAC, an LLM call, status-controlled errors. Run with voltro serverless; ship to node / Cloudflare / Scaleway. No server, no port. |
640
691
  | Testing | `node_modules/@voltro/cli/templates/agent-docs/testing.md` | Voltro's test story — the @voltro/testing package, the test pyramid (unit handlers/tools → workflow runner → e2e), and the voltro test / voltro e2e CLI commands. |
641
692
  | Workflows | `node_modules/@voltro/cli/templates/agent-docs/workflows.md` | Durable Effect workflows in Voltro — what they are, when to use them, and the current runtime boundaries. |
@@ -9,7 +9,7 @@ each plugin's own README.
9
9
 
10
10
  | Topic | Open | Summary |
11
11
  |---|---|---|
12
- | **What's new in 0.29.0** | `node_modules/@voltro/cli/templates/agent-docs/whats-new.md` | Everything that changed in this version. Read it before hand-rolling something the framework may now ship. |
12
+ | **What's new in 0.30.0** | `node_modules/@voltro/cli/templates/agent-docs/whats-new.md` | Everything that changed in this version. Read it before hand-rolling something the framework may now ship. |
13
13
  | AI | `node_modules/@voltro/cli/templates/agent-docs/ai.md` | How Voltro treats AI — agents, tools, streaming, RAG — all primitives over the same WebSocket as the rest of the framework. |
14
14
  | Authentication | `node_modules/@voltro/cli/templates/agent-docs/authentication.md` | How @voltro/plugin-auth wires password + session-cookie auth across api + web, plus the pluggable identity-strategy protocol. |
15
15
  | Caching | `node_modules/@voltro/cli/templates/agent-docs/caching.md` | Voltro's caching layer (@voltro/cache) — an always-on memory default, swappable Redis-compatible backends, a low-level wrap primitive, and automatic query-result invalidation. |
@@ -42,7 +42,8 @@ each plugin's own README.
42
42
  | templates.apiBackends | `node_modules/@voltro/cli/templates/agent-docs/templates/apibackends.md` | The minimal Voltro backend — app.config + schema + one streaming query + one tenant-guarded mutation. Tenant-aware out of the box. |
43
43
  | templates.appShells | `node_modules/@voltro/cli/templates/agent-docs/templates/appshells.md` | A marketing landing page — hero, features, CTA. Static-rendered with zero JS on the wire by default. |
44
44
  | templates.custom | `node_modules/@voltro/cli/templates/agent-docs/templates/custom.md` | Add a template the CLI can scaffold from — a directory under voltro-templates/apps/ with a manifest and the files to copy. |
45
- | Templates | `node_modules/@voltro/cli/templates/agent-docs/templates/overview.md` | Thirty-four dogfooded starter templates ship with the frameworktwenty backend shapes, thirteen frontend shapes, and a serverless function library. Scaffold any of them with one CLI call. |
45
+ | templates.mobile | `node_modules/@voltro/cli/templates/agent-docs/templates/mobile.md` | An Expo (React Native) app that is the third consumer of your api the same typed hooks (useSubscription / useMutation), offline-first by default, typed deep links, and device registration for push. Expo owns Metro; voltro dev runs the sibling api. |
46
+ | Templates | `node_modules/@voltro/cli/templates/agent-docs/templates/overview.md` | Thirty-eight dogfooded starter templates ship with the framework — twenty-two backend shapes, fourteen frontend shapes, a serverless function library, and an Expo mobile app. Scaffold any of them with one CLI call. |
46
47
  | templates.serverless | `node_modules/@voltro/cli/templates/agent-docs/templates/serverless.md` | A library of standalone *.serverless.ts functions — pure compute, request-header/geo, outbound HTTP, Web Crypto HMAC, an LLM call, status-controlled errors. Run with voltro serverless; ship to node / Cloudflare / Scaleway. No server, no port. |
47
48
  | Testing | `node_modules/@voltro/cli/templates/agent-docs/testing.md` | Voltro's test story — the @voltro/testing package, the test pyramid (unit handlers/tools → workflow runner → e2e), and the voltro test / voltro e2e CLI commands. |
48
49
  | Workflows | `node_modules/@voltro/cli/templates/agent-docs/workflows.md` | Durable Effect workflows in Voltro — what they are, when to use them, and the current runtime boundaries. |
@@ -269,7 +269,7 @@
269
269
  "group": "templates.apiBackends",
270
270
  "description": "The minimal Voltro backend — app.config + schema + one streaming query + one tenant-guarded mutation. Tenant-aware out of the box.",
271
271
  "path": "agent-docs/templates/apibackends.md",
272
- "files": 21
272
+ "files": 22
273
273
  },
274
274
  {
275
275
  "id": "templates/appshells",
@@ -278,7 +278,7 @@
278
278
  "group": "templates.appShells",
279
279
  "description": "A marketing landing page — hero, features, CTA. Static-rendered with zero JS on the wire by default.",
280
280
  "path": "agent-docs/templates/appshells.md",
281
- "files": 13
281
+ "files": 14
282
282
  },
283
283
  {
284
284
  "id": "templates/custom",
@@ -289,12 +289,21 @@
289
289
  "path": "agent-docs/templates/custom.md",
290
290
  "files": 1
291
291
  },
292
+ {
293
+ "id": "templates/mobile",
294
+ "title": "templates.mobile",
295
+ "section": "Templates",
296
+ "group": "templates.mobile",
297
+ "description": "An Expo (React Native) app that is the third consumer of your api — the same typed hooks (useSubscription / useMutation), offline-first by default, typed deep links, and device registration for push. Expo owns Metro; voltro dev runs the sibling api.",
298
+ "path": "agent-docs/templates/mobile.md",
299
+ "files": 1
300
+ },
292
301
  {
293
302
  "id": "templates/overview",
294
303
  "title": "Templates",
295
304
  "section": "Templates",
296
305
  "group": null,
297
- "description": "Thirty-four dogfooded starter templates ship with the framework — twenty backend shapes, thirteen frontend shapes, and a serverless function library. Scaffold any of them with one CLI call.",
306
+ "description": "Thirty-eight dogfooded starter templates ship with the framework — twenty-two backend shapes, fourteen frontend shapes, a serverless function library, and an Expo mobile app. Scaffold any of them with one CLI call.",
298
307
  "path": "agent-docs/templates/overview.md",
299
308
  "files": 1
300
309
  },
@@ -323,7 +332,7 @@
323
332
  "group": null,
324
333
  "description": "Durable Effect workflows in Voltro — what they are, when to use them, and the current runtime boundaries.",
325
334
  "path": "agent-docs/workflows.md",
326
- "files": 9
335
+ "files": 10
327
336
  }
328
337
  ],
329
338
  "plugins": [
@@ -631,6 +640,7 @@
631
640
  "api-backend-mariadb": "agent-docs/templates/apibackends.md",
632
641
  "api-backend-storage": "agent-docs/templates/apibackends.md",
633
642
  "api-backend": "agent-docs/templates/apibackends.md",
643
+ "api-collab": "agent-docs/templates/apibackends.md",
634
644
  "api-data-advanced": "agent-docs/templates/apibackends.md",
635
645
  "api-durable": "agent-docs/templates/apibackends.md",
636
646
  "api-feature-flags": "agent-docs/templates/apibackends.md",
@@ -648,6 +658,7 @@
648
658
  "app": "agent-docs/templates/appshells.md",
649
659
  "blank": "agent-docs/templates/appshells.md",
650
660
  "changelog": "agent-docs/templates/appshells.md",
661
+ "collab": "agent-docs/templates/appshells.md",
651
662
  "contact": "agent-docs/templates/appshells.md",
652
663
  "custom": "agent-docs/templates/custom.md",
653
664
  "dashboard": "agent-docs/templates/appshells.md",
@@ -655,6 +666,7 @@
655
666
  "edge-functions": "agent-docs/templates/serverless.md",
656
667
  "i18n": "agent-docs/templates/appshells.md",
657
668
  "landing": "agent-docs/templates/appshells.md",
669
+ "mobile-app": "agent-docs/templates/mobile.md",
658
670
  "overview": "agent-docs/templates/overview.md",
659
671
  "spa": "agent-docs/templates/appshells.md",
660
672
  "ssr-api": "agent-docs/templates/appshells.md",
@@ -1821,6 +1821,62 @@ const groundedAnswer = (prompt: string) =>
1821
1821
  - **A cache outage degrades to always-generate.** Both the lookup and the store are best-effort — a `CacheError` reads as a miss (or a swallowed put), never a failed call. The cache is an optimisation, not a dependency.
1822
1822
  - **The object variant stores the DECODED object.** With the memory backend it round-trips by reference; with a RESP backend it is JSON, so a schema whose decoded form is not JSON-safe (class instances, non-plain branded carriers) will not survive a cross-process hit — cache the text form or a JSON-safe projection for those.
1823
1823
 
1824
+ ## A model call inside a workflow — `aiStep`
1825
+
1826
+ `@voltro/ai/workflow` wraps a call as a durable step:
1827
+
1828
+ ```ts
1829
+ import { aiStep, aiObjectStep } from '@voltro/ai/workflow'
1830
+
1831
+ const summary = yield* aiStep({
1832
+ name: 'summarise-thread',
1833
+ prompt: `Summarise:\n${thread}`,
1834
+ store: ctx.store,
1835
+ tenantId: payload.tenantId,
1836
+ })
1837
+ ```
1838
+
1839
+ Journaling is **not** what this adds — every `step()` is already journaled, so a replay of a plain wrapped `generateText` returns the recorded completion rather than re-calling the model. Three things are different:
1840
+
1841
+ 1. **It records what the run cost.** A model call inside a workflow was invisible to `_voltro_ai_usage` unless the app remembered to call `recordAiUsage` by hand — so the spend ledger was systematically missing exactly the calls that run unattended. Pass `store` and every call is recorded, attributed to the workflow and the step.
1842
+ 2. **It does not copy the prompt into a second table.** `step({ input })` is written to `_voltro_workflow_run_steps` and rendered in the dashboard; for a prompt built from customer data that is a plaintext copy outside whatever boundary you established for the source. The default records a **digest** plus the length. `recordPrompt: 'full'` exists and has to be typed out.
1843
+ 3. **Provider failures retry like provider failures.** The default policy handles a 429 with a `Retry-After` and a 5xx, rather than every app rediscovering that a bare call fails the whole durable run on a rate limit.
1844
+
1845
+ `aiObjectStep` is the schema-constrained form; the schema is the step's success schema too, so the journaled value decodes on replay exactly as it did on the first run.
1846
+
1847
+ Pass `offload: true` and the run stops occupying a worker while the model thinks — see the next section.
1848
+
1849
+ ## Offloading the call — `offload: true`
1850
+
1851
+ An inline `aiStep` holds a runner fiber for the length of the model call. At six seconds a call and two hundred concurrent runs, that is two hundred parked workers waiting on a socket, and the cluster's concurrency is spent on latency rather than on work.
1852
+
1853
+ ```ts
1854
+ const summary = yield* aiStep({
1855
+ name: 'summarise-thread',
1856
+ prompt: `Summarise:\n${thread}`,
1857
+ store: ctx.store,
1858
+ offload: true,
1859
+ })
1860
+ ```
1861
+
1862
+ The run **suspends**: the worker is released, the wait lives as a row in `_voltro_ai_inferences`, and a dispatcher owns the socket. Two hundred waiting runs become two hundred rows and (by default) four in-flight requests.
1863
+
1864
+ Nothing about this needs a third party to operate an inference tier. It needs something to own the socket while the run sleeps — and a server process is something. The two pieces it is built from already existed: durable suspend/resume (`awaitSignalSuspending`, built for human-in-the-loop waits) and a leased work queue with a coordinated drainer (the same shape the admission queue has).
1865
+
1866
+ **The cost, so you can decide per call.** A suspend/resume round trip adds the dispatcher's poll interval (250 ms) plus one engine wake. On a six-second call that is under 5%; on a 200 ms classification call it doubles the latency. So it is a mode, not a default: offload the calls that are slow enough for a worker to be worth freeing — which is most of them — and leave the fast ones inline.
1867
+
1868
+ **What the queue guarantees.**
1869
+
1870
+ - The enqueue is idempotent. The row id is derived from the execution and the step name, so a replay cannot queue — and pay for — the same call twice.
1871
+ - The claim is a conditional update, not a read-then-write. Two dispatchers cannot both perform (and both bill) one call.
1872
+ - The order is **perform → resume the run → mark the row**. A crash between the resume and the mark leaves a row whose lease expires and is reclaimed, and the second resume of a resolved deferred is a no-op. The other order would leave a run waiting for a signal nobody will send again.
1873
+ - A give-up **resumes the run with the failure**. A queued call that was abandoned without telling its run is the one unrecoverable outcome here, and the ordering exists to rule it out.
1874
+ - Retries follow the same rules as the inline policy, `Retry-After` included, so the two modes do not back off differently.
1875
+
1876
+ `aiObjectStep({ offload: true })` renders your schema to JSON Schema for the dispatcher — a JavaScript Schema cannot be journaled — and still **decodes on the awaiting side**, where the real schema exists.
1877
+
1878
+ The Flow tab shows the queue: what is waiting and for how long, which calls have been waiting more than two minutes, which claims have a lease their dispatcher will never release, and the dispatcher's own last tick. A run parked on an offloaded call reads `suspended` in the run list with no step row yet, so this is the only view of the wait while it is happening.
1879
+
1824
1880
  ## Deliberately your call
1825
1881
 
1826
1882
  The toolkit prices + records + gates; a few things stay explicit by design:
@@ -1853,16 +1853,35 @@ voltro workflows resume <runId>
1853
1853
  voltro workflows signal <runId> --name approval # inject a named signal
1854
1854
  voltro workflows update <runId> --name …
1855
1855
  voltro workflows children <parentExecutionId>
1856
+ voltro workflows flow # the admission queue + ledger
1857
+ voltro workflows pause|unpause <workflowName> # stop/restart admission fleet-wide
1858
+ voltro workflows cancel-many --reason "…" # DRY RUN until --commit
1859
+ voltro workflows replay-many --mode redrive # DRY RUN until --commit
1860
+ voltro workflows inferences # offloaded model calls in flight
1856
1861
  ```
1857
1862
 
1863
+ `inferences` shows what nothing else can: a run parked on an offloaded model call reads `suspended` in the run list with no step row yet, so during a slow provider — the moment you would look — the run list has nothing to say.
1864
+
1858
1865
  Underneath, workflow state lives in the `_voltro_workflow_runs` + `_voltro_workflow_run_steps` tables and is surfaced live by the dashboard's Workflows panel. The same data is reachable over HTTP:
1859
1866
 
1860
1867
  ```bash
1861
1868
  curl -s localhost:$PORT/_voltro/inspect/workflows/runs | jq # recent runs
1862
1869
  curl -s "localhost:$PORT/_voltro/inspect/workflows/runs/<runId>/steps" | jq # step-by-step
1863
1870
  curl -s "localhost:$PORT/_voltro/inspect/workflows/runs/<runId>/events" | jq # the run's event log
1871
+ curl -s "localhost:$PORT/_voltro/inspect/workflows/stats?hours=24" | jq # bucketed run activity (the dashboard chart)
1864
1872
  ```
1865
1873
 
1874
+ The runs endpoint filters **server-side**, so a triage query over a large run history costs one narrow page instead of the whole table:
1875
+
1876
+ ```bash
1877
+ # multi-status + tag search + source + id-prefix + time range — all composable
1878
+ curl -s "localhost:$PORT/_voltro/inspect/workflows/runs?statuses=failed,cancelled&q=orders&source=workflow-rpc&idPrefix=wfrun_&from=2026-08-01T00:00:00Z&to=2026-08-08T00:00:00Z" | jq
1879
+ ```
1880
+
1881
+ `statuses` is a comma list; `q` is a case-insensitive tag substring; `idPrefix` matches the run id **or** the execution id (you never have to know which kind your log line carried); `from`/`to` bound `startedAt`. The dashboard's filter bar sends exactly these params.
1882
+
1883
+ `/workflows/stats` returns ~48 buckets over a trailing window (`hours`, default 24, max 168; optional `tag`), each with `started` / `succeeded` / `failed` / `cancelled` counts, plus per-workflow totals. When the window held more runs than the scan cap, the response says `truncated: true` — the chart renders that as a warning, because a silently-truncated chart shows throughput dropping at exactly the moment it spiked.
1884
+
1866
1885
  Each run row carries ID, name, status (running / succeeded / failed / dead), step count, last completed step, and duration. The per-run action endpoint matches `…/workflows/runs/<runId>/<action>` for `cancel` / `retry` / `suspend` / `resume` / `signal` — the `voltro workflows` subcommands and the dashboard's run-detail buttons both POST to these:
1867
1886
 
1868
1887
  ```bash
@@ -45,7 +45,7 @@ The framework's generated `.framework/app.tsx` wraps the Router in `<I18nProvide
45
45
 
46
46
  Server-side, the active locale is determined by, in priority order:
47
47
 
48
- 1. **`voltro:lang` cookie** — the user's explicit choice (written by `@voltro/ui-shadcn`'s ProfileMenu).
48
+ 1. **`voltro:locale` cookie** — the user's explicit choice (written by `@voltro/ui-shadcn`'s ProfileMenu).
49
49
  2. **`Accept-Language` header** — the browser/OS preference, q-weighted and sorted per RFC 4647.
50
50
  3. **`defaultLocale`** — last-resort fallback.
51
51
 
@@ -240,7 +240,7 @@ export const getCatalog = (locale?: string) => pickCatalog({ en, de }, locale, '
240
240
  ```
241
241
 
242
242
  ```tsx
243
- // any page — meta gets the active locale (from the URL prefix, or the voltro:lang cookie)
243
+ // any page — meta gets the active locale (from the URL prefix, or the voltro:locale cookie)
244
244
  import { getCatalog } from '../locales'
245
245
 
246
246
  export const meta = ({ locale }: { readonly locale: string }): PageMeta => ({
@@ -330,14 +330,14 @@ The wrap deliberately doesn't re-export everything, to keep the framework's bles
330
330
  <!-- source: en/i18n/url-strategies.md -->
331
331
  ## URL strategies
332
332
 
333
- _Cookie-only vs URL-prefix routing (Strategy A / B), the URL-prefix integration sketch, resolveLocale on the /server subpath, the voltro:lang / voltro:theme cookie convention._
333
+ _Cookie-only vs URL-prefix routing (Strategy A / B), the URL-prefix integration sketch, resolveLocale on the /server subpath, the voltro:locale / voltro:theme cookie convention._
334
334
 
335
335
  There are two ways an i18n app can encode the active locale. Pick **one**, based on what the app's URLs are *for*.
336
336
 
337
337
  ## Strategy A — cookie-only (default for product apps)
338
338
 
339
339
  - All locales are served from the **same** URL (`/dashboard`, `/settings`).
340
- - The `voltro:lang` cookie + `Accept-Language` determine which catalog renders.
340
+ - The `voltro:locale` cookie + `Accept-Language` determine which catalog renders.
341
341
  - The ProfileMenu's language switch — or the standalone `<LocaleSwitcher>` from `@voltro/ui-shadcn` — writes the cookie and reloads.
342
342
  - Use when URLs are **functional** (`/dashboard/projects/42/deployments`) and don't need to encode the language. This is most apps.
343
343
 
@@ -511,15 +511,16 @@ The `/server` subpath is convention. Both that path and the main entry are pure
511
511
 
512
512
  ## Cookie convention
513
513
 
514
- The framework reads + writes **`voltro:lang`** for the active language choice (lowercase IETF tag: `en`, `de`, `fr-CA`). `@voltro/ui-shadcn`'s ProfileMenu and the framework's auto-wired resolver agree on this name — **don't pick a different one in app code.**
514
+ The framework reads + writes **`voltro:locale`** for the active language choice (lowercase IETF tag: `en`, `de`, `fr-CA`). `@voltro/ui-shadcn`'s ProfileMenu and the framework's auto-wired resolver agree on this name — **don't pick a different one in app code.**
515
515
 
516
- For theme the parallel cookie is **`voltro:theme`** (values `'system' | 'light' | 'dark'`). Both are managed by the kit's ProfileMenu out of the box; the helpers live in `@voltro/ui-shadcn` (`THEME_COOKIE`, `LANG_COOKIE`, `getCookie`, `setCookie`, `deleteCookie`, `parsePreferenceCookies`, `applyTheme`).
516
+ For theme the parallel cookie is **`voltro:theme`** (values `'system' | 'light' | 'dark'`). Both are managed by the kit's ProfileMenu out of the box; the helpers live in `@voltro/ui-shadcn` (`THEME_COOKIE`, `LOCALE_COOKIE`, `getCookie`, `setCookie`, `deleteCookie`, `parsePreferenceCookies`, `applyTheme`).
517
517
 
518
518
  ## Anti-patterns
519
519
 
520
520
  - **Don't mix strategies within one app.** Pick cookie-only *or* URL-prefix per app; mixing them produces ambiguous canonical URLs and broken language switching.
521
521
  - **Don't read `Accept-Language` on the client.** It's server-only — `navigator.languages` can diverge from what the server saw and cause a hydration mismatch.
522
- - **Don't invent your own cookie name.** The kit and the resolver only agree on `voltro:lang` / `voltro:theme`.
522
+ - **Don't invent your own cookie name.** The kit and the resolver only agree on `voltro:locale` / `voltro:theme`.
523
+ - **Don't hardcode the name either — import the constant.** `LOCALE_COOKIE` / `THEME_COOKIE` from `@voltro/ui-shadcn`. A cookie name the framework READS and your app WRITES is a public API, and it is the only kind where both sides can disagree with nothing failing: the resolver finds nothing, falls back to `Accept-Language`, and a user's language choice quietly stops working — but only for the users whose browser language differs from their choice, which is the least likely case anyone tests. With the constant, a rename in the framework is a compile error in your app. `voltro doctor` flags a written literal for exactly this reason.
523
524
 
524
525
 
525
526
 
@@ -265,11 +265,23 @@ code already speaks:
265
265
 
266
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
267
 
268
- The existing React-client bindings already run in React Native — the runtime has
269
- no DOM dependency. `@voltro/react-native` adds the mobile-specific plumbing **on
270
- top** of that, limited to the parts that need **no per-tenant credentials and no
271
- native runtime**: device registration, background-sync scheduling, offline-first
272
- defaults, a connection-status surface, and the deep-link declaration shape.
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).
273
285
 
274
286
  The package **root is RN-safe** — no `node:*`, no `@voltro/database`, and React
275
287
  is reached only through the hooks (an optional peer). The `_voltro_devices` table
@@ -409,6 +421,6 @@ not built here:
409
421
  |---|---|
410
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. |
411
423
  | **Native module bindings** (camera, biometrics, secure token storage) | Need a native runtime this TS package cannot provide. |
412
- | **Swift / Kotlin SDK generators** | An open product decision not built until demand justifies the maintenance. |
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. |
413
425
  | **Universal-links / App-Links file automation** (`apple-app-site-association`, `assetlinks.json`) | A deployment-layer concern, not a client primitive. |
414
426
  | **`*.deepLink.ts` codegen discovery** | One additive file after the release settles; the descriptor shape is final, so register links via `matchFirstDeepLink()` today. |