@voltro/cli 0.51.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 +356 -0
- package/THIRD-PARTY-NOTICES.md +8311 -3318
- package/dist/{agentsMd-D6yD7IQv.js → agentsMd-0l980yhL.js} +4 -1
- package/dist/agentsMd-SDDSkyl4.js +2 -0
- package/dist/{apiBuild-CPDHXF72.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-DNuPiWMc.js → checkCommand-C5elt0tW.js} +92 -46
- package/dist/checkCommand-D2ZduVlh.js +2 -0
- package/dist/{cloudCmd-F4YJeqM3.js → cloudCmd-QUXh-b5w.js} +1 -1
- package/dist/codegen-BWpt3VgF.js +2 -0
- package/dist/{codegen-CrMXs4hb.js → codegen-FEk8AZHb.js} +2 -2
- package/dist/{codegenCommand-BNBHcNNj.js → codegenCommand-BOiWQ5hz.js} +12 -12
- package/dist/{codemodRunner-BDVixlSw.js → codemodRunner-BjtB2lq6.js} +691 -545
- package/dist/{commands-B1OiS9bX.js → commands-DyxAmhP0.js} +36 -36
- package/dist/{dashboardCommand-C-vvPY1B.js → dashboardCommand-BdKTyT13.js} +3 -3
- package/dist/{dataCommand-C1GxXW5q.js → dataCommand-Bab9X7s8.js} +27 -27
- package/dist/{dbCommand-If4Y1xQ-.js → dbCommand-06O2finM.js} +277 -236
- package/dist/dbCommand-B1EXBC6f.js +2 -0
- package/dist/{dev-kdAg9Q7l.js → dev-C6LGF4iY.js} +2998 -2379
- package/dist/dev-GjJWAYo2.js +3 -0
- package/dist/doctorCommand-B0hX0tdz.js +2 -0
- package/dist/{doctorCommand-nKmeW78u.js → doctorCommand-etMkflRc.js} +332 -220
- package/dist/{dormancyCommand-CY3wa_SW.js → dormancyCommand-UwZ1AZzB.js} +1 -1
- package/dist/{embeddingsCommand-BDLIgje_.js → embeddingsCommand-C70zWHwo.js} +1 -1
- package/dist/{envCommand-C6V_xVlT.js → envCommand-dSyKvRkM.js} +15 -15
- package/dist/{evolveCommand-D3c4DSfN.js → evolveCommand-CG0_ebO5.js} +2 -2
- package/dist/fileConventions-DASGEmj-.js +35 -0
- package/dist/{fileTaxonomy-CJfgOllU.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-BwJVEKLr.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-BnRFEF1o.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/inspectMetrics-CGF94puw.js +143 -0
- package/dist/manifestBuild-C4-J1-m_.js +2 -0
- package/dist/{manifestBuild-CuU1VrSm.js → manifestBuild-Cqgsx2bM.js} +1 -1
- package/dist/{metaCommands-CfRLra0s.js → metaCommands-Cn2oboG4.js} +9 -3
- package/dist/{migrate-DehuBakM.js → migrate-Cko9rswM.js} +2 -2
- package/dist/{pageConvention-cEiRxdab.js → pageConvention-C938S8oC.js} +1 -1
- package/dist/{privacyCommand-XejDMvmu.js → privacyCommand-DWTQMC6R.js} +2 -2
- package/dist/{probeCommand-C9gazU0H.js → probeCommand-DkGGLknv.js} +83 -24
- package/dist/{projectScaffold-mIX_DpSe.js → projectScaffold-B4dmTlwT.js} +1 -1
- package/dist/{projectScaffold-BIl97_E6.js → projectScaffold-EzlErR4E.js} +1 -1
- package/dist/{renderModeScan-D7J1B7Kw.js → renderModeScan-CUbOeOAg.js} +28 -11
- package/dist/{renderProfile-1OWWAAtx.js → renderProfile-CskIgAfn.js} +2 -2
- package/dist/{runtimeTrace-ZsBU7Tkx.js → runtimeTrace-c0APJz7E.js} +1 -1
- package/dist/{sdkgen-O4XqWOjM.js → sdkgen-BiQCgIEr.js} +1 -1
- package/dist/serveCommand-CueKQgzl.js +2443 -0
- 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-Dr2M6tpU.js → staticCommand-xlSL-IWk.js} +1 -1
- package/dist/{test-rXFq4S76.js → test-BWPQcRoB.js} +1 -1
- package/dist/updateCommand-Bqql_rsQ.js +2 -0
- package/dist/{updateCommand-Bs322Q78.js → updateCommand-C_8I8Rzo.js} +139 -115
- package/dist/webDev-C7jWJ5dX.js +2 -0
- package/dist/{webDev-B-ubQEMX.js → webDev-oczpugbx.js} +1767 -913
- package/dist/{webhooksCommand-FLYY9IXh.js → webhooksCommand-4SVPDjKg.js} +1 -1
- package/package.json +72 -18
- package/templates/AGENTS.core.md +11 -0
- package/templates/AGENTS.md +19 -6
- package/templates/agent-docs/_index.md +8 -6
- package/templates/agent-docs/_manifest.json +31 -15
- package/templates/agent-docs/ai.md +2 -2
- package/templates/agent-docs/authentication.md +1 -1
- package/templates/agent-docs/cli.md +97 -15
- package/templates/agent-docs/configuration.md +17 -0
- package/templates/agent-docs/data.md +680 -33
- 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 +2 -2
- package/templates/agent-docs/database/seedsdialects.md +2 -2
- package/templates/agent-docs/database/transactions.md +3 -3
- package/templates/agent-docs/deployment.md +30 -3
- package/templates/agent-docs/internationalization.md +2 -2
- package/templates/agent-docs/introduction.md +52 -0
- package/templates/agent-docs/local-first-mobile.md +132 -7
- package/templates/agent-docs/observability.md +2 -0
- package/templates/agent-docs/plugins/ai-flows.md +1 -1
- package/templates/agent-docs/plugins/audit.md +5 -5
- package/templates/agent-docs/plugins/auth.md +1 -1
- package/templates/agent-docs/plugins/cdc-out.md +2 -2
- 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/{versioning.md → row-history.md} +22 -22
- package/templates/agent-docs/plugins/storage.md +2 -2
- package/templates/agent-docs/plugins.md +38 -12
- package/templates/agent-docs/reference.md +54 -5
- package/templates/agent-docs/routing.md +868 -50
- package/templates/agent-docs/schema-driven-ui.md +292 -5
- package/templates/agent-docs/security.md +125 -8
- package/templates/agent-docs/templates/apibackends.md +14 -14
- package/templates/agent-docs/templates/overview.md +1 -1
- package/templates/agent-docs/whats-new.md +171 -54
- package/templates/apps/api-ai/package.json +6 -7
- package/templates/apps/api-ai/tests/summarize.test.ts +1 -1
- package/templates/apps/api-auth/package.json +8 -8
- package/templates/apps/api-backend/package.json +7 -7
- package/templates/apps/api-backend-deactivation/package.json +7 -7
- package/templates/apps/api-backend-mail/package.json +8 -8
- package/templates/apps/api-backend-mariadb/package.json +9 -9
- package/templates/apps/api-backend-sqlite/package.json +8 -8
- package/templates/apps/api-backend-storage/package.json +8 -8
- package/templates/apps/api-cms/package.json +9 -10
- package/templates/apps/api-collab/package.json +8 -8
- package/templates/apps/api-data-advanced/package.json +8 -8
- package/templates/apps/api-durable/package.json +8 -8
- package/templates/apps/api-feature-flags/package.json +9 -9
- package/templates/apps/api-governance/package.json +8 -8
- package/templates/apps/api-kv/package.json +8 -8
- package/templates/apps/api-moderation/package.json +8 -8
- package/templates/apps/api-observability/package.json +8 -8
- package/templates/apps/api-ratelimit/package.json +8 -8
- package/templates/apps/api-rbac/package.json +8 -8
- package/templates/apps/api-rest/package.json +7 -7
- package/templates/apps/{api-versioning → api-row-history}/README.md +3 -3
- package/templates/apps/{api-versioning → api-row-history}/actions/documents.asOf.action.server.ts +1 -1
- package/templates/apps/{api-versioning → api-row-history}/actions/documents.history.action.server.ts +1 -1
- package/templates/apps/{api-versioning → api-row-history}/app.config.ts +3 -3
- package/templates/apps/{api-versioning → api-row-history}/database/schema.ts +1 -1
- package/templates/apps/{api-versioning → api-row-history}/mutations/documents.create.mutation.server.ts +1 -1
- package/templates/apps/{api-versioning → api-row-history}/mutations/documents.create.mutation.ts +1 -1
- package/templates/apps/{api-versioning → api-row-history}/mutations/documents.update.mutation.ts +1 -1
- package/templates/apps/{api-versioning → api-row-history}/package.json +8 -8
- package/templates/apps/api-row-history/template.json +6 -0
- package/templates/apps/{api-versioning → api-row-history}/tests/documents.create.test.ts +1 -1
- package/templates/apps/api-saas/app.config.ts +1 -0
- package/templates/apps/api-saas/package.json +10 -11
- package/templates/apps/api-saas-starter/package.json +10 -10
- package/templates/apps/api-search/package.json +8 -8
- package/templates/apps/api-status/package.json +8 -8
- package/templates/apps/api-webhooks/package.json +9 -9
- package/templates/apps/changelog/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-collab/src/pages/page.test.tsx +10 -9
- package/templates/apps/frontend-collab/src/pages/page.tsx +42 -65
- package/templates/apps/frontend-contact/package.json +7 -7
- package/templates/apps/frontend-dashboard/package.json +7 -7
- package/templates/apps/frontend-docs/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 -7
- 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 -7
- package/templates/apps/frontend-portal/package.json +8 -8
- package/templates/apps/frontend-saas/package.json +8 -8
- package/templates/apps/frontend-spa/package.json +7 -7
- package/templates/apps/frontend-ssr/package.json +7 -7
- package/templates/apps/frontend-ssr-api/package.json +8 -8
- package/templates/apps/frontend-static-blog/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/agentsMd-Bu_XQgVf.js +0 -2
- package/dist/apiBuild-GDKuGOMV.js +0 -2
- package/dist/build-DETLZAFt.js +0 -752
- package/dist/checkCommand-CWcnDArJ.js +0 -2
- package/dist/codegen-DiMn2KkZ.js +0 -2
- package/dist/dbCommand-C27HIsGE.js +0 -2
- package/dist/dev-CK522MV5.js +0 -3
- package/dist/doctorCommand-BK4l18eG.js +0 -2
- package/dist/fileConventions-Cof68_BL.js +0 -33
- package/dist/frameworkTableAssembly-CVDB2hCq.js +0 -2
- package/dist/inspect-CuGDYES0.js +0 -2
- package/dist/inspectMetrics-CfdKLh6t.js +0 -72
- package/dist/manifestBuild-CPjhvM62.js +0 -2
- package/dist/serveCommand-BRnPCxVd.js +0 -2
- package/dist/serveCommand-DdiYNBBu.js +0 -2362
- package/dist/start-BLNmWkLa.js +0 -1154
- package/dist/start-Dzicuyw8.js +0 -3
- package/dist/updateCommand-eXB35SEv.js +0 -2
- package/dist/webDev-DposiF3j.js +0 -2
- package/templates/apps/api-versioning/template.json +0 -6
- 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
- /package/templates/apps/{api-versioning → api-row-history}/actions/documents.asOf.action.ts +0 -0
- /package/templates/apps/{api-versioning → api-row-history}/actions/documents.history.action.ts +0 -0
- /package/templates/apps/{api-versioning → api-row-history}/mutations/documents.update.mutation.server.ts +0 -0
- /package/templates/apps/{api-versioning → api-row-history}/tsconfig.json +0 -0
|
@@ -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.
|
|
@@ -0,0 +1,129 @@
|
|
|
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
|
+
Per-topic counters (consumed / retried / dead-lettered / produced + the last
|
|
127
|
+
error) on `GET /_voltro/inspect/plugins/queue/consumers` and in the
|
|
128
|
+
dashboards' Queue panel. Message headers carry `traceparent` through to
|
|
129
|
+
`ctx.traceparent` for cross-system trace continuity.
|
|
@@ -1,27 +1,27 @@
|
|
|
1
|
-
# Row
|
|
1
|
+
# Row history
|
|
2
2
|
|
|
3
|
-
> Full row history + time-travel. audit() records who/when;
|
|
3
|
+
> Full row history + time-travel. audit() records who/when; row-history records what-changed-to-what — a value snapshot of every row on every write, with as-of queries.
|
|
4
4
|
|
|
5
5
|
|
|
6
6
|
|
|
7
7
|
---
|
|
8
8
|
|
|
9
|
-
<!-- source: en/plugins/
|
|
10
|
-
## Row
|
|
9
|
+
<!-- source: en/plugins/row-history.md -->
|
|
10
|
+
## Row history
|
|
11
11
|
|
|
12
|
-
_Full row history + time-travel. audit() records who/when;
|
|
12
|
+
_Full row history + time-travel. audit() records who/when; row-history records what-changed-to-what — a value snapshot of every row on every write, with as-of queries._
|
|
13
13
|
|
|
14
|
-
`@voltro/plugin-
|
|
14
|
+
`@voltro/plugin-row-history` keeps a complete value history of selected tables. Where [`audit()`](/docs/plugins/audit) records *who* changed a row and *when*, row-history records *what* — a full snapshot of the row on every insert / update / delete — and lets you read any row **as of** a past instant. It rides the framework's post-commit ChangeEvent tap, so it captures every write that goes through the store with no per-handler wiring.
|
|
15
15
|
|
|
16
16
|
## Wiring
|
|
17
17
|
|
|
18
18
|
```ts
|
|
19
19
|
// app.config.ts
|
|
20
|
-
import {
|
|
20
|
+
import { rowHistoryPlugin } from '@voltro/plugin-row-history'
|
|
21
21
|
|
|
22
22
|
export default {
|
|
23
23
|
type: 'api' as const, name: 'api',
|
|
24
|
-
plugins: [
|
|
24
|
+
plugins: [rowHistoryPlugin({})],
|
|
25
25
|
}
|
|
26
26
|
```
|
|
27
27
|
|
|
@@ -30,14 +30,14 @@ Every committed change to a listed table appends a row to `_voltro_row_history`
|
|
|
30
30
|
The history row's own `id` is **derived** from `(tableName, rowId, version)` and has a fixed width — it is a surrogate, and every part of it is already a column beside it, so do not parse or construct it. That width is the point: an `id()` column is `VARCHAR(64)` on mysql/mariadb and `NVARCHAR(64)` on mssql, so a key built by concatenating those parts grew with your **table name** and stopped fitting past 22 characters — which failed every write to that table, not merely an import.
|
|
31
31
|
|
|
32
32
|
|
|
33
|
-
### What gets
|
|
33
|
+
### What gets recorded — opt OUT, not in
|
|
34
34
|
|
|
35
|
-
`
|
|
35
|
+
`rowHistoryPlugin({})` covers **every table your app declares**. There is no list to write and none to maintain.
|
|
36
36
|
|
|
37
37
|
```ts
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
38
|
+
rowHistoryPlugin({}) // every app table
|
|
39
|
+
rowHistoryPlugin({ exclude: [domainEvents] }) // opt one out
|
|
40
|
+
rowHistoryPlugin({ include: [aiFlowsTable] }) // add a PLUGIN's table
|
|
41
41
|
```
|
|
42
42
|
|
|
43
43
|
Both take table **values**, not names — a misspelling is a compile error at the call site, exactly as with `reference(() => table)`.
|
|
@@ -51,18 +51,18 @@ A table named in both `include` and `exclude` throws at construction — only yo
|
|
|
51
51
|
**Check the boot line once after upgrading.** It prints the RESOLVED count, not the configured one:
|
|
52
52
|
|
|
53
53
|
```txt
|
|
54
|
-
|
|
54
|
+
row-history active · tables: 41 · historyTable: _voltro_row_history · retentionDays: 365
|
|
55
55
|
```
|
|
56
56
|
|
|
57
57
|
If 41 surprises you, `exclude` is the knob. The retention sweep (`VOLTRO_ROW_HISTORY_TTL_HOURS`) still bounds age.
|
|
58
58
|
|
|
59
59
|
## What this is NOT — the grain
|
|
60
60
|
|
|
61
|
-
|
|
61
|
+
Row history records **row changes, not domain events**. One entry per row per write, named by *table*. If your product has a user-facing audit feature whose entries are named after an aggregate root — one `Team` event for a call that writes `teams` + `roles` + `userTeams` + `userTeamRoles` — this is the layer **underneath** that, not a replacement for it.
|
|
62
62
|
|
|
63
63
|
The distinction is worth reading before you plan a migration onto it. A migration off hundreds of hand-written audit calls onto this tap runs into the same wall a few hours in: the grain is different. A table-keyed tap does not produce an aggregate-keyed trail with better coverage, it produces a *different artifact*. The two compose:
|
|
64
64
|
|
|
65
|
-
- **
|
|
65
|
+
- **row history** answers "what did row R look like before, and after" — for every write, whether or not anyone remembered to record it;
|
|
66
66
|
- an **aggregate trail** (the [audit sink](/docs/plugins/audit), one row per mutation invocation) answers "what business operation happened, to which entity, and did it succeed";
|
|
67
67
|
- `traceId` joins them, so one request reads as one story.
|
|
68
68
|
|
|
@@ -95,10 +95,10 @@ migration — with the same meaning as an absent `traceId`.
|
|
|
95
95
|
|
|
96
96
|
## The correlation bridge — joining *what changed* to *who called*
|
|
97
97
|
|
|
98
|
-
`ChangeEvent` carries the calling `traceId` and `subjectId`, so a history row can be joined to the [audit sink](/docs/plugins/audit) row for the **same call**. Before this, both trails existed and shipped and nothing connected them:
|
|
98
|
+
`ChangeEvent` carries the calling `traceId` and `subjectId`, so a history row can be joined to the [audit sink](/docs/plugins/audit) row for the **same call**. Before this, both trails existed and shipped and nothing connected them: row-history knew what changed, the audit sink knew who called and whether they were refused, and no key spanned the two.
|
|
99
99
|
|
|
100
100
|
```ts
|
|
101
|
-
import { historyByTrace, historyBySubject } from '@voltro/plugin-
|
|
101
|
+
import { historyByTrace, historyBySubject } from '@voltro/plugin-row-history'
|
|
102
102
|
|
|
103
103
|
// What did this call change? (`byTrace`)
|
|
104
104
|
const touched = await historyByTrace(ctx.store, traceId, ctx.request.subject.tenantId)
|
|
@@ -118,7 +118,7 @@ Both questions were previously unanswerable at any speed — `byRow` is the only
|
|
|
118
118
|
## `timing` — when the history row is written
|
|
119
119
|
|
|
120
120
|
```ts
|
|
121
|
-
|
|
121
|
+
rowHistoryPlugin({ timing: 'in-transaction' })
|
|
122
122
|
```
|
|
123
123
|
|
|
124
124
|
| | `'post-commit'` (default) | `'in-transaction'` |
|
|
@@ -175,7 +175,7 @@ That is the correct order, not a race to engineer around: the change is durable,
|
|
|
175
175
|
{ "id": "sess_1", "secret": "enc:v1:a56iziEV9THLhzmJ:Vk0ux+0bECleTLBJkCa0Rg==:3AtMwP" }
|
|
176
176
|
```
|
|
177
177
|
|
|
178
|
-
So
|
|
178
|
+
So keeping history for a table with encrypted columns **does not widen exposure** — the history is exactly as readable as the row it came from. This is worth stating because "full row snapshot" reads alarming next to `.encrypted()`, and the cautious reader excludes the table. One did, and only found out by measuring.
|
|
179
179
|
|
|
180
180
|
**`.serverOnly()` columns ARE withheld**, and for a sharper reason than "a second copy": `crud.*` strips those columns from every row it returns, and a snapshot would smuggle the value back past that stripping inside a `json()` blob, where no column-level rule applies. A marker meaning *never serialize this to a client* cannot survive being re-exported through a different column's contents.
|
|
181
181
|
|
|
@@ -189,7 +189,7 @@ The withheld names are listed under `data._omitted`, so a reader can tell *"this
|
|
|
189
189
|
## Querying the timeline
|
|
190
190
|
|
|
191
191
|
```ts
|
|
192
|
-
import { rowHistory, rowAsOf } from '@voltro/plugin-
|
|
192
|
+
import { rowHistory, rowAsOf } from '@voltro/plugin-row-history'
|
|
193
193
|
|
|
194
194
|
// Every version of a row, oldest → newest — TENANT-SCOPED to the caller:
|
|
195
195
|
const history = await rowHistory(ctx.store, 'posts', postId, ctx.request.subject.tenantId)
|
|
@@ -204,7 +204,7 @@ Pass the caller's `tenantId` — reads are **tenant-scoped**: a row's value time
|
|
|
204
204
|
## Restore & diff
|
|
205
205
|
|
|
206
206
|
```ts
|
|
207
|
-
import { restoreAsOf, diffVersions } from '@voltro/plugin-
|
|
207
|
+
import { restoreAsOf, diffVersions } from '@voltro/plugin-row-history'
|
|
208
208
|
|
|
209
209
|
// Roll the LIVE row back to its state at a past instant (tenant-scoped like
|
|
210
210
|
// rowAsOf — no visible state then ⇒ null, nothing written). The restore goes
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Storage
|
|
2
2
|
|
|
3
|
-
> File storage behind one StorageService — public objects served direct from the bucket/CDN, private objects gated by an access policy + per-object grants. S3 / R2
|
|
3
|
+
> File storage behind one StorageService — public objects served direct from the bucket/CDN, private objects gated by an access policy + per-object grants. S3 / MinIO (R2 and GCS via their S3 interop) / Azure / database / filesystem / memory providers, presigned URLs, a dashboard browser.
|
|
4
4
|
|
|
5
5
|
|
|
6
6
|
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
<!-- source: en/plugins/storage.md -->
|
|
10
10
|
## Storage
|
|
11
11
|
|
|
12
|
-
_File storage behind one StorageService — public objects served direct from the bucket/CDN, private objects gated by an access policy + per-object grants. S3 / R2
|
|
12
|
+
_File storage behind one StorageService — public objects served direct from the bucket/CDN, private objects gated by an access policy + per-object grants. S3 / MinIO (R2 and GCS via their S3 interop) / Azure / database / filesystem / memory providers, presigned URLs, a dashboard browser._
|
|
13
13
|
|
|
14
14
|
`@voltro/plugin-storage` is file storage behind one `StorageService`. Wire a
|
|
15
15
|
provider in `app.config.ts`; consume it in handlers and actions via
|