@voltro/cli 0.51.0 → 0.53.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 (233) hide show
  1. package/CHANGELOG.md +356 -0
  2. package/THIRD-PARTY-NOTICES.md +8311 -3318
  3. package/dist/{agentsMd-D6yD7IQv.js → agentsMd-0l980yhL.js} +4 -1
  4. package/dist/agentsMd-SDDSkyl4.js +2 -0
  5. package/dist/{apiBuild-CPDHXF72.js → apiBuild-CaPfoWku.js} +11 -5
  6. package/dist/apiBuild-DHtLXYx9.js +2 -0
  7. package/dist/bin.js +1 -1
  8. package/dist/build-D-OnvNMf.js +843 -0
  9. package/dist/{checkCommand-DNuPiWMc.js → checkCommand-C5elt0tW.js} +92 -46
  10. package/dist/checkCommand-D2ZduVlh.js +2 -0
  11. package/dist/{cloudCmd-F4YJeqM3.js → cloudCmd-QUXh-b5w.js} +1 -1
  12. package/dist/codegen-BWpt3VgF.js +2 -0
  13. package/dist/{codegen-CrMXs4hb.js → codegen-FEk8AZHb.js} +2 -2
  14. package/dist/{codegenCommand-BNBHcNNj.js → codegenCommand-BOiWQ5hz.js} +12 -12
  15. package/dist/{codemodRunner-BDVixlSw.js → codemodRunner-BjtB2lq6.js} +691 -545
  16. package/dist/{commands-B1OiS9bX.js → commands-DyxAmhP0.js} +36 -36
  17. package/dist/{dashboardCommand-C-vvPY1B.js → dashboardCommand-BdKTyT13.js} +3 -3
  18. package/dist/{dataCommand-C1GxXW5q.js → dataCommand-Bab9X7s8.js} +27 -27
  19. package/dist/{dbCommand-If4Y1xQ-.js → dbCommand-06O2finM.js} +277 -236
  20. package/dist/dbCommand-B1EXBC6f.js +2 -0
  21. package/dist/{dev-kdAg9Q7l.js → dev-C6LGF4iY.js} +2998 -2379
  22. package/dist/dev-GjJWAYo2.js +3 -0
  23. package/dist/doctorCommand-B0hX0tdz.js +2 -0
  24. package/dist/{doctorCommand-nKmeW78u.js → doctorCommand-etMkflRc.js} +332 -220
  25. package/dist/{dormancyCommand-CY3wa_SW.js → dormancyCommand-UwZ1AZzB.js} +1 -1
  26. package/dist/{embeddingsCommand-BDLIgje_.js → embeddingsCommand-C70zWHwo.js} +1 -1
  27. package/dist/{envCommand-C6V_xVlT.js → envCommand-dSyKvRkM.js} +15 -15
  28. package/dist/{evolveCommand-D3c4DSfN.js → evolveCommand-CG0_ebO5.js} +2 -2
  29. package/dist/fileConventions-DASGEmj-.js +35 -0
  30. package/dist/{fileTaxonomy-CJfgOllU.js → fileTaxonomy-B7uxipWS.js} +55 -55
  31. package/dist/fontPipeline-LxIHa1vo.js +2 -0
  32. package/dist/fontPipeline-Tsh8kZfA.js +152 -0
  33. package/dist/frameworkTableAssembly-C_7Z-rMs.js +2 -0
  34. package/dist/{frameworkTableAssembly-BwJVEKLr.js → frameworkTableAssembly-DKx3ba3S.js} +5 -5
  35. package/dist/imagePipeline-B_GVJgm6.js +2 -0
  36. package/dist/imagePipeline-CBZmjT4i.js +127 -0
  37. package/dist/index.js +1 -1
  38. package/dist/{infoCommand-BnRFEF1o.js → infoCommand-_53iOc_j.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/inspectMetrics-CGF94puw.js +143 -0
  43. package/dist/manifestBuild-C4-J1-m_.js +2 -0
  44. package/dist/{manifestBuild-CuU1VrSm.js → manifestBuild-Cqgsx2bM.js} +1 -1
  45. package/dist/{metaCommands-CfRLra0s.js → metaCommands-Cn2oboG4.js} +9 -3
  46. package/dist/{migrate-DehuBakM.js → migrate-Cko9rswM.js} +2 -2
  47. package/dist/{pageConvention-cEiRxdab.js → pageConvention-C938S8oC.js} +1 -1
  48. package/dist/{privacyCommand-XejDMvmu.js → privacyCommand-DWTQMC6R.js} +2 -2
  49. package/dist/{probeCommand-C9gazU0H.js → probeCommand-DkGGLknv.js} +83 -24
  50. package/dist/{projectScaffold-mIX_DpSe.js → projectScaffold-B4dmTlwT.js} +1 -1
  51. package/dist/{projectScaffold-BIl97_E6.js → projectScaffold-EzlErR4E.js} +1 -1
  52. package/dist/{renderModeScan-D7J1B7Kw.js → renderModeScan-CUbOeOAg.js} +28 -11
  53. package/dist/{renderProfile-1OWWAAtx.js → renderProfile-CskIgAfn.js} +2 -2
  54. package/dist/{runtimeTrace-ZsBU7Tkx.js → runtimeTrace-c0APJz7E.js} +1 -1
  55. package/dist/{sdkgen-O4XqWOjM.js → sdkgen-BiQCgIEr.js} +1 -1
  56. package/dist/serveCommand-CueKQgzl.js +2443 -0
  57. package/dist/serveCommand-DsnrVN3U.js +2 -0
  58. package/dist/serveEntry.js +1 -1
  59. package/dist/start-BJzZLbt8.js +3 -0
  60. package/dist/start-ekPan8BT.js +1510 -0
  61. package/dist/startEntry.js +1 -1
  62. package/dist/{staticCommand-Dr2M6tpU.js → staticCommand-xlSL-IWk.js} +1 -1
  63. package/dist/{test-rXFq4S76.js → test-BWPQcRoB.js} +1 -1
  64. package/dist/updateCommand-Bqql_rsQ.js +2 -0
  65. package/dist/{updateCommand-Bs322Q78.js → updateCommand-C_8I8Rzo.js} +139 -115
  66. package/dist/webDev-C7jWJ5dX.js +2 -0
  67. package/dist/{webDev-B-ubQEMX.js → webDev-oczpugbx.js} +1767 -913
  68. package/dist/{webhooksCommand-FLYY9IXh.js → webhooksCommand-4SVPDjKg.js} +1 -1
  69. package/package.json +72 -18
  70. package/templates/AGENTS.core.md +11 -0
  71. package/templates/AGENTS.md +19 -6
  72. package/templates/agent-docs/_index.md +8 -6
  73. package/templates/agent-docs/_manifest.json +31 -15
  74. package/templates/agent-docs/ai.md +2 -2
  75. package/templates/agent-docs/authentication.md +1 -1
  76. package/templates/agent-docs/cli.md +97 -15
  77. package/templates/agent-docs/configuration.md +17 -0
  78. package/templates/agent-docs/data.md +680 -33
  79. package/templates/agent-docs/database/advancedqueries.md +7 -7
  80. package/templates/agent-docs/database/columntypes.md +2 -2
  81. package/templates/agent-docs/database/querying.md +1 -1
  82. package/templates/agent-docs/database/schema.md +2 -2
  83. package/templates/agent-docs/database/seedsdialects.md +2 -2
  84. package/templates/agent-docs/database/transactions.md +3 -3
  85. package/templates/agent-docs/deployment.md +30 -3
  86. package/templates/agent-docs/internationalization.md +2 -2
  87. package/templates/agent-docs/introduction.md +52 -0
  88. package/templates/agent-docs/local-first-mobile.md +132 -7
  89. package/templates/agent-docs/observability.md +2 -0
  90. package/templates/agent-docs/plugins/ai-flows.md +1 -1
  91. package/templates/agent-docs/plugins/audit.md +5 -5
  92. package/templates/agent-docs/plugins/auth.md +1 -1
  93. package/templates/agent-docs/plugins/cdc-out.md +2 -2
  94. package/templates/agent-docs/plugins/comments.md +142 -0
  95. package/templates/agent-docs/plugins/notifications.md +47 -4
  96. package/templates/agent-docs/plugins/presence.md +16 -3
  97. package/templates/agent-docs/plugins/prometheus.md +1 -1
  98. package/templates/agent-docs/plugins/queue.md +129 -0
  99. package/templates/agent-docs/plugins/{versioning.md → row-history.md} +22 -22
  100. package/templates/agent-docs/plugins/storage.md +2 -2
  101. package/templates/agent-docs/plugins.md +38 -12
  102. package/templates/agent-docs/reference.md +54 -5
  103. package/templates/agent-docs/routing.md +868 -50
  104. package/templates/agent-docs/schema-driven-ui.md +292 -5
  105. package/templates/agent-docs/security.md +125 -8
  106. package/templates/agent-docs/templates/apibackends.md +14 -14
  107. package/templates/agent-docs/templates/overview.md +1 -1
  108. package/templates/agent-docs/whats-new.md +171 -54
  109. package/templates/apps/api-ai/package.json +6 -7
  110. package/templates/apps/api-ai/tests/summarize.test.ts +1 -1
  111. package/templates/apps/api-auth/package.json +8 -8
  112. package/templates/apps/api-backend/package.json +7 -7
  113. package/templates/apps/api-backend-deactivation/package.json +7 -7
  114. package/templates/apps/api-backend-mail/package.json +8 -8
  115. package/templates/apps/api-backend-mariadb/package.json +9 -9
  116. package/templates/apps/api-backend-sqlite/package.json +8 -8
  117. package/templates/apps/api-backend-storage/package.json +8 -8
  118. package/templates/apps/api-cms/package.json +9 -10
  119. package/templates/apps/api-collab/package.json +8 -8
  120. package/templates/apps/api-data-advanced/package.json +8 -8
  121. package/templates/apps/api-durable/package.json +8 -8
  122. package/templates/apps/api-feature-flags/package.json +9 -9
  123. package/templates/apps/api-governance/package.json +8 -8
  124. package/templates/apps/api-kv/package.json +8 -8
  125. package/templates/apps/api-moderation/package.json +8 -8
  126. package/templates/apps/api-observability/package.json +8 -8
  127. package/templates/apps/api-ratelimit/package.json +8 -8
  128. package/templates/apps/api-rbac/package.json +8 -8
  129. package/templates/apps/api-rest/package.json +7 -7
  130. package/templates/apps/{api-versioning → api-row-history}/README.md +3 -3
  131. package/templates/apps/{api-versioning → api-row-history}/actions/documents.asOf.action.server.ts +1 -1
  132. package/templates/apps/{api-versioning → api-row-history}/actions/documents.history.action.server.ts +1 -1
  133. package/templates/apps/{api-versioning → api-row-history}/app.config.ts +3 -3
  134. package/templates/apps/{api-versioning → api-row-history}/database/schema.ts +1 -1
  135. package/templates/apps/{api-versioning → api-row-history}/mutations/documents.create.mutation.server.ts +1 -1
  136. package/templates/apps/{api-versioning → api-row-history}/mutations/documents.create.mutation.ts +1 -1
  137. package/templates/apps/{api-versioning → api-row-history}/mutations/documents.update.mutation.ts +1 -1
  138. package/templates/apps/{api-versioning → api-row-history}/package.json +8 -8
  139. package/templates/apps/api-row-history/template.json +6 -0
  140. package/templates/apps/{api-versioning → api-row-history}/tests/documents.create.test.ts +1 -1
  141. package/templates/apps/api-saas/app.config.ts +1 -0
  142. package/templates/apps/api-saas/package.json +10 -11
  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 +8 -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/package.json +10 -10
  168. package/templates/apps/frontend-collab/src/pages/page.test.tsx +10 -9
  169. package/templates/apps/frontend-collab/src/pages/page.tsx +42 -65
  170. package/templates/apps/frontend-contact/package.json +7 -7
  171. package/templates/apps/frontend-dashboard/package.json +7 -7
  172. package/templates/apps/frontend-docs/content/docs/de/guides/first-page.md +4 -0
  173. package/templates/apps/frontend-docs/content/docs/de/intro/getting-started.md +4 -0
  174. package/templates/apps/frontend-docs/content/docs/en/guides/first-page.md +4 -0
  175. package/templates/apps/frontend-docs/content/docs/en/intro/getting-started.md +4 -0
  176. package/templates/apps/frontend-docs/package.json +8 -7
  177. package/templates/apps/frontend-docs/src/collections/docs.collection.ts +21 -0
  178. package/templates/apps/frontend-docs/src/locales/de.ts +0 -5
  179. package/templates/apps/frontend-docs/src/locales/en.ts +0 -5
  180. package/templates/apps/frontend-docs/src/pages/[locale]/docs/[...slug]/page.tsx +17 -9
  181. package/templates/apps/frontend-docs/src/pages/[locale]/mirrors.test.tsx +10 -5
  182. package/templates/apps/frontend-docs/src/pages/[locale]/page.tsx +8 -0
  183. package/templates/apps/frontend-docs/src/pages/docs/[...slug]/page.test.tsx +53 -15
  184. package/templates/apps/frontend-docs/src/pages/docs/[...slug]/page.tsx +27 -33
  185. package/templates/apps/frontend-docs/src/pages/page.test.tsx +17 -3
  186. package/templates/apps/frontend-docs/src/pages/page.tsx +16 -12
  187. package/templates/apps/frontend-i18n/package.json +6 -6
  188. package/templates/apps/frontend-landing/package.json +6 -7
  189. package/templates/apps/frontend-portal/package.json +8 -8
  190. package/templates/apps/frontend-saas/package.json +8 -8
  191. package/templates/apps/frontend-spa/package.json +7 -7
  192. package/templates/apps/frontend-ssr/package.json +7 -7
  193. package/templates/apps/frontend-ssr-api/package.json +8 -8
  194. package/templates/apps/frontend-static-blog/content/posts/cms-to-ssg.md +12 -0
  195. package/templates/apps/frontend-static-blog/content/posts/hello-static.md +12 -0
  196. package/templates/apps/frontend-static-blog/content/posts/islands-not-hydration.md +14 -0
  197. package/templates/apps/frontend-static-blog/package.json +8 -6
  198. package/templates/apps/frontend-static-blog/src/collections/posts.collection.ts +27 -0
  199. package/templates/apps/frontend-static-blog/src/pages/[locale]/blog/[slug]/page.tsx +7 -4
  200. package/templates/apps/frontend-static-blog/src/pages/[locale]/mirrors.test.tsx +7 -1
  201. package/templates/apps/frontend-static-blog/src/pages/blog/[slug]/page.test.tsx +19 -9
  202. package/templates/apps/frontend-static-blog/src/pages/blog/[slug]/page.tsx +19 -21
  203. package/templates/apps/frontend-static-blog/src/pages/page.test.tsx +18 -5
  204. package/templates/apps/frontend-static-blog/src/pages/page.tsx +21 -14
  205. package/templates/apps/frontend-status/package.json +8 -8
  206. package/templates/apps/mobile-app/package.json +4 -4
  207. package/dist/agentsMd-Bu_XQgVf.js +0 -2
  208. package/dist/apiBuild-GDKuGOMV.js +0 -2
  209. package/dist/build-DETLZAFt.js +0 -752
  210. package/dist/checkCommand-CWcnDArJ.js +0 -2
  211. package/dist/codegen-DiMn2KkZ.js +0 -2
  212. package/dist/dbCommand-C27HIsGE.js +0 -2
  213. package/dist/dev-CK522MV5.js +0 -3
  214. package/dist/doctorCommand-BK4l18eG.js +0 -2
  215. package/dist/fileConventions-Cof68_BL.js +0 -33
  216. package/dist/frameworkTableAssembly-CVDB2hCq.js +0 -2
  217. package/dist/inspect-CuGDYES0.js +0 -2
  218. package/dist/inspectMetrics-CfdKLh6t.js +0 -72
  219. package/dist/manifestBuild-CPjhvM62.js +0 -2
  220. package/dist/serveCommand-BRnPCxVd.js +0 -2
  221. package/dist/serveCommand-DdiYNBBu.js +0 -2362
  222. package/dist/start-BLNmWkLa.js +0 -1154
  223. package/dist/start-Dzicuyw8.js +0 -3
  224. package/dist/updateCommand-eXB35SEv.js +0 -2
  225. package/dist/webDev-DposiF3j.js +0 -2
  226. package/templates/apps/api-versioning/template.json +0 -6
  227. package/templates/apps/changelog/scripts/generate-rss.mjs +0 -38
  228. package/templates/apps/changelog/src/lib/releases.ts +0 -21
  229. package/templates/apps/frontend-static-blog/src/content/posts.ts +0 -64
  230. /package/templates/apps/{api-versioning → api-row-history}/actions/documents.asOf.action.ts +0 -0
  231. /package/templates/apps/{api-versioning → api-row-history}/actions/documents.history.action.ts +0 -0
  232. /package/templates/apps/{api-versioning → api-row-history}/mutations/documents.update.mutation.server.ts +0 -0
  233. /package/templates/apps/{api-versioning → api-row-history}/tsconfig.json +0 -0
@@ -0,0 +1,142 @@
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
+ ## Mentions are tenant-safe by construction
94
+
95
+ The `resolveMentions` seam RECEIVES the calling subject — the signature makes
96
+ forgetting impossible — and the plugin re-filters whatever your resolver
97
+ returns to the caller's tenant (opt out with `crossTenant: true` for
98
+ single-tenant apps). Mentions are ALSO re-validated at create time against the
99
+ same resolver, so a hand-crafted mention on a foreign tenant is dropped, not
100
+ delivered: the `@`-autocomplete cannot leak existence or names across the
101
+ boundary, and no notification ever crosses it.
102
+
103
+ A validated mention delivers through
104
+ [`plugin-notifications`](/docs/plugins/notifications) when it is configured —
105
+ recipient preferences, quiet hours and digests apply (ten mentions inside a
106
+ digest window roll into ONE delivery). Without the notifications plugin the
107
+ mention still renders in the thread; the push half is simply absent (a log
108
+ note, never an error).
109
+
110
+ ## Moderation is opt-in, honestly
111
+
112
+ Nothing is filtered automatically. To moderate comment bodies, add one rule to
113
+ [`plugin-moderation`](/docs/plugins/moderation):
114
+
115
+ ```ts
116
+ moderationPlugin({ rules: [{ match: /^comments\./, fields: ['body'] }] })
117
+ ```
118
+
119
+ Deleting others' comments takes the `comments:moderate` scope; editing is
120
+ always author-only.
121
+
122
+ ## What else ships
123
+
124
+ - **Reactions** — per-emoji toggle, aggregated with `count` + `mine`, in the
125
+ live delta.
126
+ - **Thread unread** — a per-subject read marker (`markRead`); `useComments`
127
+ returns `unreadCount` (your own comments are never unread for you).
128
+ - **Resolve / reopen** — anyone who may read the anchor may resolve (the
129
+ Liveblocks semantic).
130
+ - **Attachments** are a declared limit: grant an upload via
131
+ [`plugin-storage`](/docs/plugins/storage) and put the URL in the body —
132
+ first-class `attachments[]` is deliberately not built until the storage
133
+ grant flow is the proven shape.
134
+ - **Known reactivity granularity:** the live feed is channel-wide — every
135
+ comment write re-runs every open `comments.list` subscription app-wide.
136
+ Fine for team-scale commenting; the read-set work on the realtime roadmap
137
+ is the named narrowing.
138
+
139
+ ## Observability
140
+
141
+ `GET /_voltro/inspect/plugins/comments/threads` + a Comments panel in both
142
+ dashboards (volume, open/resolved, recent threads).
@@ -1,6 +1,6 @@
1
1
  # Notifications
