@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
@@ -24,15 +24,17 @@ 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** (a locally queryable
33
- > database, automatic mirroring of `localFirst()` tables, partial replication),
34
- > which is planned and not yet built. Today `localFirst()` is a declaration the
35
- > tooling discovers, not an auto-synced local database.
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
35
+ > every subscribed query's rows per subject+tenant partition, `useOutbox`
36
+ > queues offline writes durably, and a reload renders mirrored rows offline
37
+ > and delta-resumes online.
36
38
 
37
39
  ## CRDT text: `crdtText` + `mergeCrdtStates`
38
40
 
@@ -207,25 +209,25 @@ import { Schema } from 'effect'
207
209
  body: Schema.NullOr(Schema.Uint8ArrayFromBase64)
208
210
  ```
209
211
 
210
- ### Known cost limits of `crdtText()` today
212
+ ### What a `crdtText()` keystroke costs
211
213
 
212
- Two amplification effects are worth knowing before you put a `crdtText()` column
213
- 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:
214
216
 
215
- - **Wire amplification downstream.** A subscription delta carries the row's
216
- columns, and for a CRDT column that is the merged **full state** (base64) —
217
- every keystroke ships the whole document to every subscriber of the query,
218
- not the one-edit update. Keep the streamed query's projection narrow (don't
219
- project `body` into a list view), or subscribe to the document row alone.
220
- - **Undo/row-history capture.** Server-side capture (the undo log default-on
221
- outside production and `plugin-row-history`'s row history, where enabled)
222
- snapshots the row per mutation, so per-keystroke mutations write a
223
- full-state blob per keystroke into those tables. Point them away from
224
- 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.
225
226
 
226
- Both limits are on the framework's roadmap (incremental delivery and
227
- CRDT-aware capture); until then they are costs to design around, not bugs to
228
- 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.
229
231
 
230
232
  ## Presence & awareness
231
233
 
@@ -266,6 +268,30 @@ propagation, and TTL expiry live in the pure `createPresenceRoom` the hook wraps
266
268
  > is fresh, plus a `useTyping` indicator, through the app's own rpc. Reach for
267
269
  > the plugin for "who is here"; reach for this one for "where is their cursor".
268
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
+
269
295
  ## The offline sync queue
270
296
 
271
297
  `useSyncQueue()` is a reactive view over a **pure, tested reducer**: writes made
@@ -315,6 +341,161 @@ const adapter = await createIndexedDbPersistence({ databaseName: 'my-app' })
315
341
  const sync = createSyncClient({ transport, adapter }) // state now survives reload
316
342
  ```
317
343
 
