@voltro/cli 0.51.0 → 0.52.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 (164) hide show
  1. package/CHANGELOG.md +127 -0
  2. package/dist/{agentsMd-D6yD7IQv.js → agentsMd-0l980yhL.js} +4 -1
  3. package/dist/agentsMd-SDDSkyl4.js +2 -0
  4. package/dist/apiBuild-BYBpL7Pz.js +2 -0
  5. package/dist/{apiBuild-CPDHXF72.js → apiBuild-CSFI8QGq.js} +3 -3
  6. package/dist/bin.js +1 -1
  7. package/dist/build-CPgcMQug.js +793 -0
  8. package/dist/checkCommand-2SbqzukH.js +2 -0
  9. package/dist/{checkCommand-DNuPiWMc.js → checkCommand-COmqc2cB.js} +92 -46
  10. package/dist/{cloudCmd-F4YJeqM3.js → cloudCmd-QUXh-b5w.js} +1 -1
  11. package/dist/codegen-CctkDO-1.js +2 -0
  12. package/dist/{codegen-CrMXs4hb.js → codegen-VF479Cnb.js} +1 -1
  13. package/dist/{codegenCommand-BNBHcNNj.js → codegenCommand-DCdG2JN-.js} +12 -12
  14. package/dist/{codemodRunner-BDVixlSw.js → codemodRunner-r7J9lIa7.js} +588 -540
  15. package/dist/{commands-B1OiS9bX.js → commands-Cc_nV8WI.js} +35 -35
  16. package/dist/{dashboardCommand-C-vvPY1B.js → dashboardCommand-C-gKvwqh.js} +5 -5
  17. package/dist/{dataCommand-C1GxXW5q.js → dataCommand-BgpBHnlB.js} +27 -27
  18. package/dist/dbCommand-DNb6yeOG.js +2 -0
  19. package/dist/{dbCommand-If4Y1xQ-.js → dbCommand-sHedr-NJ.js} +2 -2
  20. package/dist/dev--A3nsxA3.js +3 -0
  21. package/dist/{dev-kdAg9Q7l.js → dev-CRHoCEiy.js} +2142 -2103
  22. package/dist/doctorCommand-CqoWA2p5.js +2 -0
  23. package/dist/{doctorCommand-nKmeW78u.js → doctorCommand-DtfJ3FA6.js} +314 -234
  24. package/dist/{dormancyCommand-CY3wa_SW.js → dormancyCommand-Drn7o0No.js} +1 -1
  25. package/dist/{embeddingsCommand-BDLIgje_.js → embeddingsCommand-Z-jO1fWN.js} +1 -1
  26. package/dist/{envCommand-C6V_xVlT.js → envCommand-D4gCrrTZ.js} +8 -8
  27. package/dist/{evolveCommand-D3c4DSfN.js → evolveCommand-CMROeKeA.js} +2 -2
  28. package/dist/fileConventions-DOqD3lPS.js +34 -0
  29. package/dist/{fileTaxonomy-CJfgOllU.js → fileTaxonomy-DvDUV9wq.js} +1 -1
  30. package/dist/frameworkTableAssembly-C6ETawPR.js +2 -0
  31. package/dist/{frameworkTableAssembly-BwJVEKLr.js → frameworkTableAssembly-w-XnLa3q.js} +1 -1
  32. package/dist/index.js +1 -1
  33. package/dist/{infoCommand-BnRFEF1o.js → infoCommand-DXM868o_.js} +1 -1
  34. package/dist/inspectMetrics-CGF94puw.js +143 -0
  35. package/dist/{metaCommands-CfRLra0s.js → metaCommands-C6RFmF1r.js} +2 -2
  36. package/dist/{migrate-DehuBakM.js → migrate-D0F-eTlK.js} +2 -2
  37. package/dist/{pageConvention-cEiRxdab.js → pageConvention-CzUiSbtU.js} +1 -1
  38. package/dist/{privacyCommand-XejDMvmu.js → privacyCommand-DGdopOI6.js} +1 -1
  39. package/dist/{projectScaffold-mIX_DpSe.js → projectScaffold-B4dmTlwT.js} +1 -1
  40. package/dist/{projectScaffold-BIl97_E6.js → projectScaffold-EzlErR4E.js} +1 -1
  41. package/dist/{renderModeScan-D7J1B7Kw.js → renderModeScan-CcH2X1_D.js} +25 -10
  42. package/dist/{renderProfile-1OWWAAtx.js → renderProfile-Ck32Fzxr.js} +2 -2
  43. package/dist/{runtimeTrace-ZsBU7Tkx.js → runtimeTrace-BPQyCmC5.js} +1 -1
  44. package/dist/{sdkgen-O4XqWOjM.js → sdkgen-Se88ifTd.js} +1 -1
  45. package/dist/serveCommand-DLc-BznW.js +2 -0
  46. package/dist/{serveCommand-DdiYNBBu.js → serveCommand-DkP3OT0W.js} +885 -868
  47. package/dist/serveEntry.js +1 -1
  48. package/dist/start-DfL3fOiN.js +3 -0
  49. package/dist/start-jw89Xbqy.js +1339 -0
  50. package/dist/startEntry.js +1 -1
  51. package/dist/{staticCommand-Dr2M6tpU.js → staticCommand-BwNEDlSU.js} +1 -1
  52. package/dist/{test-rXFq4S76.js → test-f3amja6a.js} +1 -1
  53. package/dist/updateCommand-5gFVfK5q.js +2 -0
  54. package/dist/{updateCommand-Bs322Q78.js → updateCommand-BMk2e4ky.js} +1 -1
  55. package/dist/{webDev-B-ubQEMX.js → webDev-BgWL9gKV.js} +1156 -835
  56. package/dist/webDev-CZbTsDcH.js +2 -0
  57. package/dist/{webhooksCommand-FLYY9IXh.js → webhooksCommand-CoIO3jbj.js} +1 -1
  58. package/package.json +30 -17
  59. package/templates/AGENTS.core.md +11 -0
  60. package/templates/AGENTS.md +15 -4
  61. package/templates/agent-docs/_index.md +4 -4
  62. package/templates/agent-docs/_manifest.json +11 -11
  63. package/templates/agent-docs/cli.md +96 -14
  64. package/templates/agent-docs/data.md +210 -7
  65. package/templates/agent-docs/database/schema.md +1 -1
  66. package/templates/agent-docs/database/seedsdialects.md +1 -1
  67. package/templates/agent-docs/deployment.md +22 -3
  68. package/templates/agent-docs/introduction.md +46 -0
  69. package/templates/agent-docs/local-first-mobile.md +34 -7
  70. package/templates/agent-docs/plugins/ai-flows.md +1 -1
  71. package/templates/agent-docs/plugins/audit.md +5 -5
  72. package/templates/agent-docs/plugins/cdc-out.md +2 -2
  73. package/templates/agent-docs/plugins/{versioning.md → row-history.md} +22 -22
  74. package/templates/agent-docs/plugins/storage.md +2 -2
  75. package/templates/agent-docs/plugins.md +29 -7
  76. package/templates/agent-docs/reference.md +39 -2
  77. package/templates/agent-docs/routing.md +341 -47
  78. package/templates/agent-docs/schema-driven-ui.md +78 -2
  79. package/templates/agent-docs/security.md +125 -8
  80. package/templates/agent-docs/templates/apibackends.md +14 -14
  81. package/templates/agent-docs/templates/overview.md +1 -1
  82. package/templates/agent-docs/whats-new.md +76 -53
  83. package/templates/apps/api-ai/package.json +6 -7
  84. package/templates/apps/api-ai/tests/summarize.test.ts +1 -1
  85. package/templates/apps/api-auth/package.json +8 -8
  86. package/templates/apps/api-backend/package.json +7 -7
  87. package/templates/apps/api-backend-deactivation/package.json +7 -7
  88. package/templates/apps/api-backend-mail/package.json +8 -8
  89. package/templates/apps/api-backend-mariadb/package.json +9 -9
  90. package/templates/apps/api-backend-sqlite/package.json +8 -8
  91. package/templates/apps/api-backend-storage/package.json +8 -8
  92. package/templates/apps/api-cms/package.json +9 -10
  93. package/templates/apps/api-collab/package.json +8 -8
  94. package/templates/apps/api-data-advanced/package.json +8 -8
  95. package/templates/apps/api-durable/package.json +8 -8
  96. package/templates/apps/api-feature-flags/package.json +9 -9
  97. package/templates/apps/api-governance/package.json +8 -8
  98. package/templates/apps/api-kv/package.json +8 -8
  99. package/templates/apps/api-moderation/package.json +8 -8
  100. package/templates/apps/api-observability/package.json +8 -8
  101. package/templates/apps/api-ratelimit/package.json +8 -8
  102. package/templates/apps/api-rbac/package.json +8 -8
  103. package/templates/apps/api-rest/package.json +7 -7
  104. package/templates/apps/{api-versioning → api-row-history}/README.md +3 -3
  105. package/templates/apps/{api-versioning → api-row-history}/actions/documents.asOf.action.server.ts +1 -1
  106. package/templates/apps/{api-versioning → api-row-history}/actions/documents.history.action.server.ts +1 -1
  107. package/templates/apps/{api-versioning → api-row-history}/app.config.ts +3 -3
  108. package/templates/apps/{api-versioning → api-row-history}/database/schema.ts +1 -1
  109. package/templates/apps/{api-versioning → api-row-history}/mutations/documents.create.mutation.server.ts +1 -1
  110. package/templates/apps/{api-versioning → api-row-history}/mutations/documents.create.mutation.ts +1 -1
  111. package/templates/apps/{api-versioning → api-row-history}/mutations/documents.update.mutation.ts +1 -1
  112. package/templates/apps/{api-versioning → api-row-history}/package.json +8 -8
  113. package/templates/apps/api-row-history/template.json +6 -0
  114. package/templates/apps/{api-versioning → api-row-history}/tests/documents.create.test.ts +1 -1
  115. package/templates/apps/api-saas/app.config.ts +1 -0
  116. package/templates/apps/api-saas/package.json +10 -11
  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/package.json +6 -6
  122. package/templates/apps/edge-functions/package.json +2 -2
  123. package/templates/apps/frontend-admin/package.json +8 -8
  124. package/templates/apps/frontend-app/package.json +9 -9
  125. package/templates/apps/frontend-auth/package.json +8 -8
  126. package/templates/apps/frontend-blank/package.json +7 -7
  127. package/templates/apps/frontend-cms/package.json +9 -9
  128. package/templates/apps/frontend-collab/package.json +10 -10
  129. package/templates/apps/frontend-collab/src/pages/page.test.tsx +10 -9
  130. package/templates/apps/frontend-collab/src/pages/page.tsx +42 -65
  131. package/templates/apps/frontend-contact/package.json +7 -7
  132. package/templates/apps/frontend-dashboard/package.json +7 -7
  133. package/templates/apps/frontend-docs/package.json +6 -7
  134. package/templates/apps/frontend-i18n/package.json +6 -6
  135. package/templates/apps/frontend-landing/package.json +6 -7
  136. package/templates/apps/frontend-portal/package.json +8 -8
  137. package/templates/apps/frontend-saas/package.json +8 -8
  138. package/templates/apps/frontend-spa/package.json +7 -7
  139. package/templates/apps/frontend-ssr/package.json +7 -7
  140. package/templates/apps/frontend-ssr-api/package.json +8 -8
  141. package/templates/apps/frontend-static-blog/package.json +6 -6
  142. package/templates/apps/frontend-status/package.json +8 -8
  143. package/templates/apps/mobile-app/package.json +4 -4
  144. package/dist/agentsMd-Bu_XQgVf.js +0 -2
  145. package/dist/apiBuild-GDKuGOMV.js +0 -2
  146. package/dist/build-DETLZAFt.js +0 -752
  147. package/dist/checkCommand-CWcnDArJ.js +0 -2
  148. package/dist/codegen-DiMn2KkZ.js +0 -2
  149. package/dist/dbCommand-C27HIsGE.js +0 -2
  150. package/dist/dev-CK522MV5.js +0 -3
  151. package/dist/doctorCommand-BK4l18eG.js +0 -2
  152. package/dist/fileConventions-Cof68_BL.js +0 -33
  153. package/dist/frameworkTableAssembly-CVDB2hCq.js +0 -2
  154. package/dist/inspectMetrics-CfdKLh6t.js +0 -72
  155. package/dist/serveCommand-BRnPCxVd.js +0 -2
  156. package/dist/start-BLNmWkLa.js +0 -1154
  157. package/dist/start-Dzicuyw8.js +0 -3
  158. package/dist/updateCommand-eXB35SEv.js +0 -2
  159. package/dist/webDev-DposiF3j.js +0 -2
  160. package/templates/apps/api-versioning/template.json +0 -6
  161. /package/templates/apps/{api-versioning → api-row-history}/actions/documents.asOf.action.ts +0 -0
  162. /package/templates/apps/{api-versioning → api-row-history}/actions/documents.history.action.ts +0 -0
  163. /package/templates/apps/{api-versioning → api-row-history}/mutations/documents.update.mutation.server.ts +0 -0
  164. /package/templates/apps/{api-versioning → api-row-history}/tsconfig.json +0 -0
