@voltro/cli 0.50.1 → 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 (170) hide show
  1. package/CHANGELOG.md +231 -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-D3s7lx57.js → apiBuild-CSFI8QGq.js} +3 -3
  6. package/dist/appGraph-CFmFKHb2.js +2 -0
  7. package/dist/appGraph-yRuKjOba.js +111 -0
  8. package/dist/bin.js +1 -1
  9. package/dist/build-CPgcMQug.js +793 -0
  10. package/dist/checkCommand-2SbqzukH.js +2 -0
  11. package/dist/{checkCommand-DsKIjL6k.js → checkCommand-COmqc2cB.js} +107 -60
  12. package/dist/{cloudCmd-F4YJeqM3.js → cloudCmd-QUXh-b5w.js} +1 -1
  13. package/dist/codegen-CctkDO-1.js +2 -0
  14. package/dist/{codegen-CrMXs4hb.js → codegen-VF479Cnb.js} +1 -1
  15. package/dist/{codegenCommand-C0S27nbB.js → codegenCommand-DCdG2JN-.js} +12 -12
  16. package/dist/{codemodRunner-DwY8Rjbl.js → codemodRunner-r7J9lIa7.js} +771 -636
  17. package/dist/{commands-Brp4DI8u.js → commands-Cc_nV8WI.js} +37 -36
  18. package/dist/{dashboardCommand-bbLfc9FW.js → dashboardCommand-C-gKvwqh.js} +3 -3
  19. package/dist/{dataCommand-k9mXTXin.js → dataCommand-BgpBHnlB.js} +299 -253
  20. package/dist/dbCommand-DNb6yeOG.js +2 -0
  21. package/dist/{dbCommand-Bf7Cy0mn.js → dbCommand-sHedr-NJ.js} +3 -3
  22. package/dist/dev--A3nsxA3.js +3 -0
  23. package/dist/{dev-C0cbqysu.js → dev-CRHoCEiy.js} +2258 -2219
  24. package/dist/doctorCommand-CqoWA2p5.js +2 -0
  25. package/dist/{doctorCommand-DyViaaCP.js → doctorCommand-DtfJ3FA6.js} +314 -234
  26. package/dist/{dormancyCommand-DMZPA_HW.js → dormancyCommand-Drn7o0No.js} +1 -1
  27. package/dist/{embeddingsCommand-CMxRPkg6.js → embeddingsCommand-Z-jO1fWN.js} +1 -1
  28. package/dist/{envCommand-CDLr-yD4.js → envCommand-D4gCrrTZ.js} +15 -15
  29. package/dist/{evolveCommand-Ct4NesL3.js → evolveCommand-CMROeKeA.js} +2 -2
  30. package/dist/fileConventions-DOqD3lPS.js +34 -0
  31. package/dist/{fileTaxonomy-CJfgOllU.js → fileTaxonomy-DvDUV9wq.js} +1 -1
  32. package/dist/frameworkTableAssembly-C6ETawPR.js +2 -0
  33. package/dist/{frameworkTableAssembly-BwJVEKLr.js → frameworkTableAssembly-w-XnLa3q.js} +1 -1
  34. package/dist/{generateCommand-BDdOxhnv.js → generateCommand-DTNPGjxo.js} +1 -1
  35. package/dist/index.js +1 -1
  36. package/dist/{infoCommand-orsKJKUy.js → infoCommand-DXM868o_.js} +1 -1
  37. package/dist/inspectMetrics-CGF94puw.js +143 -0
  38. package/dist/{metaCommands-CfRLra0s.js → metaCommands-C6RFmF1r.js} +2 -2
  39. package/dist/{migrate-D3MK9BpK.js → migrate-D0F-eTlK.js} +2 -2
  40. package/dist/{pageConvention-cEiRxdab.js → pageConvention-CzUiSbtU.js} +1 -1
  41. package/dist/{privacyCommand-XejDMvmu.js → privacyCommand-DGdopOI6.js} +1 -1
  42. package/dist/{projectScaffold-mIX_DpSe.js → projectScaffold-B4dmTlwT.js} +1 -1
  43. package/dist/{projectScaffold-BIl97_E6.js → projectScaffold-EzlErR4E.js} +1 -1
  44. package/dist/{renderModeScan-D7J1B7Kw.js → renderModeScan-CcH2X1_D.js} +25 -10
  45. package/dist/{renderProfile-1OWWAAtx.js → renderProfile-Ck32Fzxr.js} +2 -2
  46. package/dist/{runtimeTrace-Bo8jWsNe.js → runtimeTrace-BPQyCmC5.js} +1 -1
  47. package/dist/{sdkgen-OZhiT2vz.js → sdkgen-Se88ifTd.js} +1 -1
  48. package/dist/serveCommand-DLc-BznW.js +2 -0
  49. package/dist/{serveCommand-YbD5VgfV.js → serveCommand-DkP3OT0W.js} +885 -868
  50. package/dist/serveEntry.js +1 -1
  51. package/dist/start-DfL3fOiN.js +3 -0
  52. package/dist/start-jw89Xbqy.js +1339 -0
  53. package/dist/startEntry.js +1 -1
  54. package/dist/{staticCommand-Dr2M6tpU.js → staticCommand-BwNEDlSU.js} +1 -1
  55. package/dist/{test-rXFq4S76.js → test-f3amja6a.js} +1 -1
  56. package/dist/updateCommand-5gFVfK5q.js +2 -0
  57. package/dist/{updateCommand-BgH6vTtq.js → updateCommand-BMk2e4ky.js} +1 -1
  58. package/dist/{webDev-BbdkzMbd.js → webDev-BgWL9gKV.js} +1233 -853
  59. package/dist/webDev-CZbTsDcH.js +2 -0
  60. package/dist/{webhooksCommand-DjE6u0r4.js → webhooksCommand-CoIO3jbj.js} +1 -1
  61. package/package.json +48 -17
  62. package/templates/AGENTS.core.md +26 -5
  63. package/templates/AGENTS.md +30 -9
  64. package/templates/agent-docs/_index.md +4 -4
  65. package/templates/agent-docs/_manifest.json +11 -11
  66. package/templates/agent-docs/cli.md +125 -14
  67. package/templates/agent-docs/data.md +210 -7
  68. package/templates/agent-docs/database/schema.md +1 -1
  69. package/templates/agent-docs/database/seedsdialects.md +1 -1
  70. package/templates/agent-docs/deployment.md +22 -3
  71. package/templates/agent-docs/internationalization.md +131 -13
  72. package/templates/agent-docs/introduction.md +46 -0
  73. package/templates/agent-docs/local-first-mobile.md +34 -7
  74. package/templates/agent-docs/plugins/ai-flows.md +1 -1
  75. package/templates/agent-docs/plugins/audit.md +5 -5
  76. package/templates/agent-docs/plugins/cdc-out.md +2 -2
  77. package/templates/agent-docs/plugins/{versioning.md → row-history.md} +22 -22
  78. package/templates/agent-docs/plugins/storage.md +2 -2
  79. package/templates/agent-docs/plugins.md +29 -7
  80. package/templates/agent-docs/reference.md +39 -2
  81. package/templates/agent-docs/routing.md +341 -47
  82. package/templates/agent-docs/schema-driven-ui.md +78 -2
  83. package/templates/agent-docs/security.md +125 -8
  84. package/templates/agent-docs/templates/apibackends.md +14 -14
  85. package/templates/agent-docs/templates/overview.md +1 -1
  86. package/templates/agent-docs/whats-new.md +100 -29
  87. package/templates/apps/api-ai/package.json +6 -7
  88. package/templates/apps/api-ai/tests/summarize.test.ts +1 -1
  89. package/templates/apps/api-auth/package.json +8 -8
  90. package/templates/apps/api-backend/package.json +7 -7
  91. package/templates/apps/api-backend-deactivation/package.json +7 -7
  92. package/templates/apps/api-backend-mail/package.json +8 -8
  93. package/templates/apps/api-backend-mariadb/package.json +9 -9
  94. package/templates/apps/api-backend-sqlite/package.json +8 -8
  95. package/templates/apps/api-backend-storage/package.json +8 -8
  96. package/templates/apps/api-cms/package.json +9 -10
  97. package/templates/apps/api-collab/package.json +8 -8
  98. package/templates/apps/api-data-advanced/package.json +8 -8
  99. package/templates/apps/api-durable/package.json +8 -8
  100. package/templates/apps/api-feature-flags/package.json +9 -9
  101. package/templates/apps/api-governance/package.json +8 -8
  102. package/templates/apps/api-kv/package.json +8 -8
  103. package/templates/apps/api-moderation/package.json +8 -8
  104. package/templates/apps/api-observability/package.json +8 -8
  105. package/templates/apps/api-ratelimit/package.json +8 -8
  106. package/templates/apps/api-rbac/package.json +8 -8
  107. package/templates/apps/api-rest/package.json +7 -7
  108. package/templates/apps/{api-versioning → api-row-history}/README.md +3 -3
  109. package/templates/apps/{api-versioning → api-row-history}/actions/documents.asOf.action.server.ts +1 -1
  110. package/templates/apps/{api-versioning → api-row-history}/actions/documents.history.action.server.ts +1 -1
  111. package/templates/apps/{api-versioning → api-row-history}/app.config.ts +3 -3
  112. package/templates/apps/{api-versioning → api-row-history}/database/schema.ts +1 -1
  113. package/templates/apps/{api-versioning → api-row-history}/mutations/documents.create.mutation.server.ts +1 -1
  114. package/templates/apps/{api-versioning → api-row-history}/mutations/documents.create.mutation.ts +1 -1
  115. package/templates/apps/{api-versioning → api-row-history}/mutations/documents.update.mutation.ts +1 -1
  116. package/templates/apps/{api-versioning → api-row-history}/package.json +8 -8
  117. package/templates/apps/api-row-history/template.json +6 -0
  118. package/templates/apps/{api-versioning → api-row-history}/tests/documents.create.test.ts +1 -1
  119. package/templates/apps/api-saas/app.config.ts +1 -0
  120. package/templates/apps/api-saas/package.json +10 -11
  121. package/templates/apps/api-saas-starter/package.json +10 -10
  122. package/templates/apps/api-search/package.json +8 -8
  123. package/templates/apps/api-status/package.json +8 -8
  124. package/templates/apps/api-webhooks/package.json +9 -9
  125. package/templates/apps/changelog/package.json +6 -6
  126. package/templates/apps/edge-functions/package.json +2 -2
  127. package/templates/apps/frontend-admin/package.json +8 -8
  128. package/templates/apps/frontend-app/package.json +9 -9
  129. package/templates/apps/frontend-auth/package.json +8 -8
  130. package/templates/apps/frontend-blank/package.json +7 -7
  131. package/templates/apps/frontend-cms/package.json +9 -9
  132. package/templates/apps/frontend-collab/package.json +10 -10
  133. package/templates/apps/frontend-collab/src/pages/page.test.tsx +10 -9
  134. package/templates/apps/frontend-collab/src/pages/page.tsx +42 -65
  135. package/templates/apps/frontend-contact/package.json +7 -7
  136. package/templates/apps/frontend-dashboard/package.json +7 -7
  137. package/templates/apps/frontend-docs/package.json +6 -7
  138. package/templates/apps/frontend-i18n/package.json +6 -6
  139. package/templates/apps/frontend-landing/package.json +6 -7
  140. package/templates/apps/frontend-portal/package.json +8 -8
  141. package/templates/apps/frontend-saas/package.json +8 -8
  142. package/templates/apps/frontend-spa/package.json +7 -7
  143. package/templates/apps/frontend-ssr/package.json +7 -7
  144. package/templates/apps/frontend-ssr-api/package.json +8 -8
  145. package/templates/apps/frontend-static-blog/package.json +6 -6
  146. package/templates/apps/frontend-status/package.json +8 -8
  147. package/templates/apps/mobile-app/package.json +4 -4
  148. package/dist/agentsMd-Bu_XQgVf.js +0 -2
  149. package/dist/apiBuild-BZ-plaQN.js +0 -2
  150. package/dist/appGraph-CM9wvUmD.js +0 -2
  151. package/dist/appGraph-Cv2W_wO5.js +0 -111
  152. package/dist/build-CFmqF39c.js +0 -721
  153. package/dist/checkCommand-DomvYDi1.js +0 -2
  154. package/dist/codegen-DiMn2KkZ.js +0 -2
  155. package/dist/dbCommand-Bnl7MSGC.js +0 -2
  156. package/dist/dev-Dz9GqA6P.js +0 -3
  157. package/dist/doctorCommand-BlciWUJb.js +0 -2
  158. package/dist/fileConventions-Cof68_BL.js +0 -33
  159. package/dist/frameworkTableAssembly-CVDB2hCq.js +0 -2
  160. package/dist/inspectMetrics-CfdKLh6t.js +0 -72
  161. package/dist/serveCommand-Bbgg7Paq.js +0 -2
  162. package/dist/start-7b_bL8Uc.js +0 -3
  163. package/dist/start-B1nH9ahf.js +0 -1150
  164. package/dist/updateCommand-BweY17z-.js +0 -2
  165. package/dist/webDev-BcRFTsMA.js +0 -2
  166. package/templates/apps/api-versioning/template.json +0 -6
  167. /package/templates/apps/{api-versioning → api-row-history}/actions/documents.asOf.action.ts +0 -0
  168. /package/templates/apps/{api-versioning → api-row-history}/actions/documents.history.action.ts +0 -0
  169. /package/templates/apps/{api-versioning → api-row-history}/mutations/documents.update.mutation.server.ts +0 -0
  170. /package/templates/apps/{api-versioning → api-row-history}/tsconfig.json +0 -0
