@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
|
@@ -379,7 +379,7 @@ Emits `CONSTRAINT <name> UNIQUE (col1, col2, ...)` inline in CREATE
|
|
|
379
379
|
TABLE on every dialect. Standard SQL.
|
|
380
380
|
|
|
381
381
|
This is what backs `ctx.store.upsert(..., { conflictColumns: ['a', 'b'] })`
|
|
382
|
-
— see [Bulk operations](/docs/database/bulk-operations#upsert).
|
|
382
|
+
— see [Bulk operations](/docs/database/bulk-operations#upsert-insert-or-update-on-conflict).
|
|
383
383
|
|
|
384
384
|
## GiST indexes (PostGIS spatial)
|
|
385
385
|
|
|
@@ -689,7 +689,7 @@ mssql, sqlite 3.8+).
|
|
|
689
689
|
depend on?"
|
|
690
690
|
|
|
691
691
|
When you don't have a recursive structure, plain
|
|
692
|
-
[`withCte()`](/docs/database/query-builder#ctes) is enough.
|
|
692
|
+
[`withCte()`](/docs/database/query-builder#ctes-common-table-expressions) is enough.
|
|
693
693
|
|
|
694
694
|
## Shape
|
|
695
695
|
|
|
@@ -820,7 +820,7 @@ eager-load with `.with({...})` to get the per-field pre-filter.
|
|
|
820
820
|
|
|
821
821
|
## See also
|
|
822
822
|
|
|
823
|
-
- [Plain CTEs](/docs/database/query-builder#ctes) — `withCte()` for
|
|
823
|
+
- [Plain CTEs](/docs/database/query-builder#ctes-common-table-expressions) — `withCte()` for
|
|
824
824
|
non-recursive named sub-queries
|
|
825
825
|
- [Self-joins](/docs/database/self-joins) — for single-level
|
|
826
826
|
parent/child queries
|
|
@@ -830,7 +830,7 @@ eager-load with `.with({...})` to get the per-field pre-filter.
|
|
|
830
830
|
`unionAll`, the mechanism a recursive CTE is built on
|
|
831
831
|
- [Joins](/docs/database/joins) — relation-based traversal when the
|
|
832
832
|
graph depth is fixed (e.g. parent + immediate children)
|
|
833
|
-
- [Aggregations](/docs/database/query-builder#
|
|
833
|
+
- [Aggregations](/docs/database/query-builder#aggregates) —
|
|
834
834
|
COUNT/SUM/AVG over a recursive CTE's result set
|
|
835
835
|
|
|
836
836
|
|
|
@@ -940,7 +940,7 @@ keep the branches' result sets bounded.
|
|
|
940
940
|
the column-level "in A but not in B" case
|
|
941
941
|
- [Aggregations](/docs/database/aggregations) — `count()` etc. on
|
|
942
942
|
a set-op result is a common pattern
|
|
943
|
-
- [CTEs](/docs/database/query-builder#ctes) — name a complex set-op
|
|
943
|
+
- [CTEs](/docs/database/query-builder#ctes-common-table-expressions) — name a complex set-op
|
|
944
944
|
result so you can reference it in a larger query
|
|
945
945
|
|
|
946
946
|
|
|
@@ -1044,7 +1044,7 @@ Non-correlated only. The inner query can NOT reference outer-row
|
|
|
1044
1044
|
columns like `WHERE inner.userId = users.id`. For correlated
|
|
1045
1045
|
sub-queries (a common shape: "user who has at least one post created
|
|
1046
1046
|
in the last hour") use a [Self-join](/docs/database/self-joins) or
|
|
1047
|
-
an [Eager-load](/docs/database/joins#eager-loading) — both can
|
|
1047
|
+
an [Eager-load](/docs/database/joins#eager-loading-via-with-spec) — both can
|
|
1048
1048
|
express the same query without the correlation reference.
|
|
1049
1049
|
|
|
1050
1050
|
## Reactivity
|
|
@@ -1069,7 +1069,7 @@ every dialect we ship. No per-dialect dispatch.
|
|
|
1069
1069
|
with `count()` etc. for "count of X where Y belongs to Z"
|
|
1070
1070
|
- [Self-joins](/docs/database/self-joins) — when the relationship
|
|
1071
1071
|
can be expressed as a join instead
|
|
1072
|
-
- [CTEs](/docs/database/query-builder#ctes) — for naming a
|
|
1072
|
+
- [CTEs](/docs/database/query-builder#ctes-common-table-expressions) — for naming a
|
|
1073
1073
|
sub-query you reuse multiple times in the same outer query
|
|
1074
1074
|
|
|
1075
1075
|
|
|
@@ -350,7 +350,7 @@ on it explicitly via `.expressionIndex(name, [...], { ... })`.
|
|
|
350
350
|
|
|
351
351
|
- [Columns](/docs/database/columns) — `.computed(row => ...)` and
|
|
352
352
|
`.default(() => ...)` for the app-side variants
|
|
353
|
-
- [Indexes](/docs/database/indexes#expression) — `.expressionIndex()`
|
|
353
|
+
- [Indexes](/docs/database/indexes#expression-indexes) — `.expressionIndex()`
|
|
354
354
|
for indexing a generated column
|
|
355
355
|
- [Full-text search](/docs/database/full-text-search) — the FTS
|
|
356
356
|
pattern uses STORED tsvector generated columns
|
|
@@ -989,7 +989,7 @@ behavioural gaps are too large to paper over.
|
|
|
989
989
|
## See also
|
|
990
990
|
|
|
991
991
|
- [Columns](/docs/database/columns) — the regular schema-DSL types
|
|
992
|
-
- [Expression indexes](/docs/database/indexes#expression) — the
|
|
992
|
+
- [Expression indexes](/docs/database/indexes#expression-indexes) — the
|
|
993
993
|
framework's index API (`kind: 'gist' | 'gin'` on postgres)
|
|
994
994
|
- [PostGIS docs](https://postgis.net/docs/) — the official manual,
|
|
995
995
|
authoritative for every spatial function the framework re-exports
|
|
@@ -309,7 +309,7 @@ The query builder has dedicated pages for the deeper topics:
|
|
|
309
309
|
- **[Self-joins](/docs/database/self-joins)** — `.as(alias)` +
|
|
310
310
|
`.innerJoin(table, alias, on)` + `.selectJoined({...})` for parent/
|
|
311
311
|
child trees and CTE references.
|
|
312
|
-
- **[CTEs](/docs/database/query-builder#ctes)** — `.withCte(name, sub)`
|
|
312
|
+
- **[CTEs](/docs/database/query-builder#ctes-common-table-expressions)** — `.withCte(name, sub)`
|
|
313
313
|
for named sub-queries reusable inside the outer SELECT.
|
|
314
314
|
- **[Recursive CTEs](/docs/database/recursive-cte)** — `.recursiveCte`
|
|
315
315
|
for tree walks (org hierarchy, comment threads, file folders).
|
|
@@ -445,7 +445,7 @@ framework runs on the post-commit change channel.
|
|
|
445
445
|
**Reach for `reference()` first.** A real foreign key across a plugin boundary
|
|
446
446
|
works and survives the plugin renaming its table, because `reference()` takes
|
|
447
447
|
the table as a VALUE — see
|
|
448
|
-
[plugins/overview](/docs/plugins/overview
|
|
448
|
+
[plugins/overview](/docs/plugins/overview).
|
|
449
449
|
`pluginRef` is for the case where you have deliberately chosen NOT to have a
|
|
450
450
|
key: it enforces nothing at the database level.
|
|
451
451
|
|
|
@@ -712,7 +712,7 @@ yield* ctx.store.update('documents', input.id, { title: input.title, version: in
|
|
|
712
712
|
|
|
713
713
|
**Why not `updatedAt`.** A timestamp cannot do this job. Two writes in the same millisecond are indistinguishable, and across replicas the clocks disagree — a comparison that looks correct in a test loses rows under load. An integer the database owns is totally ordered and needs no clock. (`.version()` therefore rejects a `text()` or `timestamp()` column at declaration.)
|
|
714
714
|
|
|
715
|
-
**What it does not do.** It is not a history — it records *that* a row changed, not what to; use [`plugin-
|
|
715
|
+
**What it does not do.** It is not a history — it records *that* a row changed, not what to; use [`plugin-row-history`](/docs/plugins/row-history) for that. It is not a lock: a conflict is **reported**, never queued or merged, because merging two intents is a decision only your application can make. And it is not a retry — "re-apply my change on top of theirs" is correct for some changes and wrong for others, so you write it.
|
|
716
716
|
|
|
717
717
|
**Three details worth knowing:**
|
|
718
718
|
|
|
@@ -543,7 +543,7 @@ and cannot be engineered away:
|
|
|
543
543
|
- **`old` is null on an oversized update, and pk-only on an oversized delete.**
|
|
544
544
|
There is nowhere to read a pre-image from. A tombstone is enough to REMOVE the
|
|
545
545
|
row from a search index, an analytics mirror or a CDC stream; it is not a
|
|
546
|
-
record of what the row contained, and `@voltro/plugin-
|
|
546
|
+
record of what the row contained, and `@voltro/plugin-row-history` writes
|
|
547
547
|
`data: null` for one rather than a fabricated empty snapshot.
|
|
548
548
|
- **`'unrecovered'` means the content is gone.** No retry can bring it back —
|
|
549
549
|
it was never delivered. Subscriptions are unaffected (they re-query); taps
|
|
@@ -1055,7 +1055,7 @@ MariaDB's GTID format differs from MySQL's: `0-1-100` (domain-server-sequence) v
|
|
|
1055
1055
|
- **`mariadb` schema package is wire-compatible with `mysql`**. If you migrate from MySQL → MariaDB, the framework re-emits DDL cleanly via `applySchema(..., 'mariadb')`. Production data round-trips through `mysqldump` without translation.
|
|
1056
1056
|
- **`sql_mode=NO_BACKSLASH_ESCAPES`** is sometimes set on MariaDB deploys. The framework's identifier escaping handles it, but user-written `unsafe()` strings that hand-escape backslashes may produce wrong output. Leave that mode off if you can.
|
|
1057
1057
|
- **Sequence-based ID columns**. MariaDB has true CREATE SEQUENCE; the framework doesn't use it (TypeID / ULID / Snowflake are client-side). If you reach for sequences for legacy reasons, they're outside the framework's auto-injection path.
|
|
1058
|
-
- **Hand-rolled `AUTO_INCREMENT` primary keys** work the same as on mysql: an `insert` / `insertMany` with no client-side `id` recovers the DB-generated id via `LAST_INSERT_ID()` (connection-pinned; `insertMany` recovers the whole consecutive range). See the [mysql page](/docs/database/dialects/mysql
|
|
1058
|
+
- **Hand-rolled `AUTO_INCREMENT` primary keys** work the same as on mysql: an `insert` / `insertMany` with no client-side `id` recovers the DB-generated id via `LAST_INSERT_ID()` (connection-pinned; `insertMany` recovers the whole consecutive range). See the [mysql page](/docs/database/dialects/mysql) for the worked example — the recovery path is identical on both engines.
|
|
1059
1059
|
|
|
1060
1060
|
## Where it lives
|
|
1061
1061
|
|
|
@@ -261,7 +261,7 @@ await ctx.store.upsert('orgSlugs', {
|
|
|
261
261
|
```
|
|
262
262
|
|
|
263
263
|
Requires a composite UNIQUE constraint on the table — declare it via
|
|
264
|
-
`.unique([cols])` in the schema (see [Indexes](/docs/database/indexes#composite-unique)).
|
|
264
|
+
`.unique([cols])` in the schema (see [Indexes](/docs/database/indexes#composite-unique-constraints)).
|
|
265
265
|
|
|
266
266
|
## `insertIgnore` — keep existing on conflict
|
|
267
267
|
|
|
@@ -314,7 +314,7 @@ const count = await ctx.store.updateMany('posts', { hidden: true }, {
|
|
|
314
314
|
// count: number of rows actually updated
|
|
315
315
|
```
|
|
316
316
|
|
|
317
|
-
The `where` predicate is a regular [Predicate](/docs/database/query-builder
|
|
317
|
+
The `where` predicate is a regular [Predicate](/docs/database/query-builder)
|
|
318
318
|
AST — same shape `.where()` uses. Sub-queries via `inSubquery` /
|
|
319
319
|
`exists` are supported.
|
|
320
320
|
|
|
@@ -395,5 +395,5 @@ matters.
|
|
|
395
395
|
`aggregate()` for read-side bulk reads
|
|
396
396
|
- [Sub-queries](/docs/database/sub-queries) — `inSubquery` /
|
|
397
397
|
`exists` in `updateMany` `where:` clauses
|
|
398
|
-
- [Composite UNIQUE](/docs/database/indexes#composite-unique) — for
|
|
398
|
+
- [Composite UNIQUE](/docs/database/indexes#composite-unique-constraints) — for
|
|
399
399
|
the constraint that backs `upsert`'s `conflictColumns: ['a', 'b']`
|
|
@@ -312,11 +312,15 @@ cause.
|
|
|
312
312
|
When one api process isn't enough:
|
|
313
313
|
|
|
314
314
|
1. Run multiple api containers behind the reverse proxy. The proxy's load-balancing default (round-robin) is fine for HTTP — for WebSocket, use sticky sessions (Caddy: `lb_policy ip_hash`).
|
|
315
|
-
2.
|
|
315
|
+
2. Cross-instance subscription invalidation: on Postgres (LISTEN/NOTIFY) and MySQL/MariaDB (binlog CDC) this is built in — nothing to install. On any other dialect, or when you prefer a broker, add [`@voltro/plugin-broadcast`](/docs/plugins/broadcast) with a Redis or NATS provider to `app.config.ts.plugins` so every replica sees every change.
|
|
316
316
|
3. Workflows: `@effect/cluster` shards work across instances by workflow ID. No further config — every instance pulls from the shared workflow queue.
|
|
317
317
|
|
|
318
318
|
For multi-region: you need to run Postgres logical replication between regions yourself (or move to Voltro Cloud which handles it).
|
|
319
319
|
|
|
320
|
+
### Scaling is replicas, not a service split
|
|
321
|
+
|
|
322
|
+
Voltro deliberately ships no microservice transports — no `@MessagePattern`-style service-to-service RPC, no broker-backed internal messaging layer. Splitting one app into services would contradict the architecture thesis this whole page rests on: **one monolith process, scaled by running more replicas of it**, with `@effect/cluster` sharding durable work across instances by workflow ID. Every capability that a service split usually buys already has a first-class path: an external system boundary is [REST routes](/docs/data/rest-routes) + [OpenAPI](/docs/plugins/openapi) (`@voltro/plugin-openapi`), and a reliable outbound side effect is the [transactional outbox](/docs/data/outbox). If you find yourself wanting an internal message bus between "services", the answer is more replicas of the same image — not a second process shape.
|
|
323
|
+
|
|
320
324
|
## Backups
|
|
321
325
|
|
|
322
326
|
Postgres is the source of truth. Use your provider's backup features (Neon PITR, RDS snapshots, `pg_dump` on a cron). Object-storage assets back up via the provider's lifecycle policies.
|
|
@@ -1008,6 +1012,7 @@ indexed query instead of scanning every table). Force a full re-introspect with
|
|
|
1008
1012
|
|
|
1009
1013
|
```sh
|
|
1010
1014
|
VOLTRO_MAX_RPC_BODY_BYTES=8388608 # default 8 MiB
|
|
1015
|
+
VOLTRO_MAX_BODY_BYTES=8388608 # default 8 MiB
|
|
1011
1016
|
```
|
|
1012
1017
|
|
|
1013
1018
|
A declared `Content-Length` over the cap is refused up front, so an honest client
|
|
@@ -1114,6 +1119,14 @@ VOLTRO_LOG_FORMAT=json
|
|
|
1114
1119
|
VOLTRO_LOG_LEVEL=info
|
|
1115
1120
|
```
|
|
1116
1121
|
|
|
1122
|
+
In `json` mode (the default off a TTY, so a pod gets it without configuration)
|
|
1123
|
+
EVERY line the framework emits is a parseable record — the boot banner, the app
|
|
1124
|
+
surface, `voltro db apply`'s plan summary, refusal detail, and every subsystem
|
|
1125
|
+
logger (schedule, broadcast, workflow, flow-control). A hand-formatted table or
|
|
1126
|
+
a `[tag]`-prefixed adapter line between JSON records is a bug, not a style: log
|
|
1127
|
+
collectors show it as unparsed noise. On a TTY the same surfaces render as the
|
|
1128
|
+
human-readable banners and tables.
|
|
1129
|
+
|
|
1117
1130
|
**Error reporting** — add `sentryPlugin()` from `@voltro/plugin-sentry`. It stays inert until `SENTRY_DSN` is set, and reported errors correlate to the request `traceId`:
|
|
1118
1131
|
|
|
1119
1132
|
```ts
|
|
@@ -1401,7 +1414,7 @@ Lower the lease for **faster failover**, at the cost of **false-positive reclaim
|
|
|
1401
1414
|
- [ ] Liveness / readiness probes point at `/internal/liveness` + `/internal/readiness`
|
|
1402
1415
|
- [ ] Serving pods run `voltro serve` (not `voltro dev`), with `VOLTRO_AUTO_MIGRATE=0`
|
|
1403
1416
|
- [ ] Schema applied by a pre-deploy Job / initContainer (`voltro db apply`), not in the serving pod
|
|
1404
|
-
- [ ] `VOLTRO_MAX_RPC_BODY_BYTES` sane; ingress caps body size + per-IP rate
|
|
1417
|
+
- [ ] `VOLTRO_MAX_RPC_BODY_BYTES` + `VOLTRO_MAX_BODY_BYTES` (plugin routes/webhooks) sane; ingress caps body size + per-IP rate
|
|
1405
1418
|
- [ ] `VOLTRO_TRUSTED_PROXIES` set if you run behind an ingress AND rate-limit per IP
|
|
1406
1419
|
- [ ] `VOLTRO_ALLOWED_ORIGINS` set if the web app is on a different origin than the api
|
|
1407
1420
|
- [ ] Security headers reviewed (`VOLTRO_SECURITY_HEADERS`, `VOLTRO_CSP`); HSTS reaching the browser over https
|
|
@@ -1759,7 +1772,7 @@ and the drill is the part people skip.
|
|
|
1759
1772
|
## Which one
|
|
1760
1773
|
|
|
1761
1774
|
| | scale-to-zero | managed DB | rolling deploys | cost floor |
|
|
1762
|
-
|
|
1775
|
+
| --- | --- | --- | --- | --- |
|
|
1763
1776
|
| Fly.io | yes (`auto_stop`) | Fly Postgres | yes | ~0 idle |
|
|
1764
1777
|
| Railway | usage-based sleep | built-in | yes | ~0 idle |
|
|
1765
1778
|
| Render | paid plans only | built-in | yes | fixed/instance |
|
|
@@ -1768,3 +1781,17 @@ and the drill is the part people skip.
|
|
|
1768
1781
|
An api that owns cron schedules should not scale to zero. An api with bursty
|
|
1769
1782
|
traffic and no schedules is exactly what scale-to-zero is for. When in doubt,
|
|
1770
1783
|
the boring answer — one always-on instance — is also the cheapest to operate.
|
|
1784
|
+
|
|
1785
|
+
## Why there is no edge-SSR adapter
|
|
1786
|
+
|
|
1787
|
+
Every recipe above deploys a container, and that is deliberate: Voltro's SSR is
|
|
1788
|
+
Node-first (`renderToPipeableStream` into a Node stream, `voltro start` as a
|
|
1789
|
+
long-running Node HTTP server), not a Workers/edge runtime — so there is no
|
|
1790
|
+
Vercel-/Netlify-edge SSR adapter, and none is planned as a posture. The edge
|
|
1791
|
+
still gets first-class use where it fits the model: isolated
|
|
1792
|
+
[`*.serverless.ts` functions](/docs/deployment/serverless-functions)
|
|
1793
|
+
(`@voltro/serverless`, with Cloudflare / Scaleway / Node adapters) for
|
|
1794
|
+
request-shaped work at the edge, and
|
|
1795
|
+
[static / ISR pages](/docs/deployment/static-sites) served from a CDN for
|
|
1796
|
+
everything that does not need a per-request render. If a page must render per
|
|
1797
|
+
request, it renders in the container.
|
|
@@ -96,7 +96,7 @@ The client **adopts what the server resolved**, reading it from the `<html lang>
|
|
|
96
96
|
| `data-voltro-tz` | the IANA zone every date/time formatter renders in — set `timeZone` in `app.config.ts` |
|
|
97
97
|
| `data-voltro-now` | the server's render instant, so `useRelativeTime` produces the same string in the hydration pass |
|
|
98
98
|
|
|
99
|
-
Locale was already agreed; the zone and the clock were each read from the ambient runtime, which meant a server-rendered timestamp was a hydration mismatch waiting for a wide enough offset or a slow enough connection. See [Plurals & formatting → Timezones under SSR](/docs/i18n/formatting#timezones-under-ssr
|
|
99
|
+
Locale was already agreed; the zone and the clock were each read from the ambient runtime, which meant a server-rendered timestamp was a hydration mismatch waiting for a wide enough offset or a slow enough connection. See [Plurals & formatting → Timezones under SSR](/docs/i18n/formatting#timezones-under-ssr-the-setting-that-is-not-a-preference) — that is the page to read before you migrate hand-rolled `toLocaleString()` calls onto the hooks.
|
|
100
100
|
|
|
101
101
|
See [Catalogs](/docs/i18n/catalogs) for the type-safe catalog convention and the component hooks, [Plurals & formatting](/docs/i18n/formatting) for CLDR plural selection and the `Intl`-backed date / number / relative-time hooks, and [URL strategies](/docs/i18n/url-strategies) for cookie-only vs URL-prefix routing.
|
|
102
102
|
|
|
@@ -951,7 +951,7 @@ default, so call sites don't handle a missing-service error.
|
|
|
951
951
|
> request, publishes it on the document, and every `@voltro/i18n` formatter on
|
|
952
952
|
> both sides of the hydration boundary uses it. Read it with `useTimeZone()`
|
|
953
953
|
> from `@voltro/i18n`. See
|
|
954
|
-
> [Formatting → Timezones under SSR](/docs/i18n/formatting#timezones-under-ssr
|
|
954
|
+
> [Formatting → Timezones under SSR](/docs/i18n/formatting#timezones-under-ssr-the-setting-that-is-not-a-preference).
|
|
955
955
|
>
|
|
956
956
|
> The **server-side compute zone** — what `startOfDay` or a workflow's "same
|
|
957
957
|
> time tomorrow" resolves against inside a handler — is still the SEAM only.
|
|
@@ -132,6 +132,22 @@ Voltro is deliberately opinionated about boring things (HTTP, state, transport,
|
|
|
132
132
|
- **Magic.** Every file convention is documented; every generated file lives in `.framework/` and you can read it.
|
|
133
133
|
- **Codegen you have to remember to run.** Schema flows from your tables to your React components automatically via Vite's module graph.
|
|
134
134
|
|
|
135
|
+
## Deliberate noes
|
|
136
|
+
|
|
137
|
+
Two questions come up in every framework comparison. Both are decided — deliberately no — and here is why, so nobody has to re-litigate them.
|
|
138
|
+
|
|
139
|
+
### Why is there no GraphQL API?
|
|
140
|
+
|
|
141
|
+
1. **GraphQL's three core promises are solved differently here.** Type-safe selective reads ⇒ typed queries + schema inference. One endpoint for every client ⇒ the RPC socket with a generated client. Third-party consumers ⇒ [REST routes](/docs/data/rest-routes) + [OpenAPI 3.1](/docs/plugins/openapi) (`@voltro/plugin-openapi`).
|
|
142
|
+
2. **A GraphQL gateway would have no access to the reactivity path** — source-based invalidation, per-delivery guards. It would be a second, dead read path whose results are never live: exactly the kind of duplicate path this framework refuses to keep.
|
|
143
|
+
3. **Resolver N+1, persisted-query complexity, and a second permission model** (field-level vs. our guards/RLS) buy nothing the existing surface cannot do.
|
|
144
|
+
|
|
145
|
+
Don't build a GraphQL layer over the stores. External consumers get REST + OpenAPI; internal clients get RPC + live subscriptions.
|
|
146
|
+
|
|
147
|
+
### Why not React Server Components?
|
|
148
|
+
|
|
149
|
+
RSC is a second rendering **and** data model — Flight serialization, `'use client'` boundaries, deep bundler integration — that would compete with the reactive subscription model instead of composing with it. The problems it solves are covered by what exists today: [islands](/docs/routing/islands) for shipping less JS, loaders for server data at render time, and streaming SSR with `defer()` for progressive delivery. Don't write `'use server'` / `'use client'` directives in a Voltro app; they mark a boundary this framework does not have.
|
|
150
|
+
|
|
135
151
|
## Where to read next
|
|
136
152
|
|
|
137
153
|
- [Getting started](/docs/intro/getting-started) — scaffold + boot in under a minute
|
|
@@ -435,6 +451,7 @@ Voltro replaces router and registry config with **filesystem conventions**. Drop
|
|
|
435
451
|
| `*.agent.server.tsx` | Server agent **executor**: `defineAgentExecutor(descriptor, { system, tools, model, maxSteps })`. | Agent runtime. |
|
|
436
452
|
| `*.tool.tsx` | A tool an agent can call. Schema + handler. | Agent runtime. |
|
|
437
453
|
| `*.webhook.tsx` | Outgoing webhook spec (target, retry, schema). | Webhook delivery worker. |
|
|
454
|
+
| `*.ws.ts` | Raw WebSocket gateway — `defineWebSocket({ path, auth, onConnection })` as the default export, for FOREIGN protocols beside the rpc socket. | Upgrade listener on the api server, both boot paths. |
|
|
438
455
|
| `*.entity.ts` | Database table — one table per file: `table()` + columns + mixins. Re-exported from a `database/index.ts` barrel. | Migrations + the runtime data store. |
|
|
439
456
|
| `*.config.ts` | App-level config (`app.config.ts`, `tsconfig.json`, etc.). | The CLI. |
|
|
440
457
|
|
|
@@ -470,6 +487,25 @@ Without the marker the leak is still caught — by the rpcGroup guard — but on
|
|
|
470
487
|
|
|
471
488
|
An unmarked file makes no claim, and that is fine: `*.client.ts` is for the shared files where the mistake is expensive, not a label to sprinkle on everything.
|
|
472
489
|
|
|
490
|
+
### Raw WebSocket gateways: `*.ws.ts`
|
|
491
|
+
|
|
492
|
+
A `*.ws.ts` file's default export mounts a raw WebSocket upgrade path beside the rpc socket — for a protocol the framework does not speak (a Yjs provider, a legacy device fleet). Discovered on **both** boot paths, `voltro dev` and `voltro serve`:
|
|
493
|
+
|
|
494
|
+
```ts
|
|
495
|
+
// gateways/collab.ws.ts
|
|
496
|
+
import { defineWebSocket } from '@voltro/protocol'
|
|
497
|
+
|
|
498
|
+
export default defineWebSocket({
|
|
499
|
+
path: '/gateways/collab',
|
|
500
|
+
auth: 'subject', // REQUIRED, no default — or 'public', a decision you write down
|
|
501
|
+
onConnection: ({ send, onMessage, subject }) => {
|
|
502
|
+
onMessage((data) => send(data)) // your protocol, your frames
|
|
503
|
+
return () => { /* teardown — runs on disconnect, credential expiry, shutdown */ }
|
|
504
|
+
},
|
|
505
|
+
})
|
|
506
|
+
```
|
|
507
|
+
|
|
508
|
+
`auth: 'subject'` authenticates through the same chain as rpc/SSR **before** the upgrade (401 while it is still http) and binds the connection to the credential's expiry (close code `4001`); every gateway path is origin-checked at upgrade. Two gateways on one path refuse the boot; a plain GET on a gateway path answers `426`. App realtime stays [subscriptions](/docs/data/subscriptions) — full detail under [Raw WebSocket gateways](/docs/data/subscriptions#raw-websocket-gateways-definewebsocket).
|
|
473
509
|
|
|
474
510
|
## The web side (`apps/*/web/`)
|
|
475
511
|
|
|
@@ -482,6 +518,10 @@ An unmarked file makes no claim, and that is fine: `*.client.ts` is for the shar
|
|
|
482
518
|
| `src/pages/loading.tsx` | Pending UI shown while loaders resolve. |
|
|
483
519
|
| `src/pages/(group)/` | Route group — does not contribute a URL segment, but layout/error files inside still apply. |
|
|
484
520
|
| `src/*.island.tsx` | A client-side hydration island (split chunk). Used in `interactive: 'islands'` pages. |
|
|
521
|
+
| `src/**/*.collection.ts` | A content collection declaration (`defineCollection`) — schema-typed markdown/JSON under `content/<name>/**`. See [Content collections](/docs/data/content-collections). |
|
|
522
|
+
| `src/**/*.consumer.ts` | A queue consumer (`defineQueueConsumer`, @voltro/plugin-queue) — Schema-decoded, at-least-once, serial per partition. See [Queue](/docs/plugins/queue). |
|
|
523
|
+
| `grpc.manifest.json` | The gRPC field-number manifest (checked in — append-only wire identity; deletes go `reserved`). See [gRPC surface](/docs/data/grpc). |
|
|
524
|
+
| `content/<name>/**` | A collection's content files (markdown with frontmatter, or `.json` for data collections). Read by `getCollection`/`getEntry`. |
|
|
485
525
|
|
|
486
526
|
Each page can opt into a render strategy via two exports:
|
|
487
527
|
|
|
@@ -494,6 +534,16 @@ export const interactive = 'islands' as const // 'none' | 'islands' | 'full
|
|
|
494
534
|
- `renderMode` controls when the HTML is produced (build vs. request).
|
|
495
535
|
- `interactive` controls how much JS ships (`'none'` strips it all, `'full'` hydrates the page, `'islands'` hydrates only `.island.tsx` files).
|
|
496
536
|
|
|
537
|
+
A page can also declare its query-string contract as a page export:
|
|
538
|
+
|
|
539
|
+
```tsx
|
|
540
|
+
export const searchParams = Schema.Struct({
|
|
541
|
+
page: Schema.optionalWith(Schema.NumberFromString, { default: () => 1 }),
|
|
542
|
+
})
|
|
543
|
+
```
|
|
544
|
+
|
|
545
|
+
- `searchParams` (an `effect/Schema` struct — every field optional or with a default) types the page's query string: `useSearchParams(searchParams)` returns the decoded shape, and links built with `withQuery` type-check against it. Details: [Pages → Query strings](/docs/routing/pages#query-strings).
|
|
546
|
+
|
|
497
547
|
## Discovery in practice
|
|
498
548
|
|
|
499
549
|
```text
|
|
@@ -553,6 +603,8 @@ If yes, the promise belongs in the name — you cannot see a contract before you
|
|
|
553
603
|
| `*.tracking.ts` | analytics happens nowhere else | `tracking/outside-tracking-file` |
|
|
554
604
|
| `*.client.ts` | it and its imports are browser-safe | boot-time import walk, `client/not-browser-safe` |
|
|
555
605
|
| `*.store.ts` | exactly one `defineStore`, no server state | `store/one-per-file`, `store/mirrors-server-state` |
|
|
606
|
+
| `*.collection.ts` | declares content collections (`defineCollection`); frontmatter schema violations fail the build naming the file | the build's collection decode + reference validation |
|
|
607
|
+
| `*.consumer.ts` | declares queue consumers (`defineQueueConsumer`, @voltro/plugin-queue); loading registers, the plugin's activation starts them | the queue runner (decode→DLQ, retry→DLQ, commit-per-message) |
|
|
556
608
|
|
|
557
609
|
A `*.component.tsx` promises exactly ONE component. It does not promise to export nothing else: types, and plain module-local values a `const COLUMNS = […]` beside the table that renders them, are fine and always were. What the rule counts is components — a declaration that renders — so an object, an array, a string or a `new` beside your component is not a second one, and neither is `export default Card` next to `export const Card`.
|
|
558
610
|
|
|
@@ -26,10 +26,14 @@ imports it directly. The React wrappers live behind `@voltro/local-first/react`
|
|
|
26
26
|
> transport, [`useCrdtText`](#a-collaborative-text-field-usecrdttext) — the React
|
|
27
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
|
-
> [runtime binding](#whats-shipped-vs-a-runtime-seam) to
|
|
31
|
-
> (a broker at scale) plus the two app-specific tags
|
|
32
|
-
>
|
|
29
|
+
> [`localFirst` table mixin](#the-localfirst-table-mixin). What remains falls in
|
|
30
|
+
> two tiers: a thin [runtime binding](#whats-shipped-vs-a-runtime-seam) to
|
|
31
|
+
> provisioned infra (a broker at scale) plus the two app-specific tags
|
|
32
|
+
> `useCrdtText` is pointed at — and the sync **engine**, which is now BUILT:
|
|
33
|
+
> the [query mirror](#the-sync-engine-query-mirror--durable-outbox) persists
|
|
34
|
+
> every subscribed query's rows per subject+tenant partition, `useOutbox`
|
|
35
|
+
> queues offline writes durably, and a reload renders mirrored rows offline
|
|
36
|
+
> and delta-resumes online.
|
|
33
37
|
|
|
34
38
|
## CRDT text: `crdtText` + `mergeCrdtStates`
|
|
35
39
|
|
|
@@ -204,6 +208,26 @@ import { Schema } from 'effect'
|
|
|
204
208
|
body: Schema.NullOr(Schema.Uint8ArrayFromBase64)
|
|
205
209
|
```
|
|
206
210
|
|
|
211
|
+
### Known cost limits of `crdtText()` today
|
|
212
|
+
|
|
213
|
+
Two amplification effects are worth knowing before you put a `crdtText()` column
|
|
214
|
+
on a hot editing path — both are per-keystroke costs, and both are real today:
|
|
215
|
+
|
|
216
|
+
- **Wire amplification downstream.** A subscription delta carries the row's
|
|
217
|
+
columns, and for a CRDT column that is the merged **full state** (base64) —
|
|
218
|
+
every keystroke ships the whole document to every subscriber of the query,
|
|
219
|
+
not the one-edit update. Keep the streamed query's projection narrow (don't
|
|
220
|
+
project `body` into a list view), or subscribe to the document row alone.
|
|
221
|
+
- **Undo/row-history capture.** Server-side capture (the undo log — default-on
|
|
222
|
+
outside production — and `plugin-row-history`'s row history, where enabled)
|
|
223
|
+
snapshots the row per mutation, so per-keystroke mutations write a
|
|
224
|
+
full-state blob per keystroke into those tables. Point them away from
|
|
225
|
+
CRDT-heavy tables, or batch edits before pushing.
|
|
226
|
+
|
|
227
|
+
Both limits are on the framework's roadmap (incremental delivery and
|
|
228
|
+
CRDT-aware capture); until then they are costs to design around, not bugs to
|
|
229
|
+
report.
|
|
230
|
+
|
|
207
231
|
## Presence & awareness
|
|
208
232
|
|
|
209
233
|
`usePresence(roomId, self, { channel })` publishes this peer's ephemeral state
|
|
@@ -292,6 +316,103 @@ const adapter = await createIndexedDbPersistence({ databaseName: 'my-app' })
|
|
|
292
316
|
const sync = createSyncClient({ transport, adapter }) // state now survives reload
|
|
293
317
|
```
|
|
294
318
|
|
|
319
|
+
## Rich text: `crdtDoc()` + `useCrdtEditor`
|
|
320
|
+
|
|
321
|
+
`crdtDoc()` stores a WHOLE collaborative document (rich text, maps, arrays)
|
|
322
|
+
as a column — same storage and authoritative server merge as `crdtText()`,
|
|
323
|
+
which stays as the plain-text specialisation. The editor binding is Tiptap
|
|
324
|
+
(`@voltro/local-first/editor`, optional peers): `useCrdtEditor({ doc })`
|
|
325
|
+
binds one editor to the document handle; content edits ride the app's
|
|
326
|
+
mutation (folded server-side under a per-row mutex) and remote edits arrive
|
|
327
|
+
as incremental `mergeCells` subscription deltas — a one-character edit ships
|
|
328
|
+
under 1 KB in BOTH directions regardless of document size.
|
|
329
|
+
|
|
330
|
+
Carets ride a `delivery: 'latest'` EVENT, deliberately not presence
|
|
331
|
+
metadata: the roster's value-compare push would make every caret move a
|
|
332
|
+
"real" change. `attachAwarenessBridge` publishes one member's state per
|
|
333
|
+
envelope (never the aggregated room). Stable positions for inline comments
|
|
334
|
+
come from `encodeAnchor`/`resolveAnchor` on the doc handle.
|
|
335
|
+
|
|
336
|
+
Operational rules: the stored blob soft-compacts past
|
|
337
|
+
`VOLTRO_CRDT_COMPACT_MAX_BYTES` (default 512 KiB) without breaking the merge
|
|
338
|
+
lineage; `rebaseText` is the explicit hard reset (a NEW EPOCH — subscribers
|
|
339
|
+
receive it as a fresh snapshot). CRDT columns are excluded from undo capture
|
|
340
|
+
and row history (document history = named snapshots taken BEFORE
|
|
341
|
+
compaction); `.serverOnly()` on a CRDT column is a declaration error and
|
|
342
|
+
`.encrypted()` makes it online-only.
|
|
343
|
+
|
|
344
|
+
## The sync engine: query mirror + durable outbox
|
|
345
|
+
|
|
346
|
+
The engine's two halves ride the primitives you already use — there is no
|
|
347
|
+
second data API:
|
|
348
|
+
|
|
349
|
+
- **Reads.** The subscription cache accepts a `mirror`; every base movement of
|
|
350
|
+
every subscribed query persists (rows + revision) into a **subject+tenant
|
|
351
|
+
partitioned** store over IndexedDB, and a cold start seeds from it — the UI
|
|
352
|
+
renders the last materialised rows offline through the SAME
|
|
353
|
+
`useSubscription` call, and the next connect presents the mirrored revision
|
|
354
|
+
as `voltro-resume-from`, so a reload inside the resume window continues with
|
|
355
|
+
deltas instead of a snapshot.
|
|
356
|
+
- **Writes.** `useOutbox` with `persistence: outboxPersistence(adapter)` is
|
|
357
|
+
the durable offline queue: writes survive a reload, replay in order on
|
|
358
|
+
reconnect, stop at the first conflict, and a conflict resolves through
|
|
359
|
+
`resolveConflict(id, resolveWithPolicy(policy, local, remote, { crdtColumns }))`
|
|
360
|
+
— `crdtText()` columns merge, scalars follow the declared `conflictPolicy()`.
|
|
361
|
+
One drain per device even with many tabs (`withDrainLock`, a per-partition
|
|
362
|
+
Web Lock).
|
|
363
|
+
|
|
364
|
+
```ts
|
|
365
|
+
import {
|
|
366
|
+
createDurableKv, createQueryMirror, createSubscriptionMirrorBinding,
|
|
367
|
+
} from '@voltro/local-first'
|
|
368
|
+
import { localFirstTables } from './.framework/localFirst.generated'
|
|
369
|
+
|
|
370
|
+
const { kv, durability } = await createDurableKv() // 'memory' = visible degradation
|
|
371
|
+
const mirror = createQueryMirror(kv, { subjectId, tenantId }) // ONE partition per subject
|
|
372
|
+
const binding = createSubscriptionMirrorBinding(mirror, {
|
|
373
|
+
tags: { 'docs.list': 'docs' }, // the app's sync set
|
|
374
|
+
metadata: localFirstTables, // codegen: encrypted columns stripped
|
|
375
|
+
schemaFingerprint: BUILD_ID, // local-DB migration gate
|
|
376
|
+
})
|
|
377
|
+
```
|
|
378
|
+
|
|
379
|
+
The deliberate design decision, recorded here because the obvious alternative
|
|
380
|
+
keeps being suggested: the engine is **not** a browser SQL database. The
|
|
381
|
+
client's whole query surface is `(tag, input)` — predicates are built and
|
|
382
|
+
evaluated on the server — so a wa-sqlite instance would evaluate a language
|
|
383
|
+
the client never sees. What offline needs is the last materialised answer per
|
|
384
|
+
query the user visited, kept current by deltas; that is what the mirror
|
|
385
|
+
stores. (The `KvStore` seam still admits a SQLite backing without touching a
|
|
386
|
+
consumer.)
|
|
387
|
+
|
|
388
|
+
Soundness rules, all enforced structurally and tested:
|
|
389
|
+
|
|
390
|
+
- **Partition by key.** Every stored key carries subject AND tenant; a
|
|
391
|
+
logout/login as somebody else can never read the predecessor's rows, and
|
|
392
|
+
`purge()` empties exactly one partition on revocation. Build ONE binding per
|
|
393
|
+
resolved subject and rebuild it on an auth change — the same blank-on-auth
|
|
394
|
+
doctrine the subscription cache itself follows.
|
|
395
|
+
- **`.encrypted()` never lands.** The server decrypts on read, so a naive
|
|
396
|
+
mirror would persist plaintext on the device; the codegen-emitted
|
|
397
|
+
`localFirst.generated.ts` names those columns and the binding strips them
|
|
398
|
+
before every save. `.serverOnly()` columns never reach the wire at all.
|
|
399
|
+
- **The snapshot is the visible state.** A save replaces the mirrored row
|
|
400
|
+
set, so a row the server stopped sending (revoked share, RLS change, soft
|
|
401
|
+
delete) is evicted by construction.
|
|
402
|
+
- **Schema migration is a visible cold start.** Entries persist under the
|
|
403
|
+
build's `schemaFingerprint`; a new build's load misses them and the query
|
|
404
|
+
falls back to loading → fresh snapshot — never a mixed-shape render. The
|
|
405
|
+
offline queue deliberately does NOT gate on it: a queued old-shape write
|
|
406
|
+
replays against the new server, whose input schema is the authority, and a
|
|
407
|
+
rejection surfaces as a visible conflict instead of silently dropped work.
|
|
408
|
+
- **Shapes are your queries.** There is no separate replication-shape
|
|
409
|
+
language: what is mirrored is exactly what the app subscribes to, so tenant
|
|
410
|
+
scoping, guards and `setRowFilter` apply server-side, fail-closed, exactly
|
|
411
|
+
as online — including parent-relative predicates ("tasks where projectId is
|
|
412
|
+
one of my projects"), which are just queries. Mirroring is per QUERY, so a
|
|
413
|
+
subgraph is N queries, not one nested shape. A storage budget is enforced
|
|
414
|
+
with `enforceBudget(maxBytes)` — oldest-saved entries evict first.
|
|
415
|
+
|
|
295
416
|
## Conflict policy for non-CRDT fields
|
|
296
417
|
|
|
297
418
|
CRDT fields resolve themselves — the merge **is** the resolver. A plain scalar
|
|
@@ -348,9 +469,13 @@ constructs the `ApiHandle` (runtime + subscription cache + rpc client) over a
|
|
|
348
469
|
WebSocket **you** inject, so RN passes its own `globalThis.WebSocket` and gets
|
|
349
470
|
the same client stack the web app uses, without pulling in `@voltro/web`.
|
|
350
471
|
|
|
351
|
-
Still open before the loop is proven end-to-end on a device:
|
|
352
|
-
|
|
353
|
-
|
|
472
|
+
Still open before the loop is proven end-to-end on a device: the device boot
|
|
473
|
+
itself — everything here is unit-tested without a simulator, so booting a real
|
|
474
|
+
Metro runtime is the remaining verification — plus `*.deepLink.ts` codegen
|
|
475
|
+
discovery (until it lands, register links via `matchFirstDeepLink(links, url)`),
|
|
476
|
+
push **sender** adapters (APNs / FCM need per-tenant credentials), and
|
|
477
|
+
native-module bindings (camera, biometrics, secure token storage need a native
|
|
478
|
+
runtime). `@voltro/react-native` ships the
|
|
354
479
|
mobile-specific plumbing around that, limited to the parts that need **no
|
|
355
480
|
per-tenant credentials and no native runtime**: device registration,
|
|
356
481
|
background-sync scheduling, offline-first defaults, a connection-status surface,
|
|
@@ -73,6 +73,8 @@ Even with no exporter, `voltro dev` installs a **buffer-only** tracer — that's
|
|
|
73
73
|
|
|
74
74
|
Separate from tracing, the framework records **metrics** into Effect's global `MetricRegistry` — one source of truth (`voltro_rpc_*`, `voltro_http_*`, `voltro_plugin_hook_*`, `voltro_subscription_*` + `voltro_subscriptions_active`, `voltro_db_*`, plus `effect_fiber_*` and any custom metric). Three ways to get them out:
|
|
75
75
|
|
|
76
|
+
The **web server** additionally counts [partial prerendering](/docs/routing/render-modes#partial-prerendering-ppr--cached-shell--per-request-holes): shell serves, hole passes/settles/errors, and last/max hole latency. Shell hit-rate is the isr cache's own `x-voltro-cache` HIT/STALE/MISS accounting — a ppr shell is a normal isr entry.
|
|
77
|
+
|
|
76
78
|
```bash
|
|
77
79
|
# OTLP metrics — the SAME OTEL endpoint that enables trace export also enables
|
|
78
80
|
# metrics. Ships to any OTLP/HTTP collector (Prometheus OTLP, Grafana Agent,
|
|
@@ -356,7 +356,7 @@ import { Effect } from 'effect'
|
|
|
356
356
|
export default defineSchedule({ cron: '*/15 * * * *', timezone: 'Europe/Berlin', handler: (s) => Effect.promise(() => runCadenceTick(s.app)) })
|
|
357
357
|
```
|
|
358
358
|
|
|
359
|
-
Adopt `@voltro/plugin-
|
|
359
|
+
Adopt `@voltro/plugin-row-history` on `_voltro_ai_flows` for automatic edit history.
|
|
360
360
|
|
|
361
361
|
## Deployment notes
|
|
362
362
|
|
|
@@ -86,7 +86,7 @@ auditPlugin({ sink: 'datastore', record: 'errors' })
|
|
|
86
86
|
```
|
|
87
87
|
|
|
88
88
|
- `'all'` (default) — every invocation.
|
|
89
|
-
- `'errors'` — refusals only: a denied guard, a revoked key, a rejected validation. This is the forensic core, and it pairs with [`@voltro/plugin-
|
|
89
|
+
- `'errors'` — refusals only: a denied guard, a revoked key, a rejected validation. This is the forensic core, and it pairs with [`@voltro/plugin-row-history`](/docs/plugins/row-history), which records the successful *writes* — so the two together still cover everything while this table stays small enough that retention is a footnote.
|
|
90
90
|
- a predicate — `(event) => boolean`, for anything else.
|
|
91
91
|
|
|
92
92
|
`'all'` is the default even though `'errors'` is often the right choice, because defaulting to errors would silently stop recording successes for every app that upgrades — and "what did this compromised account touch" is answered by successes. Shrinking the trail is a decision you make with your eyes open.
|
|
@@ -166,7 +166,7 @@ The trail is only useful if you can enter it by the questions an incident asks.
|
|
|
166
166
|
|
|
167
167
|
```ts
|
|
168
168
|
import { auditByTrace, auditBySubject } from '@voltro/plugin-audit'
|
|
169
|
-
import { historyByTrace } from '@voltro/plugin-
|
|
169
|
+
import { historyByTrace } from '@voltro/plugin-row-history'
|
|
170
170
|
|
|
171
171
|
// What happened during ONE call — and what it changed.
|
|
172
172
|
const calls = await auditByTrace(ctx.store, traceId)
|
|
@@ -176,7 +176,7 @@ const changed = await historyByTrace(ctx.store, traceId, ctx.request.subject.ten
|
|
|
176
176
|
const denied = await auditBySubject(ctx.store, actorId, { status: 'error', limit: 50 })
|
|
177
177
|
```
|
|
178
178
|
|
|
179
|
-
`traceId` is the join key. [`plugin-
|
|
179
|
+
`traceId` is the join key. [`plugin-row-history`](/docs/plugins/row-history) records *what changed*; this records *who called and whether they were refused*. Neither is complete alone, and before the join key existed they could not be read together at all.
|
|
180
180
|
|
|
181
181
|
`auditBySubject` takes `status` as a real argument rather than leaving you to filter in JS: the index is `(subjectId, status, at)`, so a filter applied after fetching would not use it.
|
|
182
182
|
|
|
@@ -300,7 +300,7 @@ philosophies.
|
|
|
300
300
|
|
|
301
301
|
That matters because the right to be forgotten is one this framework grants:
|
|
302
302
|
`@voltro/plugin-governance`'s `governance.erase` (`delete | anonymize`) exists
|
|
303
|
-
for it. Without a snapshot, installing audit +
|
|
303
|
+
for it. Without a snapshot, installing audit + row-history + governance together
|
|
304
304
|
makes the first two unreadable for exactly the subjects an investigation is
|
|
305
305
|
about. **Anonymisation is the worse half**: the join succeeds and returns
|
|
306
306
|
"Anonymised" for every entry that actor ever produced, retroactively rewriting
|
|
@@ -392,7 +392,7 @@ auditPlugin({
|
|
|
392
392
|
typeof ctx.input?.teamId === 'string' ? { teamId: ctx.input.teamId } : undefined,
|
|
393
393
|
})
|
|
394
394
|
|
|
395
|
-
|
|
395
|
+
rowHistoryPlugin({
|
|
396
396
|
// From the ROW here — that is what this plugin has.
|
|
397
397
|
resolveScope: (row) => ({ teamId: row.teamId }),
|
|
398
398
|
})
|
|
@@ -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
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# CDC-out (reverse-ETL)
|
|
2
2
|
|
|
3
|
-
> Declaratively mirror table changes outward to external sinks (webhook
|
|
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.
|
|
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
|
|
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._
|
|
13
13
|
|
|
14
14
|
# CDC-out — declarative reverse-ETL
|
|
15
15
|
|