@@ -2195,6 +2195,35 @@ mysql/mariadb (binlog), so a second node needs no extra wiring — the cost bein
2195
2195
  budgeted here is the matcher and re-query CPU each node spends on ITS OWN
2196
2196
  clients.
2197
2197
 
2198
+ ## Raw WebSocket gateways — `defineWebSocket`
2199
+
2200
+ Everything above rides the framework's subscription protocol, and that stays the answer for app realtime — live queries, optimistic patches, reconnect. A **gateway** exists for the other case: a FOREIGN protocol that needs a socket the framework does not speak — a Yjs provider, a legacy device fleet, an MQTT-over-WS bridge. It mounts its own upgrade path beside the rpc socket, in a `*.ws.ts` file discovered on **both** boot paths:
2201
+
2202
+ ```ts
2203
+ // gateways/yjs.ws.ts
2204
+ import { defineWebSocket } from '@voltro/protocol'
2205
+
2206
+ export default defineWebSocket({
2207
+ path: '/gateways/yjs',
2208
+ auth: 'subject', // REQUIRED, no default: 'subject' | 'public'
2209
+ onConnection: ({ send, close, onMessage, subject, headers, path }) => {
2210
+ const doc = attachDoc(subject!.id)
2211
+ onMessage((data) => doc.applyUpdate(data)) // binary-safe frames
2212
+ const stop = doc.onUpdate((update) => send(update))
2213
+ return () => { stop(); doc.release() } // teardown
2214
+ },
2215
+ })
2216
+ ```
2217
+
2218
+ The contract, in the order it protects you:
2219
+
2220
+ - **`auth` is mandatory and has no default.** `'subject'` runs the SAME auth chain as rpc/SSR *before* the upgrade — an unauthenticated caller gets `401` while the request is still plain http, and the connection is bound to the credential's expiry: when it lapses, the socket closes with application code `4001`, so a foreign client can re-auth and reconnect. `'public'` is a deliberate, written-down decision (a device fleet with protocol-level auth of its own).
2221
+ - **Every gateway path is origin-checked at upgrade** — cross-origin means `403`, which closes cross-site WebSocket hijacking for your protocol exactly as for the framework's socket.
2222
+ - **`onConnection({ send, close, onMessage, subject, headers, path })`** may return a teardown function — it runs on client disconnect, on credential expiry, and on server shutdown, so whatever the handler opened cannot outlive the socket.
2223
+ - A plain GET on a gateway path answers `426 Upgrade Required`; two gateways declaring one path refuse the boot.
2224
+
2225
+ **The boundary to keep:** if your own UI needs live data, that is a query + `useSubscription`, never a gateway. A gateway hands you raw frames and none of the subscription protocol's guarantees — reach for it only when the CLIENT dictates the protocol.
2226
+
2198
2227
  ## See also
