@voltro/cli 0.52.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 (195) hide show
  1. package/CHANGELOG.md +229 -0
  2. package/THIRD-PARTY-NOTICES.md +8311 -3318
  3. package/dist/{apiBuild-CSFI8QGq.js → apiBuild-CaPfoWku.js} +11 -5
  4. package/dist/apiBuild-DHtLXYx9.js +2 -0
  5. package/dist/bin.js +1 -1
  6. package/dist/build-D-OnvNMf.js +843 -0
  7. package/dist/{checkCommand-COmqc2cB.js → checkCommand-C5elt0tW.js} +6 -6
  8. package/dist/{checkCommand-2SbqzukH.js → checkCommand-D2ZduVlh.js} +1 -1
  9. package/dist/codegen-BWpt3VgF.js +2 -0
  10. package/dist/{codegen-VF479Cnb.js → codegen-FEk8AZHb.js} +2 -2
  11. package/dist/{codegenCommand-DCdG2JN-.js → codegenCommand-BOiWQ5hz.js} +4 -4
  12. package/dist/{codemodRunner-r7J9lIa7.js → codemodRunner-BjtB2lq6.js} +109 -11
  13. package/dist/{commands-Cc_nV8WI.js → commands-DyxAmhP0.js} +30 -30
  14. package/dist/{dashboardCommand-C-gKvwqh.js → dashboardCommand-BdKTyT13.js} +5 -5
  15. package/dist/{dataCommand-BgpBHnlB.js → dataCommand-Bab9X7s8.js} +2 -2
  16. package/dist/{dbCommand-sHedr-NJ.js → dbCommand-06O2finM.js} +277 -236
  17. package/dist/dbCommand-B1EXBC6f.js +2 -0
  18. package/dist/{dev-CRHoCEiy.js → dev-C6LGF4iY.js} +3006 -2426
  19. package/dist/{dev--A3nsxA3.js → dev-GjJWAYo2.js} +1 -1
  20. package/dist/doctorCommand-B0hX0tdz.js +2 -0
  21. package/dist/{doctorCommand-DtfJ3FA6.js → doctorCommand-etMkflRc.js} +101 -69
  22. package/dist/{dormancyCommand-Drn7o0No.js → dormancyCommand-UwZ1AZzB.js} +1 -1
  23. package/dist/{embeddingsCommand-Z-jO1fWN.js → embeddingsCommand-C70zWHwo.js} +1 -1
  24. package/dist/{envCommand-D4gCrrTZ.js → envCommand-dSyKvRkM.js} +18 -18
  25. package/dist/{evolveCommand-CMROeKeA.js → evolveCommand-CG0_ebO5.js} +2 -2
  26. package/dist/fileConventions-DASGEmj-.js +35 -0
  27. package/dist/{fileTaxonomy-DvDUV9wq.js → fileTaxonomy-B7uxipWS.js} +55 -55
  28. package/dist/fontPipeline-LxIHa1vo.js +2 -0
  29. package/dist/fontPipeline-Tsh8kZfA.js +152 -0
  30. package/dist/frameworkTableAssembly-C_7Z-rMs.js +2 -0
  31. package/dist/{frameworkTableAssembly-w-XnLa3q.js → frameworkTableAssembly-DKx3ba3S.js} +5 -5
  32. package/dist/imagePipeline-B_GVJgm6.js +2 -0
  33. package/dist/imagePipeline-CBZmjT4i.js +127 -0
  34. package/dist/index.js +1 -1
  35. package/dist/{infoCommand-DXM868o_.js → infoCommand-_53iOc_j.js} +1 -1
  36. package/dist/{inspect-CtL_xTbu.js → inspect-Bd8-9wsi.js} +1 -1
  37. package/dist/inspect-CuoDInfZ.js +2 -0
  38. package/dist/{inspectGateHint-BjnFubmH.js → inspectGateHint-4LxkNtrz.js} +1 -1
  39. package/dist/manifestBuild-C4-J1-m_.js +2 -0
  40. package/dist/{manifestBuild-CuU1VrSm.js → manifestBuild-Cqgsx2bM.js} +1 -1
  41. package/dist/{metaCommands-C6RFmF1r.js → metaCommands-Cn2oboG4.js} +8 -2
  42. package/dist/{migrate-D0F-eTlK.js → migrate-Cko9rswM.js} +2 -2
  43. package/dist/{pageConvention-CzUiSbtU.js → pageConvention-C938S8oC.js} +1 -1
  44. package/dist/{privacyCommand-DGdopOI6.js → privacyCommand-DWTQMC6R.js} +2 -2
  45. package/dist/{probeCommand-C9gazU0H.js → probeCommand-DkGGLknv.js} +83 -24
  46. package/dist/{renderModeScan-CcH2X1_D.js → renderModeScan-CUbOeOAg.js} +3 -1
  47. package/dist/{renderProfile-Ck32Fzxr.js → renderProfile-CskIgAfn.js} +2 -2
  48. package/dist/{runtimeTrace-BPQyCmC5.js → runtimeTrace-c0APJz7E.js} +1 -1
  49. package/dist/{sdkgen-Se88ifTd.js → sdkgen-BiQCgIEr.js} +1 -1
  50. package/dist/{serveCommand-DkP3OT0W.js → serveCommand-CueKQgzl.js} +869 -805
  51. package/dist/serveCommand-DsnrVN3U.js +2 -0
  52. package/dist/serveEntry.js +1 -1
  53. package/dist/start-BJzZLbt8.js +3 -0
  54. package/dist/start-ekPan8BT.js +1510 -0
  55. package/dist/startEntry.js +1 -1
  56. package/dist/{staticCommand-BwNEDlSU.js → staticCommand-xlSL-IWk.js} +1 -1
  57. package/dist/{test-f3amja6a.js → test-BWPQcRoB.js} +1 -1
  58. package/dist/updateCommand-Bqql_rsQ.js +2 -0
  59. package/dist/{updateCommand-BMk2e4ky.js → updateCommand-C_8I8Rzo.js} +139 -115
  60. package/dist/webDev-C7jWJ5dX.js +2 -0
  61. package/dist/{webDev-BgWL9gKV.js → webDev-oczpugbx.js} +1455 -922
  62. package/dist/{webhooksCommand-CoIO3jbj.js → webhooksCommand-4SVPDjKg.js} +1 -1
  63. package/package.json +60 -19
  64. package/templates/AGENTS.md +5 -3
  65. package/templates/agent-docs/_index.md +5 -3
  66. package/templates/agent-docs/_manifest.json +20 -4
  67. package/templates/agent-docs/ai.md +2 -2
  68. package/templates/agent-docs/authentication.md +1 -1
  69. package/templates/agent-docs/cli.md +2 -2
  70. package/templates/agent-docs/configuration.md +17 -0
  71. package/templates/agent-docs/data.md +470 -26
  72. package/templates/agent-docs/database/advancedqueries.md +7 -7
  73. package/templates/agent-docs/database/columntypes.md +2 -2
  74. package/templates/agent-docs/database/querying.md +1 -1
  75. package/templates/agent-docs/database/schema.md +1 -1
  76. package/templates/agent-docs/database/seedsdialects.md +1 -1
  77. package/templates/agent-docs/database/transactions.md +3 -3
  78. package/templates/agent-docs/deployment.md +8 -0
  79. package/templates/agent-docs/internationalization.md +2 -2
  80. package/templates/agent-docs/introduction.md +7 -1
  81. package/templates/agent-docs/local-first-mobile.md +102 -4
  82. package/templates/agent-docs/observability.md +2 -0
  83. package/templates/agent-docs/plugins/auth.md +1 -1
  84. package/templates/agent-docs/plugins/comments.md +142 -0
  85. package/templates/agent-docs/plugins/notifications.md +47 -4
  86. package/templates/agent-docs/plugins/presence.md +16 -3
  87. package/templates/agent-docs/plugins/prometheus.md +1 -1
  88. package/templates/agent-docs/plugins/queue.md +129 -0
  89. package/templates/agent-docs/plugins.md +11 -7
  90. package/templates/agent-docs/reference.md +16 -4
  91. package/templates/agent-docs/routing.md +527 -3
  92. package/templates/agent-docs/schema-driven-ui.md +214 -3
  93. package/templates/agent-docs/security.md +3 -3
  94. package/templates/agent-docs/whats-new.md +160 -66
  95. package/templates/apps/api-ai/package.json +6 -6
  96. package/templates/apps/api-auth/package.json +8 -8
  97. package/templates/apps/api-backend/package.json +7 -7
  98. package/templates/apps/api-backend-deactivation/package.json +7 -7
  99. package/templates/apps/api-backend-mail/package.json +8 -8
  100. package/templates/apps/api-backend-mariadb/package.json +9 -9
  101. package/templates/apps/api-backend-sqlite/package.json +8 -8
  102. package/templates/apps/api-backend-storage/package.json +8 -8
  103. package/templates/apps/api-cms/package.json +9 -9
  104. package/templates/apps/api-collab/package.json +8 -8
  105. package/templates/apps/api-data-advanced/package.json +8 -8
  106. package/templates/apps/api-durable/package.json +8 -8
  107. package/templates/apps/api-feature-flags/package.json +9 -9
  108. package/templates/apps/api-governance/package.json +8 -8
  109. package/templates/apps/api-kv/package.json +8 -8
  110. package/templates/apps/api-moderation/package.json +8 -8
  111. package/templates/apps/api-observability/package.json +8 -8
  112. package/templates/apps/api-ratelimit/package.json +8 -8
  113. package/templates/apps/api-rbac/package.json +8 -8
  114. package/templates/apps/api-rest/package.json +7 -7
  115. package/templates/apps/api-row-history/package.json +8 -8
  116. package/templates/apps/api-saas/package.json +10 -10
  117. package/templates/apps/api-saas-starter/package.json +10 -10
  118. package/templates/apps/api-search/package.json +8 -8
  119. package/templates/apps/api-status/package.json +8 -8
  120. package/templates/apps/api-webhooks/package.json +9 -9
  121. package/templates/apps/changelog/app.config.ts +26 -2
  122. package/templates/apps/changelog/content/releases/{0.1.0.mdx → v0-1-0.mdx} +0 -1
  123. package/templates/apps/changelog/content/releases/{0.2.0.mdx → v0-2-0.mdx} +0 -1
  124. package/templates/apps/changelog/package.json +8 -8
  125. package/templates/apps/changelog/src/collections/releases.collection.ts +48 -0
  126. package/templates/apps/changelog/src/globals.d.ts +1 -1
  127. package/templates/apps/changelog/src/locales/de.ts +1 -1
  128. package/templates/apps/changelog/src/locales/en.ts +1 -1
  129. package/templates/apps/changelog/src/pages/[locale]/[slug]/page.tsx +7 -4
  130. package/templates/apps/changelog/src/pages/[locale]/mirrors.test.tsx +13 -41
  131. package/templates/apps/changelog/src/pages/[slug]/page.test.tsx +26 -51
  132. package/templates/apps/changelog/src/pages/[slug]/page.tsx +21 -19
  133. package/templates/apps/changelog/src/pages/page.test.tsx +14 -37
  134. package/templates/apps/changelog/src/pages/page.tsx +18 -12
  135. package/templates/apps/edge-functions/package.json +2 -2
  136. package/templates/apps/frontend-admin/package.json +8 -8
  137. package/templates/apps/frontend-app/package.json +9 -9
  138. package/templates/apps/frontend-auth/package.json +8 -8
  139. package/templates/apps/frontend-blank/package.json +7 -7
  140. package/templates/apps/frontend-cms/package.json +9 -9
  141. package/templates/apps/frontend-collab/package.json +10 -10
  142. package/templates/apps/frontend-contact/package.json +7 -7
  143. package/templates/apps/frontend-dashboard/package.json +7 -7
  144. package/templates/apps/frontend-docs/content/docs/de/guides/first-page.md +4 -0
  145. package/templates/apps/frontend-docs/content/docs/de/intro/getting-started.md +4 -0
  146. package/templates/apps/frontend-docs/content/docs/en/guides/first-page.md +4 -0
  147. package/templates/apps/frontend-docs/content/docs/en/intro/getting-started.md +4 -0
  148. package/templates/apps/frontend-docs/package.json +8 -6
  149. package/templates/apps/frontend-docs/src/collections/docs.collection.ts +21 -0
  150. package/templates/apps/frontend-docs/src/locales/de.ts +0 -5
  151. package/templates/apps/frontend-docs/src/locales/en.ts +0 -5
  152. package/templates/apps/frontend-docs/src/pages/[locale]/docs/[...slug]/page.tsx +17 -9
  153. package/templates/apps/frontend-docs/src/pages/[locale]/mirrors.test.tsx +10 -5
  154. package/templates/apps/frontend-docs/src/pages/[locale]/page.tsx +8 -0
  155. package/templates/apps/frontend-docs/src/pages/docs/[...slug]/page.test.tsx +53 -15
  156. package/templates/apps/frontend-docs/src/pages/docs/[...slug]/page.tsx +27 -33
  157. package/templates/apps/frontend-docs/src/pages/page.test.tsx +17 -3
  158. package/templates/apps/frontend-docs/src/pages/page.tsx +16 -12
  159. package/templates/apps/frontend-i18n/package.json +6 -6
  160. package/templates/apps/frontend-landing/package.json +6 -6
  161. package/templates/apps/frontend-portal/package.json +8 -8
  162. package/templates/apps/frontend-saas/package.json +8 -8
  163. package/templates/apps/frontend-spa/package.json +7 -7
  164. package/templates/apps/frontend-ssr/package.json +7 -7
  165. package/templates/apps/frontend-ssr-api/package.json +8 -8
  166. package/templates/apps/frontend-static-blog/content/posts/cms-to-ssg.md +12 -0
  167. package/templates/apps/frontend-static-blog/content/posts/hello-static.md +12 -0
  168. package/templates/apps/frontend-static-blog/content/posts/islands-not-hydration.md +14 -0
  169. package/templates/apps/frontend-static-blog/package.json +8 -6
  170. package/templates/apps/frontend-static-blog/src/collections/posts.collection.ts +27 -0
  171. package/templates/apps/frontend-static-blog/src/pages/[locale]/blog/[slug]/page.tsx +7 -4
  172. package/templates/apps/frontend-static-blog/src/pages/[locale]/mirrors.test.tsx +7 -1
  173. package/templates/apps/frontend-static-blog/src/pages/blog/[slug]/page.test.tsx +19 -9
  174. package/templates/apps/frontend-static-blog/src/pages/blog/[slug]/page.tsx +19 -21
  175. package/templates/apps/frontend-static-blog/src/pages/page.test.tsx +18 -5
  176. package/templates/apps/frontend-static-blog/src/pages/page.tsx +21 -14
  177. package/templates/apps/frontend-status/package.json +8 -8
  178. package/templates/apps/mobile-app/package.json +4 -4
  179. package/dist/apiBuild-BYBpL7Pz.js +0 -2
  180. package/dist/build-CPgcMQug.js +0 -793
  181. package/dist/codegen-CctkDO-1.js +0 -2
  182. package/dist/dbCommand-DNb6yeOG.js +0 -2
  183. package/dist/doctorCommand-CqoWA2p5.js +0 -2
  184. package/dist/fileConventions-DOqD3lPS.js +0 -34
  185. package/dist/frameworkTableAssembly-C6ETawPR.js +0 -2
  186. package/dist/inspect-CuGDYES0.js +0 -2
  187. package/dist/manifestBuild-CPjhvM62.js +0 -2
  188. package/dist/serveCommand-DLc-BznW.js +0 -2
  189. package/dist/start-DfL3fOiN.js +0 -3
  190. package/dist/start-jw89Xbqy.js +0 -1339
  191. package/dist/updateCommand-5gFVfK5q.js +0 -2
  192. package/dist/webDev-CZbTsDcH.js +0 -2
  193. package/templates/apps/changelog/scripts/generate-rss.mjs +0 -38
  194. package/templates/apps/changelog/src/lib/releases.ts +0 -21
  195. package/templates/apps/frontend-static-blog/src/content/posts.ts +0 -64