@@ -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
 
@@ -1,27 +1,27 @@
1
- # Row versioning
1
+ # Row history
2
2
 
3
- > Full row history + time-travel. audit() records who/when; versioning records what-changed-to-what — a value snapshot of every row on every write, with as-of queries.
3
+ > Full row history + time-travel. audit() records who/when; row-history records what-changed-to-what — a value snapshot of every row on every write, with as-of queries.
4
4
 
5
5
 
6
6
 
7
7
  ---
8
8
 
9
- <!-- source: en/plugins/versioning.md -->
10
- ## Row versioning
9
+ <!-- source: en/plugins/row-history.md -->
10
+ ## Row history
11
11
 
12
- _Full row history + time-travel. audit() records who/when; versioning records what-changed-to-what — a value snapshot of every row on every write, with as-of queries._
12
+ _Full row history + time-travel. audit() records who/when; row-history records what-changed-to-what — a value snapshot of every row on every write, with as-of queries._
13
13
 
14
- `@voltro/plugin-versioning` keeps a complete value history of selected tables. Where [`audit()`](/docs/plugins/audit) records *who* changed a row and *when*, versioning records *what* — a full snapshot of the row on every insert / update / delete — and lets you read any row **as of** a past instant. It rides the framework's post-commit ChangeEvent tap, so it captures every write that goes through the store with no per-handler wiring.
14
+ `@voltro/plugin-row-history` keeps a complete value history of selected tables. Where [`audit()`](/docs/plugins/audit) records *who* changed a row and *when*, row-history records *what* — a full snapshot of the row on every insert / update / delete — and lets you read any row **as of** a past instant. It rides the framework's post-commit ChangeEvent tap, so it captures every write that goes through the store with no per-handler wiring.
15
15
 