2199
2228
 
2200
2229
  - [Subscribers (`*.subscribe.ts`)](/docs/data/subscribers) — server-side, best-effort post-commit reactivity to a table (NOT the client hook on this page).
@@ -2940,6 +2969,44 @@ const requireApiKey: RestGuard = (ctx) =>
2940
2969
  ctx.subject.type === 'apiKey' ? undefined : { status: 401, message: 'API key required' }
2941
2970
  ```
2942
2971
 
2972
+ ## Methods — PATCH, HEAD and OPTIONS are first-class
2973
+
2974
+ `method:` accepts `GET`, `POST`, `PUT`, `PATCH`, `DELETE`, `HEAD` and `OPTIONS`, on REST routes and plugin HTTP routes alike. Two details:
2975
+
2976
+ - **HEAD is admitted wherever GET is** (RFC 9110): a `HEAD` request runs the GET route's whole pipeline — method gate, guards, handler — and the transport drops the body. You never declare a second route for it.
2977
+ - **A wrong method is still a precise `405`**, with an `Allow:` header naming exactly the methods mounted on that path — including when several routes share one path.
2978
+
2979
+ ## Body limits — `maxBodyBytes`
2980
+
2981
+ Every HTTP body read is capped at 8 MiB by default — `POST /rpc` (as it always was), plugin routes, REST routes and incoming webhooks. The app-wide cap is `http.maxBodyBytes` in `app.config.ts` (env override `VOLTRO_MAX_BODY_BYTES`); a route that legitimately takes more declares its own:
2982
+
2983
+ ```ts
2984
+ export default defineRestRoute({
2985
+ method: 'POST',
2986
+ path: '/v1/import',
2987
+ maxBodyBytes: 64 * 1024 * 1024, // this route only — the app cap stays 8 MiB
2988
+ // …
2989
+ })
2990
+ ```
2991
+
2992
+ The same per-route override exists on an incoming webhook's handler (`maxBodyBytes`) — fat provider payloads are the normal case there, not the exception. Two details:
2993
+
2994
+ - **Routes that share one PATH share one body read.** The body is read once for the whole group, so the widest `maxBodyBytes` override in the group applies to the group.
2995
+ - An oversized body answers `413` whether it announces itself (`Content-Length`) or arrives chunked — the counter cuts it at the cap and never buffers past it.
2996
+
2997
+ ## Conditional GET — `etag: true`
2998
+
2999
+ ```ts
3000
+ export default defineRestRoute({
3001
+ method: 'GET',
3002
+ path: '/v1/customers',
3003
+ etag: true, // GET only — ignored elsewhere
3004
+ // …
3005
+ })
3006
+ ```
3007
+
3008
+ The route stamps a **weak, content-derived** `ETag` (`W/"<sha-1 of the encoded output>"`) on every `200`, and answers a matching `If-None-Match` with `304 Not Modified` — the tag, no body. Weak on purpose: the transport may vary the BYTES per content-encoding, but the representation is the same. (`voltro start` does the equivalent for the web app's HTML on its own — `If-None-Match` answers `304` for buffered `200`s, with weak `W/"md5"` tags over the *uncompressed* body; `no-store` responses excepted.)
3009
+
2943
3010
  ## Idempotency (`Idempotency-Key`)
2944
3011
 
2945
3012
  Set `idempotency: true` in `app.config.ts` and every mutating REST request (`POST`/`PUT`/`PATCH`/`DELETE`) that carries an `Idempotency-Key` header is deduplicated:
@@ -2970,6 +3037,56 @@ This is the Stripe-style contract — the **client** opts in by sending the head
2970
3037
  - **Inbound webhooks already dedup** via [`@voltro/plugin-webhooks`](/docs/plugins/webhooks) (provider key + `_voltro_webhook_*`) — don't double-cover them.
2971
3038
  - **Atomic claim, non-atomic completion.** Two concurrent same-key requests resolve to exactly one execution (the `UNIQUE(scope,key)` insert is the arbiter). But the cached response isn't committed in the handler's own transaction — a crash between the handler committing and the record flipping to `completed` leaves the key in-flight (a retry `409`s until the TTL lapses, then re-runs). REST handlers aren't auto-transactional, so this is the honest ceiling.
2972
3039
 
3040
+ ## API versions — opt-in `version:` + the sunset flow
3041
+
3042
+ A route that will evolve declares its version instead of baking it into the
3043
+ path; `version: 'v2'` mounts under `/v2/…`:
3044
+
3045
+ ```ts
3046
+ // v2 — the current shape
3047
+ export const listCustomers = defineRestRoute({
3048
+ method: 'GET',
3049
+ path: '/customers',
3050
+ version: 'v2',
3051
+ output: Schema.Struct({ data: Schema.Array(Customer), nextCursor: Schema.NullOr(Schema.String) }),
3052
+ handler: async (_i, ctx) => ({ data: await ctx.store.query(customers), nextCursor: null }),
3053
+ })
3054
+
3055
+ // v1 — still mounted, deprecated, and gone on a date
3056
+ export const listCustomersV1 = defineRestRoute({
3057
+ method: 'GET',
3058
+ path: '/customers',
3059
+ version: 'v1',
3060
+ deprecated: 'GET /v2/customers', // Deprecation header + replacement pointer
3061
+ sunset: '2027-03-01', // Sunset header; 410 Gone from this date
3062
+ output: Schema.Struct({ customers: Schema.Array(Customer) }),
3063
+ handler: async (_i, ctx) => ({ customers: await ctx.store.query(customers) }),
3064
+ })
3065
+ ```
3066
+
3067
+ Two versions are **two descriptors** — the old one is ordinary code (visible,
3068
+ testable, deletable), not an entry in a transformation DSL. While it lives,
3069
+ responses carry `Deprecation: true` + `Sunset:`; past the date it answers
3070
+ `410 Gone` with `{ version: 'v1', replacement: 'GET /v2/customers' }`. Then
3071
+ you delete it. `version` is opt-in: a route without it keeps its literal path
3072
+ (no auto-prefix), and declaring `version:` on a path that already starts with
3073
+ `/vN/` is refused at definition — both spellings at once is never intended.
3074
+ `publicApi` projections version the same way (`spec.version`, default `v1`),
3075
+ and the OpenAPI doc groups each version's operations under a version tag with
3076
+ `x-voltro-api-version` — one document, the `/vN/` paths already separate them.
3077
+
3078
+ **URI versioning only, on purpose.** Header- and media-type-versioning (the
3079
+ NestJS options) are not supported: the OpenAPI document, cache keys and plain
3080
+ `curl` are all path-shaped, and a version a URL cannot express is a version a
3081
+ cached response cannot vary on. If an edge must accept `Accept-Version:`
3082
+ headers, rewrite them to the path prefix at the proxy.
3083
+
3084
+ **And the rpc socket is deliberately outside this.** The generated client is
3085
+ versioned with the server it was generated from — there is no `/v2` for
3086
+ `useMutation`. Honest edge: a browser tab that stayed open across your deploy
3087
+ runs the PREVIOUS client until reload; that skew window exists, it is small,
3088
+ and URL versioning would not remove it.
3089
+
2973
3090
  ## Projecting an existing procedure — `publicApi`
2974
3091
 
2975
3092
  You often want to *offer* an API you don't consume from your own frontend. When the procedure already exists as a query / mutation / action, you don't need to rewrite it as a REST route — annotate it with `publicApi` and the framework mounts ONE HTTP route that runs the **same** handler, under the same guards:
@@ -3026,6 +3143,45 @@ Same guarantees as the WebSocket path, because it is the same code: the declarat
3026
3143
 
3027
3144
  For a hand-written `defineRestRoute`, the same machinery is available directly — return `sse((emit) => unsubscribe)` from the handler and frame events with `sseFrame(event, data)` (both from `@voltro/protocol/rest`).
3028
3145
 
3146
+ ## Binary downloads — `bytes()`
3147
+
3148
+ A handler that serves a file, an export or any non-JSON body returns `bytes(stream, options)` — imported beside `defineRestRoute` / `sse`:
3149
+
3150
+ ```ts
3151
+ import { defineRestRoute, bytes, requireScope } from '@voltro/protocol/rest'
3152
+
3153
+ export default defineRestRoute({
3154
+ method: 'GET',
3155
+ path: '/v1/exports/:id',
3156
+ guards: [requireScope('exports:read')],
3157
+ handler: async ({ params }, ctx) => {
3158
+ const file = await locateExport(params.id)
3159
+ // Lazy thunk form — the source is opened only when the response streams.
3160
+ return bytes(() => openExportStream(file), {
3161
+ contentType: 'application/zip',
3162
+ contentLength: file.size,
3163
+ contentDisposition: `attachment; filename="${file.name}"`,
3164
+ })
3165
+ },
3166
+ })
3167
+ ```
3168
+
3169
+ - The first argument is a web `ReadableStream<Uint8Array>` — or the **lazy thunk form** `() => ReadableStream`, which defers opening the source until the response actually streams.
3170
+ - The server **pipes without buffering** — a body larger than the heap is fine (the guarantee is exercised with a 256-MiB stream), and byte streams are **never compressed**.
3171
+ - Everything before the handler still runs — method gate, sunset, input decode, guards — so a streaming route is exactly as gated as a buffered one.
3172
+ - On a plugin HTTP route the same shape is `PluginHttpRouteResult.byteStream`.
3173
+
3174
+ ### Idempotency × streams — decided
3175
+
3176
+ `streaming: true` on a method the idempotency binding claims (`POST`/`PUT`/`PATCH`/`DELETE`) is a **mount error**: a stream cannot cache a replayable body, so the idempotency claim could never complete — every retry would `409` until the TTL lapsed. The refusal names the two ways out: serve the stream on `GET`, or keep the idempotency binding away from the app's streaming routes. A handler that returns a stream *without* declaring `streaming: true` is caught at runtime instead — the claim is **released** so a retry re-processes.
3177
+
3178
+ ## No multipart parser — a declared boundary
3179
+
3180
+ There is **no multipart parser** on REST or webhook routes — `multipart/form-data` against `/form/*` answers `415`, and a REST handler never sees parsed file parts. That boundary is deliberate, and this list of alternatives is complete:
3181
+
3182
+ - **File uploads** ride [`@voltro/plugin-storage`](/docs/plugins/storage)'s upload routes — a binary PUT plus a resumable, chunked upload with signed tickets. That is the sanctioned file path, not a workaround.
3183
+ - **A provider that delivers webhooks as multipart** (the Mailgun-inbound class) needs, today, either a small parser proxy in front of the endpoint or the provider's JSON delivery mode where it offers one.
3184
+
3029
3185
  ## REST route vs Action
3030
3186
 
3031
3187
  Both are unary request/response. Pick by transport + audience:
@@ -4464,7 +4620,7 @@ rather than letting whichever loaded last silently win.
4464
4620
  ## Retries, backoff, dead-letter
4465
4621
 
4466
4622
  | | |
4467
- |---|---|
4623
+ | --- | --- |
4468
4624
  | Retry schedule | exponential — 1s, 2s, 4s … capped at 5 minutes |
4469
4625
  | Default attempts | 8 (`maxAttempts` on the handler, or per-enqueue) |
4470
4626
  | Exhausted | row moves to `dead`, logged at ERROR, stays in the table |
@@ -4530,7 +4686,7 @@ delivery-history screen renders. So every attempt appends a row to
4530
4686
  `_voltro_outbox_attempts`:
4531
4687
 
4532
4688
  | column | |
4533
- |---|---|
4689
+ | --- | --- |
4534
4690
  | `outboxId` | the entry this attempt belongs to |
4535
4691
  | `effect` | denormalised — the history stays readable after the entry is purged |
4536
4692
  | `attempt` | 1-indexed, monotonic across the entry's whole life |
@@ -4626,6 +4782,20 @@ bound.
4626
4782
  - **Mirroring a table outward continuously** → `@voltro/plugin-cdc-out`, which
4627
4783
  is built for reverse-ETL with per-pipe ordering.
4628
4784
 
4785
+ ## There is no generic job queue — take X for Y
4786
+
4787
+ Voltro deliberately ships no `defineJob` primitive (priorities, worker pools, a
4788
+ BullMQ equivalent). The outbox, [workflows](/docs/workflows/overview) and
4789
+ [schedules](/docs/scheduling/overview) cover the cases between them, and a third
4790
+ durability primitive would drift from both. What to reach for instead:
4791
+
4792
+ | you want… | take |
4793
+ | --- | --- |
4794
+ | a concurrency-limited worker pool | workflows + [declarative flow control](/docs/workflows/declarative-flow-control) — `concurrency` / `throttle` bound how many runs execute at once |
4795
+ | true priority scheduling (high-priority work overtakes queued low-priority work) | does not exist as a primitive — a workflow draining **your own queue table** in your priority order is the honest build |
4796
+ | a delayed / scheduled message | `delayMs` on `enqueue` (above) for a one-off delayed effect; a [schedule](/docs/scheduling/overview) for recurring time-based work; workflow [`sleep`](/docs/workflows/sleep) for a pause inside a durable process |
4797
+ | exactly-once delivery | does not exist — delivery is at-least-once everywhere, which is the strongest guarantee available without a distributed transaction into the target; **idempotent handlers are mandatory** (see above) |
4798
+
4629
4799
  ## See also
4630
4800
 
4631
4801
  - [Mutations](/docs/data/mutations) — the transaction boundary this rides
@@ -5183,6 +5353,38 @@ const program = Effect.gen(function* () {
5183
5353
  )
5184
5354
  ```
5185
5355
 
5356
+ ### ISR revalidation on publish
5357
+
5358
+ A content type can declare which ISR routes fall when its content is
5359
+ published or unpublished — `publish()`/`unpublish()` fire
5360
+ [`revalidatePath` / `revalidateTag`](/docs/routing/render-modes#on-demand-revalidation)
5361
+ for each entry after the write commits, reaching every `voltro start`
5362
+ replica:
5363
+
5364
+ ```ts
5365
+ import { defineContentType, Schema } from '@voltro/cms'
5366
+
5367
+ const blogPost = defineContentType({
5368
+ name: 'blogPost',
5369
+ displayName: 'Blog post',
5370
+ pluralName: 'Blog posts',
5371
+ fields: {
5372
+ title: Schema.String.pipe(Schema.maxLength(200)),
5373
+ body: Schema.RichText({ allowImages: true, allowEmbeds: false }),
5374
+ },
5375
+ revalidate: {
5376
+ paths: ['/blog/[slug]', '/blog'],
5377
+ tags: ['blog'],
5378
+ },
5379
+ })
5380
+ ```
5381
+
5382
+ You don't need this on postgres for the plain publish case: a route declaring
5383
+ `cacheInvalidatesOn: ['blogPost_published']` is already dropped by CDC when
5384
+ the published table changes. Declare `revalidate` for what CDC can't see —
5385
+ non-postgres dialects, routes whose loaders read the content indirectly, or
5386
+ tag fanout across several routes.
5387
+
5186
5388
  ## The engine, standalone
5187
5389
 
5188
5390
  The validation/derivation engine is pure and exported on its own (also on
@@ -5269,15 +5471,16 @@ list; `mediaFields(type)` lists the top-level media field names.
5269
5471
  ## Versioning content
5270
5472
 
5271
5473
  `@voltro/cms` ships no parallel revision system — the derived tables are
5272
- ordinary database tables, so `@voltro/plugin-versioning` gives full row history
5273
- + time-travel with no new machinery. List the derived table names in the
5274
- plugin's `tables` option, then read a timeline or restore a snapshot:
5474
+ ordinary database tables, so `@voltro/plugin-row-history` gives full row history
5475
+ plus time-travel with no new machinery. The plugin records every table by default
5476
+ narrow it with `include:` (pass the derived table handles) or `exclude:` if you
5477
+ only want content history — then read a timeline or restore a snapshot:
5275
5478
 
5276
5479
  ```ts no-check
5277
- import { versioningPlugin, rowHistory, restoreAsOf } from '@voltro/plugin-versioning'
5480
+ import { rowHistoryPlugin, rowHistory, restoreAsOf } from '@voltro/plugin-row-history'
5278
5481
 
5279
5482
  // Register in your app's plugin list:
5280
- versioningPlugin({})
5483
+ rowHistoryPlugin({})
5281
5484
 
5282
5485
  const timeline = await rowHistory(ctx.store, 'blogPost_published', postId, tenantId)
5283
5486
  await restoreAsOf(ctx.store, 'blogPost_published', postId, tenantId, someEarlierDate)
@@ -712,7 +712,7 @@ yield* ctx.store.update('documents', input.id, { title: input.title, version: in
712
712
 
713
713
  **Why not `updatedAt`.** A timestamp cannot do this job. Two writes in the same millisecond are indistinguishable, and across replicas the clocks disagree — a comparison that looks correct in a test loses rows under load. An integer the database owns is totally ordered and needs no clock. (`.version()` therefore rejects a `text()` or `timestamp()` column at declaration.)
714
714
 
715
- **What it does not do.** It is not a history — it records *that* a row changed, not what to; use [`plugin-versioning`](/docs/plugins/versioning) for that. It is not a lock: a conflict is **reported**, never queued or merged, because merging two intents is a decision only your application can make. And it is not a retry — "re-apply my change on top of theirs" is correct for some changes and wrong for others, so you write it.
715
+ **What it does not do.** It is not a history — it records *that* a row changed, not what to; use [`plugin-row-history`](/docs/plugins/row-history) for that. It is not a lock: a conflict is **reported**, never queued or merged, because merging two intents is a decision only your application can make. And it is not a retry — "re-apply my change on top of theirs" is correct for some changes and wrong for others, so you write it.
716
716
 
717
717
  **Three details worth knowing:**
718
718
 
@@ -543,7 +543,7 @@ and cannot be engineered away:
543
543
  - **`old` is null on an oversized update, and pk-only on an oversized delete.**
544
544
  There is nowhere to read a pre-image from. A tombstone is enough to REMOVE the
545
545
  row from a search index, an analytics mirror or a CDC stream; it is not a
546
- record of what the row contained, and `@voltro/plugin-versioning` writes
546
+ record of what the row contained, and `@voltro/plugin-row-history` writes
547
547
  `data: null` for one rather than a fabricated empty snapshot.
548
548
  - **`'unrecovered'` means the content is gone.** No retry can bring it back —
549
549
  it was never delivered. Subscriptions are unaffected (they re-query); taps
@@ -312,11 +312,15 @@ cause.
312
312
  When one api process isn't enough:
313
313
 
314
314
  1. Run multiple api containers behind the reverse proxy. The proxy's load-balancing default (round-robin) is fine for HTTP — for WebSocket, use sticky sessions (Caddy: `lb_policy ip_hash`).
315
- 2. Install `@voltro/plugin-cluster` in `app.config.ts.plugins`. It sets up cross-instance subscription invalidation via Postgres NOTIFY (or Redis if you set `CLUSTER_TRANSPORT=redis`).
315
+ 2. Cross-instance subscription invalidation: on Postgres (LISTEN/NOTIFY) and MySQL/MariaDB (binlog CDC) this is built in — nothing to install. On any other dialect, or when you prefer a broker, add [`@voltro/plugin-broadcast`](/docs/plugins/broadcast) with a Redis or NATS provider to `app.config.ts.plugins` so every replica sees every change.
316
316
  3. Workflows: `@effect/cluster` shards work across instances by workflow ID. No further config — every instance pulls from the shared workflow queue.
317
317
 
318
318
  For multi-region: you need to run Postgres logical replication between regions yourself (or move to Voltro Cloud which handles it).
319
319
 
320
+ ### Scaling is replicas, not a service split
321
+
322
+ Voltro deliberately ships no microservice transports — no `@MessagePattern`-style service-to-service RPC, no broker-backed internal messaging layer. Splitting one app into services would contradict the architecture thesis this whole page rests on: **one monolith process, scaled by running more replicas of it**, with `@effect/cluster` sharding durable work across instances by workflow ID. Every capability that a service split usually buys already has a first-class path: an external system boundary is [REST routes](/docs/data/rest-routes) + [OpenAPI](/docs/plugins/openapi) (`@voltro/plugin-openapi`), and a reliable outbound side effect is the [transactional outbox](/docs/data/outbox). If you find yourself wanting an internal message bus between "services", the answer is more replicas of the same image — not a second process shape.
323
+
320
324
  ## Backups
321
325
 
322
326
  Postgres is the source of truth. Use your provider's backup features (Neon PITR, RDS snapshots, `pg_dump` on a cron). Object-storage assets back up via the provider's lifecycle policies.
@@ -1008,6 +1012,7 @@ indexed query instead of scanning every table). Force a full re-introspect with
1008
1012
 
1009
1013
  ```sh
1010
1014
  VOLTRO_MAX_RPC_BODY_BYTES=8388608 # default 8 MiB
1015
+ VOLTRO_MAX_BODY_BYTES=8388608 # default 8 MiB
1011
1016
  ```
1012
1017
 
1013
1018
  A declared `Content-Length` over the cap is refused up front, so an honest client
@@ -1401,7 +1406,7 @@ Lower the lease for **faster failover**, at the cost of **false-positive reclaim
1401
1406
  - [ ] Liveness / readiness probes point at `/internal/liveness` + `/internal/readiness`
1402
1407
  - [ ] Serving pods run `voltro serve` (not `voltro dev`), with `VOLTRO_AUTO_MIGRATE=0`
1403
1408
  - [ ] Schema applied by a pre-deploy Job / initContainer (`voltro db apply`), not in the serving pod
1404
- - [ ] `VOLTRO_MAX_RPC_BODY_BYTES` sane; ingress caps body size + per-IP rate
1409
+ - [ ] `VOLTRO_MAX_RPC_BODY_BYTES` + `VOLTRO_MAX_BODY_BYTES` (plugin routes/webhooks) sane; ingress caps body size + per-IP rate
1405
1410
  - [ ] `VOLTRO_TRUSTED_PROXIES` set if you run behind an ingress AND rate-limit per IP
1406
1411
  - [ ] `VOLTRO_ALLOWED_ORIGINS` set if the web app is on a different origin than the api
1407
1412
  - [ ] Security headers reviewed (`VOLTRO_SECURITY_HEADERS`, `VOLTRO_CSP`); HSTS reaching the browser over https
@@ -1759,7 +1764,7 @@ and the drill is the part people skip.
1759
1764
  ## Which one
1760
1765
 
1761
1766
  | | scale-to-zero | managed DB | rolling deploys | cost floor |
1762
- |---|---|---|---|---|
1767
+ | --- | --- | --- | --- | --- |
1763
1768
  | Fly.io | yes (`auto_stop`) | Fly Postgres | yes | ~0 idle |
1764
1769
  | Railway | usage-based sleep | built-in | yes | ~0 idle |
1765
1770
  | Render | paid plans only | built-in | yes | fixed/instance |
@@ -1768,3 +1773,17 @@ and the drill is the part people skip.
1768
1773
  An api that owns cron schedules should not scale to zero. An api with bursty
1769
1774
  traffic and no schedules is exactly what scale-to-zero is for. When in doubt,
1770
1775
  the boring answer — one always-on instance — is also the cheapest to operate.
1776
+
1777
+ ## Why there is no edge-SSR adapter
1778
+
1779
+ Every recipe above deploys a container, and that is deliberate: Voltro's SSR is
1780
+ Node-first (`renderToPipeableStream` into a Node stream, `voltro start` as a
1781
+ long-running Node HTTP server), not a Workers/edge runtime — so there is no
1782
+ Vercel-/Netlify-edge SSR adapter, and none is planned as a posture. The edge
1783
+ still gets first-class use where it fits the model: isolated
1784
+ [`*.serverless.ts` functions](/docs/deployment/serverless-functions)
1785
+ (`@voltro/serverless`, with Cloudflare / Scaleway / Node adapters) for
1786
+ request-shaped work at the edge, and
1787
+ [static / ISR pages](/docs/deployment/static-sites) served from a CDN for
1788
+ everything that does not need a per-request render. If a page must render per
1789
+ request, it renders in the container.
@@ -132,6 +132,22 @@ Voltro is deliberately opinionated about boring things (HTTP, state, transport,
132
132
  - **Magic.** Every file convention is documented; every generated file lives in `.framework/` and you can read it.
133
133
  - **Codegen you have to remember to run.** Schema flows from your tables to your React components automatically via Vite's module graph.
134
134
 
135
+ ## Deliberate noes
136
+
137
+ Two questions come up in every framework comparison. Both are decided — deliberately no — and here is why, so nobody has to re-litigate them.
138
+
139
+ ### Why is there no GraphQL API?
140
+
141
+ 1. **GraphQL's three core promises are solved differently here.** Type-safe selective reads ⇒ typed queries + schema inference. One endpoint for every client ⇒ the RPC socket with a generated client. Third-party consumers ⇒ [REST routes](/docs/data/rest-routes) + [OpenAPI 3.1](/docs/plugins/openapi) (`@voltro/plugin-openapi`).
142
+ 2. **A GraphQL gateway would have no access to the reactivity path** — source-based invalidation, per-delivery guards. It would be a second, dead read path whose results are never live: exactly the kind of duplicate path this framework refuses to keep.
143
+ 3. **Resolver N+1, persisted-query complexity, and a second permission model** (field-level vs. our guards/RLS) buy nothing the existing surface cannot do.
144
+
145
+ Don't build a GraphQL layer over the stores. External consumers get REST + OpenAPI; internal clients get RPC + live subscriptions.
146
+
147
+ ### Why not React Server Components?
148
+
149
+ RSC is a second rendering **and** data model — Flight serialization, `'use client'` boundaries, deep bundler integration — that would compete with the reactive subscription model instead of composing with it. The problems it solves are covered by what exists today: [islands](/docs/routing/islands) for shipping less JS, loaders for server data at render time, and streaming SSR with `defer()` for progressive delivery. Don't write `'use server'` / `'use client'` directives in a Voltro app; they mark a boundary this framework does not have.
150
+
135
151
  ## Where to read next
136
152
 
137
153
  - [Getting started](/docs/intro/getting-started) — scaffold + boot in under a minute
@@ -435,6 +451,7 @@ Voltro replaces router and registry config with **filesystem conventions**. Drop
435
451
  | `*.agent.server.tsx` | Server agent **executor**: `defineAgentExecutor(descriptor, { system, tools, model, maxSteps })`. | Agent runtime. |
436
452
  | `*.tool.tsx` | A tool an agent can call. Schema + handler. | Agent runtime. |
437
453
  | `*.webhook.tsx` | Outgoing webhook spec (target, retry, schema). | Webhook delivery worker. |
454
+ | `*.ws.ts` | Raw WebSocket gateway — `defineWebSocket({ path, auth, onConnection })` as the default export, for FOREIGN protocols beside the rpc socket. | Upgrade listener on the api server, both boot paths. |
438
455
  | `*.entity.ts` | Database table — one table per file: `table()` + columns + mixins. Re-exported from a `database/index.ts` barrel. | Migrations + the runtime data store. |
439
456
  | `*.config.ts` | App-level config (`app.config.ts`, `tsconfig.json`, etc.). | The CLI. |
440
457
 
@@ -470,6 +487,25 @@ Without the marker the leak is still caught — by the rpcGroup guard — but on
470
487
 
471
488
  An unmarked file makes no claim, and that is fine: `*.client.ts` is for the shared files where the mistake is expensive, not a label to sprinkle on everything.
472
489
 
490
+ ### Raw WebSocket gateways: `*.ws.ts`
491
+
492
+ A `*.ws.ts` file's default export mounts a raw WebSocket upgrade path beside the rpc socket — for a protocol the framework does not speak (a Yjs provider, a legacy device fleet). Discovered on **both** boot paths, `voltro dev` and `voltro serve`:
493
+
494
+ ```ts
495
+ // gateways/collab.ws.ts
496
+ import { defineWebSocket } from '@voltro/protocol'
497
+
498
+ export default defineWebSocket({
499
+ path: '/gateways/collab',
500
+ auth: 'subject', // REQUIRED, no default — or 'public', a decision you write down
501
+ onConnection: ({ send, onMessage, subject }) => {
502
+ onMessage((data) => send(data)) // your protocol, your frames
503
+ return () => { /* teardown — runs on disconnect, credential expiry, shutdown */ }
504
+ },
505
+ })
506
+ ```
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).
473
509
 
474
510
  ## The web side (`apps/*/web/`)
475
511
 
@@ -494,6 +530,16 @@ export const interactive = 'islands' as const // 'none' | 'islands' | 'full
494
530
  - `renderMode` controls when the HTML is produced (build vs. request).
495
531
  - `interactive` controls how much JS ships (`'none'` strips it all, `'full'` hydrates the page, `'islands'` hydrates only `.island.tsx` files).
496
532
 
533
+ A page can also declare its query-string contract as a page export:
534
+
535
+ ```tsx
536
+ export const searchParams = Schema.Struct({
537
+ page: Schema.optionalWith(Schema.NumberFromString, { default: () => 1 }),
538
+ })
539
+ ```
540
+
541
+ - `searchParams` (an `effect/Schema` struct — every field optional or with a default) types the page's query string: `useSearchParams(searchParams)` returns the decoded shape, and links built with `withQuery` type-check against it. Details: [Pages → Query strings](/docs/routing/pages#query-strings).
542
+
497
543
  ## Discovery in practice
498
544
 
499
545
  ```text
@@ -26,10 +26,13 @@ imports it directly. The React wrappers live behind `@voltro/local-first/react`
26
26
  > transport, [`useCrdtText`](#a-collaborative-text-field-usecrdttext) — the React
27
27
  > binding for a collaborative text field — [presence/awareness](#presence--awareness)
28
28
  > via `usePresence`, [durable IndexedDB persistence](#durable-persistence), and the
29
- > [`localFirst` table mixin](#the-localfirst-table-mixin). What remains is a thin
30
- > [runtime binding](#whats-shipped-vs-a-runtime-seam) to provisioned infra
31
- > (a broker at scale) plus the two app-specific tags `useCrdtText` is pointed at —
32
- > not un-built framework code.
29
+ > [`localFirst` table mixin](#the-localfirst-table-mixin). What remains falls in
30
+ > two tiers: a thin [runtime binding](#whats-shipped-vs-a-runtime-seam) to
31
+ > provisioned infra (a broker at scale) plus the two app-specific tags
32
+ > `useCrdtText` is pointed at — and the sync **engine** (a locally queryable
33
+ > database, automatic mirroring of `localFirst()` tables, partial replication),
34
+ > which is planned and not yet built. Today `localFirst()` is a declaration the
35
+ > tooling discovers, not an auto-synced local database.
33
36
 
34
37
  ## CRDT text: `crdtText` + `mergeCrdtStates`
35
38
 
@@ -204,6 +207,26 @@ import { Schema } from 'effect'
204
207
  body: Schema.NullOr(Schema.Uint8ArrayFromBase64)
205
208
  ```
206
209
 
210
+ ### Known cost limits of `crdtText()` today
211
+
212
+ Two amplification effects are worth knowing before you put a `crdtText()` column
213
+ on a hot editing path — both are per-keystroke costs, and both are real today:
214
+
215
+ - **Wire amplification downstream.** A subscription delta carries the row's
216
+ columns, and for a CRDT column that is the merged **full state** (base64) —
217
+ every keystroke ships the whole document to every subscriber of the query,
218
+ not the one-edit update. Keep the streamed query's projection narrow (don't
219
+ project `body` into a list view), or subscribe to the document row alone.
220
+ - **Undo/row-history capture.** Server-side capture (the undo log — default-on
221
+ outside production — and `plugin-row-history`'s row history, where enabled)
222
+ snapshots the row per mutation, so per-keystroke mutations write a
223
+ full-state blob per keystroke into those tables. Point them away from
224
+ CRDT-heavy tables, or batch edits before pushing.
225
+
226
+ Both limits are on the framework's roadmap (incremental delivery and
227
+ CRDT-aware capture); until then they are costs to design around, not bugs to
228
+ report.
229
+
207
230
  ## Presence & awareness
208
231
 
209
232
  `usePresence(roomId, self, { channel })` publishes this peer's ephemeral state
@@ -348,9 +371,13 @@ constructs the `ApiHandle` (runtime + subscription cache + rpc client) over a
348
371
  WebSocket **you** inject, so RN passes its own `globalThis.WebSocket` and gets
349
372
  the same client stack the web app uses, without pulling in `@voltro/web`.
350
373
 
351
- Still open before the loop is proven end-to-end on a device: codegen emitting the
352
- api's rpc group for a mobile app, an RN persistence adapter, a NetInfo connection
353
- signal, and a reconnect supervisor. `@voltro/react-native` ships the
374
+ Still open before the loop is proven end-to-end on a device: the device boot
375
+ itself everything here is unit-tested without a simulator, so booting a real
376
+ Metro runtime is the remaining verification — plus `*.deepLink.ts` codegen
377
+ discovery (until it lands, register links via `matchFirstDeepLink(links, url)`),
378
+ push **sender** adapters (APNs / FCM need per-tenant credentials), and
379
+ native-module bindings (camera, biometrics, secure token storage need a native
380
+ runtime). `@voltro/react-native` ships the
354
381
  mobile-specific plumbing around that, limited to the parts that need **no
355
382
  per-tenant credentials and no native runtime**: device registration,
356
383
  background-sync scheduling, offline-first defaults, a connection-status surface,
@@ -356,7 +356,7 @@ import { Effect } from 'effect'
356
356
  export default defineSchedule({ cron: '*/15 * * * *', timezone: 'Europe/Berlin', handler: (s) => Effect.promise(() => runCadenceTick(s.app)) })