2
2
 
3
- > Unified notifications — one send API across email / Slack / SMS / push / in-app, with per-user channel preferences, an in-app inbox, and delivery records.
3
+ > Unified notifications — one send API across email / Slack / SMS / mobile push / web push / in-app, with per-user channel preferences, an in-app inbox, and delivery records.
4
4
 
5
5
 
6
6
 
@@ -9,7 +9,7 @@
9
9
  <!-- source: en/plugins/notifications.md -->
10
10
  ## Notifications
11
11
 
12
- _Unified notifications — one send API across email / Slack / SMS / push / in-app, with per-user channel preferences, an in-app inbox, and delivery records._
12
+ _Unified notifications — one send API across email / Slack / SMS / mobile push / web push / in-app, with per-user channel preferences, an in-app inbox, and delivery records._
13
13
 
14
14
  `@voltro/plugin-notifications` is the **one** messaging answer instead of twenty brand wrappers: a single `send` across channels, per-user preferences, and an in-app inbox with unread counts — not a per-vendor SDK in every handler.
15
15
 
@@ -34,7 +34,7 @@ export default {
34
34
  }
35
35
  ```
36
36
 
37
- The built-in **in-app** channel persists to the notification store and is appended automatically; `consoleChannel()`, `webhookChannel({ url, id?, format?, headers? })`, `emailChannel(send)`, `smsChannel(send)`, `pushChannel({ tokensFor, transport })` (see [Push](#push-apns-fcm)), and `customChannel(id, deliver)` cover the rest. A channel is just `{ id, deliver: (msg) => Promise<void> }` — bring your own.
37
+ The built-in **in-app** channel persists to the notification store and is appended automatically; `consoleChannel()`, `webhookChannel({ url, id?, format?, headers? })`, `emailChannel(send)`, `smsChannel(send)`, `pushChannel({ tokensFor, transport, onTokenRejected? })` (see [Push](#push-apns-fcm)), `webPushChannel()` (see [Web Push](#web-push-vapid)), and `customChannel(id, deliver)` cover the rest. A channel is just `{ id, deliver: (msg) => Promise<void> }` — bring your own.
38
38
 
39
39
  `notificationsPlugin({ channels?, store?, digestWindowMs?, flushIntervalMs?, name? })` — the inbox, per-subject channel preferences, and delivery log **auto-persist to the framework DataStore by default**, durably, on every supported dialect. You only pass an explicit `store` for a **custom** backend (see [Store](#store)). `digestWindowMs` enables [digest/batching](#digest-batching); the scheduled flush (interval `flushIntervalMs`, default 30s) drains digest windows and quiet-hours deferrals.
40
40
 
@@ -75,7 +75,50 @@ pushChannel({
75
75
  })
76
76
  ```
