@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.
- package/CHANGELOG.md +231 -0
- package/dist/{agentsMd-D6yD7IQv.js → agentsMd-0l980yhL.js} +4 -1
- package/dist/agentsMd-SDDSkyl4.js +2 -0
- package/dist/apiBuild-BYBpL7Pz.js +2 -0
- package/dist/{apiBuild-D3s7lx57.js → apiBuild-CSFI8QGq.js} +3 -3
- package/dist/appGraph-CFmFKHb2.js +2 -0
- package/dist/appGraph-yRuKjOba.js +111 -0
- package/dist/bin.js +1 -1
- package/dist/build-CPgcMQug.js +793 -0
- package/dist/checkCommand-2SbqzukH.js +2 -0
- package/dist/{checkCommand-DsKIjL6k.js → checkCommand-COmqc2cB.js} +107 -60
- package/dist/{cloudCmd-F4YJeqM3.js → cloudCmd-QUXh-b5w.js} +1 -1
- package/dist/codegen-CctkDO-1.js +2 -0
- package/dist/{codegen-CrMXs4hb.js → codegen-VF479Cnb.js} +1 -1
- package/dist/{codegenCommand-C0S27nbB.js → codegenCommand-DCdG2JN-.js} +12 -12
- package/dist/{codemodRunner-DwY8Rjbl.js → codemodRunner-r7J9lIa7.js} +771 -636
- package/dist/{commands-Brp4DI8u.js → commands-Cc_nV8WI.js} +37 -36
- package/dist/{dashboardCommand-bbLfc9FW.js → dashboardCommand-C-gKvwqh.js} +3 -3
- package/dist/{dataCommand-k9mXTXin.js → dataCommand-BgpBHnlB.js} +299 -253
- package/dist/dbCommand-DNb6yeOG.js +2 -0
- package/dist/{dbCommand-Bf7Cy0mn.js → dbCommand-sHedr-NJ.js} +3 -3
- package/dist/dev--A3nsxA3.js +3 -0
- package/dist/{dev-C0cbqysu.js → dev-CRHoCEiy.js} +2258 -2219
- package/dist/doctorCommand-CqoWA2p5.js +2 -0
- package/dist/{doctorCommand-DyViaaCP.js → doctorCommand-DtfJ3FA6.js} +314 -234
- package/dist/{dormancyCommand-DMZPA_HW.js → dormancyCommand-Drn7o0No.js} +1 -1
- package/dist/{embeddingsCommand-CMxRPkg6.js → embeddingsCommand-Z-jO1fWN.js} +1 -1
- package/dist/{envCommand-CDLr-yD4.js → envCommand-D4gCrrTZ.js} +15 -15
- package/dist/{evolveCommand-Ct4NesL3.js → evolveCommand-CMROeKeA.js} +2 -2
- package/dist/fileConventions-DOqD3lPS.js +34 -0
- package/dist/{fileTaxonomy-CJfgOllU.js → fileTaxonomy-DvDUV9wq.js} +1 -1
- package/dist/frameworkTableAssembly-C6ETawPR.js +2 -0
- package/dist/{frameworkTableAssembly-BwJVEKLr.js → frameworkTableAssembly-w-XnLa3q.js} +1 -1
- package/dist/{generateCommand-BDdOxhnv.js → generateCommand-DTNPGjxo.js} +1 -1
- package/dist/index.js +1 -1
- package/dist/{infoCommand-orsKJKUy.js → infoCommand-DXM868o_.js} +1 -1
- package/dist/inspectMetrics-CGF94puw.js +143 -0
- package/dist/{metaCommands-CfRLra0s.js → metaCommands-C6RFmF1r.js} +2 -2
- package/dist/{migrate-D3MK9BpK.js → migrate-D0F-eTlK.js} +2 -2
- package/dist/{pageConvention-cEiRxdab.js → pageConvention-CzUiSbtU.js} +1 -1
- package/dist/{privacyCommand-XejDMvmu.js → privacyCommand-DGdopOI6.js} +1 -1
- package/dist/{projectScaffold-mIX_DpSe.js → projectScaffold-B4dmTlwT.js} +1 -1
- package/dist/{projectScaffold-BIl97_E6.js → projectScaffold-EzlErR4E.js} +1 -1
- package/dist/{renderModeScan-D7J1B7Kw.js → renderModeScan-CcH2X1_D.js} +25 -10
- package/dist/{renderProfile-1OWWAAtx.js → renderProfile-Ck32Fzxr.js} +2 -2
- package/dist/{runtimeTrace-Bo8jWsNe.js → runtimeTrace-BPQyCmC5.js} +1 -1
- package/dist/{sdkgen-OZhiT2vz.js → sdkgen-Se88ifTd.js} +1 -1
- package/dist/serveCommand-DLc-BznW.js +2 -0
- package/dist/{serveCommand-YbD5VgfV.js → serveCommand-DkP3OT0W.js} +885 -868
- package/dist/serveEntry.js +1 -1
- package/dist/start-DfL3fOiN.js +3 -0
- package/dist/start-jw89Xbqy.js +1339 -0
- package/dist/startEntry.js +1 -1
- package/dist/{staticCommand-Dr2M6tpU.js → staticCommand-BwNEDlSU.js} +1 -1
- package/dist/{test-rXFq4S76.js → test-f3amja6a.js} +1 -1
- package/dist/updateCommand-5gFVfK5q.js +2 -0
- package/dist/{updateCommand-BgH6vTtq.js → updateCommand-BMk2e4ky.js} +1 -1
- package/dist/{webDev-BbdkzMbd.js → webDev-BgWL9gKV.js} +1233 -853
- package/dist/webDev-CZbTsDcH.js +2 -0
- package/dist/{webhooksCommand-DjE6u0r4.js → webhooksCommand-CoIO3jbj.js} +1 -1
- package/package.json +48 -17
- package/templates/AGENTS.core.md +26 -5
- package/templates/AGENTS.md +30 -9
- package/templates/agent-docs/_index.md +4 -4
- package/templates/agent-docs/_manifest.json +11 -11
- package/templates/agent-docs/cli.md +125 -14
- package/templates/agent-docs/data.md +210 -7
- package/templates/agent-docs/database/schema.md +1 -1
- package/templates/agent-docs/database/seedsdialects.md +1 -1
- package/templates/agent-docs/deployment.md +22 -3
- package/templates/agent-docs/internationalization.md +131 -13
- package/templates/agent-docs/introduction.md +46 -0
- package/templates/agent-docs/local-first-mobile.md +34 -7
- package/templates/agent-docs/plugins/ai-flows.md +1 -1
- package/templates/agent-docs/plugins/audit.md +5 -5
- package/templates/agent-docs/plugins/cdc-out.md +2 -2
- package/templates/agent-docs/plugins/{versioning.md → row-history.md} +22 -22
- package/templates/agent-docs/plugins/storage.md +2 -2
- package/templates/agent-docs/plugins.md +29 -7
- package/templates/agent-docs/reference.md +39 -2
- package/templates/agent-docs/routing.md +341 -47
- package/templates/agent-docs/schema-driven-ui.md +78 -2
- package/templates/agent-docs/security.md +125 -8
- package/templates/agent-docs/templates/apibackends.md +14 -14
- package/templates/agent-docs/templates/overview.md +1 -1
- package/templates/agent-docs/whats-new.md +100 -29
- package/templates/apps/api-ai/package.json +6 -7
- package/templates/apps/api-ai/tests/summarize.test.ts +1 -1
- package/templates/apps/api-auth/package.json +8 -8
- package/templates/apps/api-backend/package.json +7 -7
- package/templates/apps/api-backend-deactivation/package.json +7 -7
- package/templates/apps/api-backend-mail/package.json +8 -8
- package/templates/apps/api-backend-mariadb/package.json +9 -9
- package/templates/apps/api-backend-sqlite/package.json +8 -8
- package/templates/apps/api-backend-storage/package.json +8 -8
- package/templates/apps/api-cms/package.json +9 -10
- package/templates/apps/api-collab/package.json +8 -8
- package/templates/apps/api-data-advanced/package.json +8 -8
- package/templates/apps/api-durable/package.json +8 -8
- package/templates/apps/api-feature-flags/package.json +9 -9
- package/templates/apps/api-governance/package.json +8 -8
- package/templates/apps/api-kv/package.json +8 -8
- package/templates/apps/api-moderation/package.json +8 -8
- package/templates/apps/api-observability/package.json +8 -8
- package/templates/apps/api-ratelimit/package.json +8 -8
- package/templates/apps/api-rbac/package.json +8 -8
- package/templates/apps/api-rest/package.json +7 -7
- package/templates/apps/{api-versioning → api-row-history}/README.md +3 -3
- package/templates/apps/{api-versioning → api-row-history}/actions/documents.asOf.action.server.ts +1 -1
- package/templates/apps/{api-versioning → api-row-history}/actions/documents.history.action.server.ts +1 -1
- package/templates/apps/{api-versioning → api-row-history}/app.config.ts +3 -3
- package/templates/apps/{api-versioning → api-row-history}/database/schema.ts +1 -1
- package/templates/apps/{api-versioning → api-row-history}/mutations/documents.create.mutation.server.ts +1 -1
- package/templates/apps/{api-versioning → api-row-history}/mutations/documents.create.mutation.ts +1 -1
- package/templates/apps/{api-versioning → api-row-history}/mutations/documents.update.mutation.ts +1 -1
- package/templates/apps/{api-versioning → api-row-history}/package.json +8 -8
- package/templates/apps/api-row-history/template.json +6 -0
- package/templates/apps/{api-versioning → api-row-history}/tests/documents.create.test.ts +1 -1
- package/templates/apps/api-saas/app.config.ts +1 -0
- package/templates/apps/api-saas/package.json +10 -11
- package/templates/apps/api-saas-starter/package.json +10 -10
- package/templates/apps/api-search/package.json +8 -8
- package/templates/apps/api-status/package.json +8 -8
- package/templates/apps/api-webhooks/package.json +9 -9
- package/templates/apps/changelog/package.json +6 -6
- package/templates/apps/edge-functions/package.json +2 -2
- package/templates/apps/frontend-admin/package.json +8 -8
- package/templates/apps/frontend-app/package.json +9 -9
- package/templates/apps/frontend-auth/package.json +8 -8
- package/templates/apps/frontend-blank/package.json +7 -7
- package/templates/apps/frontend-cms/package.json +9 -9
- package/templates/apps/frontend-collab/package.json +10 -10
- package/templates/apps/frontend-collab/src/pages/page.test.tsx +10 -9
- package/templates/apps/frontend-collab/src/pages/page.tsx +42 -65
- package/templates/apps/frontend-contact/package.json +7 -7
- package/templates/apps/frontend-dashboard/package.json +7 -7
- package/templates/apps/frontend-docs/package.json +6 -7
- package/templates/apps/frontend-i18n/package.json +6 -6
- package/templates/apps/frontend-landing/package.json +6 -7
- package/templates/apps/frontend-portal/package.json +8 -8
- package/templates/apps/frontend-saas/package.json +8 -8
- package/templates/apps/frontend-spa/package.json +7 -7
- package/templates/apps/frontend-ssr/package.json +7 -7
- package/templates/apps/frontend-ssr-api/package.json +8 -8
- package/templates/apps/frontend-static-blog/package.json +6 -6
- package/templates/apps/frontend-status/package.json +8 -8
- package/templates/apps/mobile-app/package.json +4 -4
- package/dist/agentsMd-Bu_XQgVf.js +0 -2
- package/dist/apiBuild-BZ-plaQN.js +0 -2
- package/dist/appGraph-CM9wvUmD.js +0 -2
- package/dist/appGraph-Cv2W_wO5.js +0 -111
- package/dist/build-CFmqF39c.js +0 -721
- package/dist/checkCommand-DomvYDi1.js +0 -2
- package/dist/codegen-DiMn2KkZ.js +0 -2
- package/dist/dbCommand-Bnl7MSGC.js +0 -2
- package/dist/dev-Dz9GqA6P.js +0 -3
- package/dist/doctorCommand-BlciWUJb.js +0 -2
- package/dist/fileConventions-Cof68_BL.js +0 -33
- package/dist/frameworkTableAssembly-CVDB2hCq.js +0 -2
- package/dist/inspectMetrics-CfdKLh6t.js +0 -72
- package/dist/serveCommand-Bbgg7Paq.js +0 -2
- package/dist/start-7b_bL8Uc.js +0 -3
- package/dist/start-B1nH9ahf.js +0 -1150
- package/dist/updateCommand-BweY17z-.js +0 -2
- package/dist/webDev-BcRFTsMA.js +0 -2
- package/templates/apps/api-versioning/template.json +0 -6
- /package/templates/apps/{api-versioning → api-row-history}/actions/documents.asOf.action.ts +0 -0
- /package/templates/apps/{api-versioning → api-row-history}/actions/documents.history.action.ts +0 -0
- /package/templates/apps/{api-versioning → api-row-history}/mutations/documents.update.mutation.server.ts +0 -0
- /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
|
|
30
|
-
> [runtime binding](#whats-shipped-vs-a-runtime-seam) to
|
|
31
|
-
> (a broker at scale) plus the two app-specific tags
|
|
32
|
-
>
|
|
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:
|
|
352
|
-
|
|
353
|
-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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 +
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
1
|
+
# Row history
|
|
2
2
|
|
|
3
|
-
> Full row history + time-travel. audit() records who/when;
|
|
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/
|
|
10
|
-
## Row
|
|
9
|
+
<!-- source: en/plugins/row-history.md -->
|
|
10
|
+
## Row history
|
|
11
11
|
|
|
12
|
-
_Full row history + time-travel. audit() records who/when;
|
|
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-
|
|
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 {
|
|
20
|
+
import { rowHistoryPlugin } from '@voltro/plugin-row-history'
|
|
21
21
|
|
|
22
22
|
export default {
|
|
23
23
|
type: 'api' as const, name: 'api',
|
|
24
|
-
plugins: [
|
|
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
|
|
33
|
+
### What gets recorded — opt OUT, not in
|
|
34
34
|
|
|
35
|
-
`
|
|
35
|
+
`rowHistoryPlugin({})` covers **every table your app declares**. There is no list to write and none to maintain.
|
|
36
36
|
|
|
37
37
|
```ts
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
- **
|
|
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:
|
|
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-
|
|
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
|
-
|
|
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
|
|
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-
|
|
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-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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-
|
|
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
|
|
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
|
|
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-
|
|
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
|
|
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
|
|
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
|
|
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
|