344
+ ## Rich text: `crdtDoc()` + `useCrdtDoc` + `useCrdtEditor`
345
+
346
+ `crdtDoc()` stores a WHOLE collaborative document (rich text, maps, arrays)
347
+ as a column — same storage and authoritative server merge as `crdtText()`,
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.
405
+
406
+ Carets ride a `delivery: 'latest'` EVENT, deliberately not presence
407
+ metadata: the roster's value-compare push would make every caret move a
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
421
+ lineage; `rebaseText` is the explicit hard reset (a NEW EPOCH — subscribers
422
+ receive it as a fresh snapshot). CRDT columns are excluded from undo capture
423
+ and row history (document history = named snapshots taken BEFORE
424
+ compaction); `.serverOnly()` on a CRDT column is a declaration error and
425
+ `.encrypted()` makes it online-only.
426
+
427
+ ## The sync engine: query mirror + durable outbox
428
+
429
+ The engine's two halves ride the primitives you already use — there is no
430
+ second data API:
431
+
432
+ - **Reads.** The subscription cache accepts a `mirror`; every base movement of
433
+ every subscribed query persists (rows + revision) into a **subject+tenant
434
+ partitioned** store over IndexedDB, and a cold start seeds from it — the UI
435
+ renders the last materialised rows offline through the SAME
436
+ `useSubscription` call, and the next connect presents the mirrored revision
437
+ as `voltro-resume-from`, so a reload inside the resume window continues with
438
+ deltas instead of a snapshot.
439
+ - **Writes.** `useOutbox` with `persistence: outboxPersistence(adapter)` is
440
+ the durable offline queue: writes survive a reload, replay in order on
441
+ reconnect, stop at the first conflict, and a conflict resolves through
442
+ `resolveConflict(id, resolveWithPolicy(policy, local, remote, { crdtColumns }))`
443
+ — `crdtText()` columns merge, scalars follow the declared `conflictPolicy()`.
444
+ One drain per device even with many tabs (`withDrainLock`, a per-partition
445
+ Web Lock).
446
+
447
+ ```ts
448
+ import {
449
+ createDurableKv, createQueryMirror, createSubscriptionMirrorBinding,
450
+ } from '@voltro/local-first'
451
+ import { localFirstTables } from './.framework/localFirst.generated'
452
+
453
+ const { kv, durability } = await createDurableKv() // 'memory' = visible degradation
454
+ const mirror = createQueryMirror(kv, { subjectId, tenantId }) // ONE partition per subject
455
+ const binding = createSubscriptionMirrorBinding(mirror, {
456
+ tags: { 'docs.list': 'docs' }, // the app's sync set
457
+ metadata: localFirstTables, // codegen: encrypted columns stripped
458
+ schemaFingerprint: BUILD_ID, // local-DB migration gate
459
+ })
460
+ ```
461
+
462
+ The deliberate design decision, recorded here because the obvious alternative
463
+ keeps being suggested: the engine is **not** a browser SQL database. The
464
+ client's whole query surface is `(tag, input)` — predicates are built and
465
+ evaluated on the server — so a wa-sqlite instance would evaluate a language
466
+ the client never sees. What offline needs is the last materialised answer per
467
+ query the user visited, kept current by deltas; that is what the mirror
468
+ stores. (The `KvStore` seam still admits a SQLite backing without touching a
469
+ consumer.)
470
+
471
+ Soundness rules, all enforced structurally and tested:
472
+
473
+ - **Partition by key.** Every stored key carries subject AND tenant; a
474
+ logout/login as somebody else can never read the predecessor's rows, and
475
+ `purge()` empties exactly one partition on revocation. Build ONE binding per
476
+ resolved subject and rebuild it on an auth change — the same blank-on-auth
477
+ doctrine the subscription cache itself follows.
478
+ - **`.encrypted()` never lands.** The server decrypts on read, so a naive
479
+ mirror would persist plaintext on the device; the codegen-emitted
480
+ `localFirst.generated.ts` names those columns and the binding strips them
481
+ before every save. `.serverOnly()` columns never reach the wire at all.
482
+ - **The snapshot is the visible state.** A save replaces the mirrored row
483
+ set, so a row the server stopped sending (revoked share, RLS change, soft
484
+ delete) is evicted by construction.
485
+ - **Schema migration is a visible cold start.** Entries persist under the
486
+ build's `schemaFingerprint`; a new build's load misses them and the query
487
+ falls back to loading → fresh snapshot — never a mixed-shape render. The
488
+ offline queue deliberately does NOT gate on it: a queued old-shape write
489
+ replays against the new server, whose input schema is the authority, and a
490
+ rejection surfaces as a visible conflict instead of silently dropped work.
491
+ - **Shapes are your queries.** There is no separate replication-shape
492
+ language: what is mirrored is exactly what the app subscribes to, so tenant
493
+ scoping, guards and `setRowFilter` apply server-side, fail-closed, exactly
494
+ as online — including parent-relative predicates ("tasks where projectId is
495
+ one of my projects"), which are just queries. Mirroring is per QUERY, so a
496
+ subgraph is N queries, not one nested shape. A storage budget is enforced
497
+ with `enforceBudget(maxBytes)` — oldest-saved entries evict first.
498
+
318
499
  ## Conflict policy for non-CRDT fields
319
500
 
320
501
  CRDT fields resolve themselves — the merge **is** the resolver. A plain scalar
@@ -343,16 +524,31 @@ string form), so two peers agree regardless of which side each calls "local".
343
524
 
344
525
  ## What's shipped vs. a runtime seam
