@voltro/cli 0.52.0 → 0.54.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (244) hide show
  1. package/CHANGELOG.md +424 -0
  2. package/THIRD-PARTY-NOTICES.md +8311 -3318
  3. package/dist/{agentsMd-0l980yhL.js → agentsMd-BaLC10Na.js} +110 -82
  4. package/dist/agentsMd-DCY1RSs8.js +2 -0
  5. package/dist/apiBuild-CeUN55uk.js +2 -0
  6. package/dist/{apiBuild-CSFI8QGq.js → apiBuild-DTWp0S_q.js} +11 -5
  7. package/dist/bin.js +1 -1
  8. package/dist/build-D4ygSbnV.js +843 -0
  9. package/dist/{checkCommand-COmqc2cB.js → checkCommand-Dg1G7Gwd.js} +6 -6
  10. package/dist/{checkCommand-2SbqzukH.js → checkCommand-L7DTlpIF.js} +1 -1
  11. package/dist/{cloudCmd-QUXh-b5w.js → cloudCmd-DzKcSYuy.js} +1 -1
  12. package/dist/{codegen-VF479Cnb.js → codegen-DSLM8Su9.js} +1 -1
  13. package/dist/codegen-DjgxEOnD.js +2 -0
  14. package/dist/codegenCommand-CG_Vx4lc.js +41 -0
  15. package/dist/{codemodRunner-r7J9lIa7.js → codemodRunner-Cd4xkC6u.js} +109 -11
  16. package/dist/{commands-Cc_nV8WI.js → commands-6Kzi92Np.js} +96 -73
  17. package/dist/{dashboardCommand-C-gKvwqh.js → dashboardCommand-Cq1PWvI1.js} +5 -5
  18. package/dist/{dataCommand-BgpBHnlB.js → dataCommand-DYzW8vkv.js} +3 -3
  19. package/dist/{dbCommand-sHedr-NJ.js → dbCommand-B4NWZtGL.js} +278 -237
  20. package/dist/dbCommand-CSFWs9ev.js +2 -0
  21. package/dist/{dev-CRHoCEiy.js → dev-CmuvUKRq.js} +2903 -2306
  22. package/dist/{dev--A3nsxA3.js → dev-cKUiZZsB.js} +1 -1
  23. package/dist/{doctorCommand-DtfJ3FA6.js → doctorCommand-DCiFVMtZ.js} +101 -69
  24. package/dist/doctorCommand-J3qu4E0Y.js +2 -0
  25. package/dist/{dormancyCommand-Drn7o0No.js → dormancyCommand-w1TrmgYP.js} +1 -1
  26. package/dist/{embeddingsCommand-Z-jO1fWN.js → embeddingsCommand-CMgPyRTr.js} +1 -1
  27. package/dist/{envCommand-D4gCrrTZ.js → envCommand-Cyynmcfa.js} +8 -8
  28. package/dist/{evolveCommand-CMROeKeA.js → evolveCommand-BwvQ8dVH.js} +2 -2
  29. package/dist/fileConventions-l-RIXbx8.js +36 -0
  30. package/dist/{fileTaxonomy-DvDUV9wq.js → fileTaxonomy-CbyMQYx_.js} +42 -42
  31. package/dist/fontPipeline-LxIHa1vo.js +2 -0
  32. package/dist/fontPipeline-Tsh8kZfA.js +152 -0
  33. package/dist/{frameworkTableAssembly-w-XnLa3q.js → frameworkTableAssembly-D7LJuALW.js} +5 -5
  34. package/dist/frameworkTableAssembly-IPD1pUnZ.js +2 -0
  35. package/dist/imagePipeline-B_GVJgm6.js +2 -0
  36. package/dist/imagePipeline-CBZmjT4i.js +127 -0
  37. package/dist/index.js +2 -2
  38. package/dist/{infoCommand-DXM868o_.js → infoCommand-DlYlUPqs.js} +1 -1
  39. package/dist/{inspect-CtL_xTbu.js → inspect-Bd8-9wsi.js} +1 -1
  40. package/dist/inspect-CuoDInfZ.js +2 -0
  41. package/dist/{inspectGateHint-BjnFubmH.js → inspectGateHint-4LxkNtrz.js} +1 -1
  42. package/dist/manifestBuild-C4-J1-m_.js +2 -0
  43. package/dist/{manifestBuild-CuU1VrSm.js → manifestBuild-Cqgsx2bM.js} +1 -1
  44. package/dist/{metaCommands-C6RFmF1r.js → metaCommands-x7RCi2AF.js} +9 -3
  45. package/dist/{migrate-D0F-eTlK.js → migrate-BK_Bbx-_.js} +2 -2
  46. package/dist/mobileCommand-D9O6iq3D.js +428 -0
  47. package/dist/mobileCommand-DAum7tsG.js +2 -0
  48. package/dist/{pageConvention-CzUiSbtU.js → pageConvention-CMpfDN6r.js} +1 -1
  49. package/dist/{privacyCommand-DGdopOI6.js → privacyCommand-BCa2OoZG.js} +1 -1
  50. package/dist/{probeCommand-C9gazU0H.js → probeCommand-_C0YU207.js} +83 -24
  51. package/dist/{projectScaffold-EzlErR4E.js → projectScaffold-CJfP-xbT.js} +1 -1
  52. package/dist/{projectScaffold-B4dmTlwT.js → projectScaffold-CSN0OzBV.js} +2 -2
  53. package/dist/renderModeScan-43yQ2opo.js +147 -0
  54. package/dist/{renderProfile-Ck32Fzxr.js → renderProfile-DvrhVJHa.js} +2 -2
  55. package/dist/{runtimeTrace-BPQyCmC5.js → runtimeTrace-CGWx1Q6l.js} +1 -1
  56. package/dist/{sdkgen-Se88ifTd.js → sdkgen-CDGHQUFj.js} +1 -1
  57. package/dist/serveCommand-BiPe8BJm.js +2 -0
  58. package/dist/{serveCommand-DkP3OT0W.js → serveCommand-Bje09q1v.js} +889 -825
  59. package/dist/serveEntry.js +1 -1
  60. package/dist/start-B0bnJgxI.js +3 -0
  61. package/dist/{start-jw89Xbqy.js → start-Clz-1BHB.js} +633 -455
  62. package/dist/startEntry.js +1 -1
  63. package/dist/{staticCommand-BwNEDlSU.js → staticCommand-ey0kYmOT.js} +1 -1
  64. package/dist/{subcommandNames-DpYs3DXr.js → subcommandNames-CDzfEtKV.js} +3 -3
  65. package/dist/{templates-BR-fb4SP.js → templates-BTWZkJJT.js} +41 -9
  66. package/dist/{test-f3amja6a.js → test-D_kW4KMj.js} +1 -1
  67. package/dist/updateCommand-CIoVDKnj.js +2 -0
  68. package/dist/{updateCommand-BMk2e4ky.js → updateCommand-CRJlAOaM.js} +139 -115
  69. package/dist/{webDev-BgWL9gKV.js → webDev-DSI9SOhs.js} +1598 -1028
  70. package/dist/webDev-DlvZO30c.js +2 -0
  71. package/dist/{webhooksCommand-CoIO3jbj.js → webhooksCommand-BvzXNHji.js} +1 -1
  72. package/dist/workspaceDeps-RKEkX92S.js +45 -0
  73. package/package.json +60 -19
  74. package/templates/AGENTS.core.md +2 -0
  75. package/templates/AGENTS.md +8 -4
  76. package/templates/agent-docs/_index.md +6 -4
  77. package/templates/agent-docs/_manifest.json +21 -5
  78. package/templates/agent-docs/ai.md +6 -6
  79. package/templates/agent-docs/authentication.md +73 -1
  80. package/templates/agent-docs/cli.md +6 -3
  81. package/templates/agent-docs/configuration.md +17 -0
  82. package/templates/agent-docs/data.md +522 -29
  83. package/templates/agent-docs/database/advancedqueries.md +8 -8
  84. package/templates/agent-docs/database/columntypes.md +2 -2
  85. package/templates/agent-docs/database/migrations.md +1 -1
  86. package/templates/agent-docs/database/querying.md +1 -1
  87. package/templates/agent-docs/database/schema.md +1 -1
  88. package/templates/agent-docs/database/seedsdialects.md +65 -3
  89. package/templates/agent-docs/database/transactions.md +3 -3
  90. package/templates/agent-docs/deployment.md +8 -0
  91. package/templates/agent-docs/internationalization.md +4 -2
  92. package/templates/agent-docs/introduction.md +32 -1
  93. package/templates/agent-docs/local-first-mobile.md +226 -30
  94. package/templates/agent-docs/observability.md +5 -1
  95. package/templates/agent-docs/plugins/atlassian.md +2 -2
  96. package/templates/agent-docs/plugins/audit.md +2 -2
  97. package/templates/agent-docs/plugins/auth.md +1 -1
  98. package/templates/agent-docs/plugins/billing.md +1 -1
  99. package/templates/agent-docs/plugins/cdc-out.md +8 -3
  100. package/templates/agent-docs/plugins/comments.md +164 -0
  101. package/templates/agent-docs/plugins/notifications.md +47 -4
  102. package/templates/agent-docs/plugins/presence.md +45 -3
  103. package/templates/agent-docs/plugins/prometheus.md +3 -1
  104. package/templates/agent-docs/plugins/queue.md +172 -0
  105. package/templates/agent-docs/plugins.md +17 -13
  106. package/templates/agent-docs/reference.md +35 -4
  107. package/templates/agent-docs/routing.md +585 -7
  108. package/templates/agent-docs/scheduling.md +1 -1
  109. package/templates/agent-docs/schema-driven-ui.md +226 -4
  110. package/templates/agent-docs/security.md +3 -3
  111. package/templates/agent-docs/templates/appshells.md +36 -4
  112. package/templates/agent-docs/whats-new.md +134 -66
  113. package/templates/apps/api-ai/package.json +6 -6
  114. package/templates/apps/api-auth/package.json +8 -8
  115. package/templates/apps/api-backend/package.json +7 -7
  116. package/templates/apps/api-backend-deactivation/package.json +7 -7
  117. package/templates/apps/api-backend-mail/package.json +8 -8
  118. package/templates/apps/api-backend-mariadb/package.json +9 -9
  119. package/templates/apps/api-backend-sqlite/package.json +8 -8
  120. package/templates/apps/api-backend-storage/package.json +8 -8
  121. package/templates/apps/api-cms/package.json +9 -9
  122. package/templates/apps/api-collab/README.md +3 -3
  123. package/templates/apps/api-collab/app.config.ts +1 -1
  124. package/templates/apps/api-collab/database/schema.ts +12 -8
  125. package/templates/apps/api-collab/mutations/documents.create.mutation.server.ts +1 -1
  126. package/templates/apps/api-collab/mutations/documents.setBody.mutation.server.ts +1 -1
  127. package/templates/apps/api-collab/mutations/documents.setBody.mutation.ts +1 -1
  128. package/templates/apps/api-collab/package.json +8 -8
  129. package/templates/apps/api-collab/template.json +1 -1
  130. package/templates/apps/api-collab/tests/documents.setBody.test.ts +10 -2
  131. package/templates/apps/api-data-advanced/package.json +8 -8
  132. package/templates/apps/api-durable/package.json +8 -8
  133. package/templates/apps/api-feature-flags/package.json +9 -9
  134. package/templates/apps/api-governance/package.json +8 -8
  135. package/templates/apps/api-kv/package.json +8 -8
  136. package/templates/apps/api-moderation/package.json +8 -8
  137. package/templates/apps/api-observability/package.json +8 -8
  138. package/templates/apps/api-ratelimit/package.json +8 -8
  139. package/templates/apps/api-rbac/package.json +8 -8
  140. package/templates/apps/api-rest/package.json +7 -7
  141. package/templates/apps/api-row-history/package.json +8 -8
  142. package/templates/apps/api-saas/package.json +11 -10
  143. package/templates/apps/api-saas-starter/package.json +10 -10
  144. package/templates/apps/api-search/package.json +8 -8
  145. package/templates/apps/api-status/package.json +8 -8
  146. package/templates/apps/api-webhooks/package.json +9 -9
  147. package/templates/apps/changelog/app.config.ts +26 -2
  148. package/templates/apps/changelog/content/releases/{0.1.0.mdx → v0-1-0.mdx} +0 -1
  149. package/templates/apps/changelog/content/releases/{0.2.0.mdx → v0-2-0.mdx} +0 -1
  150. package/templates/apps/changelog/package.json +9 -8
  151. package/templates/apps/changelog/src/collections/releases.collection.ts +48 -0
  152. package/templates/apps/changelog/src/globals.d.ts +1 -1
  153. package/templates/apps/changelog/src/locales/de.ts +1 -1
  154. package/templates/apps/changelog/src/locales/en.ts +1 -1
  155. package/templates/apps/changelog/src/pages/[locale]/[slug]/page.tsx +7 -4
  156. package/templates/apps/changelog/src/pages/[locale]/mirrors.test.tsx +13 -41
  157. package/templates/apps/changelog/src/pages/[slug]/page.test.tsx +26 -51
  158. package/templates/apps/changelog/src/pages/[slug]/page.tsx +21 -19
  159. package/templates/apps/changelog/src/pages/page.test.tsx +14 -37
  160. package/templates/apps/changelog/src/pages/page.tsx +18 -12
  161. package/templates/apps/edge-functions/package.json +2 -2
  162. package/templates/apps/frontend-admin/package.json +8 -8
  163. package/templates/apps/frontend-app/package.json +9 -9
  164. package/templates/apps/frontend-auth/package.json +8 -8
  165. package/templates/apps/frontend-blank/package.json +7 -7
  166. package/templates/apps/frontend-cms/package.json +9 -9
  167. package/templates/apps/frontend-collab/README.md +43 -24
  168. package/templates/apps/frontend-collab/app.config.ts +3 -3
  169. package/templates/apps/frontend-collab/package.json +14 -10
  170. package/templates/apps/frontend-collab/src/locales/de.ts +1 -2
  171. package/templates/apps/frontend-collab/src/locales/en.ts +1 -2
  172. package/templates/apps/frontend-collab/src/pages/page.test.tsx +72 -76
  173. package/templates/apps/frontend-collab/src/pages/page.tsx +45 -22
  174. package/templates/apps/frontend-collab/template.json +2 -2
  175. package/templates/apps/frontend-contact/package.json +7 -7
  176. package/templates/apps/frontend-dashboard/package.json +7 -7
  177. package/templates/apps/frontend-docs/content/docs/de/guides/first-page.md +4 -0
  178. package/templates/apps/frontend-docs/content/docs/de/intro/getting-started.md +4 -0
  179. package/templates/apps/frontend-docs/content/docs/en/guides/first-page.md +4 -0
  180. package/templates/apps/frontend-docs/content/docs/en/intro/getting-started.md +4 -0
  181. package/templates/apps/frontend-docs/package.json +9 -6
  182. package/templates/apps/frontend-docs/src/collections/docs.collection.ts +21 -0
  183. package/templates/apps/frontend-docs/src/locales/de.ts +0 -5
  184. package/templates/apps/frontend-docs/src/locales/en.ts +0 -5
  185. package/templates/apps/frontend-docs/src/pages/[locale]/docs/[...slug]/page.tsx +17 -9
  186. package/templates/apps/frontend-docs/src/pages/[locale]/mirrors.test.tsx +10 -5
  187. package/templates/apps/frontend-docs/src/pages/[locale]/page.tsx +8 -0
  188. package/templates/apps/frontend-docs/src/pages/docs/[...slug]/page.test.tsx +53 -15
  189. package/templates/apps/frontend-docs/src/pages/docs/[...slug]/page.tsx +27 -33
  190. package/templates/apps/frontend-docs/src/pages/page.test.tsx +17 -3
  191. package/templates/apps/frontend-docs/src/pages/page.tsx +16 -12
  192. package/templates/apps/frontend-i18n/package.json +6 -6
  193. package/templates/apps/frontend-landing/README.md +48 -0
  194. package/templates/apps/frontend-landing/app.config.ts +28 -0
  195. package/templates/apps/frontend-landing/package.json +7 -6
  196. package/templates/apps/frontend-landing/src/assets/hero.jpg +0 -0
  197. package/templates/apps/frontend-landing/src/fonts/Geist-Variable.woff2 +0 -0
  198. package/templates/apps/frontend-landing/src/fonts/LICENSE-Geist.txt +92 -0
  199. package/templates/apps/frontend-landing/src/globals.css +15 -0
  200. package/templates/apps/frontend-landing/src/globals.d.ts +17 -0
  201. package/templates/apps/frontend-landing/src/locales/de.ts +3 -2
  202. package/templates/apps/frontend-landing/src/locales/en.ts +5 -2
  203. package/templates/apps/frontend-landing/src/pages/page.test.tsx +79 -0
  204. package/templates/apps/frontend-landing/src/pages/page.tsx +26 -3
  205. package/templates/apps/frontend-landing/template.json +2 -2
  206. package/templates/apps/frontend-portal/package.json +8 -8
  207. package/templates/apps/frontend-saas/package.json +8 -8
  208. package/templates/apps/frontend-spa/package.json +7 -7
  209. package/templates/apps/frontend-ssr/package.json +7 -7
  210. package/templates/apps/frontend-ssr-api/package.json +8 -8
  211. package/templates/apps/frontend-static-blog/content/posts/cms-to-ssg.md +12 -0
  212. package/templates/apps/frontend-static-blog/content/posts/hello-static.md +12 -0
  213. package/templates/apps/frontend-static-blog/content/posts/islands-not-hydration.md +14 -0
  214. package/templates/apps/frontend-static-blog/package.json +9 -6
  215. package/templates/apps/frontend-static-blog/src/collections/posts.collection.ts +27 -0
  216. package/templates/apps/frontend-static-blog/src/pages/[locale]/blog/[slug]/page.tsx +7 -4
  217. package/templates/apps/frontend-static-blog/src/pages/[locale]/mirrors.test.tsx +7 -1
  218. package/templates/apps/frontend-static-blog/src/pages/blog/[slug]/page.test.tsx +19 -9
  219. package/templates/apps/frontend-static-blog/src/pages/blog/[slug]/page.tsx +19 -21
  220. package/templates/apps/frontend-static-blog/src/pages/page.test.tsx +18 -5
  221. package/templates/apps/frontend-static-blog/src/pages/page.tsx +21 -14
  222. package/templates/apps/frontend-status/package.json +8 -8
  223. package/templates/apps/mobile-app/package.json +12 -11
  224. package/templates/apps/mobile-app/src/lib/deeplinks.ts +29 -17
  225. package/templates/apps/mobile-app/tests/deeplinks.test.ts +16 -0
  226. package/dist/agentsMd-SDDSkyl4.js +0 -2
  227. package/dist/apiBuild-BYBpL7Pz.js +0 -2
  228. package/dist/build-CPgcMQug.js +0 -793
  229. package/dist/codegen-CctkDO-1.js +0 -2
  230. package/dist/codegenCommand-DCdG2JN-.js +0 -137
  231. package/dist/dbCommand-DNb6yeOG.js +0 -2
  232. package/dist/doctorCommand-CqoWA2p5.js +0 -2
  233. package/dist/fileConventions-DOqD3lPS.js +0 -34
  234. package/dist/frameworkTableAssembly-C6ETawPR.js +0 -2
  235. package/dist/inspect-CuGDYES0.js +0 -2
  236. package/dist/manifestBuild-CPjhvM62.js +0 -2
  237. package/dist/renderModeScan-CcH2X1_D.js +0 -120
  238. package/dist/serveCommand-DLc-BznW.js +0 -2
  239. package/dist/start-DfL3fOiN.js +0 -3
  240. package/dist/updateCommand-5gFVfK5q.js +0 -2
  241. package/dist/webDev-CZbTsDcH.js +0 -2
  242. package/templates/apps/changelog/scripts/generate-rss.mjs +0 -38
  243. package/templates/apps/changelog/src/lib/releases.ts +0 -21
  244. package/templates/apps/frontend-static-blog/src/content/posts.ts +0 -64