357
357
  ```
358
358
 
359
- Adopt `@voltro/plugin-versioning` on `_voltro_ai_flows` for automatic edit history.
359
+ Adopt `@voltro/plugin-row-history` on `_voltro_ai_flows` for automatic edit history.
360
360
 
361
361
  ## Deployment notes
362
362
 
@@ -86,7 +86,7 @@ auditPlugin({ sink: 'datastore', record: 'errors' })
86
86
  ```
87
87
 
88
88
  - `'all'` (default) — every invocation.
89
- - `'errors'` — refusals only: a denied guard, a revoked key, a rejected validation. This is the forensic core, and it pairs with [`@voltro/plugin-versioning`](/docs/plugins/versioning), which records the successful *writes* — so the two together still cover everything while this table stays small enough that retention is a footnote.
89
+ - `'errors'` — refusals only: a denied guard, a revoked key, a rejected validation. This is the forensic core, and it pairs with [`@voltro/plugin-row-history`](/docs/plugins/row-history), which records the successful *writes* — so the two together still cover everything while this table stays small enough that retention is a footnote.
90
90
  - a predicate — `(event) => boolean`, for anything else.
91
91
 
92
92
  `'all'` is the default even though `'errors'` is often the right choice, because defaulting to errors would silently stop recording successes for every app that upgrades — and "what did this compromised account touch" is answered by successes. Shrinking the trail is a decision you make with your eyes open.