16
16
  ## Wiring
17
17
 
18
18
  ```ts
19
19
  // app.config.ts
20
- import { versioningPlugin } from '@voltro/plugin-versioning'
20
+ import { rowHistoryPlugin } from '@voltro/plugin-row-history'
21
21
 
22
22
  export default {
23
23
  type: 'api' as const, name: 'api',
24
- plugins: [versioningPlugin({})],
24
+ plugins: [rowHistoryPlugin({})],
25
25
  }
26
26
  ```
27
27
 
@@ -30,14 +30,14 @@ Every committed change to a listed table appends a row to `_voltro_row_history`
30
30
  The history row's own `id` is **derived** from `(tableName, rowId, version)` and has a fixed width — it is a surrogate, and every part of it is already a column beside it, so do not parse or construct it. That width is the point: an `id()` column is `VARCHAR(64)` on mysql/mariadb and `NVARCHAR(64)` on mssql, so a key built by concatenating those parts grew with your **table name** and stopped fitting past 22 characters — which failed every write to that table, not merely an import.
31
31
 
32
32
 
33
- ### What gets versioned — opt OUT, not in
33
+ ### What gets recorded — opt OUT, not in
34
34
 
35
- `versioningPlugin({})` covers **every table your app declares**. There is no list to write and none to maintain.
35
+ `rowHistoryPlugin({})` covers **every table your app declares**. There is no list to write and none to maintain.
36
36
 