@@ -1,6 +1,6 @@
1
1
  # Notifications
2
2
 
3
- > Unified notifications — one send API across email / Slack / SMS / push / in-app, with per-user channel preferences, an in-app inbox, and delivery records.
3
+ > Unified notifications — one send API across email / Slack / SMS / mobile push / web push / in-app, with per-user channel preferences, an in-app inbox, and delivery records.
4
4
 
5
5
 
6
6
 
@@ -9,7 +9,7 @@
9
9
  <!-- source: en/plugins/notifications.md -->
10
10
  ## Notifications
11
11
 
12
- _Unified notifications — one send API across email / Slack / SMS / push / in-app, with per-user channel preferences, an in-app inbox, and delivery records._
12
+ _Unified notifications — one send API across email / Slack / SMS / mobile push / web push / in-app, with per-user channel preferences, an in-app inbox, and delivery records._
13
13
 
14
14
  `@voltro/plugin-notifications` is the **one** messaging answer instead of twenty brand wrappers: a single `send` across channels, per-user preferences, and an in-app inbox with unread counts — not a per-vendor SDK in every handler.
15
15
 
@@ -34,7 +34,7 @@ export default {
34
34
  }
35
35
  ```
36
36
 
37
- The built-in **in-app** channel persists to the notification store and is appended automatically; `consoleChannel()`, `webhookChannel({ url, id?, format?, headers? })`, `emailChannel(send)`, `smsChannel(send)`, `pushChannel({ tokensFor, transport })` (see [Push](#push-apns-fcm)), and `customChannel(id, deliver)` cover the rest. A channel is just `{ id, deliver: (msg) => Promise<void> }` — bring your own.
37
+ The built-in **in-app** channel persists to the notification store and is appended automatically; `consoleChannel()`, `webhookChannel({ url, id?, format?, headers? })`, `emailChannel(send)`, `smsChannel(send)`, `pushChannel({ tokensFor, transport, onTokenRejected? })` (see [Push](#push-apns-fcm)), `webPushChannel()` (see [Web Push](#web-push-vapid)), and `customChannel(id, deliver)` cover the rest. A channel is just `{ id, deliver: (msg) => Promise<void> }` — bring your own.
38
38
 
39
39
  `notificationsPlugin({ channels?, store?, digestWindowMs?, flushIntervalMs?, name? })` — the inbox, per-subject channel preferences, and delivery log **auto-persist to the framework DataStore by default**, durably, on every supported dialect. You only pass an explicit `store` for a **custom** backend (see [Store](#store)). `digestWindowMs` enables [digest/batching](#digest-batching); the scheduled flush (interval `flushIntervalMs`, default 30s) drains digest windows and quiet-hours deferrals.
40
40
 
@@ -75,7 +75,50 @@ pushChannel({
75
75
  })
76
76
  ```
