@voltro/cli 0.53.0 → 0.54.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 +195 -0
- package/dist/{agentsMd-0l980yhL.js → agentsMd-BaLC10Na.js} +110 -82
- package/dist/agentsMd-DCY1RSs8.js +2 -0
- package/dist/apiBuild-CeUN55uk.js +2 -0
- package/dist/{apiBuild-CaPfoWku.js → apiBuild-DTWp0S_q.js} +2 -2
- package/dist/bin.js +1 -1
- package/dist/{build-D-OnvNMf.js → build-D4ygSbnV.js} +114 -114
- package/dist/{checkCommand-C5elt0tW.js → checkCommand-Dg1G7Gwd.js} +6 -6
- package/dist/{checkCommand-D2ZduVlh.js → checkCommand-L7DTlpIF.js} +1 -1
- package/dist/{cloudCmd-QUXh-b5w.js → cloudCmd-DzKcSYuy.js} +1 -1
- package/dist/{codegen-FEk8AZHb.js → codegen-DSLM8Su9.js} +2 -2
- package/dist/codegen-DjgxEOnD.js +2 -0
- package/dist/codegenCommand-CG_Vx4lc.js +41 -0
- package/dist/{codemodRunner-BjtB2lq6.js → codemodRunner-Cd4xkC6u.js} +9 -9
- package/dist/{commands-DyxAmhP0.js → commands-6Kzi92Np.js} +96 -73
- package/dist/{dashboardCommand-BdKTyT13.js → dashboardCommand-Cq1PWvI1.js} +3 -3
- package/dist/{dataCommand-Bab9X7s8.js → dataCommand-DYzW8vkv.js} +3 -3
- package/dist/{dbCommand-06O2finM.js → dbCommand-B4NWZtGL.js} +3 -3
- package/dist/dbCommand-CSFWs9ev.js +2 -0
- package/dist/{dev-C6LGF4iY.js → dev-CmuvUKRq.js} +2236 -2219
- package/dist/{dev-GjJWAYo2.js → dev-cKUiZZsB.js} +1 -1
- package/dist/{doctorCommand-etMkflRc.js → doctorCommand-DCiFVMtZ.js} +21 -21
- package/dist/doctorCommand-J3qu4E0Y.js +2 -0
- package/dist/{dormancyCommand-UwZ1AZzB.js → dormancyCommand-w1TrmgYP.js} +1 -1
- package/dist/{embeddingsCommand-C70zWHwo.js → embeddingsCommand-CMgPyRTr.js} +1 -1
- package/dist/{envCommand-dSyKvRkM.js → envCommand-Cyynmcfa.js} +15 -15
- package/dist/{evolveCommand-CG0_ebO5.js → evolveCommand-BwvQ8dVH.js} +2 -2
- package/dist/fileConventions-l-RIXbx8.js +36 -0
- package/dist/{fileTaxonomy-B7uxipWS.js → fileTaxonomy-CbyMQYx_.js} +37 -37
- package/dist/{frameworkTableAssembly-DKx3ba3S.js → frameworkTableAssembly-D7LJuALW.js} +5 -5
- package/dist/frameworkTableAssembly-IPD1pUnZ.js +2 -0
- package/dist/index.js +2 -2
- package/dist/{infoCommand-_53iOc_j.js → infoCommand-DlYlUPqs.js} +1 -1
- package/dist/{metaCommands-Cn2oboG4.js → metaCommands-x7RCi2AF.js} +2 -2
- package/dist/{migrate-Cko9rswM.js → migrate-BK_Bbx-_.js} +2 -2
- package/dist/mobileCommand-D9O6iq3D.js +428 -0
- package/dist/mobileCommand-DAum7tsG.js +2 -0
- package/dist/{pageConvention-C938S8oC.js → pageConvention-CMpfDN6r.js} +1 -1
- package/dist/{privacyCommand-DWTQMC6R.js → privacyCommand-BCa2OoZG.js} +2 -2
- package/dist/{probeCommand-DkGGLknv.js → probeCommand-_C0YU207.js} +1 -1
- package/dist/{projectScaffold-EzlErR4E.js → projectScaffold-CJfP-xbT.js} +1 -1
- package/dist/{projectScaffold-B4dmTlwT.js → projectScaffold-CSN0OzBV.js} +2 -2
- package/dist/renderModeScan-43yQ2opo.js +147 -0
- package/dist/{renderProfile-CskIgAfn.js → renderProfile-DvrhVJHa.js} +2 -2
- package/dist/{runtimeTrace-c0APJz7E.js → runtimeTrace-CGWx1Q6l.js} +1 -1
- package/dist/{sdkgen-BiQCgIEr.js → sdkgen-CDGHQUFj.js} +1 -1
- package/dist/serveCommand-BiPe8BJm.js +2 -0
- package/dist/{serveCommand-CueKQgzl.js → serveCommand-Bje09q1v.js} +708 -708
- package/dist/serveEntry.js +1 -1
- package/dist/start-B0bnJgxI.js +3 -0
- package/dist/{start-ekPan8BT.js → start-Clz-1BHB.js} +511 -504
- package/dist/startEntry.js +1 -1
- package/dist/{staticCommand-xlSL-IWk.js → staticCommand-ey0kYmOT.js} +1 -1
- package/dist/{subcommandNames-DpYs3DXr.js → subcommandNames-CDzfEtKV.js} +3 -3
- package/dist/{templates-BR-fb4SP.js → templates-BTWZkJJT.js} +41 -9
- package/dist/{test-BWPQcRoB.js → test-D_kW4KMj.js} +1 -1
- package/dist/updateCommand-CIoVDKnj.js +2 -0
- package/dist/{updateCommand-C_8I8Rzo.js → updateCommand-CRJlAOaM.js} +1 -1
- package/dist/{webDev-oczpugbx.js → webDev-DSI9SOhs.js} +1127 -1090
- package/dist/{webDev-C7jWJ5dX.js → webDev-DlvZO30c.js} +1 -1
- package/dist/{webhooksCommand-4SVPDjKg.js → webhooksCommand-BvzXNHji.js} +1 -1
- package/dist/workspaceDeps-RKEkX92S.js +45 -0
- package/package.json +19 -19
- package/templates/AGENTS.core.md +2 -0
- package/templates/AGENTS.md +4 -2
- package/templates/agent-docs/_index.md +2 -2
- package/templates/agent-docs/_manifest.json +1 -1
- package/templates/agent-docs/ai.md +4 -4
- package/templates/agent-docs/authentication.md +72 -0
- package/templates/agent-docs/cli.md +4 -1
- package/templates/agent-docs/data.md +61 -12
- package/templates/agent-docs/database/advancedqueries.md +1 -1
- package/templates/agent-docs/database/migrations.md +1 -1
- package/templates/agent-docs/database/seedsdialects.md +64 -2
- package/templates/agent-docs/internationalization.md +2 -0
- package/templates/agent-docs/introduction.md +25 -0
- package/templates/agent-docs/local-first-mobile.md +139 -41
- package/templates/agent-docs/observability.md +4 -2
- package/templates/agent-docs/plugins/atlassian.md +2 -2
- package/templates/agent-docs/plugins/audit.md +2 -2
- package/templates/agent-docs/plugins/billing.md +1 -1
- package/templates/agent-docs/plugins/cdc-out.md +8 -3
- package/templates/agent-docs/plugins/comments.md +22 -0
- package/templates/agent-docs/plugins/presence.md +32 -3
- package/templates/agent-docs/plugins/prometheus.md +2 -0
- package/templates/agent-docs/plugins/queue.md +47 -4
- package/templates/agent-docs/plugins.md +6 -6
- package/templates/agent-docs/reference.md +20 -1
- package/templates/agent-docs/routing.md +63 -9
- package/templates/agent-docs/scheduling.md +1 -1
- package/templates/agent-docs/schema-driven-ui.md +12 -1
- package/templates/agent-docs/templates/appshells.md +36 -4
- package/templates/agent-docs/whats-new.md +109 -135
- package/templates/apps/api-ai/package.json +6 -6
- 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 -9
- package/templates/apps/api-collab/README.md +3 -3
- package/templates/apps/api-collab/app.config.ts +1 -1
- package/templates/apps/api-collab/database/schema.ts +12 -8
- package/templates/apps/api-collab/mutations/documents.create.mutation.server.ts +1 -1
- package/templates/apps/api-collab/mutations/documents.setBody.mutation.server.ts +1 -1
- package/templates/apps/api-collab/mutations/documents.setBody.mutation.ts +1 -1
- package/templates/apps/api-collab/package.json +8 -8
- package/templates/apps/api-collab/template.json +1 -1
- package/templates/apps/api-collab/tests/documents.setBody.test.ts +10 -2
- 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-row-history/package.json +8 -8
- package/templates/apps/api-saas/package.json +11 -10
- 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 +7 -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/README.md +43 -24
- package/templates/apps/frontend-collab/app.config.ts +3 -3
- package/templates/apps/frontend-collab/package.json +14 -10
- package/templates/apps/frontend-collab/src/locales/de.ts +1 -2
- package/templates/apps/frontend-collab/src/locales/en.ts +1 -2
- package/templates/apps/frontend-collab/src/pages/page.test.tsx +72 -76
- package/templates/apps/frontend-collab/src/pages/page.tsx +45 -22
- package/templates/apps/frontend-collab/template.json +2 -2
- 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 +8 -7
- package/templates/apps/frontend-i18n/package.json +6 -6
- package/templates/apps/frontend-landing/README.md +48 -0
- package/templates/apps/frontend-landing/app.config.ts +28 -0
- package/templates/apps/frontend-landing/package.json +7 -6
- package/templates/apps/frontend-landing/src/assets/hero.jpg +0 -0
- package/templates/apps/frontend-landing/src/fonts/Geist-Variable.woff2 +0 -0
- package/templates/apps/frontend-landing/src/fonts/LICENSE-Geist.txt +92 -0
- package/templates/apps/frontend-landing/src/globals.css +15 -0
- package/templates/apps/frontend-landing/src/globals.d.ts +17 -0
- package/templates/apps/frontend-landing/src/locales/de.ts +3 -2
- package/templates/apps/frontend-landing/src/locales/en.ts +5 -2
- package/templates/apps/frontend-landing/src/pages/page.test.tsx +79 -0
- package/templates/apps/frontend-landing/src/pages/page.tsx +26 -3
- package/templates/apps/frontend-landing/template.json +2 -2
- 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 +8 -7
- package/templates/apps/frontend-status/package.json +8 -8
- package/templates/apps/mobile-app/package.json +12 -11
- package/templates/apps/mobile-app/src/lib/deeplinks.ts +29 -17
- package/templates/apps/mobile-app/tests/deeplinks.test.ts +16 -0
- package/dist/agentsMd-SDDSkyl4.js +0 -2
- package/dist/apiBuild-DHtLXYx9.js +0 -2
- package/dist/codegen-BWpt3VgF.js +0 -2
- package/dist/codegenCommand-BOiWQ5hz.js +0 -137
- package/dist/dbCommand-B1EXBC6f.js +0 -2
- package/dist/doctorCommand-B0hX0tdz.js +0 -2
- package/dist/fileConventions-DASGEmj-.js +0 -35
- package/dist/frameworkTableAssembly-C_7Z-rMs.js +0 -2
- package/dist/renderModeScan-CUbOeOAg.js +0 -122
- package/dist/serveCommand-DsnrVN3U.js +0 -2
- package/dist/start-BJzZLbt8.js +0 -3
- package/dist/updateCommand-Bqql_rsQ.js +0 -2
|
@@ -544,6 +544,16 @@ export const searchParams = Schema.Struct({
|
|
|
544
544
|
|
|
545
545
|
- `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).
|
|
546
546
|
|
|
547
|
+
Two more page exports change what the framework produces for a route:
|
|
548
|
+
|
|
549
|
+
```tsx
|
|
550
|
+
export const ogImage = ({ params, loaderData, locale }) => ({ type: 'div', props: { /* satori JSX */ } })
|
|
551
|
+
export const intercept = { from: '/photos' }
|
|
552
|
+
```
|
|
553
|
+
|
|
554
|
+
- `ogImage` declares the page's `og:image` as a satori JSX template. `static` pages render the PNG at BUILD time into `dist/assets/og/`; `ssr` pages render it on demand over a signed route. The `og:image` / `twitter:image` / `twitter:card` tags are injected automatically unless your own `meta` already sets them. A declared font is REQUIRED (there is no bundled default), and an `ssr` route exporting it needs `VOLTRO_OG_SECRET` — `voltro start` refuses the boot otherwise. Details: [Loaders and meta → OG images](/docs/routing/loaders-and-meta#og-images-from-a-template-ogimage).
|
|
555
|
+
- `intercept` makes the page an **intercepting route**: `from` names one or more ROUTE PATTERNS (`'/photos'`, `['/photos', '/albums/[id]']`), and a soft navigation arriving from one of them renders this page as an overlay above the still-mounted origin. Every hard load — and a soft navigation from anywhere else — renders it standalone. Details: [Intercepting routes](/docs/routing/intercepting-routes).
|
|
556
|
+
|
|
547
557
|
## Discovery in practice
|
|
548
558
|
|
|
549
559
|
```text
|
|
@@ -605,11 +615,26 @@ If yes, the promise belongs in the name — you cannot see a contract before you
|
|
|
605
615
|
| `*.store.ts` | exactly one `defineStore`, no server state | `store/one-per-file`, `store/mirrors-server-state` |
|
|
606
616
|
| `*.collection.ts` | declares content collections (`defineCollection`); frontmatter schema violations fail the build naming the file | the build's collection decode + reference validation |
|
|
607
617
|
| `*.consumer.ts` | declares queue consumers (`defineQueueConsumer`, @voltro/plugin-queue); loading registers, the plugin's activation starts them | the queue runner (decode→DLQ, retry→DLQ, commit-per-message) |
|
|
618
|
+
| `*.ws.ts` | default-exports one raw WebSocket gateway (`defineWebSocket`), mounting its own upgrade path beside the rpc socket | boot discovery on BOTH paths (`voltro dev` and `voltro serve`); two gateways on one path refuse the boot |
|
|
608
619
|
|
|
609
620
|
A `*.component.tsx` promises exactly ONE component. It does not promise to export nothing else: types, and plain module-local values a `const COLUMNS = […]` beside the table that renders them, are fine and always were. What the rule counts is components — a declaration that renders — so an object, an array, a string or a `new` beside your component is not a second one, and neither is `export default Card` next to `export const Card`.
|
|
610
621
|
|
|
611
622
|
The BOUNDARY rules (`internal/foreign-import`, `fixture/production-import`, `ui/unlinked`) are assertions about your import graph, so it is worth knowing which edges they follow: relative specifiers, your tsconfig `paths` aliases (read from the nearest `tsconfig.json`, so a per-app `@/*` works when you run `voltro doctor` at the repo root), `export … from` re-exports, and dynamic `import()`. A package import is a leaf — the walk stops at the edge of your app.
|
|
612
623
|
|
|
624
|
+
## Contracts that are not suffixes
|
|
625
|
+
|
|
626
|
+
The admission test above is about the PROMISE, not about the spelling — and three of the framework's conventions carry one without being a suffix on a filename. They are listed here because a reader looking for "what does the framework read out of my tree" would otherwise stop at the table:
|
|
627
|
+
|
|
628
|
+
| Convention | Promise | Read by |
|
|
629
|
+
|---|---|---|
|
|
630
|
+
| `searchParams` page export | the page's query string decodes through this `effect/Schema` struct — every field optional or with a default | `useSearchParams(searchParams)`, `withQuery` link typing, and the render-mode scan (a page declaring BOTH `renderMode: 'isr'` and `searchParams` is refused) |
|
|
631
|
+
| `ogImage` page export | this route's `og:image` is a satori JSX template, not a file you ship | the build (`static` → a hashed PNG in `dist/assets/og/`) and `voltro start` (`ssr` → a signed on-demand route, which needs `VOLTRO_OG_SECRET`) |
|
|
632
|
+
| `intercept` page export | `from` names the routes a soft navigation may arrive from for this page to render as an overlay above them | the client router; a hard load renders the page standalone regardless |
|
|
633
|
+
| `grpc.manifest.json` (app root) | field numbers are checked in and append-only — a deleted field goes `reserved`, never re-used | `voltro grpc proto` and the gRPC surface wiring, which derive wire identity from it rather than from declaration order |
|
|
634
|
+
| `content/<name>/**` | the files a `*.collection.ts` declares — markdown with frontmatter, or `.json` for a data collection | `getCollection` / `getEntry`, the build's collection artifacts, and the dev server's watcher |
|
|
635
|
+
|
|
636
|
+
The page exports are per-ROUTE and the last two are per-APP, which is the only reason they cannot be spellings: there is nothing to rename.
|
|
637
|
+
|
|
613
638
|
## `*.component.ui.tsx` — reads, never writes
|
|
614
639
|
|
|
615
640
|
```tsx
|
|
@@ -24,13 +24,14 @@ imports it directly. The React wrappers live behind `@voltro/local-first/react`
|
|
|
24
24
|
> authoritative server-side merge on the write path), the
|
|
25
25
|
> [`SyncClient`](#the-syncclient-bi-directional-wire) that drives the queue over a
|
|
26
26
|
> transport, [`useCrdtText`](#a-collaborative-text-field-usecrdttext) — the React
|
|
27
|
-
> binding for a collaborative text field — [presence/awareness](#presence
|
|
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
|
-
>
|
|
31
|
-
>
|
|
32
|
-
>
|
|
33
|
-
>
|
|
29
|
+
> [`localFirst` table mixin](#the-localfirst-table-mixin). What remains is
|
|
30
|
+
> ONE thing — the two app-specific tags `useCrdtText` is pointed at
|
|
31
|
+
> ([runtime seam](#what-s-shipped-vs-a-runtime-seam)); the presence broker
|
|
32
|
+
> binding ships and a browser SQL engine was deliberately rejected. And the
|
|
33
|
+
> sync **engine** is now BUILT:
|
|
34
|
+
> the [query mirror](#the-sync-engine-query-mirror-durable-outbox) persists
|
|
34
35
|
> every subscribed query's rows per subject+tenant partition, `useOutbox`
|
|
35
36
|
> queues offline writes durably, and a reload renders mirrored rows offline
|
|
36
37
|
> and delta-resumes online.
|
|
@@ -208,25 +209,25 @@ import { Schema } from 'effect'
|
|
|
208
209
|
body: Schema.NullOr(Schema.Uint8ArrayFromBase64)
|
|
209
210
|
```
|
|
210
211
|
|
|
211
|
-
###
|
|
212
|
+
### What a `crdtText()` keystroke costs
|
|
212
213
|
|
|
213
|
-
|
|
214
|
-
|
|
214
|
+
A CRDT column on a hot editing path has two amplification effects to reason
|
|
215
|
+
about, and the framework handles both — neither is left to your query shape:
|
|
215
216
|
|
|
216
|
-
- **
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
- **
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
full-state blob
|
|
225
|
-
CRDT-heavy tables, or batch edits before pushing.
|
|
217
|
+
- **Downstream the wire carries the EDIT, not the document.** A changed CRDT
|
|
218
|
+
cell diffs into an incremental `mergeCells` subscription op rather than a
|
|
219
|
+
full-blob replace, so a one-character edit ships bytes proportional to the
|
|
220
|
+
edit regardless of document size — a query that projects `body` into a list
|
|
221
|
+
view does not stream the whole state per keystroke.
|
|
222
|
+
- **Server-side capture skips CRDT columns.** The undo log (default-on outside
|
|
223
|
+
production) and `plugin-row-history` strip CRDT columns from the captured
|
|
224
|
+
row, and an update touching ONLY CRDT columns is not captured at all, so
|
|
225
|
+
per-keystroke mutations write no full-state blob into those tables.
|
|
226
226
|
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
227
|
+
What stays a real cost is the stored value itself: the blob in the row grows
|
|
228
|
+
with the document's edit history and soft-compacts past
|
|
229
|
+
`crdt.compactMaxBytes` — see the [operational
|
|
230
|
+
rules](#rich-text-crdtdoc-usecrdtdoc-usecrdteditor) below.
|
|
230
231
|
|
|
231
232
|
## Presence & awareness
|
|
232
233
|
|
|
@@ -267,6 +268,30 @@ propagation, and TTL expiry live in the pure `createPresenceRoom` the hook wraps
|
|
|
267
268
|
> is fresh, plus a `useTyping` indicator, through the app's own rpc. Reach for
|
|
268
269
|
> the plugin for "who is here"; reach for this one for "where is their cursor".
|
|
269
270
|
|
|
271
|
+
### Binding it to the app's own presence lane — `usePresenceChannel`
|
|
272
|
+
|
|
273
|
+
`createInMemoryPresenceChannel()` is the local transport; in a running app the
|
|
274
|
+
binding is [`@voltro/plugin-presence`](/docs/plugins/presence)'s
|
|
275
|
+
`usePresenceChannel(roomId, { selfKey })`, which returns a `PresenceChannel`
|
|
276
|
+
backed by the plugin's existing heartbeat roster and rpc. Pass it straight in as
|
|
277
|
+
the `channel` above:
|
|
278
|
+
|
|
279
|
+
```tsx
|
|
280
|
+
import { usePresence } from '@voltro/local-first/react'
|
|
281
|
+
import { usePresenceChannel } from '@voltro/plugin-presence/web'
|
|
282
|
+
|
|
283
|
+
function Editor({ documentId, userId }) {
|
|
284
|
+
const channel = usePresenceChannel(documentId, { selfKey: userId })
|
|
285
|
+
const { others, setPresence } = usePresence(documentId, { cursor: 0 }, { channel })
|
|
286
|
+
return <Cursors others={others} />
|
|
287
|
+
}
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
That is deliberately ONE wire: awareness payloads ride the presence lane the app
|
|
291
|
+
already runs rather than a second socket with its own lifecycle. The channel is
|
|
292
|
+
room-scoped, and publishing into a room it was not created for THROWS — a silent
|
|
293
|
+
cross-room delivery is the failure worth being loud about.
|
|
294
|
+
|
|
270
295
|
## The offline sync queue
|
|
271
296
|
|
|
272
297
|
`useSyncQueue()` is a reactive view over a **pure, tested reducer**: writes made
|
|
@@ -316,25 +341,83 @@ const adapter = await createIndexedDbPersistence({ databaseName: 'my-app' })
|
|
|
316
341
|
const sync = createSyncClient({ transport, adapter }) // state now survives reload
|
|
317
342
|
```
|
|
318
343
|
|
|
319
|
-
## Rich text: `crdtDoc()` + `useCrdtEditor`
|
|
344
|
+
## Rich text: `crdtDoc()` + `useCrdtDoc` + `useCrdtEditor`
|
|
320
345
|
|
|
321
346
|
`crdtDoc()` stores a WHOLE collaborative document (rich text, maps, arrays)
|
|
322
347
|
as a column — same storage and authoritative server merge as `crdtText()`,
|
|
323
|
-
which stays as the plain-text specialisation. The
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
348
|
+
which stays as the plain-text specialisation. The column definitions are
|
|
349
|
+
identical; what differs is which client binding you reach for.
|
|
350
|
+
|
|
351
|
+
Two hooks, and they are a pair. **`useCrdtDoc`**
|
|
352
|
+
(`@voltro/local-first/react`) owns the SYNC half — one `SyncClient` and one
|
|
353
|
+
live CRDT document per cell, the same wire `useCrdtText` rides.
|
|
354
|
+
**`useCrdtEditor`** (`@voltro/local-first/editor`, Tiptap, optional peers)
|
|
355
|
+
owns the EDITOR half and takes that document:
|
|
356
|
+
|
|
357
|
+
```tsx
|
|
358
|
+
import { useCrdtDoc } from '@voltro/local-first/react'
|
|
359
|
+
import { useCrdtEditor } from '@voltro/local-first/editor'
|
|
360
|
+
import { EditorContent } from '@tiptap/react'
|
|
361
|
+
|
|
362
|
+
const Page = ({ id }: { id: string }) => {
|
|
363
|
+
const row = useSubscription<{ body: Uint8Array | null }>('app', 'documents.byId', { id })
|
|
364
|
+
const save = useMutation<{ id: string; update: Uint8Array }>('app', 'documents.setBody')
|
|
365
|
+
const shared = useCrdtDoc({
|
|
366
|
+
cell: { table: 'documents', id, column: 'body' },
|
|
367
|
+
remote: row.data?.body ?? null, // null = NOT LOADED
|
|
368
|
+
push: (w) => save.mutate({ id: w.id, update: w.update }),
|
|
369
|
+
})
|
|
370
|
+
// `doc` is null until the mount effect has run — render the editor in a
|
|
371
|
+
// CHILD so `useCrdtEditor` is never a conditional hook call.
|
|
372
|
+
return shared.doc === null ? null : <Surface doc={shared.doc} />
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
const Surface = ({ doc }: { doc: CrdtDocHandle }) => (
|
|
376
|
+
<EditorContent editor={useCrdtEditor({ doc })} />
|
|
377
|
+
)
|
|
378
|
+
```
|
|
379
|
+
|
|
380
|
+
`useCrdtDoc` returns `{ doc, loaded, outstanding, synced, setOnline }`. The
|
|
381
|
+
two names it asks for are the same two `useCrdtText` asks for and the same
|
|
382
|
+
two nothing can derive: the mutation that writes the column, and the
|
|
383
|
+
reactive query that streams the row.
|
|
384
|
+
|
|
385
|
+
Everything else is the hook's: local edits ride the app's mutation as
|
|
386
|
+
INCREMENTAL updates (folded server-side under a per-row mutex), remote edits
|
|
387
|
+
arrive as `mergeCells` subscription deltas — a one-character edit ships
|
|
388
|
+
under 1 KB in BOTH directions regardless of document size — plus the offline
|
|
389
|
+
queue, bounded retry, per-cell coalescence and durable persistence.
|
|
390
|
+
|
|
391
|
+
Two disciplines it enforces, because both fail silently when hand-rolled:
|
|
392
|
+
|
|
393
|
+
- **`remote: null` means NOT LOADED, not empty.** Folding an empty document
|
|
394
|
+
over a loading row lets the first keystroke push a state that erases what
|
|
395
|
+
was stored. `loaded` tells a UI which it is.
|
|
396
|
+
- **The echo guard.** A `crdtDoc()` document is mutated by the EDITOR, so
|
|
397
|
+
local edits surface as `doc.onUpdate` callbacks — and folding a peer's
|
|
398
|
+
state through `applyState` fires the same callback. The hook pushes only
|
|
399
|
+
when `local` is `true`. Without that every client re-broadcasts what it
|
|
400
|
+
just received: one keystroke, one server write per open tab.
|
|
401
|
+
|
|
402
|
+
A page mounting an editor needs `renderMode = 'client'`. The default is
|
|
403
|
+
`'static'`, which pre-renders at build time, and the editor finds no
|
|
404
|
+
`window` there.
|
|
329
405
|
|
|
330
406
|
Carets ride a `delivery: 'latest'` EVENT, deliberately not presence
|
|
331
407
|
metadata: the roster's value-compare push would make every caret move a
|
|
332
|
-
"real" change. `
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
408
|
+
"real" change. Pass an `awareness` transport plus `user` to
|
|
409
|
+
`useCrdtEditor` to mount them; `attachAwarenessBridge` publishes one
|
|
410
|
+
member's state per envelope (never the aggregated room). Stable positions
|
|
411
|
+
for inline comments come from `encodeAnchor`/`resolveAnchor` on the doc
|
|
412
|
+
handle.
|
|
413
|
+
|
|
414
|
+
The `frontend-collab` + `api-collab` template pair is this whole loop,
|
|
415
|
+
scaffoldable: `voltro create-project collab --api=api-collab
|
|
416
|
+
--web=frontend-collab`.
|
|
417
|
+
|
|
418
|
+
Operational rules: the stored blob soft-compacts past `crdt.compactMaxBytes`
|
|
419
|
+
in `app.config.ts` (default 512 KiB, `0` disables; env override
|
|
420
|
+
`VOLTRO_CRDT_COMPACT_MAX_BYTES`) without breaking the merge
|
|
338
421
|
lineage; `rebaseText` is the explicit hard reset (a NEW EPOCH — subscribers
|
|
339
422
|
receive it as a fresh snapshot). CRDT columns are excluded from undo capture
|
|
340
423
|
and row history (document history = named snapshots taken BEFORE
|
|
@@ -441,16 +524,31 @@ string form), so two peers agree regardless of which side each calls "local".
|
|
|
441
524
|
|
|
442
525
|
## What's shipped vs. a runtime seam
|
|
443
526
|
|
|
444
|
-
The framework code for local-first is built and tested end to end
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
code already speaks:
|
|
527
|
+
The framework code for local-first is built and tested end to end. **One** thing
|
|
528
|
+
remains, and it is not un-built framework — it is a pair of names only your app
|
|
529
|
+
knows:
|
|
448
530
|
|
|
449
531
|
| Runtime seam | What it binds | Why it's a binding, not code |
|
|
450
532
|
| --- | --- | --- |
|
|
451
533
|
| **Two app-specific tags** | Which mutation writes the `crdtText()` column, and which reactive query streams the row, in [`useCrdtText`](#a-collaborative-text-field-usecrdttext). | Voltro generates no per-table CRUD surface, so there is nothing to derive them from. The client lifecycle, optimistic merge, offline queue, retry, persistence and edit encoding all ship. |
|
|
452
|
-
|
|
453
|
-
|
|
534
|
+
|
|
535
|
+
Two entries that used to sit in that table are gone, in opposite directions —
|
|
536
|
+
worth stating, because "we have not built it" and "we decided against it" are
|
|
537
|
+
different answers:
|
|
538
|
+
|
|
539
|
+
- **The presence broker binding ships.** `usePresenceChannel`
|
|
540
|
+
(`@voltro/plugin-presence/web`) is a `PresenceChannel` over the framework's
|
|
541
|
+
own presence lane, so cross-replica fan-out is the broadcast plugin's and
|
|
542
|
+
there is ONE presence wire rather than two. Nothing to bind by hand; see
|
|
543
|
+
[Presence & awareness](#presence-awareness).
|
|
544
|
+
- **A wa-sqlite / Turso adapter was rejected, not deferred.** The client's whole
|
|
545
|
+
query surface is `(tag, input)` — predicates are built and evaluated on the
|
|
546
|
+
server — so a browser SQL engine would evaluate a language the client never
|
|
547
|
+
sees. What offline needs is the last materialised answer per query, which is
|
|
548
|
+
exactly what the [query mirror](#the-sync-engine-query-mirror-durable-outbox)
|
|
549
|
+
stores. A SQLite *backing* beneath `KvStore` remains possible without any
|
|
550
|
+
consumer changing (React Native's adapter is exactly that) — that is a
|
|
551
|
+
storage choice, not a missing engine.
|
|
454
552
|
|
|
455
553
|
|
|
456
554
|
|
|
@@ -71,9 +71,11 @@ Even with no exporter, `voltro dev` installs a **buffer-only** tracer — that's
|
|
|
71
71
|
|
|
72
72
|
## Metrics export
|
|
73
73
|
|
|
74
|
-
Separate from tracing, the framework records **metrics** into Effect's global `MetricRegistry` — one source of truth (`voltro_rpc_*`, `voltro_http_*`, `voltro_plugin_hook_*`, `voltro_subscription_*` + `voltro_subscriptions_active`, `voltro_db_*`, plus `effect_fiber_*` and any custom metric). Three ways to get them out:
|
|
74
|
+
Separate from tracing, the framework records **metrics** into Effect's global `MetricRegistry` — one source of truth (`voltro_rpc_*`, `voltro_http_*`, `voltro_plugin_hook_*`, `voltro_subscription_*` + `voltro_subscriptions_active`, `voltro_db_*`, `voltro_ppr_*`, `voltro_queue_*`, plus `effect_fiber_*` and any custom metric). Three ways to get them out:
|
|
75
75
|
|
|
76
|
-
The **web server** additionally
|
|
76
|
+
The **web server** additionally exports [partial prerendering](/docs/routing/render-modes#partial-prerendering-ppr-cached-shell-per-request-holes), labelled by `page` (the declared route pattern, never a resolved URL): `voltro_ppr_shell_serves_total{page}`, `voltro_ppr_hole_passes_total{page}`, `voltro_ppr_hole_settles_total{page}`, `voltro_ppr_hole_errors_total{page}` and the `voltro_ppr_hole_pass_seconds{page}` histogram. `voltro dev` and `voltro start` emit the same set. Shell hit-rate is the isr cache's own `x-voltro-cache` HIT/STALE/MISS accounting — a ppr shell is a normal isr entry.
|
|
77
|
+
|
|
78
|
+
`voltro_ppr_hole_errors_total` is the one to alert on: a failed hole pass is invisible from outside. The shell is already on the wire with a `200`, so the page renders and every `<Await>` boundary simply stays on its fallback — a page that looks like it is loading and never will. A single hole *rejecting* is not counted there; that settles the deferred-error envelope and renders the boundary's `errorFallback`.
|
|
77
79
|
|
|
78
80
|
```bash
|
|
79
81
|
# OTLP metrics — the SAME OTEL endpoint that enables trace export also enables
|
|
@@ -18,7 +18,7 @@ _JiraService + ConfluenceService over the Atlassian REST / Greenhopper / Agile A
|
|
|
18
18
|
Two auth modes, both first-class (choose per deployment):
|
|
19
19
|
|
|
20
20
|
- **PAT / basic** — a Personal Access Token per subject. The default for Jira/Confluence **Data Center / Server**. Wire it via `credentialsResolver` (below).
|
|
21
|
-
- **OAuth 2.0 (3LO)** — the authorization-code flow for Atlassian **Cloud**, where the app acts on behalf of a consenting user. Use the toolkit ([OAuth 2.0 (3LO)](#oauth-
|
|
21
|
+
- **OAuth 2.0 (3LO)** — the authorization-code flow for Atlassian **Cloud**, where the app acts on behalf of a consenting user. Use the toolkit ([OAuth 2.0 (3LO)](#oauth-2-0-3lo)) to obtain an access token, then feed it into the same `credentialsResolver`.
|
|
22
22
|
|
|
23
23
|
It also supports [inbound Jira/Confluence webhooks](#inbound-webhooks) (signature-verified) and writing comments to issues and pages.
|
|
24
24
|
|
|
@@ -55,7 +55,7 @@ export default {
|
|
|
55
55
|
> there it travels with the identity into everything that persists a Subject. A
|
|
56
56
|
> reporter found a working Jira PAT in plaintext in 12 of 23 rows of their
|
|
57
57
|
> `_voltro_audit_log` exactly that way. The `store` handle above exists so it
|
|
58
|
-
> never has to enter the Subject; [`connectionCredentials`](
|
|
58
|
+
> never has to enter the Subject; [`connectionCredentials`](/docs/data/connections) is
|
|
59
59
|
> better still, because then you do not hold the token at all.
|
|
60
60
|
|
|
61
61
|
The resolver returns `AtlassianCredentials`:
|
|
@@ -73,7 +73,7 @@ auditPlugin({
|
|
|
73
73
|
// custom function: (event: AuditEvent) => void | Promise<void> | Effect.Effect<void>
|
|
74
74
|
```
|
|
75
75
|
|
|
76
|
-
`'datastore'` is the production sink: it survives restarts, is shared across replicas, and is queryable via `ctx.store.select('_voltro_audit_log')`. Each row carries the flattened `tag` / `at` / `subjectId` / `tenantId` / `traceId` / `status` / `durationMs` (indexed by `tag` + `traceId`) plus the full `subject` / `input` / `outcome` as portable `json()` columns, plus the `chainId` / `seq` / `prevHash` / `hash` tamper-evidence columns (see [the hash chain](#tamper-evidence
|
|
76
|
+
`'datastore'` is the production sink: it survives restarts, is shared across replicas, and is queryable via `ctx.store.select('_voltro_audit_log')`. Each row carries the flattened `tag` / `at` / `subjectId` / `tenantId` / `traceId` / `status` / `durationMs` (indexed by `tag` + `traceId`) plus the full `subject` / `input` / `outcome` as portable `json()` columns, plus the `chainId` / `seq` / `prevHash` / `hash` tamper-evidence columns (see [the hash chain](#tamper-evidence-the-hash-chain)).
|
|
77
77
|
|
|
78
78
|
The custom function is the escape hatch for persisting events anywhere the built-in table's schema doesn't fit — e.g. an `Effect.Effect<void>` sink that writes rows into your own audit table on top of `@effect/sql`. All return shapes are normalised by the interceptor. A sink that throws / rejects / dies is caught and swallowed, so a broken sink can never mask the mutation's real outcome.
|
|
79
79
|
|
|
@@ -406,7 +406,7 @@ subjects *do* carry a `teamId`, which made subject-only worse than nothing for
|
|
|
406
406
|
them: it would have populated for key-authenticated calls and been null for every
|
|
407
407
|
human one, so a filtered view would have looked like it worked.
|
|
408
408
|
|
|
409
|
-
The input here is **raw** — not what [`redactInput`](#redactinput
|
|
409
|
+
The input here is **raw** — not what [`redactInput`](#redactinput-what-of-the-payload-is-kept)
|
|
410
410
|
will store. That is required (a scope derived from a redacted payload is not
|
|
411
411
|
derivable at all) and it is a hazard worth naming: whatever you return lands in
|
|
412
412
|
`scope`, which is *not* redacted. Return the dimension, never the payload.
|
|
@@ -69,7 +69,7 @@ export default (input: { tenantId: string }, _ctx) =>
|
|
|
69
69
|
})
|
|
70
70
|
```
|
|
71
71
|
|
|
72
|
-
The subscription row lives in your DB; the provider is the source of truth and webhooks keep the row in sync. `plan()` returns the plan whose **limits apply right now**: `'free'` with no subscription, the paid plan while active or trialing, and — because a bounced card should not downgrade a customer on the same second — the paid plan for the whole [grace period](#failed-payments
|
|
72
|
+
The subscription row lives in your DB; the provider is the source of truth and webhooks keep the row in sync. `plan()` returns the plan whose **limits apply right now**: `'free'` with no subscription, the paid plan while active or trialing, and — because a bounced card should not downgrade a customer on the same second — the paid plan for the whole [grace period](#failed-payments-stripe-retries-dunning-composes-on-the-outcome) after a failed payment, falling back only once the lockout is real.
|
|
73
73
|
|
|
74
74
|
## Entitlement checks
|
|
75
75
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# CDC-out (reverse-ETL)
|
|
2
2
|
|
|
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.
|
|
3
|
+
> Declaratively mirror table changes outward to external sinks (webhook, Kafka, 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, plus a CdcSink interface for custom sinks) 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, Kafka, 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
|
|
|
@@ -66,7 +66,12 @@ plugin name (`@voltro/plugin-cdc-out#analytics`) and the inspect mount
|
|
|
66
66
|
- **`webhookSink(url, { headers? })`** — POSTs each batch as
|
|
67
67
|
`{ records: [...] }` JSON, honoring the engine's per-attempt abort signal.
|
|
68
68
|
Its host is declared as a `network:outbound:<host>` permission automatically.
|
|
69
|
-
-
|
|
69
|
+
- **`kafkaSink({ topic })`** — from
|
|
70
|
+
[`@voltro/plugin-queue`](/docs/plugins/queue#cdc-out-to-kafka), producing
|
|
71
|
+
through the same provider your consumers use: message key = the row id (one
|
|
72
|
+
row's changes stay ordered in one partition), value = the change record, and
|
|
73
|
+
the `x-voltro-delivery-key` header carries the dedupe handle below.
|
|
74
|
+
- **Warehouse (Snowflake / BigQuery / …)** — implement the `CdcSink` interface
|
|
70
75
|
(`{ name, deliver(batch, ctx), outboundHost? }`). `deliver` may return a
|
|
71
76
|
**Promise or an Effect** — both compose without wrapping. The engine is
|
|
72
77
|
connector-agnostic; the sink is the only thing that changes.
|
|
@@ -90,6 +90,28 @@ import { useComments } from '@voltro/plugin-comments/web'
|
|
|
90
90
|
const { threads, unreadCount, create, resolve, react, markRead } = useComments(`orders:${id}`)
|
|
91
91
|
```
|
|
92
92
|
|
|
93
|
+
`useComments(anchor, apiName?)` subscribes to `comments.list`, whose `source:`
|
|
94
|
+
is the plugin's own reactivity channel — so a second client sees a new comment
|
|
95
|
+
without a reload, and every action on the handle (`create`, `edit`, `resolve`,
|
|
96
|
+
`remove`, `react`, `markRead`) re-runs the list for all subscribers.
|
|
97
|
+
`unreadCount` is the sum of the threads' own counters, i.e. the badge number.
|
|
98
|
+
|
|
99
|
+
Two focused hooks sit beside it, both from the same entry point:
|
|
100
|
+
|
|
101
|
+
```tsx
|
|
102
|
+
import { useThread, useMentionSearch } from '@voltro/plugin-comments/web'
|
|
103
|
+
|
|
104
|
+
const thread = useThread(`orders:${id}`, threadId) // ThreadView | undefined
|
|
105
|
+
const people = useMentionSearch(term) // [{ subjectId, label }]
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
`useThread(anchor, threadId, apiName?)` is a projection over the SAME live
|
|
109
|
+
list — it opens no second subscription, so a detail pane beside the thread
|
|
110
|
+
list costs nothing. `useMentionSearch(query, apiName?)` drives the
|
|
111
|
+
`@`-autocomplete over `comments.mentionSearch`; the directory it searches is
|
|
112
|
+
the app's own `resolveMentions` seam, already tenant-filtered by the rule
|
|
113
|
+
below.
|
|
114
|
+
|
|
93
115
|
## Mentions are tenant-safe by construction
|
|
94
116
|
|
|
95
117
|
The `resolveMentions` seam RECEIVES the calling subject — the signature makes
|
|
@@ -53,13 +53,42 @@ A member is `{ key, meta }`. **It pushes only when the roster actually moves**
|
|
|
53
53
|
|
|
54
54
|
```ts
|
|
55
55
|
presencePlugin({
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
56
|
+
// `store` is the app's DataStore, handed over at boot — app.config.ts is
|
|
57
|
+
// evaluated long before one exists, so the hook receives it rather than
|
|
58
|
+
// making you smuggle one in through a module cell.
|
|
59
|
+
resolveMember: async ({ subject, store }) => {
|
|
60
|
+
const rows = await store.query({
|
|
61
|
+
table: 'users', predicate: { column: 'id', op: 'eq', value: (subject as { id: string }).id },
|
|
62
|
+
order: [], projection: undefined, skip: undefined, take: 1,
|
|
63
|
+
} as never) as ReadonlyArray<{ name?: string; avatarUrl?: string }>
|
|
64
|
+
const user = rows[0]
|
|
65
|
+
return { userName: user?.name ?? null, avatarUrl: user?.avatarUrl ?? null }
|
|
59
66
|
},
|
|
67
|
+
// The keys the SERVER owns — stripped from the caller's meta before the merge.
|
|
68
|
+
identityFields: ['userName', 'avatarUrl'],
|
|
60
69
|
})
|
|
61
70
|
```
|
|
62
71
|
|
|
72
|
+
**Read the merge precisely — the obvious resolver has a hole in it.** Resolved
|
|
73
|
+
fields are merged OVER the caller's `meta`, so a key the resolver **does not
|
|
74
|
+
return** is not overwritten: it keeps whatever the client sent. A resolver that
|
|
75
|
+
returns only what it found (`{ userName }` for a user with no avatar) therefore
|
|
76
|
+
leaves a caller-supplied `avatarUrl` — or a `userName` the resolver has never
|
|
77
|
+
heard of — standing in the roster every other member reads. That is the exact
|
|
78
|
+
substitution the hook exists to prevent, and a deployment hit it while adopting
|
|
79
|
+
the hook.
|
|
80
|
+
|
|
81
|
+
Two ways to close it, and declaring is the better one:
|
|
82
|
+
|
|
83
|
+
- **`identityFields: ['userName', 'avatarUrl']`** — the keys the server owns.
|
|
84
|
+
They are removed from the caller's `meta` BEFORE the merge, so a key the
|
|
85
|
+
resolver happens not to return on this call is *absent* rather than
|
|
86
|
+
caller-controlled. Ignored without a `resolveMember`: with no server identity
|
|
87
|
+
to protect, stripping a client field would only delete data the app put there
|
|
88
|
+
deliberately.
|
|
89
|
+
- **Return every identity key on every call**, `null` for the ones you have no
|
|
90
|
+
value for.
|
|
91
|
+
|
|
63
92
|
The roster key is already the subject id, so a by-id lookup is the whole job — keep it cheap or cached. Alternatively resolve identity on the READ side from the roster keys (a `users.getByIds` query over `member.key`) and keep `meta` for ephemeral state only.
|
|
64
93
|
|
|
65
94
|
> **There is a second `usePresence`, and it is a different hook.**
|
|
@@ -23,6 +23,8 @@ The framework records metrics into Effect's global `MetricRegistry`. One snapsho
|
|
|
23
23
|
- **Subscriptions** — `voltro_subscriptions_active{tag}` (gauge of currently-open subscriptions), `voltro_subscription_deliveries_total{tag,kind}` + `voltro_subscription_delivery_seconds{tag,kind}` (per-delivery produce→push latency; `kind` = `snapshot` | `delta`). Backpressure & resume: `voltro_subscription_buffered_bytes{tag}` (gauge of pending bytes per blocked subscription), `voltro_subscription_coalesced_total{tag}` (updates collapsed onto the newest state while a consumer was blocked), `voltro_subscription_overrun_total{tag}` (streams closed with `SubscriptionOverrun`), `voltro_subscription_oversized_total{tag}` (events over `reactive.socket.oversizedEventBytes` — telemetry, not a cap).
|
|
24
24
|
- **Schedules (crons)** — `voltro_schedule_runs_total{schedule,status}` (firings by name + outcome — `status` = `succeeded` | `failed`), `voltro_schedule_duration_seconds{schedule}` (histogram), and `voltro_schedule_last_success_timestamp_seconds{schedule}` (a **gauge holding the UNIX time of the last SUCCESS**). Emitted by the framework scheduler, so every cron gets them with no per-handler wiring. A cron fires unattended — the failure mode is silent — so this is the series to alert on: `time() - voltro_schedule_last_success_timestamp_seconds{schedule="…"} > <interval × N>` fires when a job stops succeeding (a failure counter alone can't catch a job that stopped firing at all, but the last-success gauge going stale does). A failure moves the counter but deliberately NOT the gauge.
|
|
25
25
|
- **Workflows (durable execution)** — `voltro_workflow_runs_total{workflow,status}` (terminal outcomes — `status` = `succeeded` | `failed`), `voltro_workflow_duration_seconds{workflow}` (histogram), and `voltro_workflow_last_success_timestamp_seconds{workflow}` (last-success gauge). Emitted by the workflow run-recording seam. Because the framework applies no retry of its own, a `failed` run is **terminal** — it is the dead-letter state — so `voltro_workflow_runs_total{status="failed"}` **is** the dead-letter rate, and the last-success gauge going stale is the "this workflow stopped completing" alert (same shape as the schedule alert). A failure moves the counter but not the gauge.
|
|
26
|
+
- **Partial prerendering (web)** — `voltro_ppr_shell_serves_total{page}`, `voltro_ppr_hole_passes_total{page}`, `voltro_ppr_hole_settles_total{page}`, `voltro_ppr_hole_errors_total{page}` and `voltro_ppr_hole_pass_seconds{page}` (histogram). `page` is the DECLARED route pattern (`/blog/[slug]`), never a resolved URL — a label that grows with visitors is how a scrape target falls over. `voltro dev` and `voltro start` emit the same set. `voltro_ppr_hole_errors_total` is the alert: the shell already went out with a `200`, so a failed hole pass leaves every `<Await>` boundary on its fallback and nothing else says so.
|
|
27
|
+
- **[Queues](/docs/plugins/queue)** — `voltro_queue_consumed_total{topic,outcome}` (`outcome` = `ok` | `dead-lettered`; the two together are every message the runner finished with, so the dead-letter RATE is a division with no join), `voltro_queue_retries_total{topic}`, `voltro_queue_produced_total{topic}`, and `voltro_queue_lag_messages{topic,partition}` — a gauge of the backlog behind the message just picked up, read out of the fetch response rather than an admin round trip. A message abandoned by a rebalance is deliberately in no outcome: its new owner redelivers and counts it there.
|
|
26
28
|
- **`@voltro/cache`** counters, Effect's own `effect_fiber_*` runtime metrics, and **any custom metric** you or another plugin defines.
|
|
27
29
|
|
|
28
30
|
Two consumers read the SAME snapshot, so they never disagree:
|
|
@@ -123,7 +123,50 @@ cdcOutPlugin({ sinks: [{ table: 'orders', sink: kafkaSink({ topic: 'orders.cdc'
|
|
|
123
123
|
|
|
124
124
|
## Observability
|
|
125
125
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
`
|
|
126
|
+
**Metrics.** Every counter is exported to the [metrics
|
|
127
|
+
registry](/docs/observability/overview#metrics-export), so it is scrapeable via
|
|
128
|
+
[`@voltro/plugin-prometheus`](/docs/plugins/prometheus) and readable at
|
|
129
|
+
`GET /_voltro/inspect/metrics`:
|
|
130
|
+
|
|
131
|
+
| Series | Type | What it answers |
|
|
132
|
+
| --- | --- | --- |
|
|
133
|
+
| `voltro_queue_consumed_total{topic,outcome}` | counter | Throughput, and — with `outcome` = `ok` \| `dead-lettered` — the dead-letter rate as a plain division. |
|
|
134
|
+
| `voltro_queue_retries_total{topic}` | counter | In-process handler retries. A retry BLOCKS its partition, so a rising rate is head-of-line latency, not just noise. |
|
|
135
|
+
| `voltro_queue_produced_total{topic}` | counter | Messages produced through the outbox bridge. |
|
|
136
|
+
| `voltro_queue_lag_messages{topic,partition}` | gauge | Backlog behind the message just picked up — "are the consumers keeping up", which no counter can answer. |
|
|
137
|
+
|
|
138
|
+
`outcome` has two values on purpose: `ok` + `dead-lettered` is *every* message
|
|
139
|
+
the runner finished with. A message abandoned by a **rebalance** is in neither —
|
|
140
|
+
it was not consumed here, its new owner redelivers it and counts it there, and
|
|
141
|
+
counting it twice would make the dead-letter ratio wrong in the direction of
|
|
142
|
+
looking healthy.
|
|
143
|
+
|
|
144
|
+
Lag is a **sample at pickup** and costs nothing to collect (`highWatermark`
|
|
145
|
+
rides along in the fetch response — no admin round trip per message). Read it
|
|
146
|
+
together with the consume rate: nothing arrives to move the gauge on an idle or
|
|
147
|
+
revoked partition, so it holds its last value, and a frozen high lag and a
|
|
148
|
+
frozen low lag look identical on their own.
|
|
149
|
+
|
|
150
|
+
The per-topic counters plus the last error also stay on
|
|
151
|
+
`GET /_voltro/inspect/plugins/queue/consumers` and in the dashboards' Queue
|
|
152
|
+
panel — that view is this replica, right now, and carries an error *string*,
|
|
153
|
+
which is not a time series. Both are moved by one recorder each, so they cannot
|
|
154
|
+
drift.
|
|
155
|
+
|
|
156
|
+
**Tracing.** Each consumed message is processed inside a `queue.consume` span
|
|
157
|
+
that ADOPTS the producer's `traceparent` header as its parent, so a Kafka hop no
|
|
158
|
+
longer ends the trace. The span covers the whole message — decode, every retry,
|
|
159
|
+
and the dead-letter publish — and carries `messaging.system`,
|
|
160
|
+
`messaging.destination.name`, `messaging.consumer.group.name`,
|
|
161
|
+
`messaging.destination.partition.id`, `messaging.message.offset` and
|
|
162
|
+
`voltro.queue.outcome` (`ok` | `dead-lettered` | `stale`). A missing or
|
|
163
|
+
malformed `traceparent` starts a fresh root span rather than failing the
|
|
164
|
+
message. `ctx.traceparent` is still handed to your handler for hops the
|
|
165
|
+
framework does not make for you.
|
|
166
|
+
|
|
167
|
+
Consumer spans are emitted from detached work — a broker callback, outside the
|
|
168
|
+
server's Effect scope — and reach the server's tracer because the server
|
|
169
|
+
publishes its tracer instance for exactly that case. There is still only ONE
|
|
170
|
+
tracer: a second provider would mean a second exporter nothing flushes at
|
|
171
|
+
shutdown. The same applies to `cdcOut.deliver` and
|
|
172
|
+
`plugin.<name>.schedule-fire`, which run detached for the same reason.
|
|
@@ -95,7 +95,7 @@ Status legend: ✓ shipped · ◐ partial · — planned.
|
|
|
95
95
|
| `@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) |
|
|
96
96
|
| `@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) |
|
|
97
97
|
| `@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) |
|
|
98
|
-
| `@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;
|
|
98
|
+
| `@voltro/plugin-cdc-out` | ◐ | Declarative reverse-ETL — mirror table changes outward to external sinks (webhook, Kafka via [`kafkaSink`](/docs/plugins/queue#cdc-out-to-kafka), 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/Kafka sinks shipped; a warehouse connector implements the `CdcSink` interface. |
|
|
99
99
|
| `@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) |
|
|
100
100
|
| `@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) |
|
|
101
101
|
| `@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) |
|
|
@@ -1339,11 +1339,11 @@ The contract is deliberately narrow. **No raw SQL, no funnels, no cohorts, no cu
|
|
|
1339
1339
|
|
|
1340
1340
|
| Plugin | Type | Best for | Ceiling |
|
|
1341
1341
|
|---|---|---|---|
|
|
1342
|
-
| [`@voltro/plugin-analytics-postgres`](#
|
|
1343
|
-
| [`@voltro/plugin-duckdb`](#
|
|
1344
|
-
| [`@voltro/plugin-clickhouse`](#
|
|
1345
|
-
| [`@voltro/plugin-tinybird`](#
|
|
1346
|
-
| [`@voltro/plugin-posthog`](#
|
|
1342
|
+
| [`@voltro/plugin-analytics-postgres`](#voltro-plugin-analytics-postgres) | Lite | Day-1 zero-setup, dev + early production | ~10M events/day |
|
|
1343
|
+
| [`@voltro/plugin-duckdb`](#voltro-plugin-duckdb) | Embedded OLAP | Real column-store performance, no external service | Vertical scale: ~hundreds of GB in one process |
|
|
1344
|
+
| [`@voltro/plugin-clickhouse`](#voltro-plugin-clickhouse) | External OLAP | Production-scale analytics, self-hosted or ClickHouse Cloud | Billions of events comfortably |
|
|
1345
|
+
| [`@voltro/plugin-tinybird`](#voltro-plugin-tinybird) | Hosted ClickHouse | Pay-as-you-go without operating ClickHouse | Tinybird's own limits |
|
|
1346
|
+
| [`@voltro/plugin-posthog`](#voltro-plugin-posthog) | Product analytics | Sessions, feature flags, funnels in PostHog's UI | `track()` only — compose with another sink for reads |
|
|
1347
1347
|
|
|
1348
1348
|
## Picking one
|
|
1349
1349
|
|
|
@@ -68,12 +68,14 @@ these before hand-rolling a form, a table, or a picker** — full guide in
|
|
|
68
68
|
| [`useAsyncValidation`](/docs/ui/client-utilities/use-async-validation) | Live server-side validation (uniqueness, cross-row) over a query binding. |
|
|
69
69
|
| [`useDebounced`](/docs/ui/client-utilities/use-debounced) | Debounce a value (search, filter, validation input). |
|
|
70
70
|
| [`useRecord`](/docs/ui/client-utilities/use-record) | One live record from a "get" query, normalized (array → first row). |
|
|
71
|
+
| [`useValidationMessages`](/docs/ui/forms-and-tables) | The app-wide validation-message resolver from `<ValidationMessagesProvider>`, or `undefined` when none is mounted — for a widget kit that resolves message ids itself. |
|
|
71
72
|
|
|
72
73
|
## Files, Permissions, and Client Utilities
|
|
73
74
|
|
|
74
75
|
| Hook | Purpose |
|
|
75
76
|
|---|---|
|
|
76
77
|
| [`useUpload`](/docs/plugins/storage) | File upload with progress + cancel, on every storage provider. Not base64 → action. |
|
|
78
|
+
| [`usePresenceChannel`](/docs/local-first/overview) | One presence wire for local-first: the peer roster plus a `publish`/`subscribe` pair for ephemeral payloads (cursors, typing), riding the existing presence lane rather than a second socket. Room-scoped — a mismatched room throws instead of delivering across rooms. |
|
|
77
79
|
| [`useCan`](/docs/ui/client-utilities/use-can) | Scope/RBAC UI gate, over `<PermissionProvider>`. Lives in `@voltro/client` — scopes are a framework concept, so gating a button needs no rbac dependency. |
|
|
78
80
|
| [`useCanAny`](/docs/ui/client-utilities/use-permissions) | OR variant of `useCan` — true when the subject holds AT LEAST ONE of the required scopes. |
|
|
79
81
|
| [`usePermissions`](/docs/ui/client-utilities/use-permissions) / `<PermissionProvider>` | The current subject's scope set, fed once from your session query — the source `useCan` reads. Gates UI on the SAME scope strings the server checks. |
|
|
@@ -97,6 +99,23 @@ these before hand-rolling a form, a table, or a picker** — full guide in
|
|
|
97
99
|
| [`useResumableAgentStream`](/docs/ai/streaming) | Agent stream that survives reload/reconnect. |
|
|
98
100
|
| [`useDataCopilot`](/docs/ai/data-copilot) | Bind a data-copilot action by api name + tag. |
|
|
99
101
|
|
|
102
|
+
## Plugin and Local-First Hooks
|
|
103
|
+
|
|
104
|
+
Shipped by an installed plugin or by `@voltro/local-first`, not by
|
|
105
|
+
`@voltro/client` — the import path is the package, and each takes the api name
|
|
106
|
+
as its last argument (default `'app'`). The rest of the surface reads exactly
|
|
107
|
+
like the hooks above.
|
|
108
|
+
|
|
109
|
+
| Hook | Package | Purpose |
|
|
110
|
+
|---|---|---|
|
|
111
|
+
| [`useWebPush`](/docs/plugins/notifications) | `@voltro/plugin-notifications/web` | Web-push permission flow, service-worker registration and subscribe/unsubscribe — `{ status, error?, subscribe, unsubscribe }`, where `status` distinguishes `unsupported` / `denied` / `subscribed` for THIS browser. |
|
|
112
|
+
| [`useComments`](/docs/plugins/comments) | `@voltro/plugin-comments/web` | The live threads on one anchor plus every action on them (`create`, `edit`, `resolve`, `remove`, `react`, `markRead`) and the unread badge count. |
|
|
113
|
+
| [`useThread`](/docs/plugins/comments) | `@voltro/plugin-comments/web` | One thread by id — a projection over the same live list, so it opens no second subscription. |
|
|
114
|
+
| [`useMentionSearch`](/docs/plugins/comments) | `@voltro/plugin-comments/web` | `@`-mention autocomplete over the app-declared, tenant-filtered directory. |
|
|
115
|
+
| [`useCrdtText`](/docs/local-first/overview#a-collaborative-text-field-usecrdttext) | `@voltro/local-first/react` | A collaborative text field bound to one `crdtText()` cell — merged text, minimal-span edits, the offline queue and `synced`. |
|
|
116
|
+
| [`useCrdtDoc`](/docs/local-first/overview#rich-text-crdtdoc-usecrdtdoc-usecrdteditor) | `@voltro/local-first/react` | The sync half of a `crdtDoc()` column — one `SyncClient` + one live CRDT document per cell, with the echo guard that keeps a folded remote update from being pushed back. Hands the document to `useCrdtEditor`. |
|
|
117
|
+
| [`useCrdtEditor`](/docs/local-first/overview#rich-text-crdtdoc-usecrdtdoc-usecrdteditor) | `@voltro/local-first/editor` | A collaborative rich-text editor over a `crdtDoc()` column — one Tiptap instance bound to the shared document, carets bridged over an injected transport. |
|
|
118
|
+
|
|
100
119
|
## Where To Read Next
|
|
101
120
|
|
|
102
121
|
- [Data hooks](/docs/reference/hooks-data)
|
|
@@ -228,7 +247,7 @@ const { data } = useSubscription(
|
|
|
228
247
|
### Offline semantics with the local-first mirror
|
|
229
248
|
|
|
230
249
|
With `@voltro/local-first`'s query mirror bound (see
|
|
231
|
-
[the sync engine](/docs/local-first/overview#the-sync-engine-query-mirror
|
|
250
|
+
[the sync engine](/docs/local-first/overview#the-sync-engine-query-mirror-durable-outbox)),
|
|
232
251
|
`useSubscription`'s behaviour extends offline WITHOUT a second API: a cold
|
|
233
252
|
start seeds `data` (and `revision`) from the device's mirrored rows for the
|
|
234
253
|
subject's partition, so `loading` resolves against local data when the server
|