@@ -1055,7 +1055,7 @@ MariaDB's GTID format differs from MySQL's: `0-1-100` (domain-server-sequence) v
1055
1055
  - **`mariadb` schema package is wire-compatible with `mysql`**. If you migrate from MySQL → MariaDB, the framework re-emits DDL cleanly via `applySchema(..., 'mariadb')`. Production data round-trips through `mysqldump` without translation.
1056
1056
  - **`sql_mode=NO_BACKSLASH_ESCAPES`** is sometimes set on MariaDB deploys. The framework's identifier escaping handles it, but user-written `unsafe()` strings that hand-escape backslashes may produce wrong output. Leave that mode off if you can.
1057
1057
  - **Sequence-based ID columns**. MariaDB has true CREATE SEQUENCE; the framework doesn't use it (TypeID / ULID / Snowflake are client-side). If you reach for sequences for legacy reasons, they're outside the framework's auto-injection path.
1058
- - **Hand-rolled `AUTO_INCREMENT` primary keys** work the same as on mysql: an `insert` / `insertMany` with no client-side `id` recovers the DB-generated id via `LAST_INSERT_ID()` (connection-pinned; `insertMany` recovers the whole consecutive range). See the [mysql page](/docs/database/dialects/mysql#auto_increment-ids--last_insert_id-recovery) for the worked example — the recovery path is identical on both engines.
1058
+ - **Hand-rolled `AUTO_INCREMENT` primary keys** work the same as on mysql: an `insert` / `insertMany` with no client-side `id` recovers the DB-generated id via `LAST_INSERT_ID()` (connection-pinned; `insertMany` recovers the whole consecutive range). See the [mysql page](/docs/database/dialects/mysql) for the worked example — the recovery path is identical on both engines.
1059
1059
 
1060
1060
  ## Where it lives
1061
1061
 
@@ -261,7 +261,7 @@ await ctx.store.upsert('orgSlugs', {
261
261
  ```
262
262
 
263
263
  Requires a composite UNIQUE constraint on the table — declare it via
264
- `.unique([cols])` in the schema (see [Indexes](/docs/database/indexes#composite-unique)).
264
+ `.unique([cols])` in the schema (see [Indexes](/docs/database/indexes#composite-unique-constraints)).
265
265
 
266
266
  ## `insertIgnore` — keep existing on conflict
267
267
 
@@ -314,7 +314,7 @@ const count = await ctx.store.updateMany('posts', { hidden: true }, {
314
314
  // count: number of rows actually updated
315
315
  ```
316
316
 
317
- The `where` predicate is a regular [Predicate](/docs/database/query-builder#predicates)
317
+ The `where` predicate is a regular [Predicate](/docs/database/query-builder)
318
318
  AST — same shape `.where()` uses. Sub-queries via `inSubquery` /
319
319
  `exists` are supported.
320
320
 
@@ -395,5 +395,5 @@ matters.
395
395
  `aggregate()` for read-side bulk reads
396
396
  - [Sub-queries](/docs/database/sub-queries) — `inSubquery` /
397
397
  `exists` in `updateMany` `where:` clauses
398
- - [Composite UNIQUE](/docs/database/indexes#composite-unique) — for
398
+ - [Composite UNIQUE](/docs/database/indexes#composite-unique-constraints) — for
399
399
  the constraint that backs `upsert`'s `conflictColumns: ['a', 'b']`
@@ -1119,6 +1119,14 @@ VOLTRO_LOG_FORMAT=json
1119
1119
  VOLTRO_LOG_LEVEL=info
1120
1120
  ```
1121
1121
 
1122
+ In `json` mode (the default off a TTY, so a pod gets it without configuration)
1123
+ EVERY line the framework emits is a parseable record — the boot banner, the app
1124
+ surface, `voltro db apply`'s plan summary, refusal detail, and every subsystem
1125
+ logger (schedule, broadcast, workflow, flow-control). A hand-formatted table or
1126
+ a `[tag]`-prefixed adapter line between JSON records is a bug, not a style: log
1127
+ collectors show it as unparsed noise. On a TTY the same surfaces render as the
1128
+ human-readable banners and tables.
1129
+
1122
1130
  **Error reporting** — add `sentryPlugin()` from `@voltro/plugin-sentry`. It stays inert until `SENTRY_DSN` is set, and reported errors correlate to the request `traceId`:
1123
1131
 
1124
1132
  ```ts
@@ -96,7 +96,7 @@ The client **adopts what the server resolved**, reading it from the `<html lang>
96
96
  | `data-voltro-tz` | the IANA zone every date/time formatter renders in — set `timeZone` in `app.config.ts` |
97
97
  | `data-voltro-now` | the server's render instant, so `useRelativeTime` produces the same string in the hydration pass |
98
98
 
99
- Locale was already agreed; the zone and the clock were each read from the ambient runtime, which meant a server-rendered timestamp was a hydration mismatch waiting for a wide enough offset or a slow enough connection. See [Plurals & formatting → Timezones under SSR](/docs/i18n/formatting#timezones-under-ssr--the-setting-that-is-not-a-preference) — that is the page to read before you migrate hand-rolled `toLocaleString()` calls onto the hooks.
99
+ Locale was already agreed; the zone and the clock were each read from the ambient runtime, which meant a server-rendered timestamp was a hydration mismatch waiting for a wide enough offset or a slow enough connection. See [Plurals & formatting → Timezones under SSR](/docs/i18n/formatting#timezones-under-ssr-the-setting-that-is-not-a-preference) — that is the page to read before you migrate hand-rolled `toLocaleString()` calls onto the hooks.
100
100
 
101
101
  See [Catalogs](/docs/i18n/catalogs) for the type-safe catalog convention and the component hooks, [Plurals & formatting](/docs/i18n/formatting) for CLDR plural selection and the `Intl`-backed date / number / relative-time hooks, and [URL strategies](/docs/i18n/url-strategies) for cookie-only vs URL-prefix routing.
102
102
 
@@ -951,7 +951,7 @@ default, so call sites don't handle a missing-service error.
951
951
  > request, publishes it on the document, and every `@voltro/i18n` formatter on
952
952
  > both sides of the hydration boundary uses it. Read it with `useTimeZone()`
953
953
  > from `@voltro/i18n`. See
954
- > [Formatting → Timezones under SSR](/docs/i18n/formatting#timezones-under-ssr--the-setting-that-is-not-a-preference).
954
+ > [Formatting → Timezones under SSR](/docs/i18n/formatting#timezones-under-ssr-the-setting-that-is-not-a-preference).
955
955
  >
956
956
  > The **server-side compute zone** — what `startOfDay` or a workflow's "same
957
957
  > time tomorrow" resolves against inside a handler — is still the SEAM only.
@@ -505,7 +505,7 @@ export default defineWebSocket({
505
505
  })
506
506
  ```
507
507
 
508
- `auth: 'subject'` authenticates through the same chain as rpc/SSR **before** the upgrade (401 while it is still http) and binds the connection to the credential's expiry (close code `4001`); every gateway path is origin-checked at upgrade. Two gateways on one path refuse the boot; a plain GET on a gateway path answers `426`. App realtime stays [subscriptions](/docs/data/subscriptions) — full detail under [Raw WebSocket gateways](/docs/data/subscriptions#raw-websocket-gateways--definewebsocket).
508
+ `auth: 'subject'` authenticates through the same chain as rpc/SSR **before** the upgrade (401 while it is still http) and binds the connection to the credential's expiry (close code `4001`); every gateway path is origin-checked at upgrade. Two gateways on one path refuse the boot; a plain GET on a gateway path answers `426`. App realtime stays [subscriptions](/docs/data/subscriptions) — full detail under [Raw WebSocket gateways](/docs/data/subscriptions#raw-websocket-gateways-definewebsocket).
509
509
 
510
510
  ## The web side (`apps/*/web/`)
511
511
 
@@ -518,6 +518,10 @@ export default defineWebSocket({
518
518
  | `src/pages/loading.tsx` | Pending UI shown while loaders resolve. |
519
519
  | `src/pages/(group)/` | Route group — does not contribute a URL segment, but layout/error files inside still apply. |
520
520
  | `src/*.island.tsx` | A client-side hydration island (split chunk). Used in `interactive: 'islands'` pages. |
521
+ | `src/**/*.collection.ts` | A content collection declaration (`defineCollection`) — schema-typed markdown/JSON under `content/<name>/**`. See [Content collections](/docs/data/content-collections). |
522
+ | `src/**/*.consumer.ts` | A queue consumer (`defineQueueConsumer`, @voltro/plugin-queue) — Schema-decoded, at-least-once, serial per partition. See [Queue](/docs/plugins/queue). |
523
+ | `grpc.manifest.json` | The gRPC field-number manifest (checked in — append-only wire identity; deletes go `reserved`). See [gRPC surface](/docs/data/grpc). |
524
+ | `content/<name>/**` | A collection's content files (markdown with frontmatter, or `.json` for data collections). Read by `getCollection`/`getEntry`. |
521
525
 
522
526
  Each page can opt into a render strategy via two exports:
523
527
 
@@ -599,6 +603,8 @@ If yes, the promise belongs in the name — you cannot see a contract before you
599
603
  | `*.tracking.ts` | analytics happens nowhere else | `tracking/outside-tracking-file` |
600
604
  | `*.client.ts` | it and its imports are browser-safe | boot-time import walk, `client/not-browser-safe` |
601
605
  | `*.store.ts` | exactly one `defineStore`, no server state | `store/one-per-file`, `store/mirrors-server-state` |
606
+ | `*.collection.ts` | declares content collections (`defineCollection`); frontmatter schema violations fail the build naming the file | the build's collection decode + reference validation |
607
+ | `*.consumer.ts` | declares queue consumers (`defineQueueConsumer`, @voltro/plugin-queue); loading registers, the plugin's activation starts them | the queue runner (decode→DLQ, retry→DLQ, commit-per-message) |
602
608
 
603
609
  A `*.component.tsx` promises exactly ONE component. It does not promise to export nothing else: types, and plain module-local values a `const COLUMNS = […]` beside the table that renders them, are fine and always were. What the rule counts is components — a declaration that renders — so an object, an array, a string or a `new` beside your component is not a second one, and neither is `export default Card` next to `export const Card`.
604
610
 
@@ -29,10 +29,11 @@ imports it directly. The React wrappers live behind `@voltro/local-first/react`
29
29
  > [`localFirst` table mixin](#the-localfirst-table-mixin). What remains falls in
30
30
  > two tiers: a thin [runtime binding](#whats-shipped-vs-a-runtime-seam) to
31
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.
32
+ > `useCrdtText` is pointed at — and the sync **engine**, which is now BUILT:
33
+ > the [query mirror](#the-sync-engine-query-mirror--durable-outbox) persists
34
+ > every subscribed query's rows per subject+tenant partition, `useOutbox`
35
+ > queues offline writes durably, and a reload renders mirrored rows offline
36
+ > and delta-resumes online.
36
37
 
37
38
  ## CRDT text: `crdtText` + `mergeCrdtStates`
38
39
 
@@ -315,6 +316,103 @@ const adapter = await createIndexedDbPersistence({ databaseName: 'my-app' })
315
316
  const sync = createSyncClient({ transport, adapter }) // state now survives reload
316
317
  ```
317
318
 
319
+ ## Rich text: `crdtDoc()` + `useCrdtEditor`
320
+
321
+ `crdtDoc()` stores a WHOLE collaborative document (rich text, maps, arrays)
322
+ as a column — same storage and authoritative server merge as `crdtText()`,
323
+ which stays as the plain-text specialisation. The editor binding is Tiptap
324
+ (`@voltro/local-first/editor`, optional peers): `useCrdtEditor({ doc })`
325
+ binds one editor to the document handle; content edits ride the app's
326
+ mutation (folded server-side under a per-row mutex) and remote edits arrive
327
+ as incremental `mergeCells` subscription deltas — a one-character edit ships
328
+ under 1 KB in BOTH directions regardless of document size.
329
+
330
+ Carets ride a `delivery: 'latest'` EVENT, deliberately not presence
331
+ metadata: the roster's value-compare push would make every caret move a
332
+ "real" change. `attachAwarenessBridge` publishes one member's state per
333
+ envelope (never the aggregated room). Stable positions for inline comments
334
+ come from `encodeAnchor`/`resolveAnchor` on the doc handle.
335
+
336
+ Operational rules: the stored blob soft-compacts past
337
+ `VOLTRO_CRDT_COMPACT_MAX_BYTES` (default 512 KiB) without breaking the merge
338
+ lineage; `rebaseText` is the explicit hard reset (a NEW EPOCH — subscribers
339
+ receive it as a fresh snapshot). CRDT columns are excluded from undo capture
340
+ and row history (document history = named snapshots taken BEFORE
341
+ compaction); `.serverOnly()` on a CRDT column is a declaration error and
342
+ `.encrypted()` makes it online-only.
343
+
344
+ ## The sync engine: query mirror + durable outbox
345
+
346
+ The engine's two halves ride the primitives you already use — there is no
347
+ second data API:
348
+
349
+ - **Reads.** The subscription cache accepts a `mirror`; every base movement of
350
+ every subscribed query persists (rows + revision) into a **subject+tenant
351
+ partitioned** store over IndexedDB, and a cold start seeds from it — the UI
352
+ renders the last materialised rows offline through the SAME
353
+ `useSubscription` call, and the next connect presents the mirrored revision
354
+ as `voltro-resume-from`, so a reload inside the resume window continues with
355
+ deltas instead of a snapshot.
356
+ - **Writes.** `useOutbox` with `persistence: outboxPersistence(adapter)` is
357
+ the durable offline queue: writes survive a reload, replay in order on
358
+ reconnect, stop at the first conflict, and a conflict resolves through
359
+ `resolveConflict(id, resolveWithPolicy(policy, local, remote, { crdtColumns }))`
360
+ — `crdtText()` columns merge, scalars follow the declared `conflictPolicy()`.
361
+ One drain per device even with many tabs (`withDrainLock`, a per-partition
362
+ Web Lock).
363
+
364
+ ```ts
365
+ import {
366
+ createDurableKv, createQueryMirror, createSubscriptionMirrorBinding,
367
+ } from '@voltro/local-first'
368
+ import { localFirstTables } from './.framework/localFirst.generated'
369
+
370
+ const { kv, durability } = await createDurableKv() // 'memory' = visible degradation
371
+ const mirror = createQueryMirror(kv, { subjectId, tenantId }) // ONE partition per subject
372
+ const binding = createSubscriptionMirrorBinding(mirror, {
373
+ tags: { 'docs.list': 'docs' }, // the app's sync set
374
+ metadata: localFirstTables, // codegen: encrypted columns stripped
375
+ schemaFingerprint: BUILD_ID, // local-DB migration gate
376
+ })
377
+ ```
378
+
379
+ The deliberate design decision, recorded here because the obvious alternative
380
+ keeps being suggested: the engine is **not** a browser SQL database. The
381
+ client's whole query surface is `(tag, input)` — predicates are built and
382
+ evaluated on the server — so a wa-sqlite instance would evaluate a language
383
+ the client never sees. What offline needs is the last materialised answer per
384
+ query the user visited, kept current by deltas; that is what the mirror
385
+ stores. (The `KvStore` seam still admits a SQLite backing without touching a
386
+ consumer.)
387
+
388
+ Soundness rules, all enforced structurally and tested:
389
+
390
+ - **Partition by key.** Every stored key carries subject AND tenant; a
391
+ logout/login as somebody else can never read the predecessor's rows, and
392
+ `purge()` empties exactly one partition on revocation. Build ONE binding per
393
+ resolved subject and rebuild it on an auth change — the same blank-on-auth
394
+ doctrine the subscription cache itself follows.
395
+ - **`.encrypted()` never lands.** The server decrypts on read, so a naive
396
+ mirror would persist plaintext on the device; the codegen-emitted
397
+ `localFirst.generated.ts` names those columns and the binding strips them
398
+ before every save. `.serverOnly()` columns never reach the wire at all.
399
+ - **The snapshot is the visible state.** A save replaces the mirrored row
400
+ set, so a row the server stopped sending (revoked share, RLS change, soft
401
+ delete) is evicted by construction.
402
+ - **Schema migration is a visible cold start.** Entries persist under the
403
+ build's `schemaFingerprint`; a new build's load misses them and the query
404
+ falls back to loading → fresh snapshot — never a mixed-shape render. The
405
+ offline queue deliberately does NOT gate on it: a queued old-shape write
406
+ replays against the new server, whose input schema is the authority, and a
407
+ rejection surfaces as a visible conflict instead of silently dropped work.
408
+ - **Shapes are your queries.** There is no separate replication-shape
409
+ language: what is mirrored is exactly what the app subscribes to, so tenant
410
+ scoping, guards and `setRowFilter` apply server-side, fail-closed, exactly
411
+ as online — including parent-relative predicates ("tasks where projectId is
412
+ one of my projects"), which are just queries. Mirroring is per QUERY, so a
413
+ subgraph is N queries, not one nested shape. A storage budget is enforced
414
+ with `enforceBudget(maxBytes)` — oldest-saved entries evict first.
415
+
318
416
  ## Conflict policy for non-CRDT fields
319
417
 
320
418
  CRDT fields resolve themselves — the merge **is** the resolver. A plain scalar
@@ -73,6 +73,8 @@ Even with no exporter, `voltro dev` installs a **buffer-only** tracer — that's
73
73
 
74
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:
75
75
 
76
+ The **web server** additionally counts [partial prerendering](/docs/routing/render-modes#partial-prerendering-ppr--cached-shell--per-request-holes): shell serves, hole passes/settles/errors, and last/max hole latency. Shell hit-rate is the isr cache's own `x-voltro-cache` HIT/STALE/MISS accounting — a ppr shell is a normal isr entry.
77
+
76
78
  ```bash
77
79
  # OTLP metrics — the SAME OTEL endpoint that enables trace export also enables
78
80
  # metrics. Ships to any OTLP/HTTP collector (Prometheus OTLP, Grafana Agent,
@@ -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
 
@@ -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.