77
77
 
78
- `deliver` formats one `PushPayload` per device token and sends each. A `PushTokenRejected` is captured into the fan-out (recorded `failed`, naming the rejected token — **never** the auth secret) so your app can prune the dead token. The push AUTH secret lives in your `transport` closure — it never enters the package and is never logged.
78
+ Delivery is **per token and isolated**: each device token sends independently, and the delivery log records one row PER TOKEN (`endpoint` names it) — a stale token on one old phone no longer aborts the send to every current device, and the channel counts delivered when at least one token was reached. A `PushTokenRejected` thrown by your transport marks that token's record `failed` (naming the token — **never** the auth secret) and fires the optional `onTokenRejected(token, reason)` hook, which is where your app prunes the dead token from its own table. The push AUTH secret lives in your `transport` closure — it never enters the package and is never logged.
79
+
80
+ ## Web Push (VAPID)
81
+
82
+ `webPushChannel()` delivers real browser push — a user subscribes once and receives notifications with the tab closed. Subscriptions are managed by the plugin itself (per subject AND per browser endpoint, in `_voltro_notification_push_subscriptions`), payloads are encrypted per RFC 8291, auth is VAPID (RFC 8292), and dead endpoints are pruned automatically.
83
+
84
+ ```ts
85
+ // app.config.ts
86
+ notificationsPlugin({
87
+ channels: [webPushChannel({ contact: 'mailto:ops@example.com' })],
88
+ })
89
+ ```
90
+
91
+ **The key is minted, never shipped.** The channel signs with `VOLTRO_VAPID_PRIVATE_KEY` (a base64url P-256 scalar); `voltro dev` mints a per-project value into the gitignored `.env.local` on first boot, and a production boot without one **refuses by name** — there is deliberately no default. The browser-facing public key is DERIVED from the private scalar, so a public/private pair can never desync.
92
+
93
+ **Setup, three steps:**
94
+
95
+ 1. Configure the channel (above). Web push requires HTTPS in production (localhost is exempt).
96
+ 2. Copy the service worker into your web app: `cp node_modules/@voltro/plugin-notifications/sw.js public/sw.js`. Its URL decides its scope — served from the root it covers the whole app. It shows the notification, opens the declared `url` on click, reports the click, and posts the payload to open pages (`{ type: 'voltro:push' }` message) so an in-page inbox can refresh live.
97
+ 3. Offer the subscribe flow with the hook:
98
+
99
+ ```tsx
100
+ import { useWebPush } from '@voltro/plugin-notifications/web'
101
+
102
+ const PushSettings = () => {
103
+ const push = useWebPush()
104
+ if (push.status === 'unsupported') return <p>This browser cannot receive push.</p>
105
+ return push.status === 'subscribed'
106
+ ? <button onClick={() => void push.unsubscribe()}>Disable push</button>
107
+ : <button onClick={() => void push.subscribe()}>Enable push</button>
108
+ }
109
+ ```
110
+
111
+ The semantics, precisely:
112
+
113
+ - **Multi-endpoint is the design, not an edge case.** Three browsers = three endpoint rows for one subject. Delivery is per endpoint and isolated; the delivery log records one row per endpoint. A re-subscribe on the same endpoint takes the row over — the endpoint belongs to the browser profile, and the latest signed-in subject owns it.
114
+ - **Prune is automatic and exact.** A push service answering 404/410 for one endpoint deletes exactly that row; the subject's other browsers keep receiving. No app-side prune code.
115
+ - **Payload cap ~4 KB.** Push services cap the encrypted body; an oversized payload SHRINKS (the `data` bag first, then the body is truncated) rather than being dropped.
116
+ - **Click tracking is built in.** Each delivered notification carries a one-time click token; the service worker's `notificationclick` reports it and the delivery record gains `clickedAt` — open rates read straight off the delivery log.
117
+ - **Preferences, quiet hours and digests apply unchanged** — the channel is a sender like any other (its preference key is `webPush`).
118
+ - **iOS Safari, honestly:** web push works on iOS 16.4+ ONLY for installed home-screen web apps (PWA), never in the browser tab. Do not promise iOS coverage from a plain website.
119
+ - **Scheduled send** is a recipe, not a switch: `defineSchedule` + `send` covers "notify at 9am" without a second delivery queue. Quiet hours and digests already defer within their own semantics.
120
+
121
+ Subscribe/unsubscribe are RPC mutations (`notifications.webPushSubscribe` / `webPushUnsubscribe` — subject-bound, so one subject can never detach another's browser); `notifications.webPushPublicKey` hands the browser its `applicationServerKey`; `notifications.webPushStatus` counts the caller's registered endpoints.
79
122
 
80
123
  ## Digest / batching
81
124
 
@@ -1,6 +1,6 @@
1
1
  # Presence
2
2
 
3
- > Ephemeral realtime presence — who's online in a channel, with heartbeat, live roster, and per-member metadata (status, cursor). Works cross-instance.
3
+ > Ephemeral realtime presence — who's online in a channel, with heartbeat, live roster, and per-member metadata (status, cursor — client-supplied and relayed verbatim; identity fields belong in the server-side resolveMember hook). Works cross-instance.
4
4
 
5
5
 
6
6
 
@@ -9,7 +9,7 @@
9
9
  <!-- source: en/plugins/presence.md -->
10
10
  ## Presence
11
11
 
12
- _Ephemeral realtime presence — who's online in a channel, with heartbeat, live roster, and per-member metadata (status, cursor). Works cross-instance._
12
+ _Ephemeral realtime presence — who's online in a channel, with heartbeat, live roster, and per-member metadata (status, cursor — client-supplied and relayed verbatim; identity fields belong in the server-side resolveMember hook). Works cross-instance._
13
13
 
14
14
  `@voltro/plugin-presence` answers "who's here right now". A client heartbeats into a channel; the roster lists everyone whose heartbeat is fresh. Held **in memory**, owner-partitioned: every member belongs to exactly the replica holding its WebSocket, so concurrent writes to one key are impossible by construction and there is no table, no CRDT and no coordinator.
15
15
 
@@ -49,8 +49,50 @@ const Room = ({ channel }: { channel: string }) => {
49
49
 
50
50
  A member is `{ key, meta }`. **It pushes only when the roster actually moves** — a join, a leave, a change to someone's `meta`, a sweep, a peer's delta, a peer's death. A heartbeat that repeats what the server already knows pushes nothing, which is what keeps a large steady room free.
51
51
 
52
+ **`meta` is client-supplied, unvalidated, and handed to every channel member verbatim — identity does not belong in it.** That contract is right for a cursor or a status flag, and wrong for `userName` / `avatarUrl`: any member of a channel could present any name and any `<img src>` to everyone else. For identity fields, give the plugin a server-side resolver — it runs on every heartbeat and its result is merged OVER the caller's `meta`, so a client cannot override what the server says about them:
53
+
54
+ ```ts
55
+ presencePlugin({
56
+ // `store` is the app's DataStore, handed over at boot — app.config.ts is
57
+ // evaluated long before one exists, so the hook receives it rather than
58
+ // making you smuggle one in through a module cell.
59
+ resolveMember: async ({ subject, store }) => {
60
+ const rows = await store.query({
61
+ table: 'users', predicate: { column: 'id', op: 'eq', value: (subject as { id: string }).id },
62
+ order: [], projection: undefined, skip: undefined, take: 1,
63
+ } as never) as ReadonlyArray<{ name?: string; avatarUrl?: string }>
64
+ const user = rows[0]
65
+ return { userName: user?.name ?? null, avatarUrl: user?.avatarUrl ?? null }
66
+ },
67
+ // The keys the SERVER owns — stripped from the caller's meta before the merge.
68
+ identityFields: ['userName', 'avatarUrl'],
69
+ })
70
+ ```
71
+
72
+ **Read the merge precisely — the obvious resolver has a hole in it.** Resolved
73
+ fields are merged OVER the caller's `meta`, so a key the resolver **does not
74
+ return** is not overwritten: it keeps whatever the client sent. A resolver that
75
+ returns only what it found (`{ userName }` for a user with no avatar) therefore
76
+ leaves a caller-supplied `avatarUrl` — or a `userName` the resolver has never
77
+ heard of — standing in the roster every other member reads. That is the exact
78
+ substitution the hook exists to prevent, and a deployment hit it while adopting
79
+ the hook.
80
+
81
+ Two ways to close it, and declaring is the better one:
82
+
83
+ - **`identityFields: ['userName', 'avatarUrl']`** — the keys the server owns.
84
+ They are removed from the caller's `meta` BEFORE the merge, so a key the
85
+ resolver happens not to return on this call is *absent* rather than
86
+ caller-controlled. Ignored without a `resolveMember`: with no server identity
87
+ to protect, stripping a client field would only delete data the app put there
88
+ deliberately.
89
+ - **Return every identity key on every call**, `null` for the ones you have no
90
+ value for.
91
+
92
+ The roster key is already the subject id, so a by-id lookup is the whole job — keep it cheap or cached. Alternatively resolve identity on the READ side from the roster keys (a `users.getByIds` query over `member.key`) and keep `meta` for ephemeral state only.
93
+
52
94
  > **There is a second `usePresence`, and it is a different hook.**
53
- > [`@voltro/local-first/react`](/docs/local-first/overview#presence--awareness)
95
+ > [`@voltro/local-first/react`](/docs/local-first/overview#presence-awareness)
54
96
  > exports one for peer-to-peer *awareness* — `usePresence(roomId, self, { channel })`
55
97
  > → `{ presence, others, setPresence }` — carrying high-frequency cursor and
56
98
  > selection state over a pub/sub channel. This one is the server-backed roster.
@@ -20,9 +20,11 @@ The framework records metrics into Effect's global `MetricRegistry`. One snapsho
20
20
  - **Core RPC** — `voltro_rpc_requests_total{tag,status}`, `voltro_rpc_errors_total{tag}`, `voltro_rpc_duration_seconds{tag}` (histogram). Emitted at the mutation / action handler boundary.
21
21
  - **HTTP routes** — `voltro_http_requests_total{route,status}`, `voltro_http_duration_seconds{route}`.
22
22
  - **Plugin interceptors** — `voltro_plugin_hook_duration_seconds{hook}`, `voltro_plugin_hook_errors_total{hook}`.
23
- - **Subscriptions** — `voltro_subscriptions_active{tag}` (gauge of currently-open subscriptions), `voltro_subscription_deliveries_total{tag,kind}` + `voltro_subscription_delivery_seconds{tag,kind}` (per-delivery produce→push latency; `kind` = `snapshot` | `delta`).
23
+ - **Subscriptions** — `voltro_subscriptions_active{tag}` (gauge of currently-open subscriptions), `voltro_subscription_deliveries_total{tag,kind}` + `voltro_subscription_delivery_seconds{tag,kind}` (per-delivery produce→push latency; `kind` = `snapshot` | `delta`). Backpressure & resume: `voltro_subscription_buffered_bytes{tag}` (gauge of pending bytes per blocked subscription), `voltro_subscription_coalesced_total{tag}` (updates collapsed onto the newest state while a consumer was blocked), `voltro_subscription_overrun_total{tag}` (streams closed with `SubscriptionOverrun`), `voltro_subscription_oversized_total{tag}` (events over `reactive.socket.oversizedEventBytes` — telemetry, not a cap).
24
24
  - **Schedules (crons)** — `voltro_schedule_runs_total{schedule,status}` (firings by name + outcome — `status` = `succeeded` | `failed`), `voltro_schedule_duration_seconds{schedule}` (histogram), and `voltro_schedule_last_success_timestamp_seconds{schedule}` (a **gauge holding the UNIX time of the last SUCCESS**). Emitted by the framework scheduler, so every cron gets them with no per-handler wiring. A cron fires unattended — the failure mode is silent — so this is the series to alert on: `time() - voltro_schedule_last_success_timestamp_seconds{schedule="…"} > <interval × N>` fires when a job stops succeeding (a failure counter alone can't catch a job that stopped firing at all, but the last-success gauge going stale does). A failure moves the counter but deliberately NOT the gauge.
25
25
  - **Workflows (durable execution)** — `voltro_workflow_runs_total{workflow,status}` (terminal outcomes — `status` = `succeeded` | `failed`), `voltro_workflow_duration_seconds{workflow}` (histogram), and `voltro_workflow_last_success_timestamp_seconds{workflow}` (last-success gauge). Emitted by the workflow run-recording seam. Because the framework applies no retry of its own, a `failed` run is **terminal** — it is the dead-letter state — so `voltro_workflow_runs_total{status="failed"}` **is** the dead-letter rate, and the last-success gauge going stale is the "this workflow stopped completing" alert (same shape as the schedule alert). A failure moves the counter but not the gauge.
26
+ - **Partial prerendering (web)** — `voltro_ppr_shell_serves_total{page}`, `voltro_ppr_hole_passes_total{page}`, `voltro_ppr_hole_settles_total{page}`, `voltro_ppr_hole_errors_total{page}` and `voltro_ppr_hole_pass_seconds{page}` (histogram). `page` is the DECLARED route pattern (`/blog/[slug]`), never a resolved URL — a label that grows with visitors is how a scrape target falls over. `voltro dev` and `voltro start` emit the same set. `voltro_ppr_hole_errors_total` is the alert: the shell already went out with a `200`, so a failed hole pass leaves every `<Await>` boundary on its fallback and nothing else says so.
27
+ - **[Queues](/docs/plugins/queue)** — `voltro_queue_consumed_total{topic,outcome}` (`outcome` = `ok` | `dead-lettered`; the two together are every message the runner finished with, so the dead-letter RATE is a division with no join), `voltro_queue_retries_total{topic}`, `voltro_queue_produced_total{topic}`, and `voltro_queue_lag_messages{topic,partition}` — a gauge of the backlog behind the message just picked up, read out of the fetch response rather than an admin round trip. A message abandoned by a rebalance is deliberately in no outcome: its new owner redelivers and counts it there.
26
28
  - **`@voltro/cache`** counters, Effect's own `effect_fiber_*` runtime metrics, and **any custom metric** you or another plugin defines.
27
29
 
28
30
  Two consumers read the SAME snapshot, so they never disagree:
@@ -0,0 +1,172 @@
1
+ # Queue (Kafka interop)
2
+
3
+ > Consume and produce against an existing Kafka — Schema-decoded consumers (at-least-once, serial per partition, retry + dead-letter), batched producing via a handler service or transactionally through the outbox, and a kafkaSink for cdc-out.
4
+
5
+
6
+
7
+ ---
8
+
9
+ <!-- source: en/plugins/queue.md -->
10
+ ## Queue (Kafka interop)
11
+
12
+ _Consume and produce against an existing Kafka — Schema-decoded consumers (at-least-once, serial per partition, retry + dead-letter), batched producing via a handler service or transactionally through the outbox, and a kafkaSink for cdc-out._
13
+
14
+ `@voltro/plugin-queue` is the door to queues **somebody else owns**: a Voltro
15
+ backend consuming and producing against an adopter's existing Kafka. The
16
+ boundary with the built-ins, in one line each: the [outbox](/docs/data/outbox)
17
+ is *your own* durable side-effects, a [workflow](/docs/workflows/overview) is
18
+ *your own* orchestration — this plugin is interop with foreign infrastructure.
19
+ Kafka first; the provider contract is cut so SQS/RabbitMQ can be later
20
+ implementations.
21
+
22
+ ```ts
23
+ // app.config.ts
24
+ import { queuePlugin } from '@voltro/plugin-queue'
25
+
26
+ export default {
27
+ // …
28
+ plugins: [
29
+ queuePlugin({ brokers: ['kafka-1:9092', 'kafka-2:9092'] }),
30
+ ],
31
+ }
32
+ ```
33
+
34
+ ## Consuming: `*.consumer.ts`
35
+
36
+ ```ts
37
+ // src/consumers/orders.consumer.ts
38
+ import { Schema } from 'effect'
39
+ import { defineQueueConsumer } from '@voltro/plugin-queue'
40
+
41
+ export const orders = defineQueueConsumer({
42
+ topic: 'orders',
43
+ schema: Schema.Struct({ orderId: Schema.String, total: Schema.Number }),
44
+ handler: async (order, ctx) => {
45
+ // MUST be idempotent — delivery is at-least-once. A unique-column
46
+ // upsert is the standard shape:
47
+ await ctx.store.upsert('orders_mirror',
48
+ { orderId: order.orderId, total: order.total },
49
+ { conflictColumns: ['orderId'] })
50
+ },
51
+ })
52
+ ```
53
+
54
+ Both boot paths discover `*.consumer.ts`; the plugin starts every registered
55
+ consumer at activation and stops them at shutdown. The semantics, precisely:
56
+
57
+ - **Ordering: serial per partition.** Parallelism exists only ACROSS
58
+ partitions — concurrency inside one would destroy ordering and commit
59
+ semantics both. Retry backoff deliberately BLOCKS the partition.
60
+ - **Commit after the handler, per message.** A process killed mid-batch
61
+ redelivers exactly the unhandled tail — never the whole batch, never a
62
+ skipped message.
63
+ - **Decode failures dead-letter IMMEDIATELY** (to `<topic>.dlq`, with
64
+ `x-voltro-dlq-*` reason headers) — a deterministic failure retried forever
65
+ is an infinite loop with extra steps, and a poison message must release
66
+ its partition.
67
+ - **Handler failures retry with backoff, then dead-letter** after
68
+ `maxAttempts` (default 3).
69
+ - **A rebalance is not a failure.** A partition revoked mid-batch or
70
+ mid-retry stops processing without a retry-counter increment or a DLQ
71
+ publish — the new owner redelivers.
72
+ - **Handlers are NOT wrapped in a transaction** (the same documented
73
+ boundary as HTTP route handlers). A handler needing atomic multi-writes
74
+ opens `ctx.store.transactional` itself — and stays idempotent either way.
75
+ - **Replica coordination is Kafka's own.** Every replica joins the same
76
+ consumer group and the broker assigns partitions — no advisory lock, unlike
77
+ [schedules](/docs/scheduling/coordination), which coordinate through the
78
+ claim table because no broker exists to do it for them.
79
+
80
+ ## Producing
81
+
82
+ Two paths, one rule: transactional-with-a-write goes through the outbox.
83
+
84
+ ```ts
85
+ // Inside a mutation — commits or rolls back WITH the domain write:
86
+ await ctx.outbox.enqueue('queue.produce', {
87
+ topic: 'orders',
88
+ messages: [{ key: order.id, value: JSON.stringify(order) }],
89
+ })
90
+ ```
91
+
92
+ ```ts
93
+ // src/queue.outbox.ts — the bridge (once per app):
94
+ import { queueOutboxHandler } from '@voltro/plugin-queue'
95
+ export default queueOutboxHandler()
96
+ ```
97
+
98
+ The outbox runner delivers after commit — batched (`messages` is an array →
99
+ one transport round-trip), at-least-once, retried, dead-lettered. One
100
+ durability path: the existing outbox, not a second one. For fire-and-forget
101
+ producing without a surrounding write, `yield* QueueService` in a handler and
102
+ call `produce(topic, messages)` directly.
103
+
104
+ Topic creation is EXPLICIT (`provider.ensureTopics([...])`) — your Kafka is
105
+ foreign infrastructure, and whether a client may create topics is your
106
+ policy. A consumer started against a topic that does not exist yet warns and
107
+ retries in the background (it connects once the topic appears), never
108
+ aborting the boot.
109
+
110
+ ## cdc-out to Kafka
111
+
112
+ `kafkaSink` plugs table-change mirroring ([plugin-cdc-out](/docs/plugins/cdc-out))
113
+ into the SAME provider: message key = the row id (one row's changes stay
114
+ ordered in one partition), value = the change record, and the
115
+ `x-voltro-delivery-key` header carries cdc-out's at-least-once dedupe handle.
116
+
117
+ ```ts
118
+ import { cdcOutPlugin } from '@voltro/plugin-cdc-out'
119
+ import { kafkaSink } from '@voltro/plugin-queue'
120
+
121
+ cdcOutPlugin({ sinks: [{ table: 'orders', sink: kafkaSink({ topic: 'orders.cdc' }) }] })
122
+ ```
123
+
124
+ ## Observability
125
+
126
+ **Metrics.** Every counter is exported to the [metrics
127
+ registry](/docs/observability/overview#metrics-export), so it is scrapeable via
128
+ [`@voltro/plugin-prometheus`](/docs/plugins/prometheus) and readable at
129
+ `GET /_voltro/inspect/metrics`:
130
+
131
+ | Series | Type | What it answers |
132
+ | --- | --- | --- |
133
+ | `voltro_queue_consumed_total{topic,outcome}` | counter | Throughput, and — with `outcome` = `ok` \| `dead-lettered` — the dead-letter rate as a plain division. |
134
+ | `voltro_queue_retries_total{topic}` | counter | In-process handler retries. A retry BLOCKS its partition, so a rising rate is head-of-line latency, not just noise. |
135
+ | `voltro_queue_produced_total{topic}` | counter | Messages produced through the outbox bridge. |
136
+ | `voltro_queue_lag_messages{topic,partition}` | gauge | Backlog behind the message just picked up — "are the consumers keeping up", which no counter can answer. |
137
+
138
+ `outcome` has two values on purpose: `ok` + `dead-lettered` is *every* message
139
+ the runner finished with. A message abandoned by a **rebalance** is in neither —
140
+ it was not consumed here, its new owner redelivers it and counts it there, and
141
+ counting it twice would make the dead-letter ratio wrong in the direction of
142
+ looking healthy.
143
+
144
+ Lag is a **sample at pickup** and costs nothing to collect (`highWatermark`
145
+ rides along in the fetch response — no admin round trip per message). Read it
146
+ together with the consume rate: nothing arrives to move the gauge on an idle or
147
+ revoked partition, so it holds its last value, and a frozen high lag and a
148
+ frozen low lag look identical on their own.
149
+
150
+ The per-topic counters plus the last error also stay on
151
+ `GET /_voltro/inspect/plugins/queue/consumers` and in the dashboards' Queue
152
+ panel — that view is this replica, right now, and carries an error *string*,
153
+ which is not a time series. Both are moved by one recorder each, so they cannot
154
+ drift.
155
+
156
+ **Tracing.** Each consumed message is processed inside a `queue.consume` span
157
+ that ADOPTS the producer's `traceparent` header as its parent, so a Kafka hop no
158
+ longer ends the trace. The span covers the whole message — decode, every retry,
159
+ and the dead-letter publish — and carries `messaging.system`,
160
+ `messaging.destination.name`, `messaging.consumer.group.name`,
161
+ `messaging.destination.partition.id`, `messaging.message.offset` and
162
+ `voltro.queue.outcome` (`ok` | `dead-lettered` | `stale`). A missing or
163
+ malformed `traceparent` starts a fresh root span rather than failing the
164
+ message. `ctx.traceparent` is still handed to your handler for hops the
165
+ framework does not make for you.
166
+
167
+ Consumer spans are emitted from detached work — a broker callback, outside the
168
+ server's Effect scope — and reach the server's tracer because the server
169
+ publishes its tracer instance for exactly that case. There is still only ONE
170
+ tracer: a second provider would mean a second exporter nothing flushes at
171
+ shutdown. The same applies to `cdcOut.deliver` and
172
+ `plugin.<name>.schedule-fire`, which run detached for the same reason.
@@ -42,7 +42,9 @@ The framework ships some plugins; you write your own; the contract is small enou
42
42
  - [plugin-logship](/docs/plugins/logship) — ship structured logs to Better Stack / Axiom / Loki / any HTTP sink; batched, redacted, fail-soft
43
43
  - [plugin-moderation](/docs/plugins/moderation) — moderate user content before commit: keyword or AI provider, block / flag via interceptor + in-handler redact
44
44
  - [plugin-search](/docs/plugins/search) — keep an external index (Typesense / Meilisearch / Algolia) in sync via the ChangeEvent tap; tenant-scoped `search.query` + hook
45
- - [plugin-cdc-out](/docs/plugins/cdc-out) — declarative reverse-ETL: mirror table changes outward to a webhook sink (or any custom `CdcSink`) through a durable outbox; ordered per pipe, at-least-once from enqueue, dead-lettered
45
+ - [plugin-cdc-out](/docs/plugins/cdc-out) — declarative reverse-ETL: mirror table changes outward to a webhook or Kafka sink (or any custom `CdcSink`) through a durable outbox; ordered per pipe, at-least-once from enqueue, dead-lettered
46
+ - [plugin-queue](/docs/plugins/queue) — Kafka interop: Schema-decoded consumers (`*.consumer.ts`; at-least-once, serial per partition, retry + dead-letter), batched producing via a service or transactionally through the outbox, and a `kafkaSink` for cdc-out
47
+ - [plugin-comments](/docs/plugins/comments) — comment threads on any app entity: replies, resolve/reopen, tenant-safe @-mentions with notifications, reactions, unread — live over the reactive engine, with the ejectable `<CommentsThread>` UI
46
48
  - [plugin-governance](/docs/plugins/governance) — data governance: retention TTL sweep, GDPR export + erasure, consent ledger, field encryption
47
49
  - [plugin-openapi](/docs/plugins/openapi) — OpenAPI 3.1 spec + Swagger-UI docs generated from your `defineRestRoute` descriptors and (opt-in) rpc procedures
48
50
  - [plugin-row-history](/docs/plugins/row-history) — full row history + time-travel (`rowHistory` / `rowAsOf` / `restoreAsOf` / `diffVersions`); what-changed-to-what on every write
@@ -74,13 +76,15 @@ Status legend: ✓ shipped · ◐ partial · — planned.
74
76
  | `@voltro/plugin-postgis` | ✓ | Postgres-native `geography` / `geometry` columns + spatial predicates (`ST_DWithin`, `ST_Contains`, `ST_Intersects`); GiST indexes via `.expressionIndex(..., { kind: 'gist' })`. No `ST_Distance` projection yet. Postgres-only by design (fails loud elsewhere). [→ details](/docs/plugins/postgis) |
75
77
  | `@voltro/plugin-broadcast` | ✓ | Cross-replica reactivity — fans out app-mutation change events to every replica over a pub/sub bus (Redis / NATS). Closes the single-instance gap for every non-postgres dialect. [→ details](/docs/plugins/broadcast) |
76
78
  | `@voltro/plugin-webhooks` | ✓ | Incoming + outgoing webhooks — `defineIncomingWebhook` (signature verify + idempotency, Stripe/GitHub/Slack presets) and `defineEvent` (durable delivery workflow, HMAC signing, retries, filters). [→ details](/docs/plugins/webhooks) |
79
+ | `@voltro/plugin-comments` | ✓ | Comment threads anchored to any entity — fail-closed access delegation (`viaEntity`/`scope`), replies, resolve/reopen, tenant-safe mentions (validated twice, delivered via plugin-notifications incl. digests), reactions, per-subject unread, live `comments.list`, `<CommentsThread>` in `@voltro/ui`. [→ details](/docs/plugins/comments) |
80
+ | `@voltro/plugin-queue` | ✓ | Kafka interop — `defineQueueConsumer` (`*.consumer.ts`, Schema-decoded, at-least-once, serial per partition, retry + DLQ with reason headers), batched producing via `QueueService` or transactionally through the outbox (`queueOutboxHandler`), `kafkaSink` for cdc-out, per-topic counters in the dashboards. [→ details](/docs/plugins/queue) |
77
81
  | `@voltro/plugin-auth-social` | ✓ | First-party social login — Sign in with Google / GitHub / Apple with no identity vendor: authorize URL + code exchange + JWKS-verified ID tokens, mandatory PKCE (S256) and `state`, an explicit account-linking policy (`never` by default), Apple's signed-JWT client secret / one-time name / private-relay email all handled; sessions via `issueUserSession`. [→ details](/docs/plugins/auth-social) |
78
82
  | `@voltro/plugin-auth-{workos,kinde,clerk,auth0,supabase,oidc}` | ✓ | Six IdP adapters over the shared `jwtBearerStrategy` — JWKS verify + claims→tenant mapping; WorkOS additionally ships hosted-login OAuth primitives (`workosAuthorizationUrl` / `workosAuthenticateWithCode`) for a redirect-based SSO login flow. [→ details](/docs/authentication/external-idp) |
79
- | `@voltro/plugin-analytics-postgres` | ✓ | First-party lite — events on the main DataStore, cross-dialect (postgres / mysql / mariadb / mssql / sqlite / turso). [→ details](/docs/plugins/analytics#voltroplugin-analytics-postgres) |
80
- | `@voltro/plugin-duckdb` | ✓ | Embedded DuckDB sidecar — real OLAP performance, no external service. [→ details](/docs/plugins/analytics#voltroplugin-duckdb) |
81
- | `@voltro/plugin-clickhouse` | ✓ | Production OLAP via the official ClickHouse client. [→ details](/docs/plugins/analytics#voltroplugin-clickhouse) |
82
- | `@voltro/plugin-tinybird` | ✓ | Hosted ClickHouse via Events API + Pipes. [→ details](/docs/plugins/analytics#voltroplugin-tinybird) |
83
- | `@voltro/plugin-posthog` | ✓ | Product analytics — track-only; compose with another sink for reads. [→ details](/docs/plugins/analytics#voltroplugin-posthog) |
83
+ | `@voltro/plugin-analytics-postgres` | ✓ | First-party lite — events on the main DataStore, cross-dialect (postgres / mysql / mariadb / mssql / sqlite / turso). [→ details](/docs/plugins/analytics) |
84
+ | `@voltro/plugin-duckdb` | ✓ | Embedded DuckDB sidecar — real OLAP performance, no external service. [→ details](/docs/plugins/analytics) |
85
+ | `@voltro/plugin-clickhouse` | ✓ | Production OLAP via the official ClickHouse client. [→ details](/docs/plugins/analytics) |
86
+ | `@voltro/plugin-tinybird` | ✓ | Hosted ClickHouse via Events API + Pipes. [→ details](/docs/plugins/analytics) |
87
+ | `@voltro/plugin-posthog` | ✓ | Product analytics — track-only; compose with another sink for reads. [→ details](/docs/plugins/analytics) |
84
88
  | `@voltro/plugin-atlassian` | ✓ | `JiraService` + `ConfluenceService` over the Atlassian REST / Greenhopper / Agile APIs — PAT **or** OAuth 2.0 (3LO) auth, transient retry, SSRF guard, comment-write, signature-verified inbound webhooks, avatar proxy, per-tenant cache. [→ details](/docs/plugins/atlassian) |
85
89
  | `@voltro/plugin-deactivation` | ✓ | `deactivation()` schema mixin — `deactivatedAt` + `deactivatedBy` (→ Actor); subject can't log in but data stays visible. [→ details](/docs/plugins/deactivation) |
86
90
  | `@voltro/plugin-prometheus` | ✓ | Prometheus exporter — `GET /metrics` in text exposition format over the unified Metrics-API (Effect `MetricRegistry`); counters / histograms / gauges + custom metrics, optional bearer gate + node process metrics. [→ details](/docs/plugins/prometheus) |
@@ -91,7 +95,7 @@ Status legend: ✓ shipped · ◐ partial · — planned.
91
95
  | `@voltro/plugin-logship` | ✓ | Ship structured logs to Better Stack / Axiom / Loki / any HTTP sink — rides the log-sink hook, batched + redacted + fail-soft, trace-correlated. [→ details](/docs/plugins/logship) |
92
96
  | `@voltro/plugin-moderation` | ✓ | Content moderation — keyword denylist or AI provider (fails open), block (typed `ContentRejected`) / flag via rpc interceptor + in-handler `moderate()` redact helper. [→ details](/docs/plugins/moderation) |
93
97
  | `@voltro/plugin-search` | ✓ | External search index sync — rides the ChangeEvent tap to mirror tables into Typesense / Meilisearch / Algolia (memory default), tenant-scoped `search.query` action (facets · highlighting · fuzziness · range/negation filters · engine-param passthrough) + `useSearch` hook + `backfillIndex` + durable cross-replica sync stats. [→ details](/docs/plugins/search) |
94
- | `@voltro/plugin-cdc-out` | ◐ | Declarative reverse-ETL — mirror table changes outward to external sinks (webhook, plus a `CdcSink` interface for custom sinks) through a durable outbox; ordered per pipe, at-least-once from enqueue, retried with backoff, dead-lettered. Engine + memory/webhook sinks shipped; anything else implements the `CdcSink` interface. |
98
+ | `@voltro/plugin-cdc-out` | ◐ | Declarative reverse-ETL — mirror table changes outward to external sinks (webhook, Kafka via [`kafkaSink`](/docs/plugins/queue#cdc-out-to-kafka), plus a `CdcSink` interface for custom sinks) through a durable outbox; ordered per pipe, at-least-once from enqueue, retried with backoff, dead-lettered. Engine + memory/webhook/Kafka sinks shipped; a warehouse connector implements the `CdcSink` interface. |
95
99
  | `@voltro/plugin-governance` | ✓ | Data governance — retention TTL sweep (delete / anonymise), GDPR subject export + erasure (admin-gated routes + `GovernanceService`), consent ledger, field encryption. [→ details](/docs/plugins/governance) |
96
100
  | `@voltro/plugin-openapi` | ✓ | OpenAPI 3.1 spec (`GET /openapi.json`) + Swagger-UI (`GET /docs`) generated from `defineRestRoute` descriptors AND (opt-in) rpc procedures (queries/mutations/actions/streams → `POST /rpc/<name>`) — input/output/error Schemas via `JSONSchema.make`. [→ details](/docs/plugins/openapi) |
97
101
  | `@voltro/plugin-row-history` | ✓ | Full row history + time-travel — value snapshot of every insert/update/delete (every table by default; narrow with include/exclude) into `_voltro_row_history` (rides the ChangeEvent tap); `rowHistory` / `rowAsOf` queries + `restoreAsOf` / `diffVersions`; TTL + per-row cap retention. [→ details](/docs/plugins/row-history) |
@@ -880,7 +884,7 @@ A plugin can mount plain HTTP routes beside the rpc surface (`@voltro/plugin-sto
880
884
 
881
885
  - **The full method union is first-class.** `method` is `'*' | 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'HEAD' | 'OPTIONS'`. **HEAD is admitted wherever GET is** (RFC 9110) — the GET handler runs and the transport drops the body; you never mount a second route for it. A wrong method stays a precise `405` with an `Allow:` header, including when several routes share one path.
882
886
  - **The body read is capped** — 8 MiB by default, the same cap as every other surface (`http.maxBodyBytes` in `app.config.ts`, env `VOLTRO_MAX_BODY_BYTES`), and the read is binary-clean. A route that takes more declares its own `maxBodyBytes`; routes **sharing a path share one body read**, so the widest override in the group applies to the group. Oversize answers `413` for both `Content-Length` and chunked requests.
883
- - **Binary streaming responses** return `byteStream` on the `PluginHttpRouteResult` — a `ReadableStream<Uint8Array>` (or lazy thunk) with optional `contentLength` / `contentDisposition`, piped without buffering and never compressed. It is the plugin-route spelling of the REST surface's [`bytes()`](/docs/data/rest-routes#binary-downloads--bytes).
887
+ - **Binary streaming responses** return `byteStream` on the `PluginHttpRouteResult` — a `ReadableStream<Uint8Array>` (or lazy thunk) with optional `contentLength` / `contentDisposition`, piped without buffering and never compressed. It is the plugin-route spelling of the REST surface's [`bytes()`](/docs/data/rest-routes#binary-downloads-bytes).
884
888
  - **Buffered responses are compression-negotiated** (brotli/gzip, compressible types only) by the listener — nothing to declare; see [Security → compression](/docs/security/overview).
885
889
 
886
890
  A state-changing plugin route is origin-checked unless it declares `originGuard: 'exempt'`, and `req.remoteAddr` is the trusted-proxy-resolved client address — both covered with examples in [Security](/docs/security/overview#routes-that-a-third-party-legitimately-posts-to).
@@ -1335,11 +1339,11 @@ The contract is deliberately narrow. **No raw SQL, no funnels, no cohorts, no cu
1335
1339
 
1336
1340
  | Plugin | Type | Best for | Ceiling |
1337
1341
  |---|---|---|---|
1338
- | [`@voltro/plugin-analytics-postgres`](#voltroplugin-analytics-postgres) | Lite | Day-1 zero-setup, dev + early production | ~10M events/day |
1339
- | [`@voltro/plugin-duckdb`](#voltroplugin-duckdb) | Embedded OLAP | Real column-store performance, no external service | Vertical scale: ~hundreds of GB in one process |
1340
- | [`@voltro/plugin-clickhouse`](#voltroplugin-clickhouse) | External OLAP | Production-scale analytics, self-hosted or ClickHouse Cloud | Billions of events comfortably |
1341
- | [`@voltro/plugin-tinybird`](#voltroplugin-tinybird) | Hosted ClickHouse | Pay-as-you-go without operating ClickHouse | Tinybird's own limits |
1342
- | [`@voltro/plugin-posthog`](#voltroplugin-posthog) | Product analytics | Sessions, feature flags, funnels in PostHog's UI | `track()` only — compose with another sink for reads |
1342
+ | [`@voltro/plugin-analytics-postgres`](#voltro-plugin-analytics-postgres) | Lite | Day-1 zero-setup, dev + early production | ~10M events/day |
1343
+ | [`@voltro/plugin-duckdb`](#voltro-plugin-duckdb) | Embedded OLAP | Real column-store performance, no external service | Vertical scale: ~hundreds of GB in one process |
1344
+ | [`@voltro/plugin-clickhouse`](#voltro-plugin-clickhouse) | External OLAP | Production-scale analytics, self-hosted or ClickHouse Cloud | Billions of events comfortably |
1345
+ | [`@voltro/plugin-tinybird`](#voltro-plugin-tinybird) | Hosted ClickHouse | Pay-as-you-go without operating ClickHouse | Tinybird's own limits |
1346
+ | [`@voltro/plugin-posthog`](#voltro-plugin-posthog) | Product analytics | Sessions, feature flags, funnels in PostHog's UI | `track()` only — compose with another sink for reads |
1343
1347
 
1344
1348
  ## Picking one
1345
1349
 
@@ -68,12 +68,14 @@ these before hand-rolling a form, a table, or a picker** — full guide in
68
68
  | [`useAsyncValidation`](/docs/ui/client-utilities/use-async-validation) | Live server-side validation (uniqueness, cross-row) over a query binding. |
69
69
  | [`useDebounced`](/docs/ui/client-utilities/use-debounced) | Debounce a value (search, filter, validation input). |
70
70
  | [`useRecord`](/docs/ui/client-utilities/use-record) | One live record from a "get" query, normalized (array → first row). |
71
+ | [`useValidationMessages`](/docs/ui/forms-and-tables) | The app-wide validation-message resolver from `<ValidationMessagesProvider>`, or `undefined` when none is mounted — for a widget kit that resolves message ids itself. |
71
72
 
72
73
  ## Files, Permissions, and Client Utilities
73
74
 
74
75
  | Hook | Purpose |
75
76
  |---|---|
76
77
  | [`useUpload`](/docs/plugins/storage) | File upload with progress + cancel, on every storage provider. Not base64 → action. |
78
+ | [`usePresenceChannel`](/docs/local-first/overview) | One presence wire for local-first: the peer roster plus a `publish`/`subscribe` pair for ephemeral payloads (cursors, typing), riding the existing presence lane rather than a second socket. Room-scoped — a mismatched room throws instead of delivering across rooms. |
77
79
  | [`useCan`](/docs/ui/client-utilities/use-can) | Scope/RBAC UI gate, over `<PermissionProvider>`. Lives in `@voltro/client` — scopes are a framework concept, so gating a button needs no rbac dependency. |
78
80
  | [`useCanAny`](/docs/ui/client-utilities/use-permissions) | OR variant of `useCan` — true when the subject holds AT LEAST ONE of the required scopes. |
79
81
  | [`usePermissions`](/docs/ui/client-utilities/use-permissions) / `<PermissionProvider>` | The current subject's scope set, fed once from your session query — the source `useCan` reads. Gates UI on the SAME scope strings the server checks. |
@@ -97,6 +99,23 @@ these before hand-rolling a form, a table, or a picker** — full guide in
97
99
  | [`useResumableAgentStream`](/docs/ai/streaming) | Agent stream that survives reload/reconnect. |
98
100
  | [`useDataCopilot`](/docs/ai/data-copilot) | Bind a data-copilot action by api name + tag. |
99
101
 
102
+ ## Plugin and Local-First Hooks
103
+
104
+ Shipped by an installed plugin or by `@voltro/local-first`, not by
105
+ `@voltro/client` — the import path is the package, and each takes the api name
106
+ as its last argument (default `'app'`). The rest of the surface reads exactly
107
+ like the hooks above.
108
+
109
+ | Hook | Package | Purpose |
110
+ |---|---|---|
111
+ | [`useWebPush`](/docs/plugins/notifications) | `@voltro/plugin-notifications/web` | Web-push permission flow, service-worker registration and subscribe/unsubscribe — `{ status, error?, subscribe, unsubscribe }`, where `status` distinguishes `unsupported` / `denied` / `subscribed` for THIS browser. |
112
+ | [`useComments`](/docs/plugins/comments) | `@voltro/plugin-comments/web` | The live threads on one anchor plus every action on them (`create`, `edit`, `resolve`, `remove`, `react`, `markRead`) and the unread badge count. |
113
+ | [`useThread`](/docs/plugins/comments) | `@voltro/plugin-comments/web` | One thread by id — a projection over the same live list, so it opens no second subscription. |
114
+ | [`useMentionSearch`](/docs/plugins/comments) | `@voltro/plugin-comments/web` | `@`-mention autocomplete over the app-declared, tenant-filtered directory. |
115
+ | [`useCrdtText`](/docs/local-first/overview#a-collaborative-text-field-usecrdttext) | `@voltro/local-first/react` | A collaborative text field bound to one `crdtText()` cell — merged text, minimal-span edits, the offline queue and `synced`. |
116
+ | [`useCrdtDoc`](/docs/local-first/overview#rich-text-crdtdoc-usecrdtdoc-usecrdteditor) | `@voltro/local-first/react` | The sync half of a `crdtDoc()` column — one `SyncClient` + one live CRDT document per cell, with the echo guard that keeps a folded remote update from being pushed back. Hands the document to `useCrdtEditor`. |
117
+ | [`useCrdtEditor`](/docs/local-first/overview#rich-text-crdtdoc-usecrdtdoc-usecrdteditor) | `@voltro/local-first/editor` | A collaborative rich-text editor over a `crdtDoc()` column — one Tiptap instance bound to the shared document, carets bridged over an injected transport. |
118
+
100
119
  ## Where To Read Next
101
120
 
102
121
  - [Data hooks](/docs/reference/hooks-data)
@@ -225,6 +244,18 @@ const { data } = useSubscription(
225
244
  )
226
245
  ```
227
246
 
247
+ ### Offline semantics with the local-first mirror
248
+
249
+ With `@voltro/local-first`'s query mirror bound (see
250
+ [the sync engine](/docs/local-first/overview#the-sync-engine-query-mirror-durable-outbox)),
251
+ `useSubscription`'s behaviour extends offline WITHOUT a second API: a cold
252
+ start seeds `data` (and `revision`) from the device's mirrored rows for the
253
+ subject's partition, so `loading` resolves against local data when the server
254
+ is unreachable; the first live event replaces it, and a reconnect inside the
255
+ resume window continues with deltas from the mirrored revision. Offline
256
+ WRITES ride [`useOutbox`](/docs/ui/client-utilities/use-outbox) — durable
257
+ with `outboxPersistence()`, conflict resolution via `resolveConflict`.
258
+
228
259
  ## `useMutation(apiName, rpcTag)`
229
260
 
230
261
  Calls a `defineMutation` RPC.
@@ -592,7 +623,7 @@ window.history.forward() // forward
592
623
 
593
624
  ## `useBlocker()`
594
625
 
595
- Hold a pending navigation so you can prompt before the user leaves — the unsaved-changes guard.
626
+ Hold a pending navigation so you can prompt before the user leaves — the unsaved-changes guard. It guards SPA navigations, the browser's **Back/Forward gestures** (popstate: the router reverts the already-moved URL and offers `retry`/`reset` — this also covers ESC inside an [intercepting-route overlay](/docs/routing/intercepting-routes)), and full-page unloads via `beforeunload`.
596
627
 
597
628
  ```tsx
598
629
  import { useBlocker } from '@voltro/web'
@@ -637,7 +668,7 @@ setParams((p) => { p.set('page', '2'); return p }) // patch one param
637
668
  setParams({ page: '2' }, { push: true }) // distinct history entry
638
669
  ```
639
670
 
640
- Writes default to a history replace; pass `{ push: true }` for a Back entry or `{ scroll: false }` to keep scroll. See [Navigation](/docs/routing/navigation#reading--writing-search-params).
671
+ Writes default to a history replace; pass `{ push: true }` for a Back entry or `{ scroll: false }` to keep scroll. See [Navigation](/docs/routing/navigation#reading-writing-search-params).
641
672
 
642
673
  `useSetSearchParams(searchParams)` — pass the schema to get the **typed** setter. Object form replaces the query (a left-out field decodes to its default on the next read); the updater form receives the current **decoded** params, so a merge is an explicit spread:
643
674
 
@@ -715,7 +746,7 @@ Precisely, it returns `LoaderData<T>`. For every ordinary loader that IS `T`. Fo
715
746
  a loader that returned `defer()`, `LoaderData<T>` flattens the two buckets into
716
747
  one object — eager fields as values, deferred fields as `Promise<T>` — so the
717
748
  compiler tells you which fields have to be rendered through
718
- [`<Await>`](/docs/routing/loaders-and-meta#deferring-slow-data-defer--await):
749
+ [`<Await>`](/docs/routing/loaders-and-meta#deferring-slow-data-defer):
719
750
 
720
751
  ```tsx
721
752
  export const loader = async ({ query }) => defer(
@@ -856,7 +887,7 @@ that must react to router-pushed query changes without a reload re-render throug
856
887
 
857
888
  Prefer the typed form where the page declares a `searchParams` schema export —
858
889
  `useSearchParams(searchParams)` returns the decoded shape instead of a raw
859
- `URLSearchParams`. See [Routing hooks](/docs/reference/hooks-routing#usesearchparams--usesetsearchparams).
890
+ `URLSearchParams`. See [Routing hooks](/docs/reference/hooks-routing#usesearchparams-usesetsearchparams).
860
891
 
861
892
  ## Reading cookies
862
893