@voltro/cli 0.53.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 (181) hide show
  1. package/CHANGELOG.md +195 -0
  2. package/dist/{agentsMd-0l980yhL.js → agentsMd-BaLC10Na.js} +110 -82
  3. package/dist/agentsMd-DCY1RSs8.js +2 -0
  4. package/dist/apiBuild-CeUN55uk.js +2 -0
  5. package/dist/{apiBuild-CaPfoWku.js → apiBuild-DTWp0S_q.js} +2 -2
  6. package/dist/bin.js +1 -1
  7. package/dist/{build-D-OnvNMf.js → build-D4ygSbnV.js} +114 -114
  8. package/dist/{checkCommand-C5elt0tW.js → checkCommand-Dg1G7Gwd.js} +6 -6
  9. package/dist/{checkCommand-D2ZduVlh.js → checkCommand-L7DTlpIF.js} +1 -1
  10. package/dist/{cloudCmd-QUXh-b5w.js → cloudCmd-DzKcSYuy.js} +1 -1
  11. package/dist/{codegen-FEk8AZHb.js → codegen-DSLM8Su9.js} +2 -2
  12. package/dist/codegen-DjgxEOnD.js +2 -0
  13. package/dist/codegenCommand-CG_Vx4lc.js +41 -0
  14. package/dist/{codemodRunner-BjtB2lq6.js → codemodRunner-Cd4xkC6u.js} +9 -9
  15. package/dist/{commands-DyxAmhP0.js → commands-6Kzi92Np.js} +96 -73
  16. package/dist/{dashboardCommand-BdKTyT13.js → dashboardCommand-Cq1PWvI1.js} +3 -3
  17. package/dist/{dataCommand-Bab9X7s8.js → dataCommand-DYzW8vkv.js} +3 -3
  18. package/dist/{dbCommand-06O2finM.js → dbCommand-B4NWZtGL.js} +3 -3
  19. package/dist/dbCommand-CSFWs9ev.js +2 -0
  20. package/dist/{dev-C6LGF4iY.js → dev-CmuvUKRq.js} +2236 -2219
  21. package/dist/{dev-GjJWAYo2.js → dev-cKUiZZsB.js} +1 -1
  22. package/dist/{doctorCommand-etMkflRc.js → doctorCommand-DCiFVMtZ.js} +21 -21
  23. package/dist/doctorCommand-J3qu4E0Y.js +2 -0
  24. package/dist/{dormancyCommand-UwZ1AZzB.js → dormancyCommand-w1TrmgYP.js} +1 -1
  25. package/dist/{embeddingsCommand-C70zWHwo.js → embeddingsCommand-CMgPyRTr.js} +1 -1
  26. package/dist/{envCommand-dSyKvRkM.js → envCommand-Cyynmcfa.js} +15 -15
  27. package/dist/{evolveCommand-CG0_ebO5.js → evolveCommand-BwvQ8dVH.js} +2 -2
  28. package/dist/fileConventions-l-RIXbx8.js +36 -0
  29. package/dist/{fileTaxonomy-B7uxipWS.js → fileTaxonomy-CbyMQYx_.js} +37 -37
  30. package/dist/{frameworkTableAssembly-DKx3ba3S.js → frameworkTableAssembly-D7LJuALW.js} +5 -5
  31. package/dist/frameworkTableAssembly-IPD1pUnZ.js +2 -0
  32. package/dist/index.js +2 -2
  33. package/dist/{infoCommand-_53iOc_j.js → infoCommand-DlYlUPqs.js} +1 -1
  34. package/dist/{metaCommands-Cn2oboG4.js → metaCommands-x7RCi2AF.js} +2 -2
  35. package/dist/{migrate-Cko9rswM.js → migrate-BK_Bbx-_.js} +2 -2
  36. package/dist/mobileCommand-D9O6iq3D.js +428 -0
  37. package/dist/mobileCommand-DAum7tsG.js +2 -0
  38. package/dist/{pageConvention-C938S8oC.js → pageConvention-CMpfDN6r.js} +1 -1
  39. package/dist/{privacyCommand-DWTQMC6R.js → privacyCommand-BCa2OoZG.js} +2 -2
  40. package/dist/{probeCommand-DkGGLknv.js → probeCommand-_C0YU207.js} +1 -1
  41. package/dist/{projectScaffold-EzlErR4E.js → projectScaffold-CJfP-xbT.js} +1 -1
  42. package/dist/{projectScaffold-B4dmTlwT.js → projectScaffold-CSN0OzBV.js} +2 -2
  43. package/dist/renderModeScan-43yQ2opo.js +147 -0
  44. package/dist/{renderProfile-CskIgAfn.js → renderProfile-DvrhVJHa.js} +2 -2
  45. package/dist/{runtimeTrace-c0APJz7E.js → runtimeTrace-CGWx1Q6l.js} +1 -1
  46. package/dist/{sdkgen-BiQCgIEr.js → sdkgen-CDGHQUFj.js} +1 -1
  47. package/dist/serveCommand-BiPe8BJm.js +2 -0
  48. package/dist/{serveCommand-CueKQgzl.js → serveCommand-Bje09q1v.js} +708 -708
  49. package/dist/serveEntry.js +1 -1
  50. package/dist/start-B0bnJgxI.js +3 -0
  51. package/dist/{start-ekPan8BT.js → start-Clz-1BHB.js} +511 -504
  52. package/dist/startEntry.js +1 -1
  53. package/dist/{staticCommand-xlSL-IWk.js → staticCommand-ey0kYmOT.js} +1 -1
  54. package/dist/{subcommandNames-DpYs3DXr.js → subcommandNames-CDzfEtKV.js} +3 -3
  55. package/dist/{templates-BR-fb4SP.js → templates-BTWZkJJT.js} +41 -9
  56. package/dist/{test-BWPQcRoB.js → test-D_kW4KMj.js} +1 -1
  57. package/dist/updateCommand-CIoVDKnj.js +2 -0
  58. package/dist/{updateCommand-C_8I8Rzo.js → updateCommand-CRJlAOaM.js} +1 -1
  59. package/dist/{webDev-oczpugbx.js → webDev-DSI9SOhs.js} +1127 -1090
  60. package/dist/{webDev-C7jWJ5dX.js → webDev-DlvZO30c.js} +1 -1
  61. package/dist/{webhooksCommand-4SVPDjKg.js → webhooksCommand-BvzXNHji.js} +1 -1
  62. package/dist/workspaceDeps-RKEkX92S.js +45 -0
  63. package/package.json +19 -19
  64. package/templates/AGENTS.core.md +2 -0
  65. package/templates/AGENTS.md +4 -2
  66. package/templates/agent-docs/_index.md +2 -2
  67. package/templates/agent-docs/_manifest.json +1 -1
  68. package/templates/agent-docs/ai.md +4 -4
  69. package/templates/agent-docs/authentication.md +72 -0
  70. package/templates/agent-docs/cli.md +4 -1
  71. package/templates/agent-docs/data.md +61 -12
  72. package/templates/agent-docs/database/advancedqueries.md +1 -1
  73. package/templates/agent-docs/database/migrations.md +1 -1
  74. package/templates/agent-docs/database/seedsdialects.md +64 -2
  75. package/templates/agent-docs/internationalization.md +2 -0
  76. package/templates/agent-docs/introduction.md +25 -0
  77. package/templates/agent-docs/local-first-mobile.md +139 -41
  78. package/templates/agent-docs/observability.md +4 -2
  79. package/templates/agent-docs/plugins/atlassian.md +2 -2
  80. package/templates/agent-docs/plugins/audit.md +2 -2
  81. package/templates/agent-docs/plugins/billing.md +1 -1
  82. package/templates/agent-docs/plugins/cdc-out.md +8 -3
  83. package/templates/agent-docs/plugins/comments.md +22 -0
  84. package/templates/agent-docs/plugins/presence.md +32 -3
  85. package/templates/agent-docs/plugins/prometheus.md +2 -0
  86. package/templates/agent-docs/plugins/queue.md +47 -4
  87. package/templates/agent-docs/plugins.md +6 -6
  88. package/templates/agent-docs/reference.md +20 -1
  89. package/templates/agent-docs/routing.md +63 -9
  90. package/templates/agent-docs/scheduling.md +1 -1
  91. package/templates/agent-docs/schema-driven-ui.md +12 -1
  92. package/templates/agent-docs/templates/appshells.md +36 -4
  93. package/templates/agent-docs/whats-new.md +109 -135
  94. package/templates/apps/api-ai/package.json +6 -6
  95. package/templates/apps/api-auth/package.json +8 -8
  96. package/templates/apps/api-backend/package.json +7 -7
  97. package/templates/apps/api-backend-deactivation/package.json +7 -7
  98. package/templates/apps/api-backend-mail/package.json +8 -8
  99. package/templates/apps/api-backend-mariadb/package.json +9 -9
  100. package/templates/apps/api-backend-sqlite/package.json +8 -8
  101. package/templates/apps/api-backend-storage/package.json +8 -8
  102. package/templates/apps/api-cms/package.json +9 -9
  103. package/templates/apps/api-collab/README.md +3 -3
  104. package/templates/apps/api-collab/app.config.ts +1 -1
  105. package/templates/apps/api-collab/database/schema.ts +12 -8
  106. package/templates/apps/api-collab/mutations/documents.create.mutation.server.ts +1 -1
  107. package/templates/apps/api-collab/mutations/documents.setBody.mutation.server.ts +1 -1
  108. package/templates/apps/api-collab/mutations/documents.setBody.mutation.ts +1 -1
  109. package/templates/apps/api-collab/package.json +8 -8
  110. package/templates/apps/api-collab/template.json +1 -1
  111. package/templates/apps/api-collab/tests/documents.setBody.test.ts +10 -2
  112. package/templates/apps/api-data-advanced/package.json +8 -8
  113. package/templates/apps/api-durable/package.json +8 -8
  114. package/templates/apps/api-feature-flags/package.json +9 -9
  115. package/templates/apps/api-governance/package.json +8 -8
  116. package/templates/apps/api-kv/package.json +8 -8
  117. package/templates/apps/api-moderation/package.json +8 -8
  118. package/templates/apps/api-observability/package.json +8 -8
  119. package/templates/apps/api-ratelimit/package.json +8 -8
  120. package/templates/apps/api-rbac/package.json +8 -8
  121. package/templates/apps/api-rest/package.json +7 -7
  122. package/templates/apps/api-row-history/package.json +8 -8
  123. package/templates/apps/api-saas/package.json +11 -10
  124. package/templates/apps/api-saas-starter/package.json +10 -10
  125. package/templates/apps/api-search/package.json +8 -8
  126. package/templates/apps/api-status/package.json +8 -8
  127. package/templates/apps/api-webhooks/package.json +9 -9
  128. package/templates/apps/changelog/package.json +7 -6
  129. package/templates/apps/edge-functions/package.json +2 -2
  130. package/templates/apps/frontend-admin/package.json +8 -8
  131. package/templates/apps/frontend-app/package.json +9 -9
  132. package/templates/apps/frontend-auth/package.json +8 -8
  133. package/templates/apps/frontend-blank/package.json +7 -7
  134. package/templates/apps/frontend-cms/package.json +9 -9
  135. package/templates/apps/frontend-collab/README.md +43 -24
  136. package/templates/apps/frontend-collab/app.config.ts +3 -3
  137. package/templates/apps/frontend-collab/package.json +14 -10
  138. package/templates/apps/frontend-collab/src/locales/de.ts +1 -2
  139. package/templates/apps/frontend-collab/src/locales/en.ts +1 -2
  140. package/templates/apps/frontend-collab/src/pages/page.test.tsx +72 -76
  141. package/templates/apps/frontend-collab/src/pages/page.tsx +45 -22
  142. package/templates/apps/frontend-collab/template.json +2 -2
  143. package/templates/apps/frontend-contact/package.json +7 -7
  144. package/templates/apps/frontend-dashboard/package.json +7 -7
  145. package/templates/apps/frontend-docs/package.json +8 -7
  146. package/templates/apps/frontend-i18n/package.json +6 -6
  147. package/templates/apps/frontend-landing/README.md +48 -0
  148. package/templates/apps/frontend-landing/app.config.ts +28 -0
  149. package/templates/apps/frontend-landing/package.json +7 -6
  150. package/templates/apps/frontend-landing/src/assets/hero.jpg +0 -0
  151. package/templates/apps/frontend-landing/src/fonts/Geist-Variable.woff2 +0 -0
  152. package/templates/apps/frontend-landing/src/fonts/LICENSE-Geist.txt +92 -0
  153. package/templates/apps/frontend-landing/src/globals.css +15 -0
  154. package/templates/apps/frontend-landing/src/globals.d.ts +17 -0
  155. package/templates/apps/frontend-landing/src/locales/de.ts +3 -2
  156. package/templates/apps/frontend-landing/src/locales/en.ts +5 -2
  157. package/templates/apps/frontend-landing/src/pages/page.test.tsx +79 -0
  158. package/templates/apps/frontend-landing/src/pages/page.tsx +26 -3
  159. package/templates/apps/frontend-landing/template.json +2 -2
  160. package/templates/apps/frontend-portal/package.json +8 -8
  161. package/templates/apps/frontend-saas/package.json +8 -8
  162. package/templates/apps/frontend-spa/package.json +7 -7
  163. package/templates/apps/frontend-ssr/package.json +7 -7
  164. package/templates/apps/frontend-ssr-api/package.json +8 -8
  165. package/templates/apps/frontend-static-blog/package.json +8 -7
  166. package/templates/apps/frontend-status/package.json +8 -8
  167. package/templates/apps/mobile-app/package.json +12 -11
  168. package/templates/apps/mobile-app/src/lib/deeplinks.ts +29 -17
  169. package/templates/apps/mobile-app/tests/deeplinks.test.ts +16 -0
  170. package/dist/agentsMd-SDDSkyl4.js +0 -2
  171. package/dist/apiBuild-DHtLXYx9.js +0 -2
  172. package/dist/codegen-BWpt3VgF.js +0 -2
  173. package/dist/codegenCommand-BOiWQ5hz.js +0 -137
  174. package/dist/dbCommand-B1EXBC6f.js +0 -2
  175. package/dist/doctorCommand-B0hX0tdz.js +0 -2
  176. package/dist/fileConventions-DASGEmj-.js +0 -35
  177. package/dist/frameworkTableAssembly-C_7Z-rMs.js +0 -2
  178. package/dist/renderModeScan-CUbOeOAg.js +0 -122
  179. package/dist/serveCommand-DsnrVN3U.js +0 -2
  180. package/dist/start-BJzZLbt8.js +0 -3
  181. package/dist/updateCommand-Bqql_rsQ.js +0 -2
