@voltro/cli 0.52.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 +424 -0
- package/THIRD-PARTY-NOTICES.md +8311 -3318
- 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-CSFI8QGq.js → apiBuild-DTWp0S_q.js} +11 -5
- package/dist/bin.js +1 -1
- package/dist/build-D4ygSbnV.js +843 -0
- package/dist/{checkCommand-COmqc2cB.js → checkCommand-Dg1G7Gwd.js} +6 -6
- package/dist/{checkCommand-2SbqzukH.js → checkCommand-L7DTlpIF.js} +1 -1
- package/dist/{cloudCmd-QUXh-b5w.js → cloudCmd-DzKcSYuy.js} +1 -1
- package/dist/{codegen-VF479Cnb.js → codegen-DSLM8Su9.js} +1 -1
- package/dist/codegen-DjgxEOnD.js +2 -0
- package/dist/codegenCommand-CG_Vx4lc.js +41 -0
- package/dist/{codemodRunner-r7J9lIa7.js → codemodRunner-Cd4xkC6u.js} +109 -11
- package/dist/{commands-Cc_nV8WI.js → commands-6Kzi92Np.js} +96 -73
- package/dist/{dashboardCommand-C-gKvwqh.js → dashboardCommand-Cq1PWvI1.js} +5 -5
- package/dist/{dataCommand-BgpBHnlB.js → dataCommand-DYzW8vkv.js} +3 -3
- package/dist/{dbCommand-sHedr-NJ.js → dbCommand-B4NWZtGL.js} +278 -237
- package/dist/dbCommand-CSFWs9ev.js +2 -0
- package/dist/{dev-CRHoCEiy.js → dev-CmuvUKRq.js} +2903 -2306
- package/dist/{dev--A3nsxA3.js → dev-cKUiZZsB.js} +1 -1
- package/dist/{doctorCommand-DtfJ3FA6.js → doctorCommand-DCiFVMtZ.js} +101 -69
- package/dist/doctorCommand-J3qu4E0Y.js +2 -0
- package/dist/{dormancyCommand-Drn7o0No.js → dormancyCommand-w1TrmgYP.js} +1 -1
- package/dist/{embeddingsCommand-Z-jO1fWN.js → embeddingsCommand-CMgPyRTr.js} +1 -1
- package/dist/{envCommand-D4gCrrTZ.js → envCommand-Cyynmcfa.js} +8 -8
- package/dist/{evolveCommand-CMROeKeA.js → evolveCommand-BwvQ8dVH.js} +2 -2
- package/dist/fileConventions-l-RIXbx8.js +36 -0
- package/dist/{fileTaxonomy-DvDUV9wq.js → fileTaxonomy-CbyMQYx_.js} +42 -42
- package/dist/fontPipeline-LxIHa1vo.js +2 -0
- package/dist/fontPipeline-Tsh8kZfA.js +152 -0
- package/dist/{frameworkTableAssembly-w-XnLa3q.js → frameworkTableAssembly-D7LJuALW.js} +5 -5
- package/dist/frameworkTableAssembly-IPD1pUnZ.js +2 -0
- package/dist/imagePipeline-B_GVJgm6.js +2 -0
- package/dist/imagePipeline-CBZmjT4i.js +127 -0
- package/dist/index.js +2 -2
- package/dist/{infoCommand-DXM868o_.js → infoCommand-DlYlUPqs.js} +1 -1
- package/dist/{inspect-CtL_xTbu.js → inspect-Bd8-9wsi.js} +1 -1
- package/dist/inspect-CuoDInfZ.js +2 -0
- package/dist/{inspectGateHint-BjnFubmH.js → inspectGateHint-4LxkNtrz.js} +1 -1
- package/dist/manifestBuild-C4-J1-m_.js +2 -0
- package/dist/{manifestBuild-CuU1VrSm.js → manifestBuild-Cqgsx2bM.js} +1 -1
- package/dist/{metaCommands-C6RFmF1r.js → metaCommands-x7RCi2AF.js} +9 -3
- package/dist/{migrate-D0F-eTlK.js → migrate-BK_Bbx-_.js} +2 -2
- package/dist/mobileCommand-D9O6iq3D.js +428 -0
- package/dist/mobileCommand-DAum7tsG.js +2 -0
- package/dist/{pageConvention-CzUiSbtU.js → pageConvention-CMpfDN6r.js} +1 -1
- package/dist/{privacyCommand-DGdopOI6.js → privacyCommand-BCa2OoZG.js} +1 -1
- package/dist/{probeCommand-C9gazU0H.js → probeCommand-_C0YU207.js} +83 -24
- 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-Ck32Fzxr.js → renderProfile-DvrhVJHa.js} +2 -2
- package/dist/{runtimeTrace-BPQyCmC5.js → runtimeTrace-CGWx1Q6l.js} +1 -1
- package/dist/{sdkgen-Se88ifTd.js → sdkgen-CDGHQUFj.js} +1 -1
- package/dist/serveCommand-BiPe8BJm.js +2 -0
- package/dist/{serveCommand-DkP3OT0W.js → serveCommand-Bje09q1v.js} +889 -825
- package/dist/serveEntry.js +1 -1
- package/dist/start-B0bnJgxI.js +3 -0
- package/dist/{start-jw89Xbqy.js → start-Clz-1BHB.js} +633 -455
- package/dist/startEntry.js +1 -1
- package/dist/{staticCommand-BwNEDlSU.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-f3amja6a.js → test-D_kW4KMj.js} +1 -1
- package/dist/updateCommand-CIoVDKnj.js +2 -0
- package/dist/{updateCommand-BMk2e4ky.js → updateCommand-CRJlAOaM.js} +139 -115
- package/dist/{webDev-BgWL9gKV.js → webDev-DSI9SOhs.js} +1598 -1028
- package/dist/webDev-DlvZO30c.js +2 -0
- package/dist/{webhooksCommand-CoIO3jbj.js → webhooksCommand-BvzXNHji.js} +1 -1
- package/dist/workspaceDeps-RKEkX92S.js +45 -0
- package/package.json +60 -19
- package/templates/AGENTS.core.md +2 -0
- package/templates/AGENTS.md +8 -4
- package/templates/agent-docs/_index.md +6 -4
- package/templates/agent-docs/_manifest.json +21 -5
- package/templates/agent-docs/ai.md +6 -6
- package/templates/agent-docs/authentication.md +73 -1
- package/templates/agent-docs/cli.md +6 -3
- package/templates/agent-docs/configuration.md +17 -0
- package/templates/agent-docs/data.md +522 -29
- package/templates/agent-docs/database/advancedqueries.md +8 -8
- package/templates/agent-docs/database/columntypes.md +2 -2
- package/templates/agent-docs/database/migrations.md +1 -1
- package/templates/agent-docs/database/querying.md +1 -1
- package/templates/agent-docs/database/schema.md +1 -1
- package/templates/agent-docs/database/seedsdialects.md +65 -3
- package/templates/agent-docs/database/transactions.md +3 -3
- package/templates/agent-docs/deployment.md +8 -0
- package/templates/agent-docs/internationalization.md +4 -2
- package/templates/agent-docs/introduction.md +32 -1
- package/templates/agent-docs/local-first-mobile.md +226 -30
- package/templates/agent-docs/observability.md +5 -1
- package/templates/agent-docs/plugins/atlassian.md +2 -2
- package/templates/agent-docs/plugins/audit.md +2 -2
- package/templates/agent-docs/plugins/auth.md +1 -1
- 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 +164 -0
- package/templates/agent-docs/plugins/notifications.md +47 -4
- package/templates/agent-docs/plugins/presence.md +45 -3
- package/templates/agent-docs/plugins/prometheus.md +3 -1
- package/templates/agent-docs/plugins/queue.md +172 -0
- package/templates/agent-docs/plugins.md +17 -13
- package/templates/agent-docs/reference.md +35 -4
- package/templates/agent-docs/routing.md +585 -7
- package/templates/agent-docs/scheduling.md +1 -1
- package/templates/agent-docs/schema-driven-ui.md +226 -4
- package/templates/agent-docs/security.md +3 -3
- package/templates/agent-docs/templates/appshells.md +36 -4
- package/templates/agent-docs/whats-new.md +134 -66
- 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/app.config.ts +26 -2
- package/templates/apps/changelog/content/releases/{0.1.0.mdx → v0-1-0.mdx} +0 -1
- package/templates/apps/changelog/content/releases/{0.2.0.mdx → v0-2-0.mdx} +0 -1
- package/templates/apps/changelog/package.json +9 -8
- package/templates/apps/changelog/src/collections/releases.collection.ts +48 -0
- package/templates/apps/changelog/src/globals.d.ts +1 -1
- package/templates/apps/changelog/src/locales/de.ts +1 -1
- package/templates/apps/changelog/src/locales/en.ts +1 -1
- package/templates/apps/changelog/src/pages/[locale]/[slug]/page.tsx +7 -4
- package/templates/apps/changelog/src/pages/[locale]/mirrors.test.tsx +13 -41
- package/templates/apps/changelog/src/pages/[slug]/page.test.tsx +26 -51
- package/templates/apps/changelog/src/pages/[slug]/page.tsx +21 -19
- package/templates/apps/changelog/src/pages/page.test.tsx +14 -37
- package/templates/apps/changelog/src/pages/page.tsx +18 -12
- 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/content/docs/de/guides/first-page.md +4 -0
- package/templates/apps/frontend-docs/content/docs/de/intro/getting-started.md +4 -0
- package/templates/apps/frontend-docs/content/docs/en/guides/first-page.md +4 -0
- package/templates/apps/frontend-docs/content/docs/en/intro/getting-started.md +4 -0
- package/templates/apps/frontend-docs/package.json +9 -6
- package/templates/apps/frontend-docs/src/collections/docs.collection.ts +21 -0
- package/templates/apps/frontend-docs/src/locales/de.ts +0 -5
- package/templates/apps/frontend-docs/src/locales/en.ts +0 -5
- package/templates/apps/frontend-docs/src/pages/[locale]/docs/[...slug]/page.tsx +17 -9
- package/templates/apps/frontend-docs/src/pages/[locale]/mirrors.test.tsx +10 -5
- package/templates/apps/frontend-docs/src/pages/[locale]/page.tsx +8 -0
- package/templates/apps/frontend-docs/src/pages/docs/[...slug]/page.test.tsx +53 -15
- package/templates/apps/frontend-docs/src/pages/docs/[...slug]/page.tsx +27 -33
- package/templates/apps/frontend-docs/src/pages/page.test.tsx +17 -3
- package/templates/apps/frontend-docs/src/pages/page.tsx +16 -12
- 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/content/posts/cms-to-ssg.md +12 -0
- package/templates/apps/frontend-static-blog/content/posts/hello-static.md +12 -0
- package/templates/apps/frontend-static-blog/content/posts/islands-not-hydration.md +14 -0
- package/templates/apps/frontend-static-blog/package.json +9 -6
- package/templates/apps/frontend-static-blog/src/collections/posts.collection.ts +27 -0
- package/templates/apps/frontend-static-blog/src/pages/[locale]/blog/[slug]/page.tsx +7 -4
- package/templates/apps/frontend-static-blog/src/pages/[locale]/mirrors.test.tsx +7 -1
- package/templates/apps/frontend-static-blog/src/pages/blog/[slug]/page.test.tsx +19 -9
- package/templates/apps/frontend-static-blog/src/pages/blog/[slug]/page.tsx +19 -21
- package/templates/apps/frontend-static-blog/src/pages/page.test.tsx +18 -5
- package/templates/apps/frontend-static-blog/src/pages/page.tsx +21 -14
- 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-BYBpL7Pz.js +0 -2
- package/dist/build-CPgcMQug.js +0 -793
- package/dist/codegen-CctkDO-1.js +0 -2
- package/dist/codegenCommand-DCdG2JN-.js +0 -137
- package/dist/dbCommand-DNb6yeOG.js +0 -2
- package/dist/doctorCommand-CqoWA2p5.js +0 -2
- package/dist/fileConventions-DOqD3lPS.js +0 -34
- package/dist/frameworkTableAssembly-C6ETawPR.js +0 -2
- package/dist/inspect-CuGDYES0.js +0 -2
- package/dist/manifestBuild-CPjhvM62.js +0 -2
- package/dist/renderModeScan-CcH2X1_D.js +0 -120
- package/dist/serveCommand-DLc-BznW.js +0 -2
- package/dist/start-DfL3fOiN.js +0 -3
- package/dist/updateCommand-5gFVfK5q.js +0 -2
- package/dist/webDev-CZbTsDcH.js +0 -2
- package/templates/apps/changelog/scripts/generate-rss.mjs +0 -38
- package/templates/apps/changelog/src/lib/releases.ts +0 -21
- package/templates/apps/frontend-static-blog/src/content/posts.ts +0 -64
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Notifications
|
|
2
2
|
|
|
3
|
-
> Unified notifications — one send API across email / Slack / SMS / push / in-app, with per-user channel preferences, an in-app inbox, and delivery records.
|
|
3
|
+
> Unified notifications — one send API across email / Slack / SMS / mobile push / web push / in-app, with per-user channel preferences, an in-app inbox, and delivery records.
|
|
4
4
|
|
|
5
5
|
|
|
6
6
|
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
<!-- source: en/plugins/notifications.md -->
|
|
10
10
|
## Notifications
|
|
11
11
|
|
|
12
|
-
_Unified notifications — one send API across email / Slack / SMS / push / in-app, with per-user channel preferences, an in-app inbox, and delivery records._
|
|
12
|
+
_Unified notifications — one send API across email / Slack / SMS / mobile push / web push / in-app, with per-user channel preferences, an in-app inbox, and delivery records._
|
|
13
13
|
|
|
14
14
|
`@voltro/plugin-notifications` is the **one** messaging answer instead of twenty brand wrappers: a single `send` across channels, per-user preferences, and an in-app inbox with unread counts — not a per-vendor SDK in every handler.
|
|
15
15
|
|
|
@@ -34,7 +34,7 @@ export default {
|
|
|
34
34
|
}
|
|
35
35
|
```
|
|
36
36
|
|
|
37
|
-
The built-in **in-app** channel persists to the notification store and is appended automatically; `consoleChannel()`, `webhookChannel({ url, id?, format?, headers? })`, `emailChannel(send)`, `smsChannel(send)`, `pushChannel({ tokensFor, transport })` (see [Push](#push-apns-fcm)), and `customChannel(id, deliver)` cover the rest. A channel is just `{ id, deliver: (msg) => Promise<void> }` — bring your own.
|
|
37
|
+
The built-in **in-app** channel persists to the notification store and is appended automatically; `consoleChannel()`, `webhookChannel({ url, id?, format?, headers? })`, `emailChannel(send)`, `smsChannel(send)`, `pushChannel({ tokensFor, transport, onTokenRejected? })` (see [Push](#push-apns-fcm)), `webPushChannel()` (see [Web Push](#web-push-vapid)), and `customChannel(id, deliver)` cover the rest. A channel is just `{ id, deliver: (msg) => Promise<void> }` — bring your own.
|
|
38
38
|
|
|
39
39
|
`notificationsPlugin({ channels?, store?, digestWindowMs?, flushIntervalMs?, name? })` — the inbox, per-subject channel preferences, and delivery log **auto-persist to the framework DataStore by default**, durably, on every supported dialect. You only pass an explicit `store` for a **custom** backend (see [Store](#store)). `digestWindowMs` enables [digest/batching](#digest-batching); the scheduled flush (interval `flushIntervalMs`, default 30s) drains digest windows and quiet-hours deferrals.
|
|
40
40
|
|
|
@@ -75,7 +75,50 @@ pushChannel({
|
|
|
75
75
|
})
|
|
76
76
|
```
|
|
77
77
|
|
|
78
|
-
|
|
78
|
+
Delivery is **per token and isolated**: each device token sends independently, and the delivery log records one row PER TOKEN (`endpoint` names it) — a stale token on one old phone no longer aborts the send to every current device, and the channel counts delivered when at least one token was reached. A `PushTokenRejected` thrown by your transport marks that token's record `failed` (naming the token — **never** the auth secret) and fires the optional `onTokenRejected(token, reason)` hook, which is where your app prunes the dead token from its own table. The push AUTH secret lives in your `transport` closure — it never enters the package and is never logged.
|
|
79
|
+
|
|
80
|
+
## Web Push (VAPID)
|
|
81
|
+
|
|
82
|
+
`webPushChannel()` delivers real browser push — a user subscribes once and receives notifications with the tab closed. Subscriptions are managed by the plugin itself (per subject AND per browser endpoint, in `_voltro_notification_push_subscriptions`), payloads are encrypted per RFC 8291, auth is VAPID (RFC 8292), and dead endpoints are pruned automatically.
|
|
83
|
+
|
|
84
|
+
```ts
|
|
85
|
+
// app.config.ts
|
|
86
|
+
notificationsPlugin({
|
|
87
|
+
channels: [webPushChannel({ contact: 'mailto:ops@example.com' })],
|
|
88
|
+
})
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
**The key is minted, never shipped.** The channel signs with `VOLTRO_VAPID_PRIVATE_KEY` (a base64url P-256 scalar); `voltro dev` mints a per-project value into the gitignored `.env.local` on first boot, and a production boot without one **refuses by name** — there is deliberately no default. The browser-facing public key is DERIVED from the private scalar, so a public/private pair can never desync.
|
|
92
|
+
|
|
93
|
+
**Setup, three steps:**
|
|
94
|
+
|
|
95
|
+
1. Configure the channel (above). Web push requires HTTPS in production (localhost is exempt).
|
|
96
|
+
2. Copy the service worker into your web app: `cp node_modules/@voltro/plugin-notifications/sw.js public/sw.js`. Its URL decides its scope — served from the root it covers the whole app. It shows the notification, opens the declared `url` on click, reports the click, and posts the payload to open pages (`{ type: 'voltro:push' }` message) so an in-page inbox can refresh live.
|
|
97
|
+
3. Offer the subscribe flow with the hook:
|
|
98
|
+
|
|
99
|
+
```tsx
|
|
100
|
+
import { useWebPush } from '@voltro/plugin-notifications/web'
|
|
101
|
+
|
|
102
|
+
const PushSettings = () => {
|
|
103
|
+
const push = useWebPush()
|
|
104
|
+
if (push.status === 'unsupported') return <p>This browser cannot receive push.</p>
|
|
105
|
+
return push.status === 'subscribed'
|
|
106
|
+
? <button onClick={() => void push.unsubscribe()}>Disable push</button>
|
|
107
|
+
: <button onClick={() => void push.subscribe()}>Enable push</button>
|
|
108
|
+
}
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
The semantics, precisely:
|
|
112
|
+
|
|
113
|
+
- **Multi-endpoint is the design, not an edge case.** Three browsers = three endpoint rows for one subject. Delivery is per endpoint and isolated; the delivery log records one row per endpoint. A re-subscribe on the same endpoint takes the row over — the endpoint belongs to the browser profile, and the latest signed-in subject owns it.
|
|
114
|
+
- **Prune is automatic and exact.** A push service answering 404/410 for one endpoint deletes exactly that row; the subject's other browsers keep receiving. No app-side prune code.
|
|
115
|
+
- **Payload cap ~4 KB.** Push services cap the encrypted body; an oversized payload SHRINKS (the `data` bag first, then the body is truncated) rather than being dropped.
|
|
116
|
+
- **Click tracking is built in.** Each delivered notification carries a one-time click token; the service worker's `notificationclick` reports it and the delivery record gains `clickedAt` — open rates read straight off the delivery log.
|
|
117
|
+
- **Preferences, quiet hours and digests apply unchanged** — the channel is a sender like any other (its preference key is `webPush`).
|
|
118
|
+
- **iOS Safari, honestly:** web push works on iOS 16.4+ ONLY for installed home-screen web apps (PWA), never in the browser tab. Do not promise iOS coverage from a plain website.
|
|
119
|
+
- **Scheduled send** is a recipe, not a switch: `defineSchedule` + `send` covers "notify at 9am" without a second delivery queue. Quiet hours and digests already defer within their own semantics.
|
|
120
|
+
|
|
121
|
+
Subscribe/unsubscribe are RPC mutations (`notifications.webPushSubscribe` / `webPushUnsubscribe` — subject-bound, so one subject can never detach another's browser); `notifications.webPushPublicKey` hands the browser its `applicationServerKey`; `notifications.webPushStatus` counts the caller's registered endpoints.
|
|
79
122
|
|
|
80
123
|
## Digest / batching
|
|
81
124
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Presence
|
|
2
2
|
|
|
3
|
-
> Ephemeral realtime presence — who's online in a channel, with heartbeat, live roster, and per-member metadata (status, cursor). Works cross-instance.
|
|
3
|
+
> Ephemeral realtime presence — who's online in a channel, with heartbeat, live roster, and per-member metadata (status, cursor — client-supplied and relayed verbatim; identity fields belong in the server-side resolveMember hook). Works cross-instance.
|
|
4
4
|
|
|
5
5
|
|
|
6
6
|
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
<!-- source: en/plugins/presence.md -->
|
|
10
10
|
## Presence
|
|
11
11
|
|
|
12
|
-
_Ephemeral realtime presence — who's online in a channel, with heartbeat, live roster, and per-member metadata (status, cursor). Works cross-instance._
|
|
12
|
+
_Ephemeral realtime presence — who's online in a channel, with heartbeat, live roster, and per-member metadata (status, cursor — client-supplied and relayed verbatim; identity fields belong in the server-side resolveMember hook). Works cross-instance._
|
|
13
13
|
|
|
14
14
|
`@voltro/plugin-presence` answers "who's here right now". A client heartbeats into a channel; the roster lists everyone whose heartbeat is fresh. Held **in memory**, owner-partitioned: every member belongs to exactly the replica holding its WebSocket, so concurrent writes to one key are impossible by construction and there is no table, no CRDT and no coordinator.
|
|
15
15
|
|
|
@@ -49,8 +49,50 @@ const Room = ({ channel }: { channel: string }) => {
|
|
|
49
49
|
|
|
50
50
|
A member is `{ key, meta }`. **It pushes only when the roster actually moves** — a join, a leave, a change to someone's `meta`, a sweep, a peer's delta, a peer's death. A heartbeat that repeats what the server already knows pushes nothing, which is what keeps a large steady room free.
|
|
51
51
|
|
|
52
|
+
**`meta` is client-supplied, unvalidated, and handed to every channel member verbatim — identity does not belong in it.** That contract is right for a cursor or a status flag, and wrong for `userName` / `avatarUrl`: any member of a channel could present any name and any `<img src>` to everyone else. For identity fields, give the plugin a server-side resolver — it runs on every heartbeat and its result is merged OVER the caller's `meta`, so a client cannot override what the server says about them:
|
|
53
|
+
|
|
54
|
+
```ts
|
|
55
|
+
presencePlugin({
|
|
56
|
+
// `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 }
|
|
66
|
+
},
|
|
67
|
+
// The keys the SERVER owns — stripped from the caller's meta before the merge.
|
|
68
|
+
identityFields: ['userName', 'avatarUrl'],
|
|
69
|
+
})
|
|
70
|
+
```
|
|
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
|
+
|
|
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.
|
|
93
|
+
|
|
52
94
|
> **There is a second `usePresence`, and it is a different hook.**
|
|
53
|
-
> [`@voltro/local-first/react`](/docs/local-first/overview#presence
|
|
95
|
+
> [`@voltro/local-first/react`](/docs/local-first/overview#presence-awareness)
|
|
54
96
|
> exports one for peer-to-peer *awareness* — `usePresence(roomId, self, { channel })`
|
|
55
97
|
> → `{ presence, others, setPresence }` — carrying high-frequency cursor and
|
|
56
98
|
> selection state over a pub/sub channel. This one is the server-backed roster.
|
|
@@ -20,9 +20,11 @@ The framework records metrics into Effect's global `MetricRegistry`. One snapsho
|
|
|
20
20
|
- **Core RPC** — `voltro_rpc_requests_total{tag,status}`, `voltro_rpc_errors_total{tag}`, `voltro_rpc_duration_seconds{tag}` (histogram). Emitted at the mutation / action handler boundary.
|
|
21
21
|
- **HTTP routes** — `voltro_http_requests_total{route,status}`, `voltro_http_duration_seconds{route}`.
|
|
22
22
|
- **Plugin interceptors** — `voltro_plugin_hook_duration_seconds{hook}`, `voltro_plugin_hook_errors_total{hook}`.
|
|
23
|
-
- **Subscriptions** — `voltro_subscriptions_active{tag}` (gauge of currently-open subscriptions), `voltro_subscription_deliveries_total{tag,kind}` + `voltro_subscription_delivery_seconds{tag,kind}` (per-delivery produce→push latency; `kind` = `snapshot` | `delta`).
|
|
23
|
+
- **Subscriptions** — `voltro_subscriptions_active{tag}` (gauge of currently-open subscriptions), `voltro_subscription_deliveries_total{tag,kind}` + `voltro_subscription_delivery_seconds{tag,kind}` (per-delivery produce→push latency; `kind` = `snapshot` | `delta`). Backpressure & resume: `voltro_subscription_buffered_bytes{tag}` (gauge of pending bytes per blocked subscription), `voltro_subscription_coalesced_total{tag}` (updates collapsed onto the newest state while a consumer was blocked), `voltro_subscription_overrun_total{tag}` (streams closed with `SubscriptionOverrun`), `voltro_subscription_oversized_total{tag}` (events over `reactive.socket.oversizedEventBytes` — telemetry, not a cap).
|
|
24
24
|
- **Schedules (crons)** — `voltro_schedule_runs_total{schedule,status}` (firings by name + outcome — `status` = `succeeded` | `failed`), `voltro_schedule_duration_seconds{schedule}` (histogram), and `voltro_schedule_last_success_timestamp_seconds{schedule}` (a **gauge holding the UNIX time of the last SUCCESS**). Emitted by the framework scheduler, so every cron gets them with no per-handler wiring. A cron fires unattended — the failure mode is silent — so this is the series to alert on: `time() - voltro_schedule_last_success_timestamp_seconds{schedule="…"} > <interval × N>` fires when a job stops succeeding (a failure counter alone can't catch a job that stopped firing at all, but the last-success gauge going stale does). A failure moves the counter but deliberately NOT the gauge.
|
|
25
25
|
- **Workflows (durable execution)** — `voltro_workflow_runs_total{workflow,status}` (terminal outcomes — `status` = `succeeded` | `failed`), `voltro_workflow_duration_seconds{workflow}` (histogram), and `voltro_workflow_last_success_timestamp_seconds{workflow}` (last-success gauge). Emitted by the workflow run-recording seam. Because the framework applies no retry of its own, a `failed` run is **terminal** — it is the dead-letter state — so `voltro_workflow_runs_total{status="failed"}` **is** the dead-letter rate, and the last-success gauge going stale is the "this workflow stopped completing" alert (same shape as the schedule alert). A failure moves the counter but not the gauge.
|
|
26
|
+
- **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:
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
# Queue (Kafka interop)
|
|
2
|
+
|
|
3
|
+
> Consume and produce against an existing Kafka — Schema-decoded consumers (at-least-once, serial per partition, retry + dead-letter), batched producing via a handler service or transactionally through the outbox, and a kafkaSink for cdc-out.
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
<!-- source: en/plugins/queue.md -->
|
|
10
|
+
## Queue (Kafka interop)
|
|
11
|
+
|
|
12
|
+
_Consume and produce against an existing Kafka — Schema-decoded consumers (at-least-once, serial per partition, retry + dead-letter), batched producing via a handler service or transactionally through the outbox, and a kafkaSink for cdc-out._
|
|
13
|
+
|
|
14
|
+
`@voltro/plugin-queue` is the door to queues **somebody else owns**: a Voltro
|
|
15
|
+
backend consuming and producing against an adopter's existing Kafka. The
|
|
16
|
+
boundary with the built-ins, in one line each: the [outbox](/docs/data/outbox)
|
|
17
|
+
is *your own* durable side-effects, a [workflow](/docs/workflows/overview) is
|
|
18
|
+
*your own* orchestration — this plugin is interop with foreign infrastructure.
|
|
19
|
+
Kafka first; the provider contract is cut so SQS/RabbitMQ can be later
|
|
20
|
+
implementations.
|
|
21
|
+
|
|
22
|
+
```ts
|
|
23
|
+
// app.config.ts
|
|
24
|
+
import { queuePlugin } from '@voltro/plugin-queue'
|
|
25
|
+
|
|
26
|
+
export default {
|
|
27
|
+
// …
|
|
28
|
+
plugins: [
|
|
29
|
+
queuePlugin({ brokers: ['kafka-1:9092', 'kafka-2:9092'] }),
|
|
30
|
+
],
|
|
31
|
+
}
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Consuming: `*.consumer.ts`
|
|
35
|
+
|
|
36
|
+
```ts
|
|
37
|
+
// src/consumers/orders.consumer.ts
|
|
38
|
+
import { Schema } from 'effect'
|
|
39
|
+
import { defineQueueConsumer } from '@voltro/plugin-queue'
|
|
40
|
+
|
|
41
|
+
export const orders = defineQueueConsumer({
|
|
42
|
+
topic: 'orders',
|
|
43
|
+
schema: Schema.Struct({ orderId: Schema.String, total: Schema.Number }),
|
|
44
|
+
handler: async (order, ctx) => {
|
|
45
|
+
// MUST be idempotent — delivery is at-least-once. A unique-column
|
|
46
|
+
// upsert is the standard shape:
|
|
47
|
+
await ctx.store.upsert('orders_mirror',
|
|
48
|
+
{ orderId: order.orderId, total: order.total },
|
|
49
|
+
{ conflictColumns: ['orderId'] })
|
|
50
|
+
},
|
|
51
|
+
})
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Both boot paths discover `*.consumer.ts`; the plugin starts every registered
|
|
55
|
+
consumer at activation and stops them at shutdown. The semantics, precisely:
|
|
56
|
+
|
|
57
|
+
- **Ordering: serial per partition.** Parallelism exists only ACROSS
|
|
58
|
+
partitions — concurrency inside one would destroy ordering and commit
|
|
59
|
+
semantics both. Retry backoff deliberately BLOCKS the partition.
|
|
60
|
+
- **Commit after the handler, per message.** A process killed mid-batch
|
|
61
|
+
redelivers exactly the unhandled tail — never the whole batch, never a
|
|
62
|
+
skipped message.
|
|
63
|
+
- **Decode failures dead-letter IMMEDIATELY** (to `<topic>.dlq`, with
|
|
64
|
+
`x-voltro-dlq-*` reason headers) — a deterministic failure retried forever
|
|
65
|
+
is an infinite loop with extra steps, and a poison message must release
|
|
66
|
+
its partition.
|
|
67
|
+
- **Handler failures retry with backoff, then dead-letter** after
|
|
68
|
+
`maxAttempts` (default 3).
|
|
69
|
+
- **A rebalance is not a failure.** A partition revoked mid-batch or
|
|
70
|
+
mid-retry stops processing without a retry-counter increment or a DLQ
|
|
71
|
+
publish — the new owner redelivers.
|
|
72
|
+
- **Handlers are NOT wrapped in a transaction** (the same documented
|
|
73
|
+
boundary as HTTP route handlers). A handler needing atomic multi-writes
|
|
74
|
+
opens `ctx.store.transactional` itself — and stays idempotent either way.
|
|
75
|
+
- **Replica coordination is Kafka's own.** Every replica joins the same
|
|
76
|
+
consumer group and the broker assigns partitions — no advisory lock, unlike
|
|
77
|
+
[schedules](/docs/scheduling/coordination), which coordinate through the
|
|
78
|
+
claim table because no broker exists to do it for them.
|
|
79
|
+
|
|
80
|
+
## Producing
|
|
81
|
+
|
|
82
|
+
Two paths, one rule: transactional-with-a-write goes through the outbox.
|
|
83
|
+
|
|
84
|
+
```ts
|
|
85
|
+
// Inside a mutation — commits or rolls back WITH the domain write:
|
|
86
|
+
await ctx.outbox.enqueue('queue.produce', {
|
|
87
|
+
topic: 'orders',
|
|
88
|
+
messages: [{ key: order.id, value: JSON.stringify(order) }],
|
|
89
|
+
})
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
```ts
|
|
93
|
+
// src/queue.outbox.ts — the bridge (once per app):
|
|
94
|
+
import { queueOutboxHandler } from '@voltro/plugin-queue'
|
|
95
|
+
export default queueOutboxHandler()
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
The outbox runner delivers after commit — batched (`messages` is an array →
|
|
99
|
+
one transport round-trip), at-least-once, retried, dead-lettered. One
|
|
100
|
+
durability path: the existing outbox, not a second one. For fire-and-forget
|
|
101
|
+
producing without a surrounding write, `yield* QueueService` in a handler and
|
|
102
|
+
call `produce(topic, messages)` directly.
|
|
103
|
+
|
|
104
|
+
Topic creation is EXPLICIT (`provider.ensureTopics([...])`) — your Kafka is
|
|
105
|
+
foreign infrastructure, and whether a client may create topics is your
|
|
106
|
+
policy. A consumer started against a topic that does not exist yet warns and
|
|
107
|
+
retries in the background (it connects once the topic appears), never
|
|
108
|
+
aborting the boot.
|
|
109
|
+
|
|
110
|
+
## cdc-out to Kafka
|
|
111
|
+
|
|
112
|
+
`kafkaSink` plugs table-change mirroring ([plugin-cdc-out](/docs/plugins/cdc-out))
|
|
113
|
+
into the SAME provider: message key = the row id (one row's changes stay
|
|
114
|
+
ordered in one partition), value = the change record, and the
|
|
115
|
+
`x-voltro-delivery-key` header carries cdc-out's at-least-once dedupe handle.
|
|
116
|
+
|
|
117
|
+
```ts
|
|
118
|
+
import { cdcOutPlugin } from '@voltro/plugin-cdc-out'
|
|
119
|
+
import { kafkaSink } from '@voltro/plugin-queue'
|
|
120
|
+
|
|
121
|
+
cdcOutPlugin({ sinks: [{ table: 'orders', sink: kafkaSink({ topic: 'orders.cdc' }) }] })
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
## Observability
|
|
125
|
+
|
|
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.
|
|
@@ -42,7 +42,9 @@ 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 sink (or any custom `CdcSink`) through a durable outbox; ordered per pipe, at-least-once from enqueue, dead-lettered
|
|
45
|
+
- [plugin-cdc-out](/docs/plugins/cdc-out) — declarative reverse-ETL: mirror table changes outward to a webhook or Kafka sink (or any custom `CdcSink`) through a durable outbox; ordered per pipe, at-least-once from enqueue, dead-lettered
|
|
46
|
+
- [plugin-queue](/docs/plugins/queue) — Kafka interop: Schema-decoded consumers (`*.consumer.ts`; at-least-once, serial per partition, retry + dead-letter), batched producing via a service or transactionally through the outbox, and a `kafkaSink` for cdc-out
|
|
47
|
+
- [plugin-comments](/docs/plugins/comments) — comment threads on any app entity: replies, resolve/reopen, tenant-safe @-mentions with notifications, reactions, unread — live over the reactive engine, with the ejectable `<CommentsThread>` UI
|
|
46
48
|
- [plugin-governance](/docs/plugins/governance) — data governance: retention TTL sweep, GDPR export + erasure, consent ledger, field encryption
|
|
47
49
|
- [plugin-openapi](/docs/plugins/openapi) — OpenAPI 3.1 spec + Swagger-UI docs generated from your `defineRestRoute` descriptors and (opt-in) rpc procedures
|
|
48
50
|
- [plugin-row-history](/docs/plugins/row-history) — full row history + time-travel (`rowHistory` / `rowAsOf` / `restoreAsOf` / `diffVersions`); what-changed-to-what on every write
|
|
@@ -74,13 +76,15 @@ Status legend: ✓ shipped · ◐ partial · — planned.
|
|
|
74
76
|
| `@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
77
|
| `@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
78
|
| `@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) |
|
|
79
|
+
| `@voltro/plugin-comments` | ✓ | Comment threads anchored to any entity — fail-closed access delegation (`viaEntity`/`scope`), replies, resolve/reopen, tenant-safe mentions (validated twice, delivered via plugin-notifications incl. digests), reactions, per-subject unread, live `comments.list`, `<CommentsThread>` in `@voltro/ui`. [→ details](/docs/plugins/comments) |
|
|
80
|
+
| `@voltro/plugin-queue` | ✓ | Kafka interop — `defineQueueConsumer` (`*.consumer.ts`, Schema-decoded, at-least-once, serial per partition, retry + DLQ with reason headers), batched producing via `QueueService` or transactionally through the outbox (`queueOutboxHandler`), `kafkaSink` for cdc-out, per-topic counters in the dashboards. [→ details](/docs/plugins/queue) |
|
|
77
81
|
| `@voltro/plugin-auth-social` | ✓ | First-party social login — Sign in with Google / GitHub / Apple with no identity vendor: authorize URL + code exchange + JWKS-verified ID tokens, mandatory PKCE (S256) and `state`, an explicit account-linking policy (`never` by default), Apple's signed-JWT client secret / one-time name / private-relay email all handled; sessions via `issueUserSession`. [→ details](/docs/plugins/auth-social) |
|
|
78
82
|
| `@voltro/plugin-auth-{workos,kinde,clerk,auth0,supabase,oidc}` | ✓ | Six IdP adapters over the shared `jwtBearerStrategy` — JWKS verify + claims→tenant mapping; WorkOS additionally ships hosted-login OAuth primitives (`workosAuthorizationUrl` / `workosAuthenticateWithCode`) for a redirect-based SSO login flow. [→ details](/docs/authentication/external-idp) |
|
|
79
|
-
| `@voltro/plugin-analytics-postgres` | ✓ | First-party lite — events on the main DataStore, cross-dialect (postgres / mysql / mariadb / mssql / sqlite / turso). [→ details](/docs/plugins/analytics
|
|
80
|
-
| `@voltro/plugin-duckdb` | ✓ | Embedded DuckDB sidecar — real OLAP performance, no external service. [→ details](/docs/plugins/analytics
|
|
81
|
-
| `@voltro/plugin-clickhouse` | ✓ | Production OLAP via the official ClickHouse client. [→ details](/docs/plugins/analytics
|
|
82
|
-
| `@voltro/plugin-tinybird` | ✓ | Hosted ClickHouse via Events API + Pipes. [→ details](/docs/plugins/analytics
|
|
83
|
-
| `@voltro/plugin-posthog` | ✓ | Product analytics — track-only; compose with another sink for reads. [→ details](/docs/plugins/analytics
|
|
83
|
+
| `@voltro/plugin-analytics-postgres` | ✓ | First-party lite — events on the main DataStore, cross-dialect (postgres / mysql / mariadb / mssql / sqlite / turso). [→ details](/docs/plugins/analytics) |
|
|
84
|
+
| `@voltro/plugin-duckdb` | ✓ | Embedded DuckDB sidecar — real OLAP performance, no external service. [→ details](/docs/plugins/analytics) |
|
|
85
|
+
| `@voltro/plugin-clickhouse` | ✓ | Production OLAP via the official ClickHouse client. [→ details](/docs/plugins/analytics) |
|
|
86
|
+
| `@voltro/plugin-tinybird` | ✓ | Hosted ClickHouse via Events API + Pipes. [→ details](/docs/plugins/analytics) |
|
|
87
|
+
| `@voltro/plugin-posthog` | ✓ | Product analytics — track-only; compose with another sink for reads. [→ details](/docs/plugins/analytics) |
|
|
84
88
|
| `@voltro/plugin-atlassian` | ✓ | `JiraService` + `ConfluenceService` over the Atlassian REST / Greenhopper / Agile APIs — PAT **or** OAuth 2.0 (3LO) auth, transient retry, SSRF guard, comment-write, signature-verified inbound webhooks, avatar proxy, per-tenant cache. [→ details](/docs/plugins/atlassian) |
|
|
85
89
|
| `@voltro/plugin-deactivation` | ✓ | `deactivation()` schema mixin — `deactivatedAt` + `deactivatedBy` (→ Actor); subject can't log in but data stays visible. [→ details](/docs/plugins/deactivation) |
|
|
86
90
|
| `@voltro/plugin-prometheus` | ✓ | Prometheus exporter — `GET /metrics` in text exposition format over the unified Metrics-API (Effect `MetricRegistry`); counters / histograms / gauges + custom metrics, optional bearer gate + node process metrics. [→ details](/docs/plugins/prometheus) |
|
|
@@ -91,7 +95,7 @@ Status legend: ✓ shipped · ◐ partial · — planned.
|
|
|
91
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) |
|
|
92
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) |
|
|
93
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) |
|
|
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;
|
|
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. |
|
|
95
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) |
|
|
96
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) |
|
|
97
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) |
|
|
@@ -880,7 +884,7 @@ A plugin can mount plain HTTP routes beside the rpc surface (`@voltro/plugin-sto
|
|
|
880
884
|
|
|
881
885
|
- **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
886
|
- **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
|
|
887
|
+
- **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
888
|
- **Buffered responses are compression-negotiated** (brotli/gzip, compressible types only) by the listener — nothing to declare; see [Security → compression](/docs/security/overview).
|
|
885
889
|
|
|
886
890
|
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).
|
|
@@ -1335,11 +1339,11 @@ The contract is deliberately narrow. **No raw SQL, no funnels, no cohorts, no cu
|
|
|
1335
1339
|
|
|
1336
1340
|
| Plugin | Type | Best for | Ceiling |
|
|
1337
1341
|
|---|---|---|---|
|
|
1338
|
-
| [`@voltro/plugin-analytics-postgres`](#
|
|
1339
|
-
| [`@voltro/plugin-duckdb`](#
|
|
1340
|
-
| [`@voltro/plugin-clickhouse`](#
|
|
1341
|
-
| [`@voltro/plugin-tinybird`](#
|
|
1342
|
-
| [`@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 |
|
|
1343
1347
|
|
|
1344
1348
|
## Picking one
|
|
1345
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)
|
|
@@ -225,6 +244,18 @@ const { data } = useSubscription(
|
|
|
225
244
|
)
|
|
226
245
|
```
|
|
227
246
|
|
|
247
|
+
### Offline semantics with the local-first mirror
|
|
248
|
+
|
|
249
|
+
With `@voltro/local-first`'s query mirror bound (see
|
|
250
|
+
[the sync engine](/docs/local-first/overview#the-sync-engine-query-mirror-durable-outbox)),
|
|
251
|
+
`useSubscription`'s behaviour extends offline WITHOUT a second API: a cold
|
|
252
|
+
start seeds `data` (and `revision`) from the device's mirrored rows for the
|
|
253
|
+
subject's partition, so `loading` resolves against local data when the server
|
|
254
|
+
is unreachable; the first live event replaces it, and a reconnect inside the
|
|
255
|
+
resume window continues with deltas from the mirrored revision. Offline
|
|
256
|
+
WRITES ride [`useOutbox`](/docs/ui/client-utilities/use-outbox) — durable
|
|
257
|
+
with `outboxPersistence()`, conflict resolution via `resolveConflict`.
|
|
258
|
+
|
|
228
259
|
## `useMutation(apiName, rpcTag)`
|
|
229
260
|
|
|
230
261
|
Calls a `defineMutation` RPC.
|
|
@@ -592,7 +623,7 @@ window.history.forward() // forward
|
|
|
592
623
|
|
|
593
624
|
## `useBlocker()`
|
|
594
625
|
|
|
595
|
-
Hold a pending navigation so you can prompt before the user leaves — the unsaved-changes guard.
|
|
626
|
+
Hold a pending navigation so you can prompt before the user leaves — the unsaved-changes guard. It guards SPA navigations, the browser's **Back/Forward gestures** (popstate: the router reverts the already-moved URL and offers `retry`/`reset` — this also covers ESC inside an [intercepting-route overlay](/docs/routing/intercepting-routes)), and full-page unloads via `beforeunload`.
|
|
596
627
|
|
|
597
628
|
```tsx
|
|
598
629
|
import { useBlocker } from '@voltro/web'
|
|
@@ -637,7 +668,7 @@ setParams((p) => { p.set('page', '2'); return p }) // patch one param
|
|
|
637
668
|
setParams({ page: '2' }, { push: true }) // distinct history entry
|
|
638
669
|
```
|
|
639
670
|
|
|
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
|
|
671
|
+
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).
|
|
641
672
|
|
|
642
673
|
`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
674
|
|
|
@@ -715,7 +746,7 @@ Precisely, it returns `LoaderData<T>`. For every ordinary loader that IS `T`. Fo
|
|
|
715
746
|
a loader that returned `defer()`, `LoaderData<T>` flattens the two buckets into
|
|
716
747
|
one object — eager fields as values, deferred fields as `Promise<T>` — so the
|
|
717
748
|
compiler tells you which fields have to be rendered through
|
|
718
|
-
[`<Await>`](/docs/routing/loaders-and-meta#deferring-slow-data-defer
|
|
749
|
+
[`<Await>`](/docs/routing/loaders-and-meta#deferring-slow-data-defer):
|
|
719
750
|
|
|
720
751
|
```tsx
|
|
721
752
|
export const loader = async ({ query }) => defer(
|
|
@@ -856,7 +887,7 @@ that must react to router-pushed query changes without a reload re-render throug
|
|
|
856
887
|
|
|
857
888
|
Prefer the typed form where the page declares a `searchParams` schema export —
|
|
858
889
|
`useSearchParams(searchParams)` returns the decoded shape instead of a raw
|
|
859
|
-
`URLSearchParams`. See [Routing hooks](/docs/reference/hooks-routing#usesearchparams
|
|
890
|
+
`URLSearchParams`. See [Routing hooks](/docs/reference/hooks-routing#usesearchparams-usesetsearchparams).
|
|
860
891
|
|
|
861
892
|
## Reading cookies
|
|
862
893
|
|