77
77
 
78
- `deliver` formats one `PushPayload` per device token and sends each. A `PushTokenRejected` is captured into the fan-out (recorded `failed`, naming the rejected token — **never** the auth secret) so your app can prune the dead token. The push AUTH secret lives in your `transport` closure — it never enters the package and is never logged.
78
+ Delivery is **per token and isolated**: each device token sends independently, and the delivery log records one row PER TOKEN (`endpoint` names it) — a stale token on one old phone no longer aborts the send to every current device, and the channel counts delivered when at least one token was reached. A `PushTokenRejected` thrown by your transport marks that token's record `failed` (naming the token — **never** the auth secret) and fires the optional `onTokenRejected(token, reason)` hook, which is where your app prunes the dead token from its own table. The push AUTH secret lives in your `transport` closure — it never enters the package and is never logged.
79
+
80
+ ## Web Push (VAPID)
81
+
82
+ `webPushChannel()` delivers real browser push — a user subscribes once and receives notifications with the tab closed. Subscriptions are managed by the plugin itself (per subject AND per browser endpoint, in `_voltro_notification_push_subscriptions`), payloads are encrypted per RFC 8291, auth is VAPID (RFC 8292), and dead endpoints are pruned automatically.
83
+
84
+ ```ts
85
+ // app.config.ts
86
+ notificationsPlugin({
87
+ channels: [webPushChannel({ contact: 'mailto:ops@example.com' })],
88
+ })
89
+ ```
90
+
91
+ **The key is minted, never shipped.** The channel signs with `VOLTRO_VAPID_PRIVATE_KEY` (a base64url P-256 scalar); `voltro dev` mints a per-project value into the gitignored `.env.local` on first boot, and a production boot without one **refuses by name** — there is deliberately no default. The browser-facing public key is DERIVED from the private scalar, so a public/private pair can never desync.
92
+
93
+ **Setup, three steps:**
94
+
95
+ 1. Configure the channel (above). Web push requires HTTPS in production (localhost is exempt).
96
+ 2. Copy the service worker into your web app: `cp node_modules/@voltro/plugin-notifications/sw.js public/sw.js`. Its URL decides its scope — served from the root it covers the whole app. It shows the notification, opens the declared `url` on click, reports the click, and posts the payload to open pages (`{ type: 'voltro:push' }` message) so an in-page inbox can refresh live.
97
+ 3. Offer the subscribe flow with the hook:
98
+
99
+ ```tsx
100
+ import { useWebPush } from '@voltro/plugin-notifications/web'
101
+
102
+ const PushSettings = () => {
103
+ const push = useWebPush()
104
+ if (push.status === 'unsupported') return <p>This browser cannot receive push.</p>
105
+ return push.status === 'subscribed'
106
+ ? <button onClick={() => void push.unsubscribe()}>Disable push</button>
107
+ : <button onClick={() => void push.subscribe()}>Enable push</button>
108
+ }
109
+ ```
110
+
111
+ The semantics, precisely:
112
+
113
+ - **Multi-endpoint is the design, not an edge case.** Three browsers = three endpoint rows for one subject. Delivery is per endpoint and isolated; the delivery log records one row per endpoint. A re-subscribe on the same endpoint takes the row over — the endpoint belongs to the browser profile, and the latest signed-in subject owns it.
114
+ - **Prune is automatic and exact.** A push service answering 404/410 for one endpoint deletes exactly that row; the subject's other browsers keep receiving. No app-side prune code.
115
+ - **Payload cap ~4 KB.** Push services cap the encrypted body; an oversized payload SHRINKS (the `data` bag first, then the body is truncated) rather than being dropped.
116
+ - **Click tracking is built in.** Each delivered notification carries a one-time click token; the service worker's `notificationclick` reports it and the delivery record gains `clickedAt` — open rates read straight off the delivery log.
117
+ - **Preferences, quiet hours and digests apply unchanged** — the channel is a sender like any other (its preference key is `webPush`).
118
+ - **iOS Safari, honestly:** web push works on iOS 16.4+ ONLY for installed home-screen web apps (PWA), never in the browser tab. Do not promise iOS coverage from a plain website.
119
+ - **Scheduled send** is a recipe, not a switch: `defineSchedule` + `send` covers "notify at 9am" without a second delivery queue. Quiet hours and digests already defer within their own semantics.
120
+
121
+ Subscribe/unsubscribe are RPC mutations (`notifications.webPushSubscribe` / `webPushUnsubscribe` — subject-bound, so one subject can never detach another's browser); `notifications.webPushPublicKey` hands the browser its `applicationServerKey`; `notifications.webPushStatus` counts the caller's registered endpoints.
79
122
 
80
123
  ## Digest / batching
81
124
 
@@ -1,6 +1,6 @@
1
1
  # Presence
2
2
 
3
- > Ephemeral realtime presence — who's online in a channel, with heartbeat, live roster, and per-member metadata (status, cursor). Works cross-instance.
3
+ > Ephemeral realtime presence — who's online in a channel, with heartbeat, live roster, and per-member metadata (status, cursor — client-supplied and relayed verbatim; identity fields belong in the server-side resolveMember hook). Works cross-instance.
4
4
 
5
5
 
6
6
 
@@ -9,7 +9,7 @@
9
9
  <!-- source: en/plugins/presence.md -->
10
10
  ## Presence
11
11
 
12
- _Ephemeral realtime presence — who's online in a channel, with heartbeat, live roster, and per-member metadata (status, cursor). Works cross-instance._
12
+ _Ephemeral realtime presence — who's online in a channel, with heartbeat, live roster, and per-member metadata (status, cursor — client-supplied and relayed verbatim; identity fields belong in the server-side resolveMember hook). Works cross-instance._
13
13
 
14
14
  `@voltro/plugin-presence` answers "who's here right now". A client heartbeats into a channel; the roster lists everyone whose heartbeat is fresh. Held **in memory**, owner-partitioned: every member belongs to exactly the replica holding its WebSocket, so concurrent writes to one key are impossible by construction and there is no table, no CRDT and no coordinator.
15
15
 
@@ -49,8 +49,21 @@ const Room = ({ channel }: { channel: string }) => {
49
49
 
50
50
  A member is `{ key, meta }`. **It pushes only when the roster actually moves** — a join, a leave, a change to someone's `meta`, a sweep, a peer's delta, a peer's death. A heartbeat that repeats what the server already knows pushes nothing, which is what keeps a large steady room free.
51
51
 
52
+ **`meta` is client-supplied, unvalidated, and handed to every channel member verbatim — identity does not belong in it.** That contract is right for a cursor or a status flag, and wrong for `userName` / `avatarUrl`: any member of a channel could present any name and any `<img src>` to everyone else. For identity fields, give the plugin a server-side resolver — it runs on every heartbeat and its result is merged OVER the caller's `meta`, so a client cannot override what the server says about them:
53
+
54
+ ```ts
55
+ presencePlugin({
56
+ resolveMember: async ({ subject }) => {
57
+ const user = await users.byId((subject as { id: string }).id)
58
+ return user ? { userName: user.name, avatarUrl: user.avatarUrl } : undefined
59
+ },
60
+ })
61
+ ```
62
+
63
+ 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
+
52
65
  > **There is a second `usePresence`, and it is a different hook.**
53
- > [`@voltro/local-first/react`](/docs/local-first/overview#presence--awareness)
66
+ > [`@voltro/local-first/react`](/docs/local-first/overview#presence-awareness)
54
67
  > exports one for peer-to-peer *awareness* — `usePresence(roomId, self, { channel })`
55
68
  > → `{ presence, others, setPresence }` — carrying high-frequency cursor and
56
69
  > selection state over a pub/sub channel. This one is the server-backed roster.
@@ -20,7 +20,7 @@ The framework records metrics into Effect's global `MetricRegistry`. One snapsho
20
20
  - **Core RPC** — `voltro_rpc_requests_total{tag,status}`, `voltro_rpc_errors_total{tag}`, `voltro_rpc_duration_seconds{tag}` (histogram). Emitted at the mutation / action handler boundary.
21
21
  - **HTTP routes** — `voltro_http_requests_total{route,status}`, `voltro_http_duration_seconds{route}`.
22
22
  - **Plugin interceptors** — `voltro_plugin_hook_duration_seconds{hook}`, `voltro_plugin_hook_errors_total{hook}`.
23
- - **Subscriptions** — `voltro_subscriptions_active{tag}` (gauge of currently-open subscriptions), `voltro_subscription_deliveries_total{tag,kind}` + `voltro_subscription_delivery_seconds{tag,kind}` (per-delivery produce→push latency; `kind` = `snapshot` | `delta`).
23
+ - **Subscriptions** — `voltro_subscriptions_active{tag}` (gauge of currently-open subscriptions), `voltro_subscription_deliveries_total{tag,kind}` + `voltro_subscription_delivery_seconds{tag,kind}` (per-delivery produce→push latency; `kind` = `snapshot` | `delta`). Backpressure & resume: `voltro_subscription_buffered_bytes{tag}` (gauge of pending bytes per blocked subscription), `voltro_subscription_coalesced_total{tag}` (updates collapsed onto the newest state while a consumer was blocked), `voltro_subscription_overrun_total{tag}` (streams closed with `SubscriptionOverrun`), `voltro_subscription_oversized_total{tag}` (events over `reactive.socket.oversizedEventBytes` — telemetry, not a cap).
24
24
  - **Schedules (crons)** — `voltro_schedule_runs_total{schedule,status}` (firings by name + outcome — `status` = `succeeded` | `failed`), `voltro_schedule_duration_seconds{schedule}` (histogram), and `voltro_schedule_last_success_timestamp_seconds{schedule}` (a **gauge holding the UNIX time of the last SUCCESS**). Emitted by the framework scheduler, so every cron gets them with no per-handler wiring. A cron fires unattended — the failure mode is silent — so this is the series to alert on: `time() - voltro_schedule_last_success_timestamp_seconds{schedule="…"} > <interval × N>` fires when a job stops succeeding (a failure counter alone can't catch a job that stopped firing at all, but the last-success gauge going stale does). A failure moves the counter but deliberately NOT the gauge.
25
25
  - **Workflows (durable execution)** — `voltro_workflow_runs_total{workflow,status}` (terminal outcomes — `status` = `succeeded` | `failed`), `voltro_workflow_duration_seconds{workflow}` (histogram), and `voltro_workflow_last_success_timestamp_seconds{workflow}` (last-success gauge). Emitted by the workflow run-recording seam. Because the framework applies no retry of its own, a `failed` run is **terminal** — it is the dead-letter state — so `voltro_workflow_runs_total{status="failed"}` **is** the dead-letter rate, and the last-success gauge going stale is the "this workflow stopped completing" alert (same shape as the schedule alert). A failure moves the counter but not the gauge.
26
26
  - **`@voltro/cache`** counters, Effect's own `effect_fiber_*` runtime metrics, and **any custom metric** you or another plugin defines.
@@ -0,0 +1,129 @@
1
+ # Queue (Kafka interop)
2
+
3
+ > Consume and produce against an existing Kafka — Schema-decoded consumers (at-least-once, serial per partition, retry + dead-letter), batched producing via a handler service or transactionally through the outbox, and a kafkaSink for cdc-out.
4
+
5
+
6
+
7
+ ---
8
+
9
+ <!-- source: en/plugins/queue.md -->
10
+ ## Queue (Kafka interop)
11
+
12
+ _Consume and produce against an existing Kafka — Schema-decoded consumers (at-least-once, serial per partition, retry + dead-letter), batched producing via a handler service or transactionally through the outbox, and a kafkaSink for cdc-out._
13
+
14
+ `@voltro/plugin-queue` is the door to queues **somebody else owns**: a Voltro
15
+ backend consuming and producing against an adopter's existing Kafka. The
16
+ boundary with the built-ins, in one line each: the [outbox](/docs/data/outbox)
17
+ is *your own* durable side-effects, a [workflow](/docs/workflows/overview) is
18
+ *your own* orchestration — this plugin is interop with foreign infrastructure.
19
+ Kafka first; the provider contract is cut so SQS/RabbitMQ can be later
20
+ implementations.
21
+
22
+ ```ts
23
+ // app.config.ts
24
+ import { queuePlugin } from '@voltro/plugin-queue'
25
+
26
+ export default {
27
+ // …
28
+ plugins: [
29
+ queuePlugin({ brokers: ['kafka-1:9092', 'kafka-2:9092'] }),
30
+ ],
31
+ }
32
+ ```
33
+
34
+ ## Consuming: `*.consumer.ts`
35
+
36
+ ```ts
37
+ // src/consumers/orders.consumer.ts
38
+ import { Schema } from 'effect'
39
+ import { defineQueueConsumer } from '@voltro/plugin-queue'
40
+
41
+ export const orders = defineQueueConsumer({
42
+ topic: 'orders',
43
+ schema: Schema.Struct({ orderId: Schema.String, total: Schema.Number }),
44
+ handler: async (order, ctx) => {
45
+ // MUST be idempotent — delivery is at-least-once. A unique-column
46
+ // upsert is the standard shape:
47
+ await ctx.store.upsert('orders_mirror',
48
+ { orderId: order.orderId, total: order.total },
49
+ { conflictColumns: ['orderId'] })
50
+ },
51
+ })
52
+ ```
53
+
54
+ Both boot paths discover `*.consumer.ts`; the plugin starts every registered
55
+ consumer at activation and stops them at shutdown. The semantics, precisely:
56
+
57
+ - **Ordering: serial per partition.** Parallelism exists only ACROSS
58
+ partitions — concurrency inside one would destroy ordering and commit
59
+ semantics both. Retry backoff deliberately BLOCKS the partition.
60
+ - **Commit after the handler, per message.** A process killed mid-batch
61
+ redelivers exactly the unhandled tail — never the whole batch, never a
62
+ skipped message.
63
+ - **Decode failures dead-letter IMMEDIATELY** (to `<topic>.dlq`, with
64
+ `x-voltro-dlq-*` reason headers) — a deterministic failure retried forever
65
+ is an infinite loop with extra steps, and a poison message must release
66
+ its partition.
67
+ - **Handler failures retry with backoff, then dead-letter** after
68
+ `maxAttempts` (default 3).
69
+ - **A rebalance is not a failure.** A partition revoked mid-batch or
70
+ mid-retry stops processing without a retry-counter increment or a DLQ
71
+ publish — the new owner redelivers.
72
+ - **Handlers are NOT wrapped in a transaction** (the same documented
73
+ boundary as HTTP route handlers). A handler needing atomic multi-writes
74
+ opens `ctx.store.transactional` itself — and stays idempotent either way.
75
+ - **Replica coordination is Kafka's own.** Every replica joins the same
76
+ consumer group and the broker assigns partitions — no advisory lock, unlike
77
+ [schedules](/docs/scheduling/coordination), which coordinate through the
78
+ claim table because no broker exists to do it for them.
79
+
80
+ ## Producing
81
+
82
+ Two paths, one rule: transactional-with-a-write goes through the outbox.
83
+
84
+ ```ts
85
+ // Inside a mutation — commits or rolls back WITH the domain write:
86
+ await ctx.outbox.enqueue('queue.produce', {
87
+ topic: 'orders',
88
+ messages: [{ key: order.id, value: JSON.stringify(order) }],
89
+ })
90
+ ```
91
+
92
+ ```ts
93
+ // src/queue.outbox.ts — the bridge (once per app):
94
+ import { queueOutboxHandler } from '@voltro/plugin-queue'
95
+ export default queueOutboxHandler()
96
+ ```
97
+
98
+ The outbox runner delivers after commit — batched (`messages` is an array →
99
+ one transport round-trip), at-least-once, retried, dead-lettered. One
100
+ durability path: the existing outbox, not a second one. For fire-and-forget
101
+ producing without a surrounding write, `yield* QueueService` in a handler and
102
+ call `produce(topic, messages)` directly.
103
+
104
+ Topic creation is EXPLICIT (`provider.ensureTopics([...])`) — your Kafka is
105
+ foreign infrastructure, and whether a client may create topics is your
106
+ policy. A consumer started against a topic that does not exist yet warns and
107
+ retries in the background (it connects once the topic appears), never
108
+ aborting the boot.
109
+
110
+ ## cdc-out to Kafka
111
+
112
+ `kafkaSink` plugs table-change mirroring ([plugin-cdc-out](/docs/plugins/cdc-out))
113
+ into the SAME provider: message key = the row id (one row's changes stay
114
+ ordered in one partition), value = the change record, and the
115
+ `x-voltro-delivery-key` header carries cdc-out's at-least-once dedupe handle.
116
+
117
+ ```ts
118
+ import { cdcOutPlugin } from '@voltro/plugin-cdc-out'
119
+ import { kafkaSink } from '@voltro/plugin-queue'
120
+
121
+ cdcOutPlugin({ sinks: [{ table: 'orders', sink: kafkaSink({ topic: 'orders.cdc' }) }] })
122
+ ```
123
+
124
+ ## Observability
125
+
126
+ 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.
@@ -1,27 +1,27 @@
1
- # Row versioning
1
+ # Row history
2
2
 
3
- > Full row history + time-travel. audit() records who/when; versioning records what-changed-to-what — a value snapshot of every row on every write, with as-of queries.
3
+ > Full row history + time-travel. audit() records who/when; row-history records what-changed-to-what — a value snapshot of every row on every write, with as-of queries.
4
4
 
5
5
 
6
6
 
7
7
  ---
8
8
 
9
- <!-- source: en/plugins/versioning.md -->
10
- ## Row versioning
9
+ <!-- source: en/plugins/row-history.md -->
10
+ ## Row history
11
11
 
12
- _Full row history + time-travel. audit() records who/when; versioning records what-changed-to-what — a value snapshot of every row on every write, with as-of queries._
12
+ _Full row history + time-travel. audit() records who/when; row-history records what-changed-to-what — a value snapshot of every row on every write, with as-of queries._
13
13
 
14
- `@voltro/plugin-versioning` keeps a complete value history of selected tables. Where [`audit()`](/docs/plugins/audit) records *who* changed a row and *when*, versioning records *what* — a full snapshot of the row on every insert / update / delete — and lets you read any row **as of** a past instant. It rides the framework's post-commit ChangeEvent tap, so it captures every write that goes through the store with no per-handler wiring.
14
+ `@voltro/plugin-row-history` keeps a complete value history of selected tables. Where [`audit()`](/docs/plugins/audit) records *who* changed a row and *when*, row-history records *what* — a full snapshot of the row on every insert / update / delete — and lets you read any row **as of** a past instant. It rides the framework's post-commit ChangeEvent tap, so it captures every write that goes through the store with no per-handler wiring.
15
15
 
16
16
  ## Wiring
17
17
 
18
18
  ```ts
19
19
  // app.config.ts
20
- import { versioningPlugin } from '@voltro/plugin-versioning'
20
+ import { rowHistoryPlugin } from '@voltro/plugin-row-history'
21
21
 
22
22
  export default {
23
23
  type: 'api' as const, name: 'api',
24
- plugins: [versioningPlugin({})],
24
+ plugins: [rowHistoryPlugin({})],
25
25
  }
26
26
  ```
27
27
 
@@ -30,14 +30,14 @@ Every committed change to a listed table appends a row to `_voltro_row_history`
30
30
  The history row's own `id` is **derived** from `(tableName, rowId, version)` and has a fixed width — it is a surrogate, and every part of it is already a column beside it, so do not parse or construct it. That width is the point: an `id()` column is `VARCHAR(64)` on mysql/mariadb and `NVARCHAR(64)` on mssql, so a key built by concatenating those parts grew with your **table name** and stopped fitting past 22 characters — which failed every write to that table, not merely an import.
31
31
 
32
32
 
33
- ### What gets versioned — opt OUT, not in
33
+ ### What gets recorded — opt OUT, not in
34
34
 
35
- `versioningPlugin({})` covers **every table your app declares**. There is no list to write and none to maintain.
35
+ `rowHistoryPlugin({})` covers **every table your app declares**. There is no list to write and none to maintain.
36
36
 
37
37
  ```ts
38
- versioningPlugin({}) // every app table
39
- versioningPlugin({ exclude: [domainEvents] }) // opt one out
40
- versioningPlugin({ include: [aiFlowsTable] }) // add a PLUGIN's table
38
+ rowHistoryPlugin({}) // every app table
39
+ rowHistoryPlugin({ exclude: [domainEvents] }) // opt one out
40
+ rowHistoryPlugin({ include: [aiFlowsTable] }) // add a PLUGIN's table
41
41
  ```
42
42
 
43
43
  Both take table **values**, not names — a misspelling is a compile error at the call site, exactly as with `reference(() => table)`.
@@ -51,18 +51,18 @@ A table named in both `include` and `exclude` throws at construction — only yo
51
51
  **Check the boot line once after upgrading.** It prints the RESOLVED count, not the configured one:
52
52
 
53
53
  ```txt
54
- versioning active · tables: 41 · historyTable: _voltro_row_history · retentionDays: 365
54
+ row-history active · tables: 41 · historyTable: _voltro_row_history · retentionDays: 365
55
55
  ```
56
56
 
57
57
  If 41 surprises you, `exclude` is the knob. The retention sweep (`VOLTRO_ROW_HISTORY_TTL_HOURS`) still bounds age.
58
58
 
59
59
  ## What this is NOT — the grain
60
60
 
61
- Versioning records **row changes, not domain events**. One entry per row per write, named by *table*. If your product has a user-facing audit feature whose entries are named after an aggregate root — one `Team` event for a call that writes `teams` + `roles` + `userTeams` + `userTeamRoles` — this is the layer **underneath** that, not a replacement for it.
61
+ Row history records **row changes, not domain events**. One entry per row per write, named by *table*. If your product has a user-facing audit feature whose entries are named after an aggregate root — one `Team` event for a call that writes `teams` + `roles` + `userTeams` + `userTeamRoles` — this is the layer **underneath** that, not a replacement for it.
62
62
 
63
63
  The distinction is worth reading before you plan a migration onto it. A migration off hundreds of hand-written audit calls onto this tap runs into the same wall a few hours in: the grain is different. A table-keyed tap does not produce an aggregate-keyed trail with better coverage, it produces a *different artifact*. The two compose:
64
64
 
65
- - **versioning** answers "what did row R look like before, and after" — for every write, whether or not anyone remembered to record it;
65
+ - **row history** answers "what did row R look like before, and after" — for every write, whether or not anyone remembered to record it;
66
66
  - an **aggregate trail** (the [audit sink](/docs/plugins/audit), one row per mutation invocation) answers "what business operation happened, to which entity, and did it succeed";
67
67
  - `traceId` joins them, so one request reads as one story.
68
68
 
@@ -95,10 +95,10 @@ migration — with the same meaning as an absent `traceId`.
95
95
 
96
96
  ## The correlation bridge — joining *what changed* to *who called*
97
97
 
98
- `ChangeEvent` carries the calling `traceId` and `subjectId`, so a history row can be joined to the [audit sink](/docs/plugins/audit) row for the **same call**. Before this, both trails existed and shipped and nothing connected them: versioning knew what changed, the audit sink knew who called and whether they were refused, and no key spanned the two.
98
+ `ChangeEvent` carries the calling `traceId` and `subjectId`, so a history row can be joined to the [audit sink](/docs/plugins/audit) row for the **same call**. Before this, both trails existed and shipped and nothing connected them: row-history knew what changed, the audit sink knew who called and whether they were refused, and no key spanned the two.
99
99
 
100
100
  ```ts
101
- import { historyByTrace, historyBySubject } from '@voltro/plugin-versioning'
101
+ import { historyByTrace, historyBySubject } from '@voltro/plugin-row-history'
102
102
 
103
103
  // What did this call change? (`byTrace`)
104
104
  const touched = await historyByTrace(ctx.store, traceId, ctx.request.subject.tenantId)
@@ -118,7 +118,7 @@ Both questions were previously unanswerable at any speed — `byRow` is the only
118
118
  ## `timing` — when the history row is written
119
119
 
120
120
  ```ts
121
- versioningPlugin({ timing: 'in-transaction' })
121
+ rowHistoryPlugin({ timing: 'in-transaction' })
122
122
  ```
123
123
 
124
124
  | | `'post-commit'` (default) | `'in-transaction'` |
@@ -175,7 +175,7 @@ That is the correct order, not a race to engineer around: the change is durable,
175
175
  { "id": "sess_1", "secret": "enc:v1:a56iziEV9THLhzmJ:Vk0ux+0bECleTLBJkCa0Rg==:3AtMwP" }
176
176
  ```
177
177
 
178
- So versioning a table with encrypted columns **does not widen exposure** — the history is exactly as readable as the row it came from. This is worth stating because "full row snapshot" reads alarming next to `.encrypted()`, and the cautious reader excludes the table. One did, and only found out by measuring.
178
+ So keeping history for a table with encrypted columns **does not widen exposure** — the history is exactly as readable as the row it came from. This is worth stating because "full row snapshot" reads alarming next to `.encrypted()`, and the cautious reader excludes the table. One did, and only found out by measuring.
179
179
 
180
180
  **`.serverOnly()` columns ARE withheld**, and for a sharper reason than "a second copy": `crud.*` strips those columns from every row it returns, and a snapshot would smuggle the value back past that stripping inside a `json()` blob, where no column-level rule applies. A marker meaning *never serialize this to a client* cannot survive being re-exported through a different column's contents.
181
181
 
@@ -189,7 +189,7 @@ The withheld names are listed under `data._omitted`, so a reader can tell *"this
189
189
  ## Querying the timeline
190
190
 
191
191
  ```ts
192
- import { rowHistory, rowAsOf } from '@voltro/plugin-versioning'
192
+ import { rowHistory, rowAsOf } from '@voltro/plugin-row-history'
193
193
 
194
194
  // Every version of a row, oldest → newest — TENANT-SCOPED to the caller:
195
195
  const history = await rowHistory(ctx.store, 'posts', postId, ctx.request.subject.tenantId)
@@ -204,7 +204,7 @@ Pass the caller's `tenantId` — reads are **tenant-scoped**: a row's value time
204
204
  ## Restore & diff
205
205
 
206
206
  ```ts
207
- import { restoreAsOf, diffVersions } from '@voltro/plugin-versioning'
207
+ import { restoreAsOf, diffVersions } from '@voltro/plugin-row-history'
208
208
 
209
209
  // Roll the LIVE row back to its state at a past instant (tenant-scoped like
210
210
  // rowAsOf — no visible state then ⇒ null, nothing written). The restore goes
@@ -1,6 +1,6 @@
1
1
  # Storage
2
2
 
3
- > File storage behind one StorageService — public objects served direct from the bucket/CDN, private objects gated by an access policy + per-object grants. S3 / R2 / GCS / MinIO / filesystem / memory providers, presigned URLs, a dashboard browser.
3
+ > File storage behind one StorageService — public objects served direct from the bucket/CDN, private objects gated by an access policy + per-object grants. S3 / MinIO (R2 and GCS via their S3 interop) / Azure / database / filesystem / memory providers, presigned URLs, a dashboard browser.
4
4
 
5
5
 
6
6
 
@@ -9,7 +9,7 @@
9
9
  <!-- source: en/plugins/storage.md -->
10
10
  ## Storage
11
11
 
12
- _File storage behind one StorageService — public objects served direct from the bucket/CDN, private objects gated by an access policy + per-object grants. S3 / R2 / GCS / MinIO / filesystem / memory providers, presigned URLs, a dashboard browser._
12
+ _File storage behind one StorageService — public objects served direct from the bucket/CDN, private objects gated by an access policy + per-object grants. S3 / MinIO (R2 and GCS via their S3 interop) / Azure / database / filesystem / memory providers, presigned URLs, a dashboard browser._
13
13
 
14
14
  `@voltro/plugin-storage` is file storage behind one `StorageService`. Wire a
15
15
  provider in `app.config.ts`; consume it in handlers and actions via