@@ -166,7 +166,7 @@ The trail is only useful if you can enter it by the questions an incident asks.
166
166
 
167
167
  ```ts
168
168
  import { auditByTrace, auditBySubject } from '@voltro/plugin-audit'
169
- import { historyByTrace } from '@voltro/plugin-versioning'
169
+ import { historyByTrace } from '@voltro/plugin-row-history'
170
170
 
171
171
  // What happened during ONE call — and what it changed.
172
172
  const calls = await auditByTrace(ctx.store, traceId)
@@ -176,7 +176,7 @@ const changed = await historyByTrace(ctx.store, traceId, ctx.request.subject.ten
176
176
  const denied = await auditBySubject(ctx.store, actorId, { status: 'error', limit: 50 })
177
177
  ```
178
178
 
179
- `traceId` is the join key. [`plugin-versioning`](/docs/plugins/versioning) records *what changed*; this records *who called and whether they were refused*. Neither is complete alone, and before the join key existed they could not be read together at all.
179
+ `traceId` is the join key. [`plugin-row-history`](/docs/plugins/row-history) records *what changed*; this records *who called and whether they were refused*. Neither is complete alone, and before the join key existed they could not be read together at all.
180
180
 
181
181
  `auditBySubject` takes `status` as a real argument rather than leaving you to filter in JS: the index is `(subjectId, status, at)`, so a filter applied after fetching would not use it.