37
37
  ```ts
38
- versioningPlugin({}) // every app table
39
- versioningPlugin({ exclude: [domainEvents] }) // opt one out
40
- versioningPlugin({ include: [aiFlowsTable] }) // add a PLUGIN's table
38
+ rowHistoryPlugin({}) // every app table
39
+ rowHistoryPlugin({ exclude: [domainEvents] }) // opt one out
40
+ rowHistoryPlugin({ include: [aiFlowsTable] }) // add a PLUGIN's table
41
41
  ```
42
42
 
43
43
  Both take table **values**, not names — a misspelling is a compile error at the call site, exactly as with `reference(() => table)`.
@@ -51,18 +51,18 @@ A table named in both `include` and `exclude` throws at construction — only yo
51
51
  **Check the boot line once after upgrading.** It prints the RESOLVED count, not the configured one:
52
52
 
53
53
  ```txt
54
- versioning active · tables: 41 · historyTable: _voltro_row_history · retentionDays: 365
54
+ row-history active · tables: 41 · historyTable: _voltro_row_history · retentionDays: 365
55
55
  ```
56
56
 
57
57
  If 41 surprises you, `exclude` is the knob. The retention sweep (`VOLTRO_ROW_HISTORY_TTL_HOURS`) still bounds age.
58
58
 
59
59
  ## What this is NOT — the grain
60
60
 
61
- Versioning records **row changes, not domain events**. One entry per row per write, named by *table*. If your product has a user-facing audit feature whose entries are named after an aggregate root — one `Team` event for a call that writes `teams` + `roles` + `userTeams` + `userTeamRoles` — this is the layer **underneath** that, not a replacement for it.
61
+ Row history records **row changes, not domain events**. One entry per row per write, named by *table*. If your product has a user-facing audit feature whose entries are named after an aggregate root — one `Team` event for a call that writes `teams` + `roles` + `userTeams` + `userTeamRoles` — this is the layer **underneath** that, not a replacement for it.
62
62
 
63
63
  The distinction is worth reading before you plan a migration onto it. A migration off hundreds of hand-written audit calls onto this tap runs into the same wall a few hours in: the grain is different. A table-keyed tap does not produce an aggregate-keyed trail with better coverage, it produces a *different artifact*. The two compose:
64
64
 
65
- - **versioning** answers "what did row R look like before, and after" — for every write, whether or not anyone remembered to record it;
65
+ - **row history** answers "what did row R look like before, and after" — for every write, whether or not anyone remembered to record it;
66
66
  - an **aggregate trail** (the [audit sink](/docs/plugins/audit), one row per mutation invocation) answers "what business operation happened, to which entity, and did it succeed";
67
67
  - `traceId` joins them, so one request reads as one story.
68
68
 
@@ -95,10 +95,10 @@ migration — with the same meaning as an absent `traceId`.
95
95
 
96
96
  ## The correlation bridge — joining *what changed* to *who called*
97
97
 
98
- `ChangeEvent` carries the calling `traceId` and `subjectId`, so a history row can be joined to the [audit sink](/docs/plugins/audit) row for the **same call**. Before this, both trails existed and shipped and nothing connected them: versioning knew what changed, the audit sink knew who called and whether they were refused, and no key spanned the two.
98
+ `ChangeEvent` carries the calling `traceId` and `subjectId`, so a history row can be joined to the [audit sink](/docs/plugins/audit) row for the **same call**. Before this, both trails existed and shipped and nothing connected them: row-history knew what changed, the audit sink knew who called and whether they were refused, and no key spanned the two.
99
99
 
100
100
  ```ts
101
- import { historyByTrace, historyBySubject } from '@voltro/plugin-versioning'
101
+ import { historyByTrace, historyBySubject } from '@voltro/plugin-row-history'
102
102
 
103
103
  // What did this call change? (`byTrace`)
104
104
  const touched = await historyByTrace(ctx.store, traceId, ctx.request.subject.tenantId)
@@ -118,7 +118,7 @@ Both questions were previously unanswerable at any speed — `byRow` is the only
118
118
  ## `timing` — when the history row is written
119
119
 
120
120
  ```ts
121
- versioningPlugin({ timing: 'in-transaction' })
121
+ rowHistoryPlugin({ timing: 'in-transaction' })
122
122
  ```
123
123
 
124
124
  | | `'post-commit'` (default) | `'in-transaction'` |
@@ -175,7 +175,7 @@ That is the correct order, not a race to engineer around: the change is durable,
175
175
  { "id": "sess_1", "secret": "enc:v1:a56iziEV9THLhzmJ:Vk0ux+0bECleTLBJkCa0Rg==:3AtMwP" }
176
176
  ```
177
177
 
178
- So versioning a table with encrypted columns **does not widen exposure** — the history is exactly as readable as the row it came from. This is worth stating because "full row snapshot" reads alarming next to `.encrypted()`, and the cautious reader excludes the table. One did, and only found out by measuring.
178
+ So keeping history for a table with encrypted columns **does not widen exposure** — the history is exactly as readable as the row it came from. This is worth stating because "full row snapshot" reads alarming next to `.encrypted()`, and the cautious reader excludes the table. One did, and only found out by measuring.
179
179
 
180
180
  **`.serverOnly()` columns ARE withheld**, and for a sharper reason than "a second copy": `crud.*` strips those columns from every row it returns, and a snapshot would smuggle the value back past that stripping inside a `json()` blob, where no column-level rule applies. A marker meaning *never serialize this to a client* cannot survive being re-exported through a different column's contents.
181
181
 
@@ -189,7 +189,7 @@ The withheld names are listed under `data._omitted`, so a reader can tell *"this
189
189
  ## Querying the timeline
190
190
 
