@voltro/cli 0.52.0 → 0.54.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +424 -0
- package/THIRD-PARTY-NOTICES.md +8311 -3318
- package/dist/{agentsMd-0l980yhL.js → agentsMd-BaLC10Na.js} +110 -82
- package/dist/agentsMd-DCY1RSs8.js +2 -0
- package/dist/apiBuild-CeUN55uk.js +2 -0
- package/dist/{apiBuild-CSFI8QGq.js → apiBuild-DTWp0S_q.js} +11 -5
- package/dist/bin.js +1 -1
- package/dist/build-D4ygSbnV.js +843 -0
- package/dist/{checkCommand-COmqc2cB.js → checkCommand-Dg1G7Gwd.js} +6 -6
- package/dist/{checkCommand-2SbqzukH.js → checkCommand-L7DTlpIF.js} +1 -1
- package/dist/{cloudCmd-QUXh-b5w.js → cloudCmd-DzKcSYuy.js} +1 -1
- package/dist/{codegen-VF479Cnb.js → codegen-DSLM8Su9.js} +1 -1
- package/dist/codegen-DjgxEOnD.js +2 -0
- package/dist/codegenCommand-CG_Vx4lc.js +41 -0
- package/dist/{codemodRunner-r7J9lIa7.js → codemodRunner-Cd4xkC6u.js} +109 -11
- package/dist/{commands-Cc_nV8WI.js → commands-6Kzi92Np.js} +96 -73
- package/dist/{dashboardCommand-C-gKvwqh.js → dashboardCommand-Cq1PWvI1.js} +5 -5
- package/dist/{dataCommand-BgpBHnlB.js → dataCommand-DYzW8vkv.js} +3 -3
- package/dist/{dbCommand-sHedr-NJ.js → dbCommand-B4NWZtGL.js} +278 -237
- package/dist/dbCommand-CSFWs9ev.js +2 -0
- package/dist/{dev-CRHoCEiy.js → dev-CmuvUKRq.js} +2903 -2306
- package/dist/{dev--A3nsxA3.js → dev-cKUiZZsB.js} +1 -1
- package/dist/{doctorCommand-DtfJ3FA6.js → doctorCommand-DCiFVMtZ.js} +101 -69
- package/dist/doctorCommand-J3qu4E0Y.js +2 -0
- package/dist/{dormancyCommand-Drn7o0No.js → dormancyCommand-w1TrmgYP.js} +1 -1
- package/dist/{embeddingsCommand-Z-jO1fWN.js → embeddingsCommand-CMgPyRTr.js} +1 -1
- package/dist/{envCommand-D4gCrrTZ.js → envCommand-Cyynmcfa.js} +8 -8
- package/dist/{evolveCommand-CMROeKeA.js → evolveCommand-BwvQ8dVH.js} +2 -2
- package/dist/fileConventions-l-RIXbx8.js +36 -0
- package/dist/{fileTaxonomy-DvDUV9wq.js → fileTaxonomy-CbyMQYx_.js} +42 -42
- package/dist/fontPipeline-LxIHa1vo.js +2 -0
- package/dist/fontPipeline-Tsh8kZfA.js +152 -0
- package/dist/{frameworkTableAssembly-w-XnLa3q.js → frameworkTableAssembly-D7LJuALW.js} +5 -5
- package/dist/frameworkTableAssembly-IPD1pUnZ.js +2 -0
- package/dist/imagePipeline-B_GVJgm6.js +2 -0
- package/dist/imagePipeline-CBZmjT4i.js +127 -0
- package/dist/index.js +2 -2
- package/dist/{infoCommand-DXM868o_.js → infoCommand-DlYlUPqs.js} +1 -1
- package/dist/{inspect-CtL_xTbu.js → inspect-Bd8-9wsi.js} +1 -1
- package/dist/inspect-CuoDInfZ.js +2 -0
- package/dist/{inspectGateHint-BjnFubmH.js → inspectGateHint-4LxkNtrz.js} +1 -1
- package/dist/manifestBuild-C4-J1-m_.js +2 -0
- package/dist/{manifestBuild-CuU1VrSm.js → manifestBuild-Cqgsx2bM.js} +1 -1
- package/dist/{metaCommands-C6RFmF1r.js → metaCommands-x7RCi2AF.js} +9 -3
- package/dist/{migrate-D0F-eTlK.js → migrate-BK_Bbx-_.js} +2 -2
- package/dist/mobileCommand-D9O6iq3D.js +428 -0
- package/dist/mobileCommand-DAum7tsG.js +2 -0
- package/dist/{pageConvention-CzUiSbtU.js → pageConvention-CMpfDN6r.js} +1 -1
- package/dist/{privacyCommand-DGdopOI6.js → privacyCommand-BCa2OoZG.js} +1 -1
- package/dist/{probeCommand-C9gazU0H.js → probeCommand-_C0YU207.js} +83 -24
- package/dist/{projectScaffold-EzlErR4E.js → projectScaffold-CJfP-xbT.js} +1 -1
- package/dist/{projectScaffold-B4dmTlwT.js → projectScaffold-CSN0OzBV.js} +2 -2
- package/dist/renderModeScan-43yQ2opo.js +147 -0
- package/dist/{renderProfile-Ck32Fzxr.js → renderProfile-DvrhVJHa.js} +2 -2
- package/dist/{runtimeTrace-BPQyCmC5.js → runtimeTrace-CGWx1Q6l.js} +1 -1
- package/dist/{sdkgen-Se88ifTd.js → sdkgen-CDGHQUFj.js} +1 -1
- package/dist/serveCommand-BiPe8BJm.js +2 -0
- package/dist/{serveCommand-DkP3OT0W.js → serveCommand-Bje09q1v.js} +889 -825
- package/dist/serveEntry.js +1 -1
- package/dist/start-B0bnJgxI.js +3 -0
- package/dist/{start-jw89Xbqy.js → start-Clz-1BHB.js} +633 -455
- package/dist/startEntry.js +1 -1
- package/dist/{staticCommand-BwNEDlSU.js → staticCommand-ey0kYmOT.js} +1 -1
- package/dist/{subcommandNames-DpYs3DXr.js → subcommandNames-CDzfEtKV.js} +3 -3
- package/dist/{templates-BR-fb4SP.js → templates-BTWZkJJT.js} +41 -9
- package/dist/{test-f3amja6a.js → test-D_kW4KMj.js} +1 -1
- package/dist/updateCommand-CIoVDKnj.js +2 -0
- package/dist/{updateCommand-BMk2e4ky.js → updateCommand-CRJlAOaM.js} +139 -115
- package/dist/{webDev-BgWL9gKV.js → webDev-DSI9SOhs.js} +1598 -1028
- package/dist/webDev-DlvZO30c.js +2 -0
- package/dist/{webhooksCommand-CoIO3jbj.js → webhooksCommand-BvzXNHji.js} +1 -1
- package/dist/workspaceDeps-RKEkX92S.js +45 -0
- package/package.json +60 -19
- package/templates/AGENTS.core.md +2 -0
- package/templates/AGENTS.md +8 -4
- package/templates/agent-docs/_index.md +6 -4
- package/templates/agent-docs/_manifest.json +21 -5
- package/templates/agent-docs/ai.md +6 -6
- package/templates/agent-docs/authentication.md +73 -1
- package/templates/agent-docs/cli.md +6 -3
- package/templates/agent-docs/configuration.md +17 -0
- package/templates/agent-docs/data.md +522 -29
- package/templates/agent-docs/database/advancedqueries.md +8 -8
- package/templates/agent-docs/database/columntypes.md +2 -2
- package/templates/agent-docs/database/migrations.md +1 -1
- package/templates/agent-docs/database/querying.md +1 -1
- package/templates/agent-docs/database/schema.md +1 -1
- package/templates/agent-docs/database/seedsdialects.md +65 -3
- package/templates/agent-docs/database/transactions.md +3 -3
- package/templates/agent-docs/deployment.md +8 -0
- package/templates/agent-docs/internationalization.md +4 -2
- package/templates/agent-docs/introduction.md +32 -1
- package/templates/agent-docs/local-first-mobile.md +226 -30
- package/templates/agent-docs/observability.md +5 -1
- package/templates/agent-docs/plugins/atlassian.md +2 -2
- package/templates/agent-docs/plugins/audit.md +2 -2
- package/templates/agent-docs/plugins/auth.md +1 -1
- package/templates/agent-docs/plugins/billing.md +1 -1
- package/templates/agent-docs/plugins/cdc-out.md +8 -3
- package/templates/agent-docs/plugins/comments.md +164 -0
- package/templates/agent-docs/plugins/notifications.md +47 -4
- package/templates/agent-docs/plugins/presence.md +45 -3
- package/templates/agent-docs/plugins/prometheus.md +3 -1
- package/templates/agent-docs/plugins/queue.md +172 -0
- package/templates/agent-docs/plugins.md +17 -13
- package/templates/agent-docs/reference.md +35 -4
- package/templates/agent-docs/routing.md +585 -7
- package/templates/agent-docs/scheduling.md +1 -1
- package/templates/agent-docs/schema-driven-ui.md +226 -4
- package/templates/agent-docs/security.md +3 -3
- package/templates/agent-docs/templates/appshells.md +36 -4
- package/templates/agent-docs/whats-new.md +134 -66
- package/templates/apps/api-ai/package.json +6 -6
- package/templates/apps/api-auth/package.json +8 -8
- package/templates/apps/api-backend/package.json +7 -7
- package/templates/apps/api-backend-deactivation/package.json +7 -7
- package/templates/apps/api-backend-mail/package.json +8 -8
- package/templates/apps/api-backend-mariadb/package.json +9 -9
- package/templates/apps/api-backend-sqlite/package.json +8 -8
- package/templates/apps/api-backend-storage/package.json +8 -8
- package/templates/apps/api-cms/package.json +9 -9
- package/templates/apps/api-collab/README.md +3 -3
- package/templates/apps/api-collab/app.config.ts +1 -1
- package/templates/apps/api-collab/database/schema.ts +12 -8
- package/templates/apps/api-collab/mutations/documents.create.mutation.server.ts +1 -1
- package/templates/apps/api-collab/mutations/documents.setBody.mutation.server.ts +1 -1
- package/templates/apps/api-collab/mutations/documents.setBody.mutation.ts +1 -1
- package/templates/apps/api-collab/package.json +8 -8
- package/templates/apps/api-collab/template.json +1 -1
- package/templates/apps/api-collab/tests/documents.setBody.test.ts +10 -2
- package/templates/apps/api-data-advanced/package.json +8 -8
- package/templates/apps/api-durable/package.json +8 -8
- package/templates/apps/api-feature-flags/package.json +9 -9
- package/templates/apps/api-governance/package.json +8 -8
- package/templates/apps/api-kv/package.json +8 -8
- package/templates/apps/api-moderation/package.json +8 -8
- package/templates/apps/api-observability/package.json +8 -8
- package/templates/apps/api-ratelimit/package.json +8 -8
- package/templates/apps/api-rbac/package.json +8 -8
- package/templates/apps/api-rest/package.json +7 -7
- package/templates/apps/api-row-history/package.json +8 -8
- package/templates/apps/api-saas/package.json +11 -10
- package/templates/apps/api-saas-starter/package.json +10 -10
- package/templates/apps/api-search/package.json +8 -8
- package/templates/apps/api-status/package.json +8 -8
- package/templates/apps/api-webhooks/package.json +9 -9
- package/templates/apps/changelog/app.config.ts +26 -2
- package/templates/apps/changelog/content/releases/{0.1.0.mdx → v0-1-0.mdx} +0 -1
- package/templates/apps/changelog/content/releases/{0.2.0.mdx → v0-2-0.mdx} +0 -1
- package/templates/apps/changelog/package.json +9 -8
- package/templates/apps/changelog/src/collections/releases.collection.ts +48 -0
- package/templates/apps/changelog/src/globals.d.ts +1 -1
- package/templates/apps/changelog/src/locales/de.ts +1 -1
- package/templates/apps/changelog/src/locales/en.ts +1 -1
- package/templates/apps/changelog/src/pages/[locale]/[slug]/page.tsx +7 -4
- package/templates/apps/changelog/src/pages/[locale]/mirrors.test.tsx +13 -41
- package/templates/apps/changelog/src/pages/[slug]/page.test.tsx +26 -51
- package/templates/apps/changelog/src/pages/[slug]/page.tsx +21 -19
- package/templates/apps/changelog/src/pages/page.test.tsx +14 -37
- package/templates/apps/changelog/src/pages/page.tsx +18 -12
- package/templates/apps/edge-functions/package.json +2 -2
- package/templates/apps/frontend-admin/package.json +8 -8
- package/templates/apps/frontend-app/package.json +9 -9
- package/templates/apps/frontend-auth/package.json +8 -8
- package/templates/apps/frontend-blank/package.json +7 -7
- package/templates/apps/frontend-cms/package.json +9 -9
- package/templates/apps/frontend-collab/README.md +43 -24
- package/templates/apps/frontend-collab/app.config.ts +3 -3
- package/templates/apps/frontend-collab/package.json +14 -10
- package/templates/apps/frontend-collab/src/locales/de.ts +1 -2
- package/templates/apps/frontend-collab/src/locales/en.ts +1 -2
- package/templates/apps/frontend-collab/src/pages/page.test.tsx +72 -76
- package/templates/apps/frontend-collab/src/pages/page.tsx +45 -22
- package/templates/apps/frontend-collab/template.json +2 -2
- package/templates/apps/frontend-contact/package.json +7 -7
- package/templates/apps/frontend-dashboard/package.json +7 -7
- package/templates/apps/frontend-docs/content/docs/de/guides/first-page.md +4 -0
- package/templates/apps/frontend-docs/content/docs/de/intro/getting-started.md +4 -0
- package/templates/apps/frontend-docs/content/docs/en/guides/first-page.md +4 -0
- package/templates/apps/frontend-docs/content/docs/en/intro/getting-started.md +4 -0
- package/templates/apps/frontend-docs/package.json +9 -6
- package/templates/apps/frontend-docs/src/collections/docs.collection.ts +21 -0
- package/templates/apps/frontend-docs/src/locales/de.ts +0 -5
- package/templates/apps/frontend-docs/src/locales/en.ts +0 -5
- package/templates/apps/frontend-docs/src/pages/[locale]/docs/[...slug]/page.tsx +17 -9
- package/templates/apps/frontend-docs/src/pages/[locale]/mirrors.test.tsx +10 -5
- package/templates/apps/frontend-docs/src/pages/[locale]/page.tsx +8 -0
- package/templates/apps/frontend-docs/src/pages/docs/[...slug]/page.test.tsx +53 -15
- package/templates/apps/frontend-docs/src/pages/docs/[...slug]/page.tsx +27 -33
- package/templates/apps/frontend-docs/src/pages/page.test.tsx +17 -3
- package/templates/apps/frontend-docs/src/pages/page.tsx +16 -12
- package/templates/apps/frontend-i18n/package.json +6 -6
- package/templates/apps/frontend-landing/README.md +48 -0
- package/templates/apps/frontend-landing/app.config.ts +28 -0
- package/templates/apps/frontend-landing/package.json +7 -6
- package/templates/apps/frontend-landing/src/assets/hero.jpg +0 -0
- package/templates/apps/frontend-landing/src/fonts/Geist-Variable.woff2 +0 -0
- package/templates/apps/frontend-landing/src/fonts/LICENSE-Geist.txt +92 -0
- package/templates/apps/frontend-landing/src/globals.css +15 -0
- package/templates/apps/frontend-landing/src/globals.d.ts +17 -0
- package/templates/apps/frontend-landing/src/locales/de.ts +3 -2
- package/templates/apps/frontend-landing/src/locales/en.ts +5 -2
- package/templates/apps/frontend-landing/src/pages/page.test.tsx +79 -0
- package/templates/apps/frontend-landing/src/pages/page.tsx +26 -3
- package/templates/apps/frontend-landing/template.json +2 -2
- package/templates/apps/frontend-portal/package.json +8 -8
- package/templates/apps/frontend-saas/package.json +8 -8
- package/templates/apps/frontend-spa/package.json +7 -7
- package/templates/apps/frontend-ssr/package.json +7 -7
- package/templates/apps/frontend-ssr-api/package.json +8 -8
- package/templates/apps/frontend-static-blog/content/posts/cms-to-ssg.md +12 -0
- package/templates/apps/frontend-static-blog/content/posts/hello-static.md +12 -0
- package/templates/apps/frontend-static-blog/content/posts/islands-not-hydration.md +14 -0
- package/templates/apps/frontend-static-blog/package.json +9 -6
- package/templates/apps/frontend-static-blog/src/collections/posts.collection.ts +27 -0
- package/templates/apps/frontend-static-blog/src/pages/[locale]/blog/[slug]/page.tsx +7 -4
- package/templates/apps/frontend-static-blog/src/pages/[locale]/mirrors.test.tsx +7 -1
- package/templates/apps/frontend-static-blog/src/pages/blog/[slug]/page.test.tsx +19 -9
- package/templates/apps/frontend-static-blog/src/pages/blog/[slug]/page.tsx +19 -21
- package/templates/apps/frontend-static-blog/src/pages/page.test.tsx +18 -5
- package/templates/apps/frontend-static-blog/src/pages/page.tsx +21 -14
- package/templates/apps/frontend-status/package.json +8 -8
- package/templates/apps/mobile-app/package.json +12 -11
- package/templates/apps/mobile-app/src/lib/deeplinks.ts +29 -17
- package/templates/apps/mobile-app/tests/deeplinks.test.ts +16 -0
- package/dist/agentsMd-SDDSkyl4.js +0 -2
- package/dist/apiBuild-BYBpL7Pz.js +0 -2
- package/dist/build-CPgcMQug.js +0 -793
- package/dist/codegen-CctkDO-1.js +0 -2
- package/dist/codegenCommand-DCdG2JN-.js +0 -137
- package/dist/dbCommand-DNb6yeOG.js +0 -2
- package/dist/doctorCommand-CqoWA2p5.js +0 -2
- package/dist/fileConventions-DOqD3lPS.js +0 -34
- package/dist/frameworkTableAssembly-C6ETawPR.js +0 -2
- package/dist/inspect-CuGDYES0.js +0 -2
- package/dist/manifestBuild-CPjhvM62.js +0 -2
- package/dist/renderModeScan-CcH2X1_D.js +0 -120
- package/dist/serveCommand-DLc-BznW.js +0 -2
- package/dist/start-DfL3fOiN.js +0 -3
- package/dist/updateCommand-5gFVfK5q.js +0 -2
- package/dist/webDev-CZbTsDcH.js +0 -2
- package/templates/apps/changelog/scripts/generate-rss.mjs +0 -38
- package/templates/apps/changelog/src/lib/releases.ts +0 -21
- package/templates/apps/frontend-static-blog/src/content/posts.ts +0 -64
|
@@ -24,15 +24,17 @@ imports it directly. The React wrappers live behind `@voltro/local-first/react`
|
|
|
24
24
|
> authoritative server-side merge on the write path), the
|
|
25
25
|
> [`SyncClient`](#the-syncclient-bi-directional-wire) that drives the queue over a
|
|
26
26
|
> transport, [`useCrdtText`](#a-collaborative-text-field-usecrdttext) — the React
|
|
27
|
-
> binding for a collaborative text field — [presence/awareness](#presence
|
|
27
|
+
> binding for a collaborative text field — [presence/awareness](#presence-awareness)
|
|
28
28
|
> via `usePresence`, [durable IndexedDB persistence](#durable-persistence), and the
|
|
29
|
-
> [`localFirst` table mixin](#the-localfirst-table-mixin). What remains
|
|
30
|
-
>
|
|
31
|
-
>
|
|
32
|
-
>
|
|
33
|
-
>
|
|
34
|
-
>
|
|
35
|
-
>
|
|
29
|
+
> [`localFirst` table mixin](#the-localfirst-table-mixin). What remains is
|
|
30
|
+
> ONE thing — the two app-specific tags `useCrdtText` is pointed at
|
|
31
|
+
> ([runtime seam](#what-s-shipped-vs-a-runtime-seam)); the presence broker
|
|
32
|
+
> binding ships and a browser SQL engine was deliberately rejected. And the
|
|
33
|
+
> sync **engine** is now BUILT:
|
|
34
|
+
> the [query mirror](#the-sync-engine-query-mirror-durable-outbox) persists
|
|
35
|
+
> every subscribed query's rows per subject+tenant partition, `useOutbox`
|
|
36
|
+
> queues offline writes durably, and a reload renders mirrored rows offline
|
|
37
|
+
> and delta-resumes online.
|
|
36
38
|
|
|
37
39
|
## CRDT text: `crdtText` + `mergeCrdtStates`
|
|
38
40
|
|
|
@@ -207,25 +209,25 @@ import { Schema } from 'effect'
|
|
|
207
209
|
body: Schema.NullOr(Schema.Uint8ArrayFromBase64)
|
|
208
210
|
```
|
|
209
211
|
|
|
210
|
-
###
|
|
212
|
+
### What a `crdtText()` keystroke costs
|
|
211
213
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
+
A CRDT column on a hot editing path has two amplification effects to reason
|
|
215
|
+
about, and the framework handles both — neither is left to your query shape:
|
|
214
216
|
|
|
215
|
-
- **
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
- **
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
full-state blob
|
|
224
|
-
CRDT-heavy tables, or batch edits before pushing.
|
|
217
|
+
- **Downstream the wire carries the EDIT, not the document.** A changed CRDT
|
|
218
|
+
cell diffs into an incremental `mergeCells` subscription op rather than a
|
|
219
|
+
full-blob replace, so a one-character edit ships bytes proportional to the
|
|
220
|
+
edit regardless of document size — a query that projects `body` into a list
|
|
221
|
+
view does not stream the whole state per keystroke.
|
|
222
|
+
- **Server-side capture skips CRDT columns.** The undo log (default-on outside
|
|
223
|
+
production) and `plugin-row-history` strip CRDT columns from the captured
|
|
224
|
+
row, and an update touching ONLY CRDT columns is not captured at all, so
|
|
225
|
+
per-keystroke mutations write no full-state blob into those tables.
|
|
225
226
|
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
227
|
+
What stays a real cost is the stored value itself: the blob in the row grows
|
|
228
|
+
with the document's edit history and soft-compacts past
|
|
229
|
+
`crdt.compactMaxBytes` — see the [operational
|
|
230
|
+
rules](#rich-text-crdtdoc-usecrdtdoc-usecrdteditor) below.
|
|
229
231
|
|
|
230
232
|
## Presence & awareness
|
|
231
233
|
|
|
@@ -266,6 +268,30 @@ propagation, and TTL expiry live in the pure `createPresenceRoom` the hook wraps
|
|
|
266
268
|
> is fresh, plus a `useTyping` indicator, through the app's own rpc. Reach for
|
|
267
269
|
> the plugin for "who is here"; reach for this one for "where is their cursor".
|
|
268
270
|
|
|
271
|
+
### Binding it to the app's own presence lane — `usePresenceChannel`
|
|
272
|
+
|
|
273
|
+
`createInMemoryPresenceChannel()` is the local transport; in a running app the
|
|
274
|
+
binding is [`@voltro/plugin-presence`](/docs/plugins/presence)'s
|
|
275
|
+
`usePresenceChannel(roomId, { selfKey })`, which returns a `PresenceChannel`
|
|
276
|
+
backed by the plugin's existing heartbeat roster and rpc. Pass it straight in as
|
|
277
|
+
the `channel` above:
|
|
278
|
+
|
|
279
|
+
```tsx
|
|
280
|
+
import { usePresence } from '@voltro/local-first/react'
|
|
281
|
+
import { usePresenceChannel } from '@voltro/plugin-presence/web'
|
|
282
|
+
|
|
283
|
+
function Editor({ documentId, userId }) {
|
|
284
|
+
const channel = usePresenceChannel(documentId, { selfKey: userId })
|
|
285
|
+
const { others, setPresence } = usePresence(documentId, { cursor: 0 }, { channel })
|
|
286
|
+
return <Cursors others={others} />
|
|
287
|
+
}
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
That is deliberately ONE wire: awareness payloads ride the presence lane the app
|
|
291
|
+
already runs rather than a second socket with its own lifecycle. The channel is
|
|
292
|
+
room-scoped, and publishing into a room it was not created for THROWS — a silent
|
|
293
|
+
cross-room delivery is the failure worth being loud about.
|
|
294
|
+
|
|
269
295
|
## The offline sync queue
|
|
270
296
|
|
|
271
297
|
`useSyncQueue()` is a reactive view over a **pure, tested reducer**: writes made
|
|
@@ -315,6 +341,161 @@ const adapter = await createIndexedDbPersistence({ databaseName: 'my-app' })
|
|
|
315
341
|
const sync = createSyncClient({ transport, adapter }) // state now survives reload
|
|
316
342
|
```
|
|
317
343
|
|
|
344
|
+
## Rich text: `crdtDoc()` + `useCrdtDoc` + `useCrdtEditor`
|
|
345
|
+
|
|
346
|
+
`crdtDoc()` stores a WHOLE collaborative document (rich text, maps, arrays)
|
|
347
|
+
as a column — same storage and authoritative server merge as `crdtText()`,
|
|
348
|
+
which stays as the plain-text specialisation. The column definitions are
|
|
349
|
+
identical; what differs is which client binding you reach for.
|
|
350
|
+
|
|
351
|
+
Two hooks, and they are a pair. **`useCrdtDoc`**
|
|
352
|
+
(`@voltro/local-first/react`) owns the SYNC half — one `SyncClient` and one
|
|
353
|
+
live CRDT document per cell, the same wire `useCrdtText` rides.
|
|
354
|
+
**`useCrdtEditor`** (`@voltro/local-first/editor`, Tiptap, optional peers)
|
|
355
|
+
owns the EDITOR half and takes that document:
|
|
356
|
+
|
|
357
|
+
```tsx
|
|
358
|
+
import { useCrdtDoc } from '@voltro/local-first/react'
|
|
359
|
+
import { useCrdtEditor } from '@voltro/local-first/editor'
|
|
360
|
+
import { EditorContent } from '@tiptap/react'
|
|
361
|
+
|
|
362
|
+
const Page = ({ id }: { id: string }) => {
|
|
363
|
+
const row = useSubscription<{ body: Uint8Array | null }>('app', 'documents.byId', { id })
|
|
364
|
+
const save = useMutation<{ id: string; update: Uint8Array }>('app', 'documents.setBody')
|
|
365
|
+
const shared = useCrdtDoc({
|
|
366
|
+
cell: { table: 'documents', id, column: 'body' },
|
|
367
|
+
remote: row.data?.body ?? null, // null = NOT LOADED
|
|
368
|
+
push: (w) => save.mutate({ id: w.id, update: w.update }),
|
|
369
|
+
})
|
|
370
|
+
// `doc` is null until the mount effect has run — render the editor in a
|
|
371
|
+
// CHILD so `useCrdtEditor` is never a conditional hook call.
|
|
372
|
+
return shared.doc === null ? null : <Surface doc={shared.doc} />
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
const Surface = ({ doc }: { doc: CrdtDocHandle }) => (
|
|
376
|
+
<EditorContent editor={useCrdtEditor({ doc })} />
|
|
377
|
+
)
|
|
378
|
+
```
|
|
379
|
+
|
|
380
|
+
`useCrdtDoc` returns `{ doc, loaded, outstanding, synced, setOnline }`. The
|
|
381
|
+
two names it asks for are the same two `useCrdtText` asks for and the same
|
|
382
|
+
two nothing can derive: the mutation that writes the column, and the
|
|
383
|
+
reactive query that streams the row.
|
|
384
|
+
|
|
385
|
+
Everything else is the hook's: local edits ride the app's mutation as
|
|
386
|
+
INCREMENTAL updates (folded server-side under a per-row mutex), remote edits
|
|
387
|
+
arrive as `mergeCells` subscription deltas — a one-character edit ships
|
|
388
|
+
under 1 KB in BOTH directions regardless of document size — plus the offline
|
|
389
|
+
queue, bounded retry, per-cell coalescence and durable persistence.
|
|
390
|
+
|
|
391
|
+
Two disciplines it enforces, because both fail silently when hand-rolled:
|
|
392
|
+
|
|
393
|
+
- **`remote: null` means NOT LOADED, not empty.** Folding an empty document
|
|
394
|
+
over a loading row lets the first keystroke push a state that erases what
|
|
395
|
+
was stored. `loaded` tells a UI which it is.
|
|
396
|
+
- **The echo guard.** A `crdtDoc()` document is mutated by the EDITOR, so
|
|
397
|
+
local edits surface as `doc.onUpdate` callbacks — and folding a peer's
|
|
398
|
+
state through `applyState` fires the same callback. The hook pushes only
|
|
399
|
+
when `local` is `true`. Without that every client re-broadcasts what it
|
|
400
|
+
just received: one keystroke, one server write per open tab.
|
|
401
|
+
|
|
402
|
+
A page mounting an editor needs `renderMode = 'client'`. The default is
|
|
403
|
+
`'static'`, which pre-renders at build time, and the editor finds no
|
|
404
|
+
`window` there.
|
|
405
|
+
|
|
406
|
+
Carets ride a `delivery: 'latest'` EVENT, deliberately not presence
|
|
407
|
+
metadata: the roster's value-compare push would make every caret move a
|
|
408
|
+
"real" change. Pass an `awareness` transport plus `user` to
|
|
409
|
+
`useCrdtEditor` to mount them; `attachAwarenessBridge` publishes one
|
|
410
|
+
member's state per envelope (never the aggregated room). Stable positions
|
|
411
|
+
for inline comments come from `encodeAnchor`/`resolveAnchor` on the doc
|
|
412
|
+
handle.
|
|
413
|
+
|
|
414
|
+
The `frontend-collab` + `api-collab` template pair is this whole loop,
|
|
415
|
+
scaffoldable: `voltro create-project collab --api=api-collab
|
|
416
|
+
--web=frontend-collab`.
|
|
417
|
+
|
|
418
|
+
Operational rules: the stored blob soft-compacts past `crdt.compactMaxBytes`
|
|
419
|
+
in `app.config.ts` (default 512 KiB, `0` disables; env override
|
|
420
|
+
`VOLTRO_CRDT_COMPACT_MAX_BYTES`) without breaking the merge
|
|
421
|
+
lineage; `rebaseText` is the explicit hard reset (a NEW EPOCH — subscribers
|
|
422
|
+
receive it as a fresh snapshot). CRDT columns are excluded from undo capture
|
|
423
|
+
and row history (document history = named snapshots taken BEFORE
|
|
424
|
+
compaction); `.serverOnly()` on a CRDT column is a declaration error and
|
|
425
|
+
`.encrypted()` makes it online-only.
|
|
426
|
+
|
|
427
|
+
## The sync engine: query mirror + durable outbox
|
|
428
|
+
|
|
429
|
+
The engine's two halves ride the primitives you already use — there is no
|
|
430
|
+
second data API:
|
|
431
|
+
|
|
432
|
+
- **Reads.** The subscription cache accepts a `mirror`; every base movement of
|
|
433
|
+
every subscribed query persists (rows + revision) into a **subject+tenant
|
|
434
|
+
partitioned** store over IndexedDB, and a cold start seeds from it — the UI
|
|
435
|
+
renders the last materialised rows offline through the SAME
|
|
436
|
+
`useSubscription` call, and the next connect presents the mirrored revision
|
|
437
|
+
as `voltro-resume-from`, so a reload inside the resume window continues with
|
|
438
|
+
deltas instead of a snapshot.
|
|
439
|
+
- **Writes.** `useOutbox` with `persistence: outboxPersistence(adapter)` is
|
|
440
|
+
the durable offline queue: writes survive a reload, replay in order on
|
|
441
|
+
reconnect, stop at the first conflict, and a conflict resolves through
|
|
442
|
+
`resolveConflict(id, resolveWithPolicy(policy, local, remote, { crdtColumns }))`
|
|
443
|
+
— `crdtText()` columns merge, scalars follow the declared `conflictPolicy()`.
|
|
444
|
+
One drain per device even with many tabs (`withDrainLock`, a per-partition
|
|
445
|
+
Web Lock).
|
|
446
|
+
|
|
447
|
+
```ts
|
|
448
|
+
import {
|
|
449
|
+
createDurableKv, createQueryMirror, createSubscriptionMirrorBinding,
|
|
450
|
+
} from '@voltro/local-first'
|
|
451
|
+
import { localFirstTables } from './.framework/localFirst.generated'
|
|
452
|
+
|
|
453
|
+
const { kv, durability } = await createDurableKv() // 'memory' = visible degradation
|
|
454
|
+
const mirror = createQueryMirror(kv, { subjectId, tenantId }) // ONE partition per subject
|
|
455
|
+
const binding = createSubscriptionMirrorBinding(mirror, {
|
|
456
|
+
tags: { 'docs.list': 'docs' }, // the app's sync set
|
|
457
|
+
metadata: localFirstTables, // codegen: encrypted columns stripped
|
|
458
|
+
schemaFingerprint: BUILD_ID, // local-DB migration gate
|
|
459
|
+
})
|
|
460
|
+
```
|
|
461
|
+
|
|
462
|
+
The deliberate design decision, recorded here because the obvious alternative
|
|
463
|
+
keeps being suggested: the engine is **not** a browser SQL database. The
|
|
464
|
+
client's whole query surface is `(tag, input)` — predicates are built and
|
|
465
|
+
evaluated on the server — so a wa-sqlite instance would evaluate a language
|
|
466
|
+
the client never sees. What offline needs is the last materialised answer per
|
|
467
|
+
query the user visited, kept current by deltas; that is what the mirror
|
|
468
|
+
stores. (The `KvStore` seam still admits a SQLite backing without touching a
|
|
469
|
+
consumer.)
|
|
470
|
+
|
|
471
|
+
Soundness rules, all enforced structurally and tested:
|
|
472
|
+
|
|
473
|
+
- **Partition by key.** Every stored key carries subject AND tenant; a
|
|
474
|
+
logout/login as somebody else can never read the predecessor's rows, and
|
|
475
|
+
`purge()` empties exactly one partition on revocation. Build ONE binding per
|
|
476
|
+
resolved subject and rebuild it on an auth change — the same blank-on-auth
|
|
477
|
+
doctrine the subscription cache itself follows.
|
|
478
|
+
- **`.encrypted()` never lands.** The server decrypts on read, so a naive
|
|
479
|
+
mirror would persist plaintext on the device; the codegen-emitted
|
|
480
|
+
`localFirst.generated.ts` names those columns and the binding strips them
|
|
481
|
+
before every save. `.serverOnly()` columns never reach the wire at all.
|
|
482
|
+
- **The snapshot is the visible state.** A save replaces the mirrored row
|
|
483
|
+
set, so a row the server stopped sending (revoked share, RLS change, soft
|
|
484
|
+
delete) is evicted by construction.
|
|
485
|
+
- **Schema migration is a visible cold start.** Entries persist under the
|
|
486
|
+
build's `schemaFingerprint`; a new build's load misses them and the query
|
|
487
|
+
falls back to loading → fresh snapshot — never a mixed-shape render. The
|
|
488
|
+
offline queue deliberately does NOT gate on it: a queued old-shape write
|
|
489
|
+
replays against the new server, whose input schema is the authority, and a
|
|
490
|
+
rejection surfaces as a visible conflict instead of silently dropped work.
|
|
491
|
+
- **Shapes are your queries.** There is no separate replication-shape
|
|
492
|
+
language: what is mirrored is exactly what the app subscribes to, so tenant
|
|
493
|
+
scoping, guards and `setRowFilter` apply server-side, fail-closed, exactly
|
|
494
|
+
as online — including parent-relative predicates ("tasks where projectId is
|
|
495
|
+
one of my projects"), which are just queries. Mirroring is per QUERY, so a
|
|
496
|
+
subgraph is N queries, not one nested shape. A storage budget is enforced
|
|
497
|
+
with `enforceBudget(maxBytes)` — oldest-saved entries evict first.
|
|
498
|
+
|
|
318
499
|
## Conflict policy for non-CRDT fields
|
|
319
500
|
|
|
320
501
|
CRDT fields resolve themselves — the merge **is** the resolver. A plain scalar
|
|
@@ -343,16 +524,31 @@ string form), so two peers agree regardless of which side each calls "local".
|
|
|
343
524
|
|
|
344
525
|
## What's shipped vs. a runtime seam
|
|
345
526
|
|
|
346
|
-
The framework code for local-first is built and tested end to end
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
code already speaks:
|
|
527
|
+
The framework code for local-first is built and tested end to end. **One** thing
|
|
528
|
+
remains, and it is not un-built framework — it is a pair of names only your app
|
|
529
|
+
knows:
|
|
350
530
|
|
|
351
531
|
| Runtime seam | What it binds | Why it's a binding, not code |
|
|
352
532
|
| --- | --- | --- |
|
|
353
533
|
| **Two app-specific tags** | Which mutation writes the `crdtText()` column, and which reactive query streams the row, in [`useCrdtText`](#a-collaborative-text-field-usecrdttext). | Voltro generates no per-table CRUD surface, so there is nothing to derive them from. The client lifecycle, optimistic merge, offline queue, retry, persistence and edit encoding all ship. |
|
|
354
|
-
|
|
355
|
-
|
|
534
|
+
|
|
535
|
+
Two entries that used to sit in that table are gone, in opposite directions —
|
|
536
|
+
worth stating, because "we have not built it" and "we decided against it" are
|
|
537
|
+
different answers:
|
|
538
|
+
|
|
539
|
+
- **The presence broker binding ships.** `usePresenceChannel`
|
|
540
|
+
(`@voltro/plugin-presence/web`) is a `PresenceChannel` over the framework's
|
|
541
|
+
own presence lane, so cross-replica fan-out is the broadcast plugin's and
|
|
542
|
+
there is ONE presence wire rather than two. Nothing to bind by hand; see
|
|
543
|
+
[Presence & awareness](#presence-awareness).
|
|
544
|
+
- **A wa-sqlite / Turso adapter was rejected, not deferred.** The client's whole
|
|
545
|
+
query surface is `(tag, input)` — predicates are built and evaluated on the
|
|
546
|
+
server — so a browser SQL engine would evaluate a language the client never
|
|
547
|
+
sees. What offline needs is the last materialised answer per query, which is
|
|
548
|
+
exactly what the [query mirror](#the-sync-engine-query-mirror-durable-outbox)
|
|
549
|
+
stores. A SQLite *backing* beneath `KvStore` remains possible without any
|
|
550
|
+
consumer changing (React Native's adapter is exactly that) — that is a
|
|
551
|
+
storage choice, not a missing engine.
|
|
356
552
|
|
|
357
553
|
|
|
358
554
|
|
|
@@ -71,7 +71,11 @@ Even with no exporter, `voltro dev` installs a **buffer-only** tracer — that's
|
|
|
71
71
|
|
|
72
72
|
## Metrics export
|
|
73
73
|
|
|
74
|
-
Separate from tracing, the framework records **metrics** into Effect's global `MetricRegistry` — one source of truth (`voltro_rpc_*`, `voltro_http_*`, `voltro_plugin_hook_*`, `voltro_subscription_*` + `voltro_subscriptions_active`, `voltro_db_*`, plus `effect_fiber_*` and any custom metric). Three ways to get them out:
|
|
74
|
+
Separate from tracing, the framework records **metrics** into Effect's global `MetricRegistry` — one source of truth (`voltro_rpc_*`, `voltro_http_*`, `voltro_plugin_hook_*`, `voltro_subscription_*` + `voltro_subscriptions_active`, `voltro_db_*`, `voltro_ppr_*`, `voltro_queue_*`, plus `effect_fiber_*` and any custom metric). Three ways to get them out:
|
|
75
|
+
|
|
76
|
+
The **web server** additionally exports [partial prerendering](/docs/routing/render-modes#partial-prerendering-ppr-cached-shell-per-request-holes), labelled by `page` (the declared route pattern, never a resolved URL): `voltro_ppr_shell_serves_total{page}`, `voltro_ppr_hole_passes_total{page}`, `voltro_ppr_hole_settles_total{page}`, `voltro_ppr_hole_errors_total{page}` and the `voltro_ppr_hole_pass_seconds{page}` histogram. `voltro dev` and `voltro start` emit the same set. Shell hit-rate is the isr cache's own `x-voltro-cache` HIT/STALE/MISS accounting — a ppr shell is a normal isr entry.
|
|
77
|
+
|
|
78
|
+
`voltro_ppr_hole_errors_total` is the one to alert on: a failed hole pass is invisible from outside. The shell is already on the wire with a `200`, so the page renders and every `<Await>` boundary simply stays on its fallback — a page that looks like it is loading and never will. A single hole *rejecting* is not counted there; that settles the deferred-error envelope and renders the boundary's `errorFallback`.
|
|
75
79
|
|
|
76
80
|
```bash
|
|
77
81
|
# OTLP metrics — the SAME OTEL endpoint that enables trace export also enables
|
|
@@ -18,7 +18,7 @@ _JiraService + ConfluenceService over the Atlassian REST / Greenhopper / Agile A
|
|
|
18
18
|
Two auth modes, both first-class (choose per deployment):
|
|
19
19
|
|
|
20
20
|
- **PAT / basic** — a Personal Access Token per subject. The default for Jira/Confluence **Data Center / Server**. Wire it via `credentialsResolver` (below).
|
|
21
|
-
- **OAuth 2.0 (3LO)** — the authorization-code flow for Atlassian **Cloud**, where the app acts on behalf of a consenting user. Use the toolkit ([OAuth 2.0 (3LO)](#oauth-
|
|
21
|
+
- **OAuth 2.0 (3LO)** — the authorization-code flow for Atlassian **Cloud**, where the app acts on behalf of a consenting user. Use the toolkit ([OAuth 2.0 (3LO)](#oauth-2-0-3lo)) to obtain an access token, then feed it into the same `credentialsResolver`.
|
|
22
22
|
|
|
23
23
|
It also supports [inbound Jira/Confluence webhooks](#inbound-webhooks) (signature-verified) and writing comments to issues and pages.
|
|
24
24
|
|
|
@@ -55,7 +55,7 @@ export default {
|
|
|
55
55
|
> there it travels with the identity into everything that persists a Subject. A
|
|
56
56
|
> reporter found a working Jira PAT in plaintext in 12 of 23 rows of their
|
|
57
57
|
> `_voltro_audit_log` exactly that way. The `store` handle above exists so it
|
|
58
|
-
> never has to enter the Subject; [`connectionCredentials`](
|
|
58
|
+
> never has to enter the Subject; [`connectionCredentials`](/docs/data/connections) is
|
|
59
59
|
> better still, because then you do not hold the token at all.
|
|
60
60
|
|
|
61
61
|
The resolver returns `AtlassianCredentials`:
|
|
@@ -73,7 +73,7 @@ auditPlugin({
|
|
|
73
73
|
// custom function: (event: AuditEvent) => void | Promise<void> | Effect.Effect<void>
|
|
74
74
|
```
|
|
75
75
|
|
|
76
|
-
`'datastore'` is the production sink: it survives restarts, is shared across replicas, and is queryable via `ctx.store.select('_voltro_audit_log')`. Each row carries the flattened `tag` / `at` / `subjectId` / `tenantId` / `traceId` / `status` / `durationMs` (indexed by `tag` + `traceId`) plus the full `subject` / `input` / `outcome` as portable `json()` columns, plus the `chainId` / `seq` / `prevHash` / `hash` tamper-evidence columns (see [the hash chain](#tamper-evidence
|
|
76
|
+
`'datastore'` is the production sink: it survives restarts, is shared across replicas, and is queryable via `ctx.store.select('_voltro_audit_log')`. Each row carries the flattened `tag` / `at` / `subjectId` / `tenantId` / `traceId` / `status` / `durationMs` (indexed by `tag` + `traceId`) plus the full `subject` / `input` / `outcome` as portable `json()` columns, plus the `chainId` / `seq` / `prevHash` / `hash` tamper-evidence columns (see [the hash chain](#tamper-evidence-the-hash-chain)).
|
|
77
77
|
|
|
78
78
|
The custom function is the escape hatch for persisting events anywhere the built-in table's schema doesn't fit — e.g. an `Effect.Effect<void>` sink that writes rows into your own audit table on top of `@effect/sql`. All return shapes are normalised by the interceptor. A sink that throws / rejects / dies is caught and swallowed, so a broken sink can never mask the mutation's real outcome.
|
|
79
79
|
|
|
@@ -406,7 +406,7 @@ subjects *do* carry a `teamId`, which made subject-only worse than nothing for
|
|
|
406
406
|
them: it would have populated for key-authenticated calls and been null for every
|
|
407
407
|
human one, so a filtered view would have looked like it worked.
|
|
408
408
|
|
|
409
|
-
The input here is **raw** — not what [`redactInput`](#redactinput
|
|
409
|
+
The input here is **raw** — not what [`redactInput`](#redactinput-what-of-the-payload-is-kept)
|
|
410
410
|
will store. That is required (a scope derived from a redacted payload is not
|
|
411
411
|
derivable at all) and it is a hazard worth naming: whatever you return lands in
|
|
412
412
|
`scope`, which is *not* redacted. Return the dimension, never the payload.
|
|
@@ -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
|
|
|
@@ -69,7 +69,7 @@ export default (input: { tenantId: string }, _ctx) =>
|
|
|
69
69
|
})
|
|
70
70
|
```
|
|
71
71
|
|
|
72
|
-
The subscription row lives in your DB; the provider is the source of truth and webhooks keep the row in sync. `plan()` returns the plan whose **limits apply right now**: `'free'` with no subscription, the paid plan while active or trialing, and — because a bounced card should not downgrade a customer on the same second — the paid plan for the whole [grace period](#failed-payments
|
|
72
|
+
The subscription row lives in your DB; the provider is the source of truth and webhooks keep the row in sync. `plan()` returns the plan whose **limits apply right now**: `'free'` with no subscription, the paid plan while active or trialing, and — because a bounced card should not downgrade a customer on the same second — the paid plan for the whole [grace period](#failed-payments-stripe-retries-dunning-composes-on-the-outcome) after a failed payment, falling back only once the lockout is real.
|
|
73
73
|
|
|
74
74
|
## Entitlement checks
|
|
75
75
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# CDC-out (reverse-ETL)
|
|
2
2
|
|
|
3
|
-
> Declaratively mirror table changes outward to external sinks (webhook, plus a CdcSink interface for custom sinks) through a durable outbox — ordered per pipe, at-least-once from enqueue, retried with backoff, dead-lettered.
|
|
3
|
+
> Declaratively mirror table changes outward to external sinks (webhook, Kafka, plus a CdcSink interface for custom sinks) through a durable outbox — ordered per pipe, at-least-once from enqueue, retried with backoff, dead-lettered.
|
|
4
4
|
|
|
5
5
|
|
|
6
6
|
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
<!-- source: en/plugins/cdc-out.md -->
|
|
10
10
|
## CDC-out (reverse-ETL)
|
|
11
11
|
|
|
12
|
-
_Declaratively mirror table changes outward to external sinks (webhook, plus a CdcSink interface for custom sinks) through a durable outbox — ordered per pipe, at-least-once from enqueue, retried with backoff, dead-lettered._
|
|
12
|
+
_Declaratively mirror table changes outward to external sinks (webhook, Kafka, plus a CdcSink interface for custom sinks) through a durable outbox — ordered per pipe, at-least-once from enqueue, retried with backoff, dead-lettered._
|
|
13
13
|
|
|
14
14
|
# CDC-out — declarative reverse-ETL
|
|
15
15
|
|
|
@@ -66,7 +66,12 @@ plugin name (`@voltro/plugin-cdc-out#analytics`) and the inspect mount
|
|
|
66
66
|
- **`webhookSink(url, { headers? })`** — POSTs each batch as
|
|
67
67
|
`{ records: [...] }` JSON, honoring the engine's per-attempt abort signal.
|
|
68
68
|
Its host is declared as a `network:outbound:<host>` permission automatically.
|
|
69
|
-
-
|
|
69
|
+
- **`kafkaSink({ topic })`** — from
|
|
70
|
+
[`@voltro/plugin-queue`](/docs/plugins/queue#cdc-out-to-kafka), producing
|
|
71
|
+
through the same provider your consumers use: message key = the row id (one
|
|
72
|
+
row's changes stay ordered in one partition), value = the change record, and
|
|
73
|
+
the `x-voltro-delivery-key` header carries the dedupe handle below.
|
|
74
|
+
- **Warehouse (Snowflake / BigQuery / …)** — implement the `CdcSink` interface
|
|
70
75
|
(`{ name, deliver(batch, ctx), outboundHost? }`). `deliver` may return a
|
|
71
76
|
**Promise or an Effect** — both compose without wrapping. The engine is
|
|
72
77
|
connector-agnostic; the sink is the only thing that changes.
|
|
@@ -0,0 +1,164 @@
|
|
|
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
|
+
`useComments(anchor, apiName?)` subscribes to `comments.list`, whose `source:`
|
|
94
|
+
is the plugin's own reactivity channel — so a second client sees a new comment
|
|
95
|
+
without a reload, and every action on the handle (`create`, `edit`, `resolve`,
|
|
96
|
+
`remove`, `react`, `markRead`) re-runs the list for all subscribers.
|
|
97
|
+
`unreadCount` is the sum of the threads' own counters, i.e. the badge number.
|
|
98
|
+
|
|
99
|
+
Two focused hooks sit beside it, both from the same entry point:
|
|
100
|
+
|
|
101
|
+
```tsx
|
|
102
|
+
import { useThread, useMentionSearch } from '@voltro/plugin-comments/web'
|
|
103
|
+
|
|
104
|
+
const thread = useThread(`orders:${id}`, threadId) // ThreadView | undefined
|
|
105
|
+
const people = useMentionSearch(term) // [{ subjectId, label }]
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
`useThread(anchor, threadId, apiName?)` is a projection over the SAME live
|
|
109
|
+
list — it opens no second subscription, so a detail pane beside the thread
|
|
110
|
+
list costs nothing. `useMentionSearch(query, apiName?)` drives the
|
|
111
|
+
`@`-autocomplete over `comments.mentionSearch`; the directory it searches is
|
|
112
|
+
the app's own `resolveMentions` seam, already tenant-filtered by the rule
|
|
113
|
+
below.
|
|
114
|
+
|
|
115
|
+
## Mentions are tenant-safe by construction
|
|
116
|
+
|
|
117
|
+
The `resolveMentions` seam RECEIVES the calling subject — the signature makes
|
|
118
|
+
forgetting impossible — and the plugin re-filters whatever your resolver
|
|
119
|
+
returns to the caller's tenant (opt out with `crossTenant: true` for
|
|
120
|
+
single-tenant apps). Mentions are ALSO re-validated at create time against the
|
|
121
|
+
same resolver, so a hand-crafted mention on a foreign tenant is dropped, not
|
|
122
|
+
delivered: the `@`-autocomplete cannot leak existence or names across the
|
|
123
|
+
boundary, and no notification ever crosses it.
|
|
124
|
+
|
|
125
|
+
A validated mention delivers through
|
|
126
|
+
[`plugin-notifications`](/docs/plugins/notifications) when it is configured —
|
|
127
|
+
recipient preferences, quiet hours and digests apply (ten mentions inside a
|
|
128
|
+
digest window roll into ONE delivery). Without the notifications plugin the
|
|
129
|
+
mention still renders in the thread; the push half is simply absent (a log
|
|
130
|
+
note, never an error).
|
|
131
|
+
|
|
132
|
+
## Moderation is opt-in, honestly
|
|
133
|
+
|
|
134
|
+
Nothing is filtered automatically. To moderate comment bodies, add one rule to
|
|
135
|
+
[`plugin-moderation`](/docs/plugins/moderation):
|
|
136
|
+
|
|
137
|
+
```ts
|
|
138
|
+
moderationPlugin({ rules: [{ match: /^comments\./, fields: ['body'] }] })
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
Deleting others' comments takes the `comments:moderate` scope; editing is
|
|
142
|
+
always author-only.
|
|
143
|
+
|
|
144
|
+
## What else ships
|
|
145
|
+
|
|
146
|
+
- **Reactions** — per-emoji toggle, aggregated with `count` + `mine`, in the
|
|
147
|
+
live delta.
|
|
148
|
+
- **Thread unread** — a per-subject read marker (`markRead`); `useComments`
|
|
149
|
+
returns `unreadCount` (your own comments are never unread for you).
|
|
150
|
+
- **Resolve / reopen** — anyone who may read the anchor may resolve (the
|
|
151
|
+
Liveblocks semantic).
|
|
152
|
+
- **Attachments** are a declared limit: grant an upload via
|
|
153
|
+
[`plugin-storage`](/docs/plugins/storage) and put the URL in the body —
|
|
154
|
+
first-class `attachments[]` is deliberately not built until the storage
|
|
155
|
+
grant flow is the proven shape.
|
|
156
|
+
- **Known reactivity granularity:** the live feed is channel-wide — every
|
|
157
|
+
comment write re-runs every open `comments.list` subscription app-wide.
|
|
158
|
+
Fine for team-scale commenting; the read-set work on the realtime roadmap
|
|
159
|
+
is the named narrowing.
|
|
160
|
+
|
|
161
|
+
## Observability
|
|
162
|
+
|
|
163
|
+
`GET /_voltro/inspect/plugins/comments/threads` + a Comments panel in both
|
|
164
|
+
dashboards (volume, open/resolved, recent threads).
|