182
182
 
@@ -300,7 +300,7 @@ philosophies.
300
300
 
301
301
  That matters because the right to be forgotten is one this framework grants:
302
302
  `@voltro/plugin-governance`'s `governance.erase` (`delete | anonymize`) exists
303
- for it. Without a snapshot, installing audit + versioning + governance together
303
+ for it. Without a snapshot, installing audit + row-history + governance together
304
304
  makes the first two unreadable for exactly the subjects an investigation is
305
305
  about. **Anonymisation is the worse half**: the join succeeds and returns
306
306
  "Anonymised" for every entry that actor ever produced, retroactively rewriting
@@ -392,7 +392,7 @@ auditPlugin({
392
392
  typeof ctx.input?.teamId === 'string' ? { teamId: ctx.input.teamId } : undefined,
393
393
  })
394
394
 
395
- versioningPlugin({
395
+ rowHistoryPlugin({
396
396
  // From the ROW here — that is what this plugin has.
397
397
  resolveScope: (row) => ({ teamId: row.teamId }),
398
398
  })
@@ -1,6 +1,6 @@
1
1
  # CDC-out (reverse-ETL)
2
2
 
3
- > Declaratively mirror table changes outward to external sinks (webhook / Kafka / Snowflake / BigQuery) through a durable outbox — ordered per pipe, at-least-once from enqueue, retried with backoff, dead-lettered.
3
+ > Declaratively mirror table changes outward to external sinks (webhook, plus a CdcSink interface for custom sinks) through a durable outbox — ordered per pipe, at-least-once from enqueue, retried with backoff, dead-lettered.
4
4
 
5
5
 
6
6
 
@@ -9,7 +9,7 @@
9
9
  <!-- source: en/plugins/cdc-out.md -->
10
10
  ## CDC-out (reverse-ETL)
11
11
 
12
- _Declaratively mirror table changes outward to external sinks (webhook / Kafka / Snowflake / BigQuery) through a durable outbox — ordered per pipe, at-least-once from enqueue, retried with backoff, dead-lettered._
12
+ _Declaratively mirror table changes outward to external sinks (webhook, plus a CdcSink interface for custom sinks) through a durable outbox — ordered per pipe, at-least-once from enqueue, retried with backoff, dead-lettered._
13
13
 
14
14
  # CDC-out — declarative reverse-ETL
15
15