191
191
  ```ts
192
- import { rowHistory, rowAsOf } from '@voltro/plugin-versioning'
192
+ import { rowHistory, rowAsOf } from '@voltro/plugin-row-history'
193
193
 
194
194
  // Every version of a row, oldest → newest — TENANT-SCOPED to the caller:
195
195
  const history = await rowHistory(ctx.store, 'posts', postId, ctx.request.subject.tenantId)
@@ -204,7 +204,7 @@ Pass the caller's `tenantId` — reads are **tenant-scoped**: a row's value time
204
204
  ## Restore & diff
205
205
 
206
206
  ```ts
207
- import { restoreAsOf, diffVersions } from '@voltro/plugin-versioning'
207
+ import { restoreAsOf, diffVersions } from '@voltro/plugin-row-history'
208
208
 
209
209
  // Roll the LIVE row back to its state at a past instant (tenant-scoped like
210
210
  // rowAsOf — no visible state then ⇒ null, nothing written). The restore goes
@@ -1,6 +1,6 @@
1
1
  # Storage
2
2
 
3
- > File storage behind one StorageService — public objects served direct from the bucket/CDN, private objects gated by an access policy + per-object grants. S3 / R2 / GCS / MinIO / filesystem / memory providers, presigned URLs, a dashboard browser.
3
+ > File storage behind one StorageService — public objects served direct from the bucket/CDN, private objects gated by an access policy + per-object grants. S3 / MinIO (R2 and GCS via their S3 interop) / Azure / database / filesystem / memory providers, presigned URLs, a dashboard browser.
4
4
 
5
5
 
6
6
 
@@ -9,7 +9,7 @@
9
9
  <!-- source: en/plugins/storage.md -->
10
10
  ## Storage
11
11
 
12
- _File storage behind one StorageService — public objects served direct from the bucket/CDN, private objects gated by an access policy + per-object grants. S3 / R2 / GCS / MinIO / filesystem / memory providers, presigned URLs, a dashboard browser._
12
+ _File storage behind one StorageService — public objects served direct from the bucket/CDN, private objects gated by an access policy + per-object grants. S3 / MinIO (R2 and GCS via their S3 interop) / Azure / database / filesystem / memory providers, presigned URLs, a dashboard browser._
13
13
 
14
14
  `@voltro/plugin-storage` is file storage behind one `StorageService`. Wire a
15
15
  provider in `app.config.ts`; consume it in handlers and actions via
@@ -27,7 +27,7 @@ The framework ships some plugins; you write your own; the contract is small enou
27
27
  - [plugin-billing](/docs/plugins/billing) — subscriptions, plans, entitlements + usage metering (Stripe + mock provider); seat-based billing on Stripe's own proration, retries, tax and checkout; money as integer minor units
28
28
  - [plugin-licensing](/docs/plugins/licensing) — offline-verified EdDSA license keys + cloud-issued entitlement snapshots that feed plugin-billing; plan entitlements + pricing decided server-side, never baked into a published version
29
29
  - [plugin-mail](/docs/plugins/mail) — transactional email (Resend / Postmark / SendGrid / SES / Mailgun / SMTP, templates, suppression, scheduling, batch, idempotency)
30
- - [plugin-storage](/docs/plugins/storage) — file storage: public (CDN-direct) + private (access policy + per-object grants), S3 / R2 / GCS / MinIO / filesystem
30
+ - [plugin-storage](/docs/plugins/storage) — file storage: public (CDN-direct) + private (access policy + per-object grants), S3 / MinIO (R2 and GCS via their S3 interop) / Azure / database / filesystem
31
31
  - [plugin-ai-flows](/docs/plugins/ai-flows) — durable multi-step AI pipelines (deterministic + agentic) with human-in-the-loop, chaining, and cadence; code-first `defineFlow` or data-driven rows
32
32
  - [plugin-postgis](/docs/plugins/postgis) — postgres-native `geography` / `geometry` columns + spatial operators
33
33
  - [plugin-broadcast](/docs/plugins/broadcast) — cross-replica reactivity over a pub/sub bus (Redis / NATS) for non-postgres dialects
@@ -42,10 +42,10 @@ The framework ships some plugins; you write your own; the contract is small enou
42
42
  - [plugin-logship](/docs/plugins/logship) — ship structured logs to Better Stack / Axiom / Loki / any HTTP sink; batched, redacted, fail-soft
43
43
  - [plugin-moderation](/docs/plugins/moderation) — moderate user content before commit: keyword or AI provider, block / flag via interceptor + in-handler redact
44
44
  - [plugin-search](/docs/plugins/search) — keep an external index (Typesense / Meilisearch / Algolia) in sync via the ChangeEvent tap; tenant-scoped `search.query` + hook
45
- - [plugin-cdc-out](/docs/plugins/cdc-out) — declarative reverse-ETL: mirror table changes outward to a webhook / Kafka / Snowflake / BigQuery sink through a durable outbox; ordered per pipe, at-least-once from enqueue, dead-lettered
45
+ - [plugin-cdc-out](/docs/plugins/cdc-out) — declarative reverse-ETL: mirror table changes outward to a webhook sink (or any custom `CdcSink`) through a durable outbox; ordered per pipe, at-least-once from enqueue, dead-lettered
46
46
  - [plugin-governance](/docs/plugins/governance) — data governance: retention TTL sweep, GDPR export + erasure, consent ledger, field encryption
47
47
  - [plugin-openapi](/docs/plugins/openapi) — OpenAPI 3.1 spec + Swagger-UI docs generated from your `defineRestRoute` descriptors and (opt-in) rpc procedures