345
526
 
346
- The framework code for local-first is built and tested end to end against
347
- in-memory transports. What remains is not un-built framework — it is the thin
348
- binding to **provisioned infrastructure**, sitting behind interfaces the tested
349
- 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:
350
530
 
351
531
  | Runtime seam | What it binds | Why it's a binding, not code |
352
532
  | --- | --- | --- |
353
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. |
354
- | **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. |
355
- | **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.
356
552
 
357
553
 
358
554
 
@@ -71,7 +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
+
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`.
75
79
 
76
80
  ```bash
77
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.
@@ -333,7 +333,7 @@ authRoutesPlugin({
333
333
  })
334
334
  ```
335
335
 
336
- The canonical guard ships in `@voltro/plugin-deactivation`: `deactivationGuard()` refuses login when the user's `deactivatedAt` is set — making the `deactivation()` mixin's "a deactivated user can't log in" promise self-enforcing without a hand-rolled resolver check. See [deactivation](/docs/plugins/deactivation#enforcing-a-deactivated-user-cant-log-in). With no guards configured, every authenticated user proceeds exactly as before.
336
+ The canonical guard ships in `@voltro/plugin-deactivation`: `deactivationGuard()` refuses login when the user's `deactivatedAt` is set — making the `deactivation()` mixin's "a deactivated user can't log in" promise self-enforcing without a hand-rolled resolver check. See [deactivation](/docs/plugins/deactivation). With no guards configured, every authenticated user proceeds exactly as before.
337
337
 
338
338
  ## Rehash-on-verify
339
339
 
@@ -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.
@@ -0,0 +1,164 @@
1
+ # Comments
2
+
3
+ > Comment threads on any app entity — replies, resolve/reopen, @-mentions with notifications, reactions, unread counters — live over the reactive engine, with an ejectable thread UI.
4
+
5
+
6
+
7
+ ---
8
+
9
+ <!-- source: en/plugins/comments.md -->
10
+ ## Comments
11
+
12
+ _Comment threads on any app entity — replies, resolve/reopen, @-mentions with notifications, reactions, unread counters — live over the reactive engine, with an ejectable thread UI._
13
+
14
+ `@voltro/plugin-comments` hangs discussion threads on anything your app can
15
+ name — an order, a document, a row, a section anchor — and keeps every open
16
+ view LIVE: a second client sees a new comment without a reload, because
17
+ `comments.list` declares the plugin's reactivity channel as its `source:` and
18
+ every write publishes it. No second push mechanism, no vendor websocket.
19
+
20
+ ```ts
21
+ // app.config.ts
22
+ import { commentsPlugin } from '@voltro/plugin-comments'
23
+
24
+ export default {
25
+ // …
26
+ plugins: [
27
+ commentsPlugin({
28
+ access: {
29
+ viaEntity: async ({ anchor, subject, store }) => {
30
+ // Resolve the anchor to YOUR entity and answer with YOUR rules —
31
+ // the same guard your queries use. A row the guard cannot read
32
+ // (including a soft-deleted one) is a refusal.
33
+ const [table, rowId] = anchor.split(':')
34
+ if (table !== 'orders' || store === undefined) return false
35
+ const rows = await store.query({
36
+ table: 'orders', predicate: eq('id', rowId!),
37
+ order: [], take: 1, skip: undefined, projection: undefined,
38
+ })
39
+ return rows.length > 0
40
+ },
41
+ },
42
+ resolveMentions: async ({ query, subject }) =>
43
+ searchTeamMembers(query, subject.tenantId),
44
+ }),
45
+ ],
46
+ }
47
+ ```
48
+
49
+ ## Access follows the anchor — fail-closed
50
+
51
+ Only your app knows who may see the entity a thread hangs on. The plugin
52
+ therefore takes a declared rule and REFUSES every read and write when none is
53
+ declared — a comments surface with no access rule serves nobody rather than
54
+ everybody:
55
+
56
+ - **`access.viaEntity`** — guard delegation (above). Receives the anchor, the
57
+ calling subject and the bound store.
58
+ - **`access.scope`** — one scope every comment reader/writer must hold, for
59
+ team-internal comment surfaces.
60
+
61
+ A **soft-deleted anchor** is the same door: your guard cannot approve what it
62
+ cannot read, so the whole thread answers `CommentAccessRefused` — an inbox
63
+ notification that still points at it finds "no longer available", not a leak.
64
+
65
+ ## The live thread UI
66
+
67
+ ```tsx
68
+ import { CommentsThread } from '@voltro/ui'
69
+
70
+ const OrderPage = ({ order }) => <CommentsThread anchor={`orders:${order.id}`} />
71
+ ```
72
+
73
+ Try it — this is the real plugin against this docs site's demo backend. Open
74
+ this page in a **second tab**: a comment typed in one appears in the other
75
+ without a reload (tabs share your per-browser visitor identity; other
76
+ visitors' threads are isolated):
77
+
78
+ ```tsx
79
+ import { CommentsThread } from '@voltro/ui'
80
+
81
+ <CommentsThread anchor="demo:comments" />
82
+ ```
83
+
84
+ `<CommentsThread>` is deliberately unstyled (semantic markup + `data-*`
85
+ hooks) and ejectable; underneath it is `useComments(anchor)`:
86
+
87
+ ```tsx
88
+ import { useComments } from '@voltro/plugin-comments/web'
89
+
90
+ const { threads, unreadCount, create, resolve, react, markRead } = useComments(`orders:${id}`)
91
+ ```
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
+
115
+ ## Mentions are tenant-safe by construction
116
+
117
+ The `resolveMentions` seam RECEIVES the calling subject — the signature makes
118
+ forgetting impossible — and the plugin re-filters whatever your resolver
119
+ returns to the caller's tenant (opt out with `crossTenant: true` for
120
+ single-tenant apps). Mentions are ALSO re-validated at create time against the
121
+ same resolver, so a hand-crafted mention on a foreign tenant is dropped, not
122
+ delivered: the `@`-autocomplete cannot leak existence or names across the
123
+ boundary, and no notification ever crosses it.
124
+
125
+ A validated mention delivers through
126
+ [`plugin-notifications`](/docs/plugins/notifications) when it is configured —
127
+ recipient preferences, quiet hours and digests apply (ten mentions inside a
128
+ digest window roll into ONE delivery). Without the notifications plugin the
129
+ mention still renders in the thread; the push half is simply absent (a log
130
+ note, never an error).
131
+
132
+ ## Moderation is opt-in, honestly
133
+
134
+ Nothing is filtered automatically. To moderate comment bodies, add one rule to
135
+ [`plugin-moderation`](/docs/plugins/moderation):
136
+
137
+ ```ts
138
+ moderationPlugin({ rules: [{ match: /^comments\./, fields: ['body'] }] })
139
+ ```
140
+
141
+ Deleting others' comments takes the `comments:moderate` scope; editing is
142
+ always author-only.
143
+
144
+ ## What else ships
145
+
146
+ - **Reactions** — per-emoji toggle, aggregated with `count` + `mine`, in the
147
+ live delta.
148
+ - **Thread unread** — a per-subject read marker (`markRead`); `useComments`
149
+ returns `unreadCount` (your own comments are never unread for you).
150
+ - **Resolve / reopen** — anyone who may read the anchor may resolve (the
151
+ Liveblocks semantic).
152
+ - **Attachments** are a declared limit: grant an upload via
153
+ [`plugin-storage`](/docs/plugins/storage) and put the URL in the body —
154
+ first-class `attachments[]` is deliberately not built until the storage
155
+ grant flow is the proven shape.
156
+ - **Known reactivity granularity:** the live feed is channel-wide — every
157
+ comment write re-runs every open `comments.list` subscription app-wide.
158
+ Fine for team-scale commenting; the read-set work on the realtime roadmap
159
+ is the named narrowing.
160
+
161
+ ## Observability
162
+
163
+ `GET /_voltro/inspect/plugins/comments/threads` + a Comments panel in both
164
+ dashboards (volume, open/resolved, recent threads).