@@ -544,6 +544,16 @@ export const searchParams = Schema.Struct({
544
544
 
545
545
  - `searchParams` (an `effect/Schema` struct — every field optional or with a default) types the page's query string: `useSearchParams(searchParams)` returns the decoded shape, and links built with `withQuery` type-check against it. Details: [Pages → Query strings](/docs/routing/pages#query-strings).
546
546
 
547
+ Two more page exports change what the framework produces for a route:
548
+
549
+ ```tsx
550
+ export const ogImage = ({ params, loaderData, locale }) => ({ type: 'div', props: { /* satori JSX */ } })
551
+ export const intercept = { from: '/photos' }
552
+ ```
553
+
554
+ - `ogImage` declares the page's `og:image` as a satori JSX template. `static` pages render the PNG at BUILD time into `dist/assets/og/`; `ssr` pages render it on demand over a signed route. The `og:image` / `twitter:image` / `twitter:card` tags are injected automatically unless your own `meta` already sets them. A declared font is REQUIRED (there is no bundled default), and an `ssr` route exporting it needs `VOLTRO_OG_SECRET` — `voltro start` refuses the boot otherwise. Details: [Loaders and meta → OG images](/docs/routing/loaders-and-meta#og-images-from-a-template-ogimage).
555
+ - `intercept` makes the page an **intercepting route**: `from` names one or more ROUTE PATTERNS (`'/photos'`, `['/photos', '/albums/[id]']`), and a soft navigation arriving from one of them renders this page as an overlay above the still-mounted origin. Every hard load — and a soft navigation from anywhere else — renders it standalone. Details: [Intercepting routes](/docs/routing/intercepting-routes).
556
+
547
557
  ## Discovery in practice
548
558
 
549
559
  ```text
@@ -605,11 +615,26 @@ If yes, the promise belongs in the name — you cannot see a contract before you
605
615
  | `*.store.ts` | exactly one `defineStore`, no server state | `store/one-per-file`, `store/mirrors-server-state` |
606
616
  | `*.collection.ts` | declares content collections (`defineCollection`); frontmatter schema violations fail the build naming the file | the build's collection decode + reference validation |
607
617
  | `*.consumer.ts` | declares queue consumers (`defineQueueConsumer`, @voltro/plugin-queue); loading registers, the plugin's activation starts them | the queue runner (decode→DLQ, retry→DLQ, commit-per-message) |
618
+ | `*.ws.ts` | default-exports one raw WebSocket gateway (`defineWebSocket`), mounting its own upgrade path beside the rpc socket | boot discovery on BOTH paths (`voltro dev` and `voltro serve`); two gateways on one path refuse the boot |
608
619
 
609
620
  A `*.component.tsx` promises exactly ONE component. It does not promise to export nothing else: types, and plain module-local values a `const COLUMNS = […]` beside the table that renders them, are fine and always were. What the rule counts is components — a declaration that renders — so an object, an array, a string or a `new` beside your component is not a second one, and neither is `export default Card` next to `export const Card`.
610
621
 
611
622
  The BOUNDARY rules (`internal/foreign-import`, `fixture/production-import`, `ui/unlinked`) are assertions about your import graph, so it is worth knowing which edges they follow: relative specifiers, your tsconfig `paths` aliases (read from the nearest `tsconfig.json`, so a per-app `@/*` works when you run `voltro doctor` at the repo root), `export … from` re-exports, and dynamic `import()`. A package import is a leaf — the walk stops at the edge of your app.
612
623
 
624
+ ## Contracts that are not suffixes
625
+
626
+ The admission test above is about the PROMISE, not about the spelling — and three of the framework's conventions carry one without being a suffix on a filename. They are listed here because a reader looking for "what does the framework read out of my tree" would otherwise stop at the table:
627
+
628
+ | Convention | Promise | Read by |
629
+ |---|---|---|
630
+ | `searchParams` page export | the page's query string decodes through this `effect/Schema` struct — every field optional or with a default | `useSearchParams(searchParams)`, `withQuery` link typing, and the render-mode scan (a page declaring BOTH `renderMode: 'isr'` and `searchParams` is refused) |
631
+ | `ogImage` page export | this route's `og:image` is a satori JSX template, not a file you ship | the build (`static` → a hashed PNG in `dist/assets/og/`) and `voltro start` (`ssr` → a signed on-demand route, which needs `VOLTRO_OG_SECRET`) |
632
+ | `intercept` page export | `from` names the routes a soft navigation may arrive from for this page to render as an overlay above them | the client router; a hard load renders the page standalone regardless |
633
+ | `grpc.manifest.json` (app root) | field numbers are checked in and append-only — a deleted field goes `reserved`, never re-used | `voltro grpc proto` and the gRPC surface wiring, which derive wire identity from it rather than from declaration order |
634
+ | `content/<name>/**` | the files a `*.collection.ts` declares — markdown with frontmatter, or `.json` for a data collection | `getCollection` / `getEntry`, the build's collection artifacts, and the dev server's watcher |
635
+
636
+ The page exports are per-ROUTE and the last two are per-APP, which is the only reason they cannot be spellings: there is nothing to rename.
637
+
613
638
  ## `*.component.ui.tsx` — reads, never writes
614
639
 
615
640
  ```tsx
@@ -24,13 +24,14 @@ imports it directly. The React wrappers live behind `@voltro/local-first/react`
24
24
  > authoritative server-side merge on the write path), the
25
25
  > [`SyncClient`](#the-syncclient-bi-directional-wire) that drives the queue over a
26
26
  > transport, [`useCrdtText`](#a-collaborative-text-field-usecrdttext) — the React
27
- > binding for a collaborative text field — [presence/awareness](#presence--awareness)
27
+ > binding for a collaborative text field — [presence/awareness](#presence-awareness)
28
28
  > via `usePresence`, [durable IndexedDB persistence](#durable-persistence), and the
29
- > [`localFirst` table mixin](#the-localfirst-table-mixin). What remains falls in
30
- > two tiers: a thin [runtime binding](#whats-shipped-vs-a-runtime-seam) to
31
- > provisioned infra (a broker at scale) plus the two app-specific tags
32
- > `useCrdtText` is pointed at and the sync **engine**, which is now BUILT:
33
- > the [query mirror](#the-sync-engine-query-mirror--durable-outbox) persists
29
+ > [`localFirst` table mixin](#the-localfirst-table-mixin). What remains is
30
+ > ONE thing the two app-specific tags `useCrdtText` is pointed at
31
+ > ([runtime seam](#what-s-shipped-vs-a-runtime-seam)); the presence broker
32
+ > binding ships and a browser SQL engine was deliberately rejected. And the
33
+ > sync **engine** is now BUILT:
34
+ > the [query mirror](#the-sync-engine-query-mirror-durable-outbox) persists
34
35
  > every subscribed query's rows per subject+tenant partition, `useOutbox`
35
36
  > queues offline writes durably, and a reload renders mirrored rows offline
36
37
  > and delta-resumes online.
@@ -208,25 +209,25 @@ import { Schema } from 'effect'
208
209
  body: Schema.NullOr(Schema.Uint8ArrayFromBase64)
209
210
  ```
210
211
 
211
- ### Known cost limits of `crdtText()` today
212
+ ### What a `crdtText()` keystroke costs
212
213
 
213
- Two amplification effects are worth knowing before you put a `crdtText()` column
214
- on a hot editing path both are per-keystroke costs, and both are real today:
214
+ A CRDT column on a hot editing path has two amplification effects to reason
215
+ about, and the framework handles both neither is left to your query shape:
215
216
 
216
- - **Wire amplification downstream.** A subscription delta carries the row's
217
- columns, and for a CRDT column that is the merged **full state** (base64) —
218
- every keystroke ships the whole document to every subscriber of the query,
219
- not the one-edit update. Keep the streamed query's projection narrow (don't
220
- project `body` into a list view), or subscribe to the document row alone.
221
- - **Undo/row-history capture.** Server-side capture (the undo log default-on
222
- outside production and `plugin-row-history`'s row history, where enabled)
223
- snapshots the row per mutation, so per-keystroke mutations write a
224
- full-state blob per keystroke into those tables. Point them away from
225
- CRDT-heavy tables, or batch edits before pushing.
217
+ - **Downstream the wire carries the EDIT, not the document.** A changed CRDT
218
+ cell diffs into an incremental `mergeCells` subscription op rather than a
219
+ full-blob replace, so a one-character edit ships bytes proportional to the
220
+ edit regardless of document size a query that projects `body` into a list
221
+ view does not stream the whole state per keystroke.
222
+ - **Server-side capture skips CRDT columns.** The undo log (default-on outside
223
+ production) and `plugin-row-history` strip CRDT columns from the captured
224
+ row, and an update touching ONLY CRDT columns is not captured at all, so
225
+ per-keystroke mutations write no full-state blob into those tables.
226
226
 
227
- Both limits are on the framework's roadmap (incremental delivery and
228
- CRDT-aware capture); until then they are costs to design around, not bugs to
229
- report.
227
+ What stays a real cost is the stored value itself: the blob in the row grows
228
+ with the document's edit history and soft-compacts past
229
+ `crdt.compactMaxBytes` — see the [operational
230
+ rules](#rich-text-crdtdoc-usecrdtdoc-usecrdteditor) below.
230
231
 
231
232
  ## Presence & awareness
232
233
 
@@ -267,6 +268,30 @@ propagation, and TTL expiry live in the pure `createPresenceRoom` the hook wraps
267
268
  > is fresh, plus a `useTyping` indicator, through the app's own rpc. Reach for
268
269
  > the plugin for "who is here"; reach for this one for "where is their cursor".
269
270
 
271
+ ### Binding it to the app's own presence lane — `usePresenceChannel`
272
+
273
+ `createInMemoryPresenceChannel()` is the local transport; in a running app the
274
+ binding is [`@voltro/plugin-presence`](/docs/plugins/presence)'s
275
+ `usePresenceChannel(roomId, { selfKey })`, which returns a `PresenceChannel`
276
+ backed by the plugin's existing heartbeat roster and rpc. Pass it straight in as
277
+ the `channel` above:
278
+
279
+ ```tsx
280
+ import { usePresence } from '@voltro/local-first/react'
281
+ import { usePresenceChannel } from '@voltro/plugin-presence/web'
282
+
283
+ function Editor({ documentId, userId }) {
284
+ const channel = usePresenceChannel(documentId, { selfKey: userId })
285
+ const { others, setPresence } = usePresence(documentId, { cursor: 0 }, { channel })
286
+ return <Cursors others={others} />
287
+ }
288
+ ```
289
+
290
+ That is deliberately ONE wire: awareness payloads ride the presence lane the app
291
+ already runs rather than a second socket with its own lifecycle. The channel is
292
+ room-scoped, and publishing into a room it was not created for THROWS — a silent
293
+ cross-room delivery is the failure worth being loud about.
294
+
270
295
  ## The offline sync queue
271
296
 
272
297
  `useSyncQueue()` is a reactive view over a **pure, tested reducer**: writes made
@@ -316,25 +341,83 @@ const adapter = await createIndexedDbPersistence({ databaseName: 'my-app' })
316
341
  const sync = createSyncClient({ transport, adapter }) // state now survives reload
317
342
  ```
318
343
 
319
- ## Rich text: `crdtDoc()` + `useCrdtEditor`
344
+ ## Rich text: `crdtDoc()` + `useCrdtDoc` + `useCrdtEditor`
320
345
 
321
346
  `crdtDoc()` stores a WHOLE collaborative document (rich text, maps, arrays)
322
347
  as a column — same storage and authoritative server merge as `crdtText()`,
323
- which stays as the plain-text specialisation. The editor binding is Tiptap
324
- (`@voltro/local-first/editor`, optional peers): `useCrdtEditor({ doc })`
325
- binds one editor to the document handle; content edits ride the app's
326
- mutation (folded server-side under a per-row mutex) and remote edits arrive
327
- as incremental `mergeCells` subscription deltasa one-character edit ships
328
- under 1 KB in BOTH directions regardless of document size.
348
+ which stays as the plain-text specialisation. The column definitions are
349
+ identical; what differs is which client binding you reach for.
350
+
351
+ Two hooks, and they are a pair. **`useCrdtDoc`**
352
+ (`@voltro/local-first/react`) owns the SYNC half — one `SyncClient` and one
353
+ live CRDT document per cell, the same wire `useCrdtText` rides.
354
+ **`useCrdtEditor`** (`@voltro/local-first/editor`, Tiptap, optional peers)
355
+ owns the EDITOR half and takes that document:
356
+
357
+ ```tsx
358
+ import { useCrdtDoc } from '@voltro/local-first/react'
359
+ import { useCrdtEditor } from '@voltro/local-first/editor'
360
+ import { EditorContent } from '@tiptap/react'
361
+
362
+ const Page = ({ id }: { id: string }) => {
363
+ const row = useSubscription<{ body: Uint8Array | null }>('app', 'documents.byId', { id })
364
+ const save = useMutation<{ id: string; update: Uint8Array }>('app', 'documents.setBody')
365
+ const shared = useCrdtDoc({
366
+ cell: { table: 'documents', id, column: 'body' },
367
+ remote: row.data?.body ?? null, // null = NOT LOADED
368
+ push: (w) => save.mutate({ id: w.id, update: w.update }),
369
+ })
370
+ // `doc` is null until the mount effect has run — render the editor in a
371
+ // CHILD so `useCrdtEditor` is never a conditional hook call.
372
+ return shared.doc === null ? null : <Surface doc={shared.doc} />
373
+ }
374
+
375
+ const Surface = ({ doc }: { doc: CrdtDocHandle }) => (
376
+ <EditorContent editor={useCrdtEditor({ doc })} />
377
+ )
378
+ ```
379
+
380
+ `useCrdtDoc` returns `{ doc, loaded, outstanding, synced, setOnline }`. The
381
+ two names it asks for are the same two `useCrdtText` asks for and the same
382
+ two nothing can derive: the mutation that writes the column, and the
383
+ reactive query that streams the row.
384
+
385
+ Everything else is the hook's: local edits ride the app's mutation as
386
+ INCREMENTAL updates (folded server-side under a per-row mutex), remote edits
387
+ arrive as `mergeCells` subscription deltas — a one-character edit ships
388
+ under 1 KB in BOTH directions regardless of document size — plus the offline
389
+ queue, bounded retry, per-cell coalescence and durable persistence.
390
+
391
+ Two disciplines it enforces, because both fail silently when hand-rolled:
392
+
393
+ - **`remote: null` means NOT LOADED, not empty.** Folding an empty document
394
+ over a loading row lets the first keystroke push a state that erases what
395
+ was stored. `loaded` tells a UI which it is.
396
+ - **The echo guard.** A `crdtDoc()` document is mutated by the EDITOR, so
397
+ local edits surface as `doc.onUpdate` callbacks — and folding a peer's
398
+ state through `applyState` fires the same callback. The hook pushes only
399
+ when `local` is `true`. Without that every client re-broadcasts what it
400
+ just received: one keystroke, one server write per open tab.
401
+
402
+ A page mounting an editor needs `renderMode = 'client'`. The default is
403
+ `'static'`, which pre-renders at build time, and the editor finds no
404
+ `window` there.
329
405
 
330
406
  Carets ride a `delivery: 'latest'` EVENT, deliberately not presence
331
407
  metadata: the roster's value-compare push would make every caret move a
332
- "real" change. `attachAwarenessBridge` publishes one member's state per
333
- envelope (never the aggregated room). Stable positions for inline comments
334
- come from `encodeAnchor`/`resolveAnchor` on the doc handle.
335
-
336
- Operational rules: the stored blob soft-compacts past
337
- `VOLTRO_CRDT_COMPACT_MAX_BYTES` (default 512 KiB) without breaking the merge
408
+ "real" change. Pass an `awareness` transport plus `user` to
409
+ `useCrdtEditor` to mount them; `attachAwarenessBridge` publishes one
410
+ member's state per envelope (never the aggregated room). Stable positions
411
+ for inline comments come from `encodeAnchor`/`resolveAnchor` on the doc
412
+ handle.
413
+
414
+ The `frontend-collab` + `api-collab` template pair is this whole loop,
415
+ scaffoldable: `voltro create-project collab --api=api-collab
416
+ --web=frontend-collab`.
417
+
418
+ Operational rules: the stored blob soft-compacts past `crdt.compactMaxBytes`
419
+ in `app.config.ts` (default 512 KiB, `0` disables; env override
420
+ `VOLTRO_CRDT_COMPACT_MAX_BYTES`) without breaking the merge
338
421
  lineage; `rebaseText` is the explicit hard reset (a NEW EPOCH — subscribers
339
422
  receive it as a fresh snapshot). CRDT columns are excluded from undo capture
340
423
  and row history (document history = named snapshots taken BEFORE
@@ -441,16 +524,31 @@ string form), so two peers agree regardless of which side each calls "local".
441
524
 
442
525
  ## What's shipped vs. a runtime seam
443
526
 
444
- The framework code for local-first is built and tested end to end against
445
- in-memory transports. What remains is not un-built framework — it is the thin
446
- binding to **provisioned infrastructure**, sitting behind interfaces the tested
447
- code already speaks:
527
+ The framework code for local-first is built and tested end to end. **One** thing
528
+ remains, and it is not un-built framework — it is a pair of names only your app
529
+ knows:
448
530
 
449
531
  | Runtime seam | What it binds | Why it's a binding, not code |
450
532
  | --- | --- | --- |
451
533
  | **Two app-specific tags** | Which mutation writes the `crdtText()` column, and which reactive query streams the row, in [`useCrdtText`](#a-collaborative-text-field-usecrdttext). | Voltro generates no per-table CRUD surface, so there is nothing to derive them from. The client lifecycle, optimistic merge, offline queue, retry, persistence and edit encoding all ship. |
452
- | **Presence channel → a broker at scale** | The `PresenceChannel` to a provisioned Redis/NATS broker. | It's a network hop over an already-shipped broker; the awareness logic ships and is tested over the in-memory channel. |
453
- | **wa-sqlite / Turso adapter** *(optional)* | A SQL durable adapter for cross-tab queries, behind `PersistenceAdapter`. | IndexedDB is the durable default today; a SQL backing is a sibling factory, nothing above it changes. |
534
+
535
+ Two entries that used to sit in that table are gone, in opposite directions
536
+ worth stating, because "we have not built it" and "we decided against it" are
537
+ different answers:
538
+
539
+ - **The presence broker binding ships.** `usePresenceChannel`
540
+ (`@voltro/plugin-presence/web`) is a `PresenceChannel` over the framework's
541
+ own presence lane, so cross-replica fan-out is the broadcast plugin's and
542
+ there is ONE presence wire rather than two. Nothing to bind by hand; see
543
+ [Presence & awareness](#presence-awareness).
544
+ - **A wa-sqlite / Turso adapter was rejected, not deferred.** The client's whole
545
+ query surface is `(tag, input)` — predicates are built and evaluated on the
546
+ server — so a browser SQL engine would evaluate a language the client never
547
+ sees. What offline needs is the last materialised answer per query, which is
548
+ exactly what the [query mirror](#the-sync-engine-query-mirror-durable-outbox)
549
+ stores. A SQLite *backing* beneath `KvStore` remains possible without any
550
+ consumer changing (React Native's adapter is exactly that) — that is a
551
+ storage choice, not a missing engine.
454
552
 
455
553
 
456
554
 
@@ -71,9 +71,11 @@ Even with no exporter, `voltro dev` installs a **buffer-only** tracer — that's
71
71
 
72
72
  ## Metrics export
73
73
 
74
- Separate from tracing, the framework records **metrics** into Effect's global `MetricRegistry` — one source of truth (`voltro_rpc_*`, `voltro_http_*`, `voltro_plugin_hook_*`, `voltro_subscription_*` + `voltro_subscriptions_active`, `voltro_db_*`, plus `effect_fiber_*` and any custom metric). Three ways to get them out:
74
+ Separate from tracing, the framework records **metrics** into Effect's global `MetricRegistry` — one source of truth (`voltro_rpc_*`, `voltro_http_*`, `voltro_plugin_hook_*`, `voltro_subscription_*` + `voltro_subscriptions_active`, `voltro_db_*`, `voltro_ppr_*`, `voltro_queue_*`, plus `effect_fiber_*` and any custom metric). Three ways to get them out:
75
75
 
76
- The **web server** additionally counts [partial prerendering](/docs/routing/render-modes#partial-prerendering-ppr--cached-shell--per-request-holes): shell serves, hole passes/settles/errors, and last/max hole latency. Shell hit-rate is the isr cache's own `x-voltro-cache` HIT/STALE/MISS accounting — a ppr shell is a normal isr entry.
76
+ The **web server** additionally exports [partial prerendering](/docs/routing/render-modes#partial-prerendering-ppr-cached-shell-per-request-holes), labelled by `page` (the declared route pattern, never a resolved URL): `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 the `voltro_ppr_hole_pass_seconds{page}` histogram. `voltro dev` and `voltro start` emit the same set. Shell hit-rate is the isr cache's own `x-voltro-cache` HIT/STALE/MISS accounting — a ppr shell is a normal isr entry.
77
+
78
+ `voltro_ppr_hole_errors_total` is the one to alert on: a failed hole pass is invisible from outside. The shell is already on the wire with a `200`, so the page renders and every `<Await>` boundary simply stays on its fallback — a page that looks like it is loading and never will. A single hole *rejecting* is not counted there; that settles the deferred-error envelope and renders the boundary's `errorFallback`.
77
79
 
78
80
  ```bash
79
81
  # OTLP metrics — the SAME OTEL endpoint that enables trace export also enables
@@ -18,7 +18,7 @@ _JiraService + ConfluenceService over the Atlassian REST / Greenhopper / Agile A
18
18
  Two auth modes, both first-class (choose per deployment):
19
19
 
20
20
  - **PAT / basic** — a Personal Access Token per subject. The default for Jira/Confluence **Data Center / Server**. Wire it via `credentialsResolver` (below).
21
- - **OAuth 2.0 (3LO)** — the authorization-code flow for Atlassian **Cloud**, where the app acts on behalf of a consenting user. Use the toolkit ([OAuth 2.0 (3LO)](#oauth-20-3lo)) to obtain an access token, then feed it into the same `credentialsResolver`.
21
+ - **OAuth 2.0 (3LO)** — the authorization-code flow for Atlassian **Cloud**, where the app acts on behalf of a consenting user. Use the toolkit ([OAuth 2.0 (3LO)](#oauth-2-0-3lo)) to obtain an access token, then feed it into the same `credentialsResolver`.
22
22
 
23
23
  It also supports [inbound Jira/Confluence webhooks](#inbound-webhooks) (signature-verified) and writing comments to issues and pages.
24
24
 
@@ -55,7 +55,7 @@ export default {
55
55
  > there it travels with the identity into everything that persists a Subject. A
56
56
  > reporter found a working Jira PAT in plaintext in 12 of 23 rows of their
57
57
  > `_voltro_audit_log` exactly that way. The `store` handle above exists so it
58
- > never has to enter the Subject; [`connectionCredentials`](#connections) is
58
+ > never has to enter the Subject; [`connectionCredentials`](/docs/data/connections) is
59
59
  > better still, because then you do not hold the token at all.
60
60
 
61
61
  The resolver returns `AtlassianCredentials`:
@@ -73,7 +73,7 @@ auditPlugin({
73
73
  // custom function: (event: AuditEvent) => void | Promise<void> | Effect.Effect<void>
74
74
  ```
75
75
 
76
- `'datastore'` is the production sink: it survives restarts, is shared across replicas, and is queryable via `ctx.store.select('_voltro_audit_log')`. Each row carries the flattened `tag` / `at` / `subjectId` / `tenantId` / `traceId` / `status` / `durationMs` (indexed by `tag` + `traceId`) plus the full `subject` / `input` / `outcome` as portable `json()` columns, plus the `chainId` / `seq` / `prevHash` / `hash` tamper-evidence columns (see [the hash chain](#tamper-evidence--the-hash-chain)).
76
+ `'datastore'` is the production sink: it survives restarts, is shared across replicas, and is queryable via `ctx.store.select('_voltro_audit_log')`. Each row carries the flattened `tag` / `at` / `subjectId` / `tenantId` / `traceId` / `status` / `durationMs` (indexed by `tag` + `traceId`) plus the full `subject` / `input` / `outcome` as portable `json()` columns, plus the `chainId` / `seq` / `prevHash` / `hash` tamper-evidence columns (see [the hash chain](#tamper-evidence-the-hash-chain)).
77
77
 
78
78
  The custom function is the escape hatch for persisting events anywhere the built-in table's schema doesn't fit — e.g. an `Effect.Effect<void>` sink that writes rows into your own audit table on top of `@effect/sql`. All return shapes are normalised by the interceptor. A sink that throws / rejects / dies is caught and swallowed, so a broken sink can never mask the mutation's real outcome.
79
79
 
@@ -406,7 +406,7 @@ subjects *do* carry a `teamId`, which made subject-only worse than nothing for
406
406
  them: it would have populated for key-authenticated calls and been null for every
407
407
  human one, so a filtered view would have looked like it worked.
408
408
 
409
- The input here is **raw** — not what [`redactInput`](#redactinput--what-of-the-payload-is-kept)
409
+ The input here is **raw** — not what [`redactInput`](#redactinput-what-of-the-payload-is-kept)
410
410
  will store. That is required (a scope derived from a redacted payload is not
411
411
  derivable at all) and it is a hazard worth naming: whatever you return lands in
412
412
  `scope`, which is *not* redacted. Return the dimension, never the payload.
@@ -69,7 +69,7 @@ export default (input: { tenantId: string }, _ctx) =>
69
69
  })
70
70
  ```
71
71
 
72
- The subscription row lives in your DB; the provider is the source of truth and webhooks keep the row in sync. `plan()` returns the plan whose **limits apply right now**: `'free'` with no subscription, the paid plan while active or trialing, and — because a bounced card should not downgrade a customer on the same second — the paid plan for the whole [grace period](#failed-payments--stripe-retries-dunning-composes-on-the-outcome) after a failed payment, falling back only once the lockout is real.
72
+ The subscription row lives in your DB; the provider is the source of truth and webhooks keep the row in sync. `plan()` returns the plan whose **limits apply right now**: `'free'` with no subscription, the paid plan while active or trialing, and — because a bounced card should not downgrade a customer on the same second — the paid plan for the whole [grace period](#failed-payments-stripe-retries-dunning-composes-on-the-outcome) after a failed payment, falling back only once the lockout is real.
73
73
 
74
74
  ## Entitlement checks
75
75
 
@@ -1,6 +1,6 @@
1
1
  # CDC-out (reverse-ETL)
2
2
 
3
- > Declaratively 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.
3
+ > Declaratively mirror table changes outward to external sinks (webhook, 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.
4
4
 
5
5
 
6
6
 
@@ -9,7 +9,7 @@
9
9
  <!-- source: en/plugins/cdc-out.md -->
10
10
  ## CDC-out (reverse-ETL)
11
11
 
12
- _Declaratively 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._
12
+ _Declaratively mirror table changes outward to external sinks (webhook, 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._
13
13
 
14
14
  # CDC-out — declarative reverse-ETL
15
15
 
@@ -66,7 +66,12 @@ plugin name (`@voltro/plugin-cdc-out#analytics`) and the inspect mount
66
66
  - **`webhookSink(url, { headers? })`** — POSTs each batch as
67
67
  `{ records: [...] }` JSON, honoring the engine's per-attempt abort signal.
68
68
  Its host is declared as a `network:outbound:<host>` permission automatically.
69
- - **Warehouse / Kafka**implement the `CdcSink` interface
69
+ - **`kafkaSink({ topic })`**from
70
+ [`@voltro/plugin-queue`](/docs/plugins/queue#cdc-out-to-kafka), producing
71
+ through the same provider your consumers use: message key = the row id (one
72
+ row's changes stay ordered in one partition), value = the change record, and
73
+ the `x-voltro-delivery-key` header carries the dedupe handle below.
74
+ - **Warehouse (Snowflake / BigQuery / …)** — implement the `CdcSink` interface
70
75
  (`{ name, deliver(batch, ctx), outboundHost? }`). `deliver` may return a
71
76
  **Promise or an Effect** — both compose without wrapping. The engine is
72
77
  connector-agnostic; the sink is the only thing that changes.
@@ -90,6 +90,28 @@ import { useComments } from '@voltro/plugin-comments/web'
90
90
  const { threads, unreadCount, create, resolve, react, markRead } = useComments(`orders:${id}`)
91
91
  ```
92
92
 
93
+ `useComments(anchor, apiName?)` subscribes to `comments.list`, whose `source:`
94
+ is the plugin's own reactivity channel — so a second client sees a new comment
95
+ without a reload, and every action on the handle (`create`, `edit`, `resolve`,
96
+ `remove`, `react`, `markRead`) re-runs the list for all subscribers.
97
+ `unreadCount` is the sum of the threads' own counters, i.e. the badge number.
98
+
99
+ Two focused hooks sit beside it, both from the same entry point:
100
+
101
+ ```tsx
102
+ import { useThread, useMentionSearch } from '@voltro/plugin-comments/web'
103
+
104
+ const thread = useThread(`orders:${id}`, threadId) // ThreadView | undefined
105
+ const people = useMentionSearch(term) // [{ subjectId, label }]
106
+ ```
107
+
108
+ `useThread(anchor, threadId, apiName?)` is a projection over the SAME live
109
+ list — it opens no second subscription, so a detail pane beside the thread
110
+ list costs nothing. `useMentionSearch(query, apiName?)` drives the
111
+ `@`-autocomplete over `comments.mentionSearch`; the directory it searches is
112
+ the app's own `resolveMentions` seam, already tenant-filtered by the rule
113
+ below.
114
+
93
115
  ## Mentions are tenant-safe by construction
94
116
 
95
117
  The `resolveMentions` seam RECEIVES the calling subject — the signature makes
@@ -53,13 +53,42 @@ A member is `{ key, meta }`. **It pushes only when the roster actually moves**
53
53
 
54
54
  ```ts
55
55
  presencePlugin({
56
- resolveMember: async ({ subject }) => {
57
- const user = await users.byId((subject as { id: string }).id)
58
- return user ? { userName: user.name, avatarUrl: user.avatarUrl } : undefined
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 }
59
66
  },
67
+ // The keys the SERVER owns — stripped from the caller's meta before the merge.
68
+ identityFields: ['userName', 'avatarUrl'],
60
69
  })
61
70
  ```
62
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
+
63
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.
64
93
 
65
94
  > **There is a second `usePresence`, and it is a different hook.**
@@ -23,6 +23,8 @@ The framework records metrics into Effect's global `MetricRegistry`. One snapsho
23
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:
@@ -123,7 +123,50 @@ cdcOutPlugin({ sinks: [{ table: 'orders', sink: kafkaSink({ topic: 'orders.cdc'
123
123
 
124
124
  ## Observability
125
125
 
126
- Per-topic counters (consumed / retried / dead-lettered / produced + the last
127
- error) on `GET /_voltro/inspect/plugins/queue/consumers` and in the
128
- dashboards' Queue panel. Message headers carry `traceparent` through to
129
- `ctx.traceparent` for cross-system trace continuity.
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.
@@ -95,7 +95,7 @@ Status legend: ✓ shipped · ◐ partial · — planned.
95
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) |
96
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) |
97
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) |
98
- | `@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. |
99
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) |
100
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) |
101
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) |
@@ -1339,11 +1339,11 @@ The contract is deliberately narrow. **No raw SQL, no funnels, no cohorts, no cu
1339
1339
 
1340
1340
  | Plugin | Type | Best for | Ceiling |
1341
1341
  |---|---|---|---|
1342
- | [`@voltro/plugin-analytics-postgres`](#voltroplugin-analytics-postgres) | Lite | Day-1 zero-setup, dev + early production | ~10M events/day |
1343
- | [`@voltro/plugin-duckdb`](#voltroplugin-duckdb) | Embedded OLAP | Real column-store performance, no external service | Vertical scale: ~hundreds of GB in one process |
1344
- | [`@voltro/plugin-clickhouse`](#voltroplugin-clickhouse) | External OLAP | Production-scale analytics, self-hosted or ClickHouse Cloud | Billions of events comfortably |
1345
- | [`@voltro/plugin-tinybird`](#voltroplugin-tinybird) | Hosted ClickHouse | Pay-as-you-go without operating ClickHouse | Tinybird's own limits |
1346
- | [`@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 |
1347
1347
 
1348
1348
  ## Picking one
1349
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)
@@ -228,7 +247,7 @@ const { data } = useSubscription(
228
247
  ### Offline semantics with the local-first mirror
229
248
 
230
249
  With `@voltro/local-first`'s query mirror bound (see
231
- [the sync engine](/docs/local-first/overview#the-sync-engine-query-mirror--durable-outbox)),
250
+ [the sync engine](/docs/local-first/overview#the-sync-engine-query-mirror-durable-outbox)),
232
251
  `useSubscription`'s behaviour extends offline WITHOUT a second API: a cold
233
252
  start seeds `data` (and `revision`) from the device's mirrored rows for the
234
253
  subject's partition, so `loading` resolves against local data when the server