48
- - [plugin-versioning](/docs/plugins/versioning) — full row history + time-travel (`rowHistory` / `rowAsOf` / `restoreAsOf` / `diffVersions`); what-changed-to-what on every write
48
+ - [plugin-row-history](/docs/plugins/row-history) — full row history + time-travel (`rowHistory` / `rowAsOf` / `restoreAsOf` / `diffVersions`); what-changed-to-what on every write
49
49
  - [plugin-presence](/docs/plugins/presence) — ephemeral realtime presence: heartbeat roster per channel + `usePresence` / `useTyping` hooks, held in memory; cross-instance with [plugin-broadcast](/docs/plugins/broadcast)
50
50
  - [plugin-auth-social](/docs/plugins/auth-social) — first-party Sign in with Google / GitHub / Apple: mandatory PKCE + state, JWKS-verified ID tokens, a deliberate account-linking policy, sessions issued through plugin-auth
51
51
  - [plugin-scim](/docs/plugins/scim) — SCIM 2.0 provisioning (Users + Groups at `/scim/v2`) so an enterprise IdP can create/deactivate users
@@ -70,7 +70,7 @@ Status legend: ✓ shipped · ◐ partial · — planned.
70
70
  | `@voltro/plugin-licensing` | ✓ | Offline-verified EdDSA license keys + cloud-issued entitlement snapshots that feed plugin-billing; plan entitlements + pricing decided server-side, never baked into a published version. [→ details](/docs/plugins/licensing) |
71
71
  | `@voltro/plugin-ai-flows` | ✓ | Durable multi-step AI pipelines — deterministic or agentic, with human-in-the-loop, chaining and cadence; author flows in code (`defineFlow`) or as data (visual-editor rows), one engine runs both. [→ details](/docs/plugins/ai-flows) |
72
72
  | `@voltro/plugin-mail` | ✓ | Transactional email — Resend / Postmark / SendGrid / SES / Mailgun / SMTP, *.email.tsx templates, per-tenant suppression, send-time scheduling, bulk/batch send, per-send idempotency, durable via workflows |
73
- | `@voltro/plugin-storage` | ✓ | File storage — public (CDN-direct) + private (access policy + per-object grants), S3 / R2 / GCS / MinIO / filesystem providers, presigned URLs, `listRefs` browse/search, HTTP Range (206) serving, dashboard browser |
73
+ | `@voltro/plugin-storage` | ✓ | File storage — public (CDN-direct) + private (access policy + per-object grants), S3 / MinIO (R2 and GCS via their S3 interop) / Azure / database / filesystem providers, presigned URLs, `listRefs` browse/search, HTTP Range (206) serving, dashboard browser |
74
74
  | `@voltro/plugin-postgis` | ✓ | Postgres-native `geography` / `geometry` columns + spatial predicates (`ST_DWithin`, `ST_Contains`, `ST_Intersects`); GiST indexes via `.expressionIndex(..., { kind: 'gist' })`. No `ST_Distance` projection yet. Postgres-only by design (fails loud elsewhere). [→ details](/docs/plugins/postgis) |
75
75
  | `@voltro/plugin-broadcast` | ✓ | Cross-replica reactivity — fans out app-mutation change events to every replica over a pub/sub bus (Redis / NATS). Closes the single-instance gap for every non-postgres dialect. [→ details](/docs/plugins/broadcast) |
76
76
  | `@voltro/plugin-webhooks` | ✓ | Incoming + outgoing webhooks — `defineIncomingWebhook` (signature verify + idempotency, Stripe/GitHub/Slack presets) and `defineEvent` (durable delivery workflow, HMAC signing, retries, filters). [→ details](/docs/plugins/webhooks) |
@@ -91,10 +91,10 @@ Status legend: ✓ shipped · ◐ partial · — planned.
91
91
  | `@voltro/plugin-logship` | ✓ | Ship structured logs to Better Stack / Axiom / Loki / any HTTP sink — rides the log-sink hook, batched + redacted + fail-soft, trace-correlated. [→ details](/docs/plugins/logship) |
92
92
  | `@voltro/plugin-moderation` | ✓ | Content moderation — keyword denylist or AI provider (fails open), block (typed `ContentRejected`) / flag via rpc interceptor + in-handler `moderate()` redact helper. [→ details](/docs/plugins/moderation) |
93
93
  | `@voltro/plugin-search` | ✓ | External search index sync — rides the ChangeEvent tap to mirror tables into Typesense / Meilisearch / Algolia (memory default), tenant-scoped `search.query` action (facets · highlighting · fuzziness · range/negation filters · engine-param passthrough) + `useSearch` hook + `backfillIndex` + durable cross-replica sync stats. [→ details](/docs/plugins/search) |
94
- | `@voltro/plugin-cdc-out` | ◐ | Declarative reverse-ETL — 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. Engine + memory/webhook sinks shipped; warehouse connectors implement the `CdcSink` interface. |
94
+ | `@voltro/plugin-cdc-out` | ◐ | Declarative reverse-ETL — mirror table changes outward to external sinks (webhook, plus a `CdcSink` interface for custom sinks) through a durable outbox; ordered per pipe, at-least-once from enqueue, retried with backoff, dead-lettered. Engine + memory/webhook sinks shipped; anything else implements the `CdcSink` interface. |
95
95
  | `@voltro/plugin-governance` | ✓ | Data governance — retention TTL sweep (delete / anonymise), GDPR subject export + erasure (admin-gated routes + `GovernanceService`), consent ledger, field encryption. [→ details](/docs/plugins/governance) |
96
96
  | `@voltro/plugin-openapi` | ✓ | OpenAPI 3.1 spec (`GET /openapi.json`) + Swagger-UI (`GET /docs`) generated from `defineRestRoute` descriptors AND (opt-in) rpc procedures (queries/mutations/actions/streams → `POST /rpc/<name>`) — input/output/error Schemas via `JSONSchema.make`. [→ details](/docs/plugins/openapi) |
