@voltro/cli 0.52.0 → 0.53.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 +229 -0
- package/THIRD-PARTY-NOTICES.md +8311 -3318
- package/dist/{apiBuild-CSFI8QGq.js → apiBuild-CaPfoWku.js} +11 -5
- package/dist/apiBuild-DHtLXYx9.js +2 -0
- package/dist/bin.js +1 -1
- package/dist/build-D-OnvNMf.js +843 -0
- package/dist/{checkCommand-COmqc2cB.js → checkCommand-C5elt0tW.js} +6 -6
- package/dist/{checkCommand-2SbqzukH.js → checkCommand-D2ZduVlh.js} +1 -1
- package/dist/codegen-BWpt3VgF.js +2 -0
- package/dist/{codegen-VF479Cnb.js → codegen-FEk8AZHb.js} +2 -2
- package/dist/{codegenCommand-DCdG2JN-.js → codegenCommand-BOiWQ5hz.js} +4 -4
- package/dist/{codemodRunner-r7J9lIa7.js → codemodRunner-BjtB2lq6.js} +109 -11
- package/dist/{commands-Cc_nV8WI.js → commands-DyxAmhP0.js} +30 -30
- package/dist/{dashboardCommand-C-gKvwqh.js → dashboardCommand-BdKTyT13.js} +5 -5
- package/dist/{dataCommand-BgpBHnlB.js → dataCommand-Bab9X7s8.js} +2 -2
- package/dist/{dbCommand-sHedr-NJ.js → dbCommand-06O2finM.js} +277 -236
- package/dist/dbCommand-B1EXBC6f.js +2 -0
- package/dist/{dev-CRHoCEiy.js → dev-C6LGF4iY.js} +3006 -2426
- package/dist/{dev--A3nsxA3.js → dev-GjJWAYo2.js} +1 -1
- package/dist/doctorCommand-B0hX0tdz.js +2 -0
- package/dist/{doctorCommand-DtfJ3FA6.js → doctorCommand-etMkflRc.js} +101 -69
- package/dist/{dormancyCommand-Drn7o0No.js → dormancyCommand-UwZ1AZzB.js} +1 -1
- package/dist/{embeddingsCommand-Z-jO1fWN.js → embeddingsCommand-C70zWHwo.js} +1 -1
- package/dist/{envCommand-D4gCrrTZ.js → envCommand-dSyKvRkM.js} +18 -18
- package/dist/{evolveCommand-CMROeKeA.js → evolveCommand-CG0_ebO5.js} +2 -2
- package/dist/fileConventions-DASGEmj-.js +35 -0
- package/dist/{fileTaxonomy-DvDUV9wq.js → fileTaxonomy-B7uxipWS.js} +55 -55
- package/dist/fontPipeline-LxIHa1vo.js +2 -0
- package/dist/fontPipeline-Tsh8kZfA.js +152 -0
- package/dist/frameworkTableAssembly-C_7Z-rMs.js +2 -0
- package/dist/{frameworkTableAssembly-w-XnLa3q.js → frameworkTableAssembly-DKx3ba3S.js} +5 -5
- package/dist/imagePipeline-B_GVJgm6.js +2 -0
- package/dist/imagePipeline-CBZmjT4i.js +127 -0
- package/dist/index.js +1 -1
- package/dist/{infoCommand-DXM868o_.js → infoCommand-_53iOc_j.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-Cn2oboG4.js} +8 -2
- package/dist/{migrate-D0F-eTlK.js → migrate-Cko9rswM.js} +2 -2
- package/dist/{pageConvention-CzUiSbtU.js → pageConvention-C938S8oC.js} +1 -1
- package/dist/{privacyCommand-DGdopOI6.js → privacyCommand-DWTQMC6R.js} +2 -2
- package/dist/{probeCommand-C9gazU0H.js → probeCommand-DkGGLknv.js} +83 -24
- package/dist/{renderModeScan-CcH2X1_D.js → renderModeScan-CUbOeOAg.js} +3 -1
- package/dist/{renderProfile-Ck32Fzxr.js → renderProfile-CskIgAfn.js} +2 -2
- package/dist/{runtimeTrace-BPQyCmC5.js → runtimeTrace-c0APJz7E.js} +1 -1
- package/dist/{sdkgen-Se88ifTd.js → sdkgen-BiQCgIEr.js} +1 -1
- package/dist/{serveCommand-DkP3OT0W.js → serveCommand-CueKQgzl.js} +869 -805
- package/dist/serveCommand-DsnrVN3U.js +2 -0
- package/dist/serveEntry.js +1 -1
- package/dist/start-BJzZLbt8.js +3 -0
- package/dist/start-ekPan8BT.js +1510 -0
- package/dist/startEntry.js +1 -1
- package/dist/{staticCommand-BwNEDlSU.js → staticCommand-xlSL-IWk.js} +1 -1
- package/dist/{test-f3amja6a.js → test-BWPQcRoB.js} +1 -1
- package/dist/updateCommand-Bqql_rsQ.js +2 -0
- package/dist/{updateCommand-BMk2e4ky.js → updateCommand-C_8I8Rzo.js} +139 -115
- package/dist/webDev-C7jWJ5dX.js +2 -0
- package/dist/{webDev-BgWL9gKV.js → webDev-oczpugbx.js} +1455 -922
- package/dist/{webhooksCommand-CoIO3jbj.js → webhooksCommand-4SVPDjKg.js} +1 -1
- package/package.json +60 -19
- package/templates/AGENTS.md +5 -3
- package/templates/agent-docs/_index.md +5 -3
- package/templates/agent-docs/_manifest.json +20 -4
- package/templates/agent-docs/ai.md +2 -2
- package/templates/agent-docs/authentication.md +1 -1
- package/templates/agent-docs/cli.md +2 -2
- package/templates/agent-docs/configuration.md +17 -0
- package/templates/agent-docs/data.md +470 -26
- package/templates/agent-docs/database/advancedqueries.md +7 -7
- package/templates/agent-docs/database/columntypes.md +2 -2
- 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 +1 -1
- package/templates/agent-docs/database/transactions.md +3 -3
- package/templates/agent-docs/deployment.md +8 -0
- package/templates/agent-docs/internationalization.md +2 -2
- package/templates/agent-docs/introduction.md +7 -1
- package/templates/agent-docs/local-first-mobile.md +102 -4
- package/templates/agent-docs/observability.md +2 -0
- package/templates/agent-docs/plugins/auth.md +1 -1
- package/templates/agent-docs/plugins/comments.md +142 -0
- package/templates/agent-docs/plugins/notifications.md +47 -4
- package/templates/agent-docs/plugins/presence.md +16 -3
- package/templates/agent-docs/plugins/prometheus.md +1 -1
- package/templates/agent-docs/plugins/queue.md +129 -0
- package/templates/agent-docs/plugins.md +11 -7
- package/templates/agent-docs/reference.md +16 -4
- package/templates/agent-docs/routing.md +527 -3
- package/templates/agent-docs/schema-driven-ui.md +214 -3
- package/templates/agent-docs/security.md +3 -3
- package/templates/agent-docs/whats-new.md +160 -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/package.json +8 -8
- package/templates/apps/api-data-advanced/package.json +8 -8
- package/templates/apps/api-durable/package.json +8 -8
- package/templates/apps/api-feature-flags/package.json +9 -9
- package/templates/apps/api-governance/package.json +8 -8
- package/templates/apps/api-kv/package.json +8 -8
- package/templates/apps/api-moderation/package.json +8 -8
- package/templates/apps/api-observability/package.json +8 -8
- package/templates/apps/api-ratelimit/package.json +8 -8
- package/templates/apps/api-rbac/package.json +8 -8
- package/templates/apps/api-rest/package.json +7 -7
- package/templates/apps/api-row-history/package.json +8 -8
- package/templates/apps/api-saas/package.json +10 -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 +8 -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/package.json +10 -10
- 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 +8 -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/package.json +6 -6
- 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 +8 -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 +4 -4
- package/dist/apiBuild-BYBpL7Pz.js +0 -2
- package/dist/build-CPgcMQug.js +0 -793
- package/dist/codegen-CctkDO-1.js +0 -2
- 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/serveCommand-DLc-BznW.js +0 -2
- package/dist/start-DfL3fOiN.js +0 -3
- package/dist/start-jw89Xbqy.js +0 -1339
- 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
|
@@ -1055,7 +1055,7 @@ MariaDB's GTID format differs from MySQL's: `0-1-100` (domain-server-sequence) v
|
|
|
1055
1055
|
- **`mariadb` schema package is wire-compatible with `mysql`**. If you migrate from MySQL → MariaDB, the framework re-emits DDL cleanly via `applySchema(..., 'mariadb')`. Production data round-trips through `mysqldump` without translation.
|
|
1056
1056
|
- **`sql_mode=NO_BACKSLASH_ESCAPES`** is sometimes set on MariaDB deploys. The framework's identifier escaping handles it, but user-written `unsafe()` strings that hand-escape backslashes may produce wrong output. Leave that mode off if you can.
|
|
1057
1057
|
- **Sequence-based ID columns**. MariaDB has true CREATE SEQUENCE; the framework doesn't use it (TypeID / ULID / Snowflake are client-side). If you reach for sequences for legacy reasons, they're outside the framework's auto-injection path.
|
|
1058
|
-
- **Hand-rolled `AUTO_INCREMENT` primary keys** work the same as on mysql: an `insert` / `insertMany` with no client-side `id` recovers the DB-generated id via `LAST_INSERT_ID()` (connection-pinned; `insertMany` recovers the whole consecutive range). See the [mysql page](/docs/database/dialects/mysql
|
|
1058
|
+
- **Hand-rolled `AUTO_INCREMENT` primary keys** work the same as on mysql: an `insert` / `insertMany` with no client-side `id` recovers the DB-generated id via `LAST_INSERT_ID()` (connection-pinned; `insertMany` recovers the whole consecutive range). See the [mysql page](/docs/database/dialects/mysql) for the worked example — the recovery path is identical on both engines.
|
|
1059
1059
|
|
|
1060
1060
|
## Where it lives
|
|
1061
1061
|
|
|
@@ -261,7 +261,7 @@ await ctx.store.upsert('orgSlugs', {
|
|
|
261
261
|
```
|
|
262
262
|
|
|
263
263
|
Requires a composite UNIQUE constraint on the table — declare it via
|
|
264
|
-
`.unique([cols])` in the schema (see [Indexes](/docs/database/indexes#composite-unique)).
|
|
264
|
+
`.unique([cols])` in the schema (see [Indexes](/docs/database/indexes#composite-unique-constraints)).
|
|
265
265
|
|
|
266
266
|
## `insertIgnore` — keep existing on conflict
|
|
267
267
|
|
|
@@ -314,7 +314,7 @@ const count = await ctx.store.updateMany('posts', { hidden: true }, {
|
|
|
314
314
|
// count: number of rows actually updated
|
|
315
315
|
```
|
|
316
316
|
|
|
317
|
-
The `where` predicate is a regular [Predicate](/docs/database/query-builder
|
|
317
|
+
The `where` predicate is a regular [Predicate](/docs/database/query-builder)
|
|
318
318
|
AST — same shape `.where()` uses. Sub-queries via `inSubquery` /
|
|
319
319
|
`exists` are supported.
|
|
320
320
|
|
|
@@ -395,5 +395,5 @@ matters.
|
|
|
395
395
|
`aggregate()` for read-side bulk reads
|
|
396
396
|
- [Sub-queries](/docs/database/sub-queries) — `inSubquery` /
|
|
397
397
|
`exists` in `updateMany` `where:` clauses
|
|
398
|
-
- [Composite UNIQUE](/docs/database/indexes#composite-unique) — for
|
|
398
|
+
- [Composite UNIQUE](/docs/database/indexes#composite-unique-constraints) — for
|
|
399
399
|
the constraint that backs `upsert`'s `conflictColumns: ['a', 'b']`
|
|
@@ -1119,6 +1119,14 @@ VOLTRO_LOG_FORMAT=json
|
|
|
1119
1119
|
VOLTRO_LOG_LEVEL=info
|
|
1120
1120
|
```
|
|
1121
1121
|
|
|
1122
|
+
In `json` mode (the default off a TTY, so a pod gets it without configuration)
|
|
1123
|
+
EVERY line the framework emits is a parseable record — the boot banner, the app
|
|
1124
|
+
surface, `voltro db apply`'s plan summary, refusal detail, and every subsystem
|
|
1125
|
+
logger (schedule, broadcast, workflow, flow-control). A hand-formatted table or
|
|
1126
|
+
a `[tag]`-prefixed adapter line between JSON records is a bug, not a style: log
|
|
1127
|
+
collectors show it as unparsed noise. On a TTY the same surfaces render as the
|
|
1128
|
+
human-readable banners and tables.
|
|
1129
|
+
|
|
1122
1130
|
**Error reporting** — add `sentryPlugin()` from `@voltro/plugin-sentry`. It stays inert until `SENTRY_DSN` is set, and reported errors correlate to the request `traceId`:
|
|
1123
1131
|
|
|
1124
1132
|
```ts
|
|
@@ -96,7 +96,7 @@ The client **adopts what the server resolved**, reading it from the `<html lang>
|
|
|
96
96
|
| `data-voltro-tz` | the IANA zone every date/time formatter renders in — set `timeZone` in `app.config.ts` |
|
|
97
97
|
| `data-voltro-now` | the server's render instant, so `useRelativeTime` produces the same string in the hydration pass |
|
|
98
98
|
|
|
99
|
-
Locale was already agreed; the zone and the clock were each read from the ambient runtime, which meant a server-rendered timestamp was a hydration mismatch waiting for a wide enough offset or a slow enough connection. See [Plurals & formatting → Timezones under SSR](/docs/i18n/formatting#timezones-under-ssr
|
|
99
|
+
Locale was already agreed; the zone and the clock were each read from the ambient runtime, which meant a server-rendered timestamp was a hydration mismatch waiting for a wide enough offset or a slow enough connection. See [Plurals & formatting → Timezones under SSR](/docs/i18n/formatting#timezones-under-ssr-the-setting-that-is-not-a-preference) — that is the page to read before you migrate hand-rolled `toLocaleString()` calls onto the hooks.
|
|
100
100
|
|
|
101
101
|
See [Catalogs](/docs/i18n/catalogs) for the type-safe catalog convention and the component hooks, [Plurals & formatting](/docs/i18n/formatting) for CLDR plural selection and the `Intl`-backed date / number / relative-time hooks, and [URL strategies](/docs/i18n/url-strategies) for cookie-only vs URL-prefix routing.
|
|
102
102
|
|
|
@@ -951,7 +951,7 @@ default, so call sites don't handle a missing-service error.
|
|
|
951
951
|
> request, publishes it on the document, and every `@voltro/i18n` formatter on
|
|
952
952
|
> both sides of the hydration boundary uses it. Read it with `useTimeZone()`
|
|
953
953
|
> from `@voltro/i18n`. See
|
|
954
|
-
> [Formatting → Timezones under SSR](/docs/i18n/formatting#timezones-under-ssr
|
|
954
|
+
> [Formatting → Timezones under SSR](/docs/i18n/formatting#timezones-under-ssr-the-setting-that-is-not-a-preference).
|
|
955
955
|
>
|
|
956
956
|
> The **server-side compute zone** — what `startOfDay` or a workflow's "same
|
|
957
957
|
> time tomorrow" resolves against inside a handler — is still the SEAM only.
|
|
@@ -505,7 +505,7 @@ export default defineWebSocket({
|
|
|
505
505
|
})
|
|
506
506
|
```
|
|
507
507
|
|
|
508
|
-
`auth: 'subject'` authenticates through the same chain as rpc/SSR **before** the upgrade (401 while it is still http) and binds the connection to the credential's expiry (close code `4001`); every gateway path is origin-checked at upgrade. Two gateways on one path refuse the boot; a plain GET on a gateway path answers `426`. App realtime stays [subscriptions](/docs/data/subscriptions) — full detail under [Raw WebSocket gateways](/docs/data/subscriptions#raw-websocket-gateways
|
|
508
|
+
`auth: 'subject'` authenticates through the same chain as rpc/SSR **before** the upgrade (401 while it is still http) and binds the connection to the credential's expiry (close code `4001`); every gateway path is origin-checked at upgrade. Two gateways on one path refuse the boot; a plain GET on a gateway path answers `426`. App realtime stays [subscriptions](/docs/data/subscriptions) — full detail under [Raw WebSocket gateways](/docs/data/subscriptions#raw-websocket-gateways-definewebsocket).
|
|
509
509
|
|
|
510
510
|
## The web side (`apps/*/web/`)
|
|
511
511
|
|
|
@@ -518,6 +518,10 @@ export default defineWebSocket({
|
|
|
518
518
|
| `src/pages/loading.tsx` | Pending UI shown while loaders resolve. |
|
|
519
519
|
| `src/pages/(group)/` | Route group — does not contribute a URL segment, but layout/error files inside still apply. |
|
|
520
520
|
| `src/*.island.tsx` | A client-side hydration island (split chunk). Used in `interactive: 'islands'` pages. |
|
|
521
|
+
| `src/**/*.collection.ts` | A content collection declaration (`defineCollection`) — schema-typed markdown/JSON under `content/<name>/**`. See [Content collections](/docs/data/content-collections). |
|
|
522
|
+
| `src/**/*.consumer.ts` | A queue consumer (`defineQueueConsumer`, @voltro/plugin-queue) — Schema-decoded, at-least-once, serial per partition. See [Queue](/docs/plugins/queue). |
|
|
523
|
+
| `grpc.manifest.json` | The gRPC field-number manifest (checked in — append-only wire identity; deletes go `reserved`). See [gRPC surface](/docs/data/grpc). |
|
|
524
|
+
| `content/<name>/**` | A collection's content files (markdown with frontmatter, or `.json` for data collections). Read by `getCollection`/`getEntry`. |
|
|
521
525
|
|
|
522
526
|
Each page can opt into a render strategy via two exports:
|
|
523
527
|
|
|
@@ -599,6 +603,8 @@ If yes, the promise belongs in the name — you cannot see a contract before you
|
|
|
599
603
|
| `*.tracking.ts` | analytics happens nowhere else | `tracking/outside-tracking-file` |
|
|
600
604
|
| `*.client.ts` | it and its imports are browser-safe | boot-time import walk, `client/not-browser-safe` |
|
|
601
605
|
| `*.store.ts` | exactly one `defineStore`, no server state | `store/one-per-file`, `store/mirrors-server-state` |
|
|
606
|
+
| `*.collection.ts` | declares content collections (`defineCollection`); frontmatter schema violations fail the build naming the file | the build's collection decode + reference validation |
|
|
607
|
+
| `*.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) |
|
|
602
608
|
|
|
603
609
|
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`.
|
|
604
610
|
|
|
@@ -29,10 +29,11 @@ imports it directly. The React wrappers live behind `@voltro/local-first/react`
|
|
|
29
29
|
> [`localFirst` table mixin](#the-localfirst-table-mixin). What remains falls in
|
|
30
30
|
> two tiers: a thin [runtime binding](#whats-shipped-vs-a-runtime-seam) to
|
|
31
31
|
> provisioned infra (a broker at scale) plus the two app-specific tags
|
|
32
|
-
> `useCrdtText` is pointed at — and the sync **engine
|
|
33
|
-
>
|
|
34
|
-
>
|
|
35
|
-
>
|
|
32
|
+
> `useCrdtText` is pointed at — and the sync **engine**, which is now BUILT:
|
|
33
|
+
> the [query mirror](#the-sync-engine-query-mirror--durable-outbox) persists
|
|
34
|
+
> every subscribed query's rows per subject+tenant partition, `useOutbox`
|
|
35
|
+
> queues offline writes durably, and a reload renders mirrored rows offline
|
|
36
|
+
> and delta-resumes online.
|
|
36
37
|
|
|
37
38
|
## CRDT text: `crdtText` + `mergeCrdtStates`
|
|
38
39
|
|
|
@@ -315,6 +316,103 @@ const adapter = await createIndexedDbPersistence({ databaseName: 'my-app' })
|
|
|
315
316
|
const sync = createSyncClient({ transport, adapter }) // state now survives reload
|
|
316
317
|
```
|
|
317
318
|
|
|
319
|
+
## Rich text: `crdtDoc()` + `useCrdtEditor`
|
|
320
|
+
|
|
321
|
+
`crdtDoc()` stores a WHOLE collaborative document (rich text, maps, arrays)
|
|
322
|
+
as a column — same storage and authoritative server merge as `crdtText()`,
|
|
323
|
+
which stays as the plain-text specialisation. The editor binding is Tiptap
|
|
324
|
+
(`@voltro/local-first/editor`, optional peers): `useCrdtEditor({ doc })`
|
|
325
|
+
binds one editor to the document handle; content edits ride the app's
|
|
326
|
+
mutation (folded server-side under a per-row mutex) and remote edits arrive
|
|
327
|
+
as incremental `mergeCells` subscription deltas — a one-character edit ships
|
|
328
|
+
under 1 KB in BOTH directions regardless of document size.
|
|
329
|
+
|
|
330
|
+
Carets ride a `delivery: 'latest'` EVENT, deliberately not presence
|
|
331
|
+
metadata: the roster's value-compare push would make every caret move a
|
|
332
|
+
"real" change. `attachAwarenessBridge` publishes one member's state per
|
|
333
|
+
envelope (never the aggregated room). Stable positions for inline comments
|
|
334
|
+
come from `encodeAnchor`/`resolveAnchor` on the doc handle.
|
|
335
|
+
|
|
336
|
+
Operational rules: the stored blob soft-compacts past
|
|
337
|
+
`VOLTRO_CRDT_COMPACT_MAX_BYTES` (default 512 KiB) without breaking the merge
|
|
338
|
+
lineage; `rebaseText` is the explicit hard reset (a NEW EPOCH — subscribers
|
|
339
|
+
receive it as a fresh snapshot). CRDT columns are excluded from undo capture
|
|
340
|
+
and row history (document history = named snapshots taken BEFORE
|
|
341
|
+
compaction); `.serverOnly()` on a CRDT column is a declaration error and
|
|
342
|
+
`.encrypted()` makes it online-only.
|
|
343
|
+
|
|
344
|
+
## The sync engine: query mirror + durable outbox
|
|
345
|
+
|
|
346
|
+
The engine's two halves ride the primitives you already use — there is no
|
|
347
|
+
second data API:
|
|
348
|
+
|
|
349
|
+
- **Reads.** The subscription cache accepts a `mirror`; every base movement of
|
|
350
|
+
every subscribed query persists (rows + revision) into a **subject+tenant
|
|
351
|
+
partitioned** store over IndexedDB, and a cold start seeds from it — the UI
|
|
352
|
+
renders the last materialised rows offline through the SAME
|
|
353
|
+
`useSubscription` call, and the next connect presents the mirrored revision
|
|
354
|
+
as `voltro-resume-from`, so a reload inside the resume window continues with
|
|
355
|
+
deltas instead of a snapshot.
|
|
356
|
+
- **Writes.** `useOutbox` with `persistence: outboxPersistence(adapter)` is
|
|
357
|
+
the durable offline queue: writes survive a reload, replay in order on
|
|
358
|
+
reconnect, stop at the first conflict, and a conflict resolves through
|
|
359
|
+
`resolveConflict(id, resolveWithPolicy(policy, local, remote, { crdtColumns }))`
|
|
360
|
+
— `crdtText()` columns merge, scalars follow the declared `conflictPolicy()`.
|
|
361
|
+
One drain per device even with many tabs (`withDrainLock`, a per-partition
|
|
362
|
+
Web Lock).
|
|
363
|
+
|
|
364
|
+
```ts
|
|
365
|
+
import {
|
|
366
|
+
createDurableKv, createQueryMirror, createSubscriptionMirrorBinding,
|
|
367
|
+
} from '@voltro/local-first'
|
|
368
|
+
import { localFirstTables } from './.framework/localFirst.generated'
|
|
369
|
+
|
|
370
|
+
const { kv, durability } = await createDurableKv() // 'memory' = visible degradation
|
|
371
|
+
const mirror = createQueryMirror(kv, { subjectId, tenantId }) // ONE partition per subject
|
|
372
|
+
const binding = createSubscriptionMirrorBinding(mirror, {
|
|
373
|
+
tags: { 'docs.list': 'docs' }, // the app's sync set
|
|
374
|
+
metadata: localFirstTables, // codegen: encrypted columns stripped
|
|
375
|
+
schemaFingerprint: BUILD_ID, // local-DB migration gate
|
|
376
|
+
})
|
|
377
|
+
```
|
|
378
|
+
|
|
379
|
+
The deliberate design decision, recorded here because the obvious alternative
|
|
380
|
+
keeps being suggested: the engine is **not** a browser SQL database. The
|
|
381
|
+
client's whole query surface is `(tag, input)` — predicates are built and
|
|
382
|
+
evaluated on the server — so a wa-sqlite instance would evaluate a language
|
|
383
|
+
the client never sees. What offline needs is the last materialised answer per
|
|
384
|
+
query the user visited, kept current by deltas; that is what the mirror
|
|
385
|
+
stores. (The `KvStore` seam still admits a SQLite backing without touching a
|
|
386
|
+
consumer.)
|
|
387
|
+
|
|
388
|
+
Soundness rules, all enforced structurally and tested:
|
|
389
|
+
|
|
390
|
+
- **Partition by key.** Every stored key carries subject AND tenant; a
|
|
391
|
+
logout/login as somebody else can never read the predecessor's rows, and
|
|
392
|
+
`purge()` empties exactly one partition on revocation. Build ONE binding per
|
|
393
|
+
resolved subject and rebuild it on an auth change — the same blank-on-auth
|
|
394
|
+
doctrine the subscription cache itself follows.
|
|
395
|
+
- **`.encrypted()` never lands.** The server decrypts on read, so a naive
|
|
396
|
+
mirror would persist plaintext on the device; the codegen-emitted
|
|
397
|
+
`localFirst.generated.ts` names those columns and the binding strips them
|
|
398
|
+
before every save. `.serverOnly()` columns never reach the wire at all.
|
|
399
|
+
- **The snapshot is the visible state.** A save replaces the mirrored row
|
|
400
|
+
set, so a row the server stopped sending (revoked share, RLS change, soft
|
|
401
|
+
delete) is evicted by construction.
|
|
402
|
+
- **Schema migration is a visible cold start.** Entries persist under the
|
|
403
|
+
build's `schemaFingerprint`; a new build's load misses them and the query
|
|
404
|
+
falls back to loading → fresh snapshot — never a mixed-shape render. The
|
|
405
|
+
offline queue deliberately does NOT gate on it: a queued old-shape write
|
|
406
|
+
replays against the new server, whose input schema is the authority, and a
|
|
407
|
+
rejection surfaces as a visible conflict instead of silently dropped work.
|
|
408
|
+
- **Shapes are your queries.** There is no separate replication-shape
|
|
409
|
+
language: what is mirrored is exactly what the app subscribes to, so tenant
|
|
410
|
+
scoping, guards and `setRowFilter` apply server-side, fail-closed, exactly
|
|
411
|
+
as online — including parent-relative predicates ("tasks where projectId is
|
|
412
|
+
one of my projects"), which are just queries. Mirroring is per QUERY, so a
|
|
413
|
+
subgraph is N queries, not one nested shape. A storage budget is enforced
|
|
414
|
+
with `enforceBudget(maxBytes)` — oldest-saved entries evict first.
|
|
415
|
+
|
|
318
416
|
## Conflict policy for non-CRDT fields
|
|
319
417
|
|
|
320
418
|
CRDT fields resolve themselves — the merge **is** the resolver. A plain scalar
|
|
@@ -73,6 +73,8 @@ Even with no exporter, `voltro dev` installs a **buffer-only** tracer — that's
|
|
|
73
73
|
|
|
74
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:
|
|
75
75
|
|
|
76
|
+
The **web server** additionally counts [partial prerendering](/docs/routing/render-modes#partial-prerendering-ppr--cached-shell--per-request-holes): shell serves, hole passes/settles/errors, and last/max hole latency. 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
|
+
|
|
76
78
|
```bash
|
|
77
79
|
# OTLP metrics — the SAME OTEL endpoint that enables trace export also enables
|
|
78
80
|
# metrics. Ships to any OTLP/HTTP collector (Prometheus OTLP, Grafana Agent,
|
|
@@ -333,7 +333,7 @@ authRoutesPlugin({
|
|
|
333
333
|
})
|
|
334
334
|
```
|
|
335
335
|
|
|
336
|
-
The canonical guard ships in `@voltro/plugin-deactivation`: `deactivationGuard()` refuses login when the user's `deactivatedAt` is set — making the `deactivation()` mixin's "a deactivated user can't log in" promise self-enforcing without a hand-rolled resolver check. See [deactivation](/docs/plugins/deactivation
|
|
336
|
+
The canonical guard ships in `@voltro/plugin-deactivation`: `deactivationGuard()` refuses login when the user's `deactivatedAt` is set — making the `deactivation()` mixin's "a deactivated user can't log in" promise self-enforcing without a hand-rolled resolver check. See [deactivation](/docs/plugins/deactivation). With no guards configured, every authenticated user proceeds exactly as before.
|
|
337
337
|
|
|
338
338
|
## Rehash-on-verify
|
|
339
339
|
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# Comments
|
|
2
|
+
|
|
3
|
+
> Comment threads on any app entity — replies, resolve/reopen, @-mentions with notifications, reactions, unread counters — live over the reactive engine, with an ejectable thread UI.
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
<!-- source: en/plugins/comments.md -->
|
|
10
|
+
## Comments
|
|
11
|
+
|
|
12
|
+
_Comment threads on any app entity — replies, resolve/reopen, @-mentions with notifications, reactions, unread counters — live over the reactive engine, with an ejectable thread UI._
|
|
13
|
+
|
|
14
|
+
`@voltro/plugin-comments` hangs discussion threads on anything your app can
|
|
15
|
+
name — an order, a document, a row, a section anchor — and keeps every open
|
|
16
|
+
view LIVE: a second client sees a new comment without a reload, because
|
|
17
|
+
`comments.list` declares the plugin's reactivity channel as its `source:` and
|
|
18
|
+
every write publishes it. No second push mechanism, no vendor websocket.
|
|
19
|
+
|
|
20
|
+
```ts
|
|
21
|
+
// app.config.ts
|
|
22
|
+
import { commentsPlugin } from '@voltro/plugin-comments'
|
|
23
|
+
|
|
24
|
+
export default {
|
|
25
|
+
// …
|
|
26
|
+
plugins: [
|
|
27
|
+
commentsPlugin({
|
|
28
|
+
access: {
|
|
29
|
+
viaEntity: async ({ anchor, subject, store }) => {
|
|
30
|
+
// Resolve the anchor to YOUR entity and answer with YOUR rules —
|
|
31
|
+
// the same guard your queries use. A row the guard cannot read
|
|
32
|
+
// (including a soft-deleted one) is a refusal.
|
|
33
|
+
const [table, rowId] = anchor.split(':')
|
|
34
|
+
if (table !== 'orders' || store === undefined) return false
|
|
35
|
+
const rows = await store.query({
|
|
36
|
+
table: 'orders', predicate: eq('id', rowId!),
|
|
37
|
+
order: [], take: 1, skip: undefined, projection: undefined,
|
|
38
|
+
})
|
|
39
|
+
return rows.length > 0
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
resolveMentions: async ({ query, subject }) =>
|
|
43
|
+
searchTeamMembers(query, subject.tenantId),
|
|
44
|
+
}),
|
|
45
|
+
],
|
|
46
|
+
}
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Access follows the anchor — fail-closed
|
|
50
|
+
|
|
51
|
+
Only your app knows who may see the entity a thread hangs on. The plugin
|
|
52
|
+
therefore takes a declared rule and REFUSES every read and write when none is
|
|
53
|
+
declared — a comments surface with no access rule serves nobody rather than
|
|
54
|
+
everybody:
|
|
55
|
+
|
|
56
|
+
- **`access.viaEntity`** — guard delegation (above). Receives the anchor, the
|
|
57
|
+
calling subject and the bound store.
|
|
58
|
+
- **`access.scope`** — one scope every comment reader/writer must hold, for
|
|
59
|
+
team-internal comment surfaces.
|
|
60
|
+
|
|
61
|
+
A **soft-deleted anchor** is the same door: your guard cannot approve what it
|
|
62
|
+
cannot read, so the whole thread answers `CommentAccessRefused` — an inbox
|
|
63
|
+
notification that still points at it finds "no longer available", not a leak.
|
|
64
|
+
|
|
65
|
+
## The live thread UI
|
|
66
|
+
|
|
67
|
+
```tsx
|
|
68
|
+
import { CommentsThread } from '@voltro/ui'
|
|
69
|
+
|
|
70
|
+
const OrderPage = ({ order }) => <CommentsThread anchor={`orders:${order.id}`} />
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Try it — this is the real plugin against this docs site's demo backend. Open
|
|
74
|
+
this page in a **second tab**: a comment typed in one appears in the other
|
|
75
|
+
without a reload (tabs share your per-browser visitor identity; other
|
|
76
|
+
visitors' threads are isolated):
|
|
77
|
+
|
|
78
|
+
```tsx
|
|
79
|
+
import { CommentsThread } from '@voltro/ui'
|
|
80
|
+
|
|
81
|
+
<CommentsThread anchor="demo:comments" />
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
`<CommentsThread>` is deliberately unstyled (semantic markup + `data-*`
|
|
85
|
+
hooks) and ejectable; underneath it is `useComments(anchor)`:
|
|
86
|
+
|
|
87
|
+
```tsx
|
|
88
|
+
import { useComments } from '@voltro/plugin-comments/web'
|
|
89
|
+
|
|
90
|
+
const { threads, unreadCount, create, resolve, react, markRead } = useComments(`orders:${id}`)
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## Mentions are tenant-safe by construction
|
|
94
|
+
|
|
95
|
+
The `resolveMentions` seam RECEIVES the calling subject — the signature makes
|
|
96
|
+
forgetting impossible — and the plugin re-filters whatever your resolver
|
|
97
|
+
returns to the caller's tenant (opt out with `crossTenant: true` for
|
|
98
|
+
single-tenant apps). Mentions are ALSO re-validated at create time against the
|
|
99
|
+
same resolver, so a hand-crafted mention on a foreign tenant is dropped, not
|
|
100
|
+
delivered: the `@`-autocomplete cannot leak existence or names across the
|
|
101
|
+
boundary, and no notification ever crosses it.
|
|
102
|
+
|
|
103
|
+
A validated mention delivers through
|
|
104
|
+
[`plugin-notifications`](/docs/plugins/notifications) when it is configured —
|
|
105
|
+
recipient preferences, quiet hours and digests apply (ten mentions inside a
|
|
106
|
+
digest window roll into ONE delivery). Without the notifications plugin the
|
|
107
|
+
mention still renders in the thread; the push half is simply absent (a log
|
|
108
|
+
note, never an error).
|
|
109
|
+
|
|
110
|
+
## Moderation is opt-in, honestly
|
|
111
|
+
|
|
112
|
+
Nothing is filtered automatically. To moderate comment bodies, add one rule to
|
|
113
|
+
[`plugin-moderation`](/docs/plugins/moderation):
|
|
114
|
+
|
|
115
|
+
```ts
|
|
116
|
+
moderationPlugin({ rules: [{ match: /^comments\./, fields: ['body'] }] })
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Deleting others' comments takes the `comments:moderate` scope; editing is
|
|
120
|
+
always author-only.
|
|
121
|
+
|
|
122
|
+
## What else ships
|
|
123
|
+
|
|
124
|
+
- **Reactions** — per-emoji toggle, aggregated with `count` + `mine`, in the
|
|
125
|
+
live delta.
|
|
126
|
+
- **Thread unread** — a per-subject read marker (`markRead`); `useComments`
|
|
127
|
+
returns `unreadCount` (your own comments are never unread for you).
|
|
128
|
+
- **Resolve / reopen** — anyone who may read the anchor may resolve (the
|
|
129
|
+
Liveblocks semantic).
|
|
130
|
+
- **Attachments** are a declared limit: grant an upload via
|
|
131
|
+
[`plugin-storage`](/docs/plugins/storage) and put the URL in the body —
|
|
132
|
+
first-class `attachments[]` is deliberately not built until the storage
|
|
133
|
+
grant flow is the proven shape.
|
|
134
|
+
- **Known reactivity granularity:** the live feed is channel-wide — every
|
|
135
|
+
comment write re-runs every open `comments.list` subscription app-wide.
|
|
136
|
+
Fine for team-scale commenting; the read-set work on the realtime roadmap
|
|
137
|
+
is the named narrowing.
|
|
138
|
+
|
|
139
|
+
## Observability
|
|
140
|
+
|
|
141
|
+
`GET /_voltro/inspect/plugins/comments/threads` + a Comments panel in both
|
|
142
|
+
dashboards (volume, open/resolved, recent threads).
|
|
@@ -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,21 @@ 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
|
+
resolveMember: async ({ subject }) => {
|
|
57
|
+
const user = await users.byId((subject as { id: string }).id)
|
|
58
|
+
return user ? { userName: user.name, avatarUrl: user.avatarUrl } : undefined
|
|
59
|
+
},
|
|
60
|
+
})
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
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
|
+
|
|
52
65
|
> **There is a second `usePresence`, and it is a different hook.**
|
|
53
|
-
> [`@voltro/local-first/react`](/docs/local-first/overview#presence
|
|
66
|
+
> [`@voltro/local-first/react`](/docs/local-first/overview#presence-awareness)
|
|
54
67
|
> exports one for peer-to-peer *awareness* — `usePresence(roomId, self, { channel })`
|
|
55
68
|
> → `{ presence, others, setPresence }` — carrying high-frequency cursor and
|
|
56
69
|
> selection state over a pub/sub channel. This one is the server-backed roster.
|
|
@@ -20,7 +20,7 @@ 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
26
|
- **`@voltro/cache`** counters, Effect's own `effect_fiber_*` runtime metrics, and **any custom metric** you or another plugin defines.
|