97
- | `@voltro/plugin-versioning` | ✓ | Full row history + time-travel — value snapshot of every insert/update/delete on listed tables into `_voltro_row_history` (rides the ChangeEvent tap); `rowHistory` / `rowAsOf` queries + `restoreAsOf` / `diffVersions`; TTL + per-row cap retention. [→ details](/docs/plugins/versioning) |
97
+ | `@voltro/plugin-row-history` | ✓ | Full row history + time-travel — value snapshot of every insert/update/delete (every table by default; narrow with include/exclude) into `_voltro_row_history` (rides the ChangeEvent tap); `rowHistory` / `rowAsOf` queries + `restoreAsOf` / `diffVersions`; TTL + per-row cap retention. [→ details](/docs/plugins/row-history) |
98
98
  | `@voltro/plugin-presence` | ✓ | Ephemeral realtime presence — heartbeat roster per channel (`presence.heartbeat`/`list`/`leave` + `usePresence`), a `useTyping` typing indicator. Held **in memory**, owner-partitioned — no table is written; cross-instance requires [`@voltro/plugin-broadcast`](/docs/plugins/broadcast), and without a broker each replica sees only its own clients. [→ details](/docs/plugins/presence) |
99
99
  | `@voltro/plugin-scim` | ✓ | SCIM 2.0 provisioning — Users + Groups REST at `/scim/v2` (bearer-gated) incl. group-membership PATCH/PUT + the RFC 7644 discovery trio (ServiceProviderConfig/Schemas/ResourceTypes), `userName`/`externalId`/`displayName eq` filters, pagination, unique `userName`, `active:false` deactivation; `_voltro_scim_users`/`_voltro_scim_groups`. [→ details](/docs/plugins/scim) |
100
100
  | `@voltro/plugin-sso-saml` | ✓ | Enterprise SAML 2.0 SSO — SP-initiated login + Single Logout (both directions) + ACS + SP metadata under `/saml`; IdP-metadata-URL auto cert rotation, encrypted assertions, clock-skew, SP request signing. Signature verify via `@node-saml/node-saml` (optional+lazy), mints a framework session. [→ details](/docs/plugins/sso-saml) |
@@ -746,7 +746,7 @@ A package can ship BOTH a schema mixin AND a runtime plugin (`@voltro/plugin-aud
746
746
 
747
747
  ## More extension surfaces
748
748
 
749
- The plugin contract carries eight additional surfaces beyond the
749
+ The plugin contract carries the additional surfaces below, beyond the
750
750
  interceptors + lifecycle + manifest fields covered above:
751
751
 
752
752
  ### `inspectEndpoints: PluginInspectEndpoint[]` — plugin-mounted HTTP endpoints
@@ -874,6 +874,17 @@ plugin-provided template is scaffolded exactly like a built-in one —
874
874
  `scaffoldFromTemplate` copies the declared tree and substitutes the
875
875
  `{{…}}` tokens in both file content AND file/directory names.
876
876
 
877
+ ### `httpRoutes: PluginHttpRoute[]` — raw HTTP endpoints on the framework listener
878
+
879
+ A plugin can mount plain HTTP routes beside the rpc surface (`@voltro/plugin-storage`'s upload/download routes, an IdP callback). The request/response shape is transport-honest, and four properties are worth pinning:
880
+
881
+ - **The full method union is first-class.** `method` is `'*' | 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'HEAD' | 'OPTIONS'`. **HEAD is admitted wherever GET is** (RFC 9110) — the GET handler runs and the transport drops the body; you never mount a second route for it. A wrong method stays a precise `405` with an `Allow:` header, including when several routes share one path.
882
+ - **The body read is capped** — 8 MiB by default, the same cap as every other surface (`http.maxBodyBytes` in `app.config.ts`, env `VOLTRO_MAX_BODY_BYTES`), and the read is binary-clean. A route that takes more declares its own `maxBodyBytes`; routes **sharing a path share one body read**, so the widest override in the group applies to the group. Oversize answers `413` for both `Content-Length` and chunked requests.
883
+ - **Binary streaming responses** return `byteStream` on the `PluginHttpRouteResult` — a `ReadableStream<Uint8Array>` (or lazy thunk) with optional `contentLength` / `contentDisposition`, piped without buffering and never compressed. It is the plugin-route spelling of the REST surface's [`bytes()`](/docs/data/rest-routes#binary-downloads--bytes).
884
+ - **Buffered responses are compression-negotiated** (brotli/gzip, compressible types only) by the listener — nothing to declare; see [Security → compression](/docs/security/overview).
885
+
886
+ A state-changing plugin route is origin-checked unless it declares `originGuard: 'exempt'`, and `req.remoteAddr` is the trusted-proxy-resolved client address — both covered with examples in [Security](/docs/security/overview#routes-that-a-third-party-legitimately-posts-to).
887
+
877
888
  ### `onHttpRequest: HttpRequestInterceptor` — pre-auth HTTP-pipeline hook
878
889
 
879
890
  Fires at the **very top** of every HTTP request — BEFORE auth resolution,
@@ -937,6 +948,17 @@ is deliberate: `GET /internal/liveness` and `GET /internal/readiness` are
937
948
  answered before the interceptor, so a rate-limit or geo-block plugin cannot 503
938
949
  a Kubernetes probe and take the replica out of rotation.
939
950
 
951
+ **The chain is FAIL-CLOSED.** An interceptor that throws is a `500` plus a log
952
+ line — the request does NOT continue. It used to: the failure was swallowed and
953
+ the request flowed on, which meant a crashed security gate was an open one.
954
+ That polarity puts a decision on every interceptor author: if your hook is a
955
+ GATE (geo-block, bot detection), let a failure propagate — refusing is the
956
+ correct degraded behaviour. If it is protection with a DEPENDENCY (a rate-limit
957
+ counter in Redis), catch your own failure inside the hook and **degrade
958
+ loudly** — `@voltro/plugin-ratelimit`'s `httpShield` does exactly that: a Redis
959
+ outage means unlimited-with-a-warning, never a self-inflicted API outage.
960
+ What no interceptor gets to do anymore is fail silently and stay in the chain.
961
+
940
962
  ### `extendSchema: { tables, migrations }` — contribute schema + migrations
941
963
 
942
964
  A plugin contributes BOTH declarative table descriptors AND custom SQL
@@ -581,6 +581,7 @@ Returns a function `(to: string, options?) => void`.
581
581
  |---|---|
582
582
  | `replace: true` | Replace the history entry (no back-button entry). |
583
583
  | `scroll: false` | Don't scroll to top after navigation. |
584
+ | `transition: true / false` | Run (or suppress) this navigation's swap through `document.startViewTransition`, overriding the app-wide `router.viewTransitions` default. `<Link transition>` is the declarative mirror. See [View transitions](/docs/routing/navigation#view-transitions). |
584
585
 
585
586
  `navigate` takes a path string only — there is no numeric history overload. For back / forward, reach for the browser API:
586
587
 
@@ -607,12 +608,22 @@ Pass `true`/`false` or a predicate `({ to, opts }) => boolean` (to allow some de
607
608
 
608
609
  ## `useSearchParams()` + `useSetSearchParams()`
609
610
 
610
- Read the query string as a `URLSearchParams` (SSR-aware the request URL on the server, `window.location.search` on the client):
611
+ Read the query string. Two overloads, both SSR-aware (the request URL on the server, `window.location.search` on the client):
612
+
613
+ - `useSearchParams()` — the raw `URLSearchParams`, for routes without a schema.
614
+ - `useSearchParams(searchParams)` — pass the page's own `searchParams` schema export to get the decoded, typed shape. Defaults applied; an invalid query falls back to the defaults instead of crashing.
611
615
 
612
616
  ```tsx
617
+ import { Schema } from 'effect'
613
618
  import { useSearchParams } from '@voltro/web'
614
619
 
615
- const tab = useSearchParams().get('tab') ?? 'overview'
620
+ export const searchParams = Schema.Struct({
621
+ tab: Schema.optionalWith(Schema.String, { default: () => 'overview' }),
622
+ page: Schema.optionalWith(Schema.NumberFromString, { default: () => 1 }),
623
+ })
624
+
625
+ const { tab, page } = useSearchParams(searchParams) // tab: string · page: number
626
+ const raw = useSearchParams() // URLSearchParams (schema-less routes)
616
627
  ```
617
628
 
618
629
  Write it with `useSetSearchParams()` — the setter updates the query via `navigate`, so readers re-render immediately:
@@ -628,6 +639,28 @@ setParams({ page: '2' }, { push: true }) // distinct history entry
628
639
 
629
640
  Writes default to a history replace; pass `{ push: true }` for a Back entry or `{ scroll: false }` to keep scroll. See [Navigation](/docs/routing/navigation#reading--writing-search-params).
630
641
 
642
+ `useSetSearchParams(searchParams)` — pass the schema to get the **typed** setter. Object form replaces the query (a left-out field decodes to its default on the next read); the updater form receives the current **decoded** params, so a merge is an explicit spread:
643
+
644
+ ```tsx
645
+ const setTyped = useSetSearchParams(searchParams)
646
+ setTyped({ page: 2 }) // replaces → ?page=2
647
+ setTyped((p) => ({ ...p, page: p.page + 1 })) // keeps every other param — typed merge
648
+ ```
649
+
650
+ ## Typed `withQuery()`
651
+
652
+ Not a hook, but the link-side half of the same contract: for a route whose page exports a `searchParams` schema, the generated `routes` builder brands the URL with the schema's shape (through a type-only import — no page code enters the routes module), and `withQuery` type-checks the params against it — a misspelt key or a wrong value type is a compile error:
653
+
654
+ ```tsx
655
+ import { withQuery } from '@voltro/web'
656
+ import { routes } from './.framework/routes'
657
+
658
+ withQuery(routes['/notes'](), { page: 2 }) // OK — typed against the schema
659
+ // withQuery(routes['/notes'](), { pgae: 2 }) // compile error (unknown key)
660
+ ```
661
+
662
+ The encode is canonical: strings pass through, numbers/booleans via `String()`, arrays as repeated keys, `undefined` omitted; a `Date` (or any object) is refused loudly — declare the field as a string/number transform in the schema instead. See [Navigation → typed `withQuery`](/docs/routing/navigation#typed-withquery).
663
+
631
664
  ## `usePrefetch()`
632
665
 
633
666
  Trigger loader-data prefetch on hover / focus. Wired automatically by `<Link prefetch />`; export only useful for custom triggers.
@@ -821,6 +854,10 @@ export default function SearchPage() {
821
854
  that must react to router-pushed query changes without a reload re-render through the router
822
855
  (`useNavigate`/`useLocation`); the hook then re-resolves on that render.
823
856
 
857
+ Prefer the typed form where the page declares a `searchParams` schema export —
858
+ `useSearchParams(searchParams)` returns the decoded shape instead of a raw
859
+ `URLSearchParams`. See [Routing hooks](/docs/reference/hooks-routing#usesearchparams--usesetsearchparams).
860
+
824
861
  ## Reading cookies
825
862
 
826
863
  ```ts