@voltro/cli 0.53.0 → 0.55.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 +335 -0
- package/dist/{agentsMd-0l980yhL.js → agentsMd-BaLC10Na.js} +110 -82
- package/dist/agentsMd-DCY1RSs8.js +2 -0
- package/dist/{apiBuild-CaPfoWku.js → apiBuild-CMvLJM_K.js} +2 -2
- package/dist/apiBuild-Cl0IDx8c.js +2 -0
- package/dist/bin.js +1 -1
- package/dist/{build-D-OnvNMf.js → build-S0QOzqPT.js} +115 -115
- package/dist/{checkCommand-D2ZduVlh.js → checkCommand-DNkY5kwF.js} +1 -1
- package/dist/{checkCommand-C5elt0tW.js → checkCommand-fbj9GDjN.js} +6 -6
- package/dist/{cloudCmd-QUXh-b5w.js → cloudCmd-DzKcSYuy.js} +1 -1
- package/dist/codegen-CN6vMM4J.js +2 -0
- package/dist/{codegen-FEk8AZHb.js → codegen-SIepQtUl.js} +76 -65
- package/dist/codegenCommand-3TDJezom.js +42 -0
- package/dist/{codemodRunner-BjtB2lq6.js → codemodRunner-C2zxZUIw.js} +64 -9
- package/dist/{commands-DyxAmhP0.js → commands-BBYJ7Q3B.js} +96 -73
- package/dist/{dashboardCommand-BdKTyT13.js → dashboardCommand-D2kmyCLL.js} +3 -3
- package/dist/{dataCommand-Bab9X7s8.js → dataCommand-BEPPQiTl.js} +267 -195
- package/dist/dbCommand-BTyBGhIA.js +2 -0
- package/dist/{dbCommand-06O2finM.js → dbCommand-DZTmOFT4.js} +3 -3
- package/dist/{dev-C6LGF4iY.js → dev-Ca_A_S9v.js} +2439 -2397
- package/dist/{dev-GjJWAYo2.js → dev-DfVZaoys.js} +1 -1
- package/dist/{doctorCommand-etMkflRc.js → doctorCommand-CGZJK_4o.js} +21 -21
- package/dist/doctorCommand-djmqEcDC.js +2 -0
- package/dist/{dormancyCommand-UwZ1AZzB.js → dormancyCommand-DY2rYpTa.js} +1 -1
- package/dist/{embeddingsCommand-C70zWHwo.js → embeddingsCommand-BoCqZsgp.js} +1 -1
- package/dist/{envCommand-dSyKvRkM.js → envCommand-Bxy2fOjc.js} +15 -15
- package/dist/{evolveCommand-CG0_ebO5.js → evolveCommand-BsbZ-XDg.js} +2 -2
- package/dist/fileConventions-l-RIXbx8.js +36 -0
- package/dist/{fileTaxonomy-B7uxipWS.js → fileTaxonomy-CbyMQYx_.js} +37 -37
- package/dist/frameworkTableAssembly-Df2Ymp2f.js +2 -0
- package/dist/{frameworkTableAssembly-DKx3ba3S.js → frameworkTableAssembly-Do-cf6RJ.js} +96 -102
- package/dist/index.js +2 -2
- package/dist/{infoCommand-_53iOc_j.js → infoCommand-EmM3jPKD.js} +1 -1
- package/dist/{inspect-Bd8-9wsi.js → inspect-DCqILJ1G.js} +4 -0
- package/dist/inspect-DGJwpOAb.js +2 -0
- package/dist/interruptedReplace-CwnkBb2X.js +41 -0
- package/dist/interruptedReplace-qzmFI020.js +2 -0
- package/dist/manifestBuild-CJ2zvPvT.js +2 -0
- package/dist/{manifestBuild-Cqgsx2bM.js → manifestBuild-DjX5MoXy.js} +1 -1
- package/dist/{metaCommands-Cn2oboG4.js → metaCommands-x7RCi2AF.js} +2 -2
- package/dist/{migrate-Cko9rswM.js → migrate-CGFZS-1a.js} +2 -2
- package/dist/mobileCommand-D9O6iq3D.js +428 -0
- package/dist/mobileCommand-DAum7tsG.js +2 -0
- package/dist/{pageConvention-C938S8oC.js → pageConvention-CMpfDN6r.js} +1 -1
- package/dist/{privacyCommand-DWTQMC6R.js → privacyCommand-BCa2OoZG.js} +2 -2
- package/dist/{probeCommand-DkGGLknv.js → probeCommand-Bs3iVBSL.js} +1 -1
- 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-CskIgAfn.js → renderProfile-DvrhVJHa.js} +2 -2
- package/dist/{runtimeTrace-c0APJz7E.js → runtimeTrace-C1BTpHGQ.js} +1 -1
- package/dist/{sdkgen-BiQCgIEr.js → sdkgen-CXMwLg9n.js} +1 -1
- package/dist/{serveCommand-CueKQgzl.js → serveCommand-C7IrCD58.js} +899 -897
- package/dist/serveCommand-Cjt5S9hD.js +2 -0
- package/dist/serveEntry.js +1 -1
- package/dist/start-DH7cat4-.js +3 -0
- package/dist/{start-ekPan8BT.js → start-EOV7s1NZ.js} +544 -527
- package/dist/startEntry.js +1 -1
- package/dist/{staticCommand-xlSL-IWk.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-BWPQcRoB.js → test-DO27-x2P.js} +1 -1
- package/dist/{updateCommand-C_8I8Rzo.js → updateCommand-C_jN1w18.js} +1 -1
- package/dist/updateCommand-nnFjDbl4.js +2 -0
- package/dist/{webDev-C7jWJ5dX.js → webDev-1XpVnYkW.js} +1 -1
- package/dist/{webDev-oczpugbx.js → webDev-B7vNj4Bq.js} +1231 -1186
- package/dist/{webhooksCommand-4SVPDjKg.js → webhooksCommand-B1LVcyO3.js} +1 -1
- package/dist/workspaceDeps-RKEkX92S.js +45 -0
- package/package.json +31 -19
- package/templates/AGENTS.core.md +2 -0
- package/templates/AGENTS.md +4 -2
- package/templates/agent-docs/_index.md +2 -2
- package/templates/agent-docs/_manifest.json +1 -1
- package/templates/agent-docs/ai.md +4 -4
- package/templates/agent-docs/authentication.md +115 -0
- package/templates/agent-docs/cli.md +98 -12
- package/templates/agent-docs/data.md +121 -21
- package/templates/agent-docs/database/advancedqueries.md +1 -1
- package/templates/agent-docs/database/migrations.md +1 -1
- package/templates/agent-docs/database/seedsdialects.md +64 -2
- package/templates/agent-docs/internationalization.md +2 -0
- package/templates/agent-docs/introduction.md +25 -0
- package/templates/agent-docs/local-first-mobile.md +139 -41
- package/templates/agent-docs/observability.md +4 -2
- package/templates/agent-docs/plugins/atlassian.md +2 -2
- package/templates/agent-docs/plugins/audit.md +2 -2
- 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 +22 -0
- package/templates/agent-docs/plugins/presence.md +32 -3
- package/templates/agent-docs/plugins/prometheus.md +2 -0
- package/templates/agent-docs/plugins/queue.md +47 -4
- package/templates/agent-docs/plugins.md +52 -14
- package/templates/agent-docs/reference.md +25 -4
- package/templates/agent-docs/routing.md +81 -9
- package/templates/agent-docs/scheduling.md +1 -1
- package/templates/agent-docs/schema-driven-ui.md +137 -1
- package/templates/agent-docs/templates/appshells.md +36 -4
- package/templates/agent-docs/whats-new.md +75 -158
- 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/package.json +7 -6
- 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/package.json +8 -7
- 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/package.json +8 -7
- 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-DHtLXYx9.js +0 -2
- package/dist/codegen-BWpt3VgF.js +0 -2
- package/dist/codegenCommand-BOiWQ5hz.js +0 -137
- package/dist/dbCommand-B1EXBC6f.js +0 -2
- package/dist/doctorCommand-B0hX0tdz.js +0 -2
- package/dist/fileConventions-DASGEmj-.js +0 -35
- package/dist/frameworkTableAssembly-C_7Z-rMs.js +0 -2
- package/dist/inspect-CuoDInfZ.js +0 -2
- package/dist/interruptedReplace-C3O3M1MM.js +0 -28
- package/dist/interruptedReplace-CvmiAM9K.js +0 -2
- package/dist/manifestBuild-C4-J1-m_.js +0 -2
- package/dist/renderModeScan-CUbOeOAg.js +0 -122
- package/dist/serveCommand-DsnrVN3U.js +0 -2
- package/dist/start-BJzZLbt8.js +0 -3
- package/dist/updateCommand-Bqql_rsQ.js +0 -2
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# What's new in 0.
|
|
1
|
+
# What's new in 0.55.0
|
|
2
2
|
|
|
3
3
|
Read this FIRST when a task touches an area you have not worked in recently.
|
|
4
4
|
It is the cheapest way to notice that the framework grew the thing you were
|
|
@@ -9,217 +9,134 @@ BREAKING entries name a codemod; run `voltro update` to apply it.
|
|
|
9
9
|
|
|
10
10
|
### ⚠ BREAKING
|
|
11
11
|
|
|
12
|
-
- **@voltro/
|
|
12
|
+
- **@voltro/protocol, @voltro/client, @voltro/runtime, @voltro/cli** — **A multi-reference field now flips as instantly as a scalar one.** A mutation target's declared `relations:` reconciled the junction inside the server's transaction and nothing else: the client learned about the link change only when the delta came back. On the same submit, the renamed title flipped immediately and the assigned stores did not — the half of the promise that was never stated.
|
|
13
13
|
|
|
14
|
-
The
|
|
14
|
+
The declaration now drives BOTH. `useMutation`'s auto-optimistic stages a patch on every subscription sourced on the junction table, reconciling that anchor's links against `input[field]` — surplus links removed, new ones staged, surviving links left untouched with their real ids (a diff, mirroring `store.relationLinks(...).set`, not a drop-and-restage that would blink every unchanged row).
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
The patches ride the ordinary optimistic lane, staged under the mutation id, so the rollback rule holds by construction: reverted on failure, kept on success until the base actually moves. Nothing here is on a timer.
|
|
17
17
|
|
|
18
|
-
|
|
19
|
-
- **@voltro/client, @voltro/ui, @voltro/cli, @voltro/web** — `useFormBinding` carries a WHOLE form now, on a per-field engine behind the facade — the engine is an implementation detail (no engine type in the public API, pinned by a contract test; production builds stub its devtools channel). The old surface is unchanged; everything new is additive:
|
|
20
|
-
|
|
21
|
-
- **Nested values + sections.** A nested struct flattens into dotted fields (`address.city`) grouped under `section('address')` — no more `custom` placeholder. The no-JS POST rebuilds the nested object from dotted input names, so both submit paths agree. - **Field arrays.** `array('entries')` — push / insert / remove / move / swap + per-item field handles; arrays of structs carry item descriptors. - **Bound field handles.** `field('address.city')` returns value, setValue, onBlur, the display-gated error, touched/blurred/dirty, required, label, widget, options, and a11y props (`aria-invalid`, `aria-required`, `aria-describedby`) ready to spread onto any widget kit. - **Error timing a form can trust.** A form never opens with errors: a field reveals its error after ITS blur or after the first submit attempt, then live (`validate: { onChange: 'afterTouched' | 'always' | 'never' }`). `isValid`/`canSubmit` always tell the truth underneath. This changes VISIBLE behavior — errors used to appear only after submit; now blur reveals them earlier. - **`toInput` / `onSubmit` — values ≠ mutation input.** Map form values to the wire input before validation, route input-schema issues back to form fields via `errorPath` (same-name automatic), or own the composed save with the mutation handle in hand — optimistic + server-error routing kept. - **One `form.state`**: isDirty (interaction-based), canSubmit, isSubmitting, isSubmitted, isSubmitSuccessful, submissionAttempts, errorCount, firstInvalidPath, pending, isLoading, submitError, data. - **`reset(nextDefaults)`** switches the edited record without a remount; `focusFirstInvalid()` moves focus to the first visible error. - **Per-field rendering.** `subscribe: 'fields'` + `useFormField(form, path)` — a keystroke re-renders one field, not the page. - **Schema-declared structure.** `formField({ section, order, label, widget })` annotation, `description` → help text, `Schema.Date` / `Schema.DateTimeUtc` → date/datetime widgets.
|
|
22
|
-
|
|
23
|
-
The one compile-visible break: `WidgetKind` gained `'array'`, so an app registry typed as a TOTAL `Record<WidgetKind, Widget>` needs one new entry (the codemod note finds the shape). `reset` and `validateFields` only gained optional parameters.
|
|
24
|
-
- **@voltro/client** — Client-side validation messages are STRUCTURED now — stable ids with params (`validation.required`, `validation.minLength {min}`), read from the ParseIssue tree (schema ids + annotations), not effect's English developer text ("Expected string, actual undefined"). The binding renders them through a built-in en/de catalog (locale from `<html lang>`, override via `locale:`), and `messages: (id, params) => t(id, params)` wires an app's own i18n catalog in one line — the regexes apps laid over the developer texts can go. A `message` annotation may BE an id (`'validation.between|{"min":2,"max":50}'`), and a struct-level `filter` returning `[{ path, message }]` lands each issue at ITS field.
|
|
25
|
-
|
|
26
|
-
Two observable changes: `errors` keys are now the FULL dotted path (`'address.city'`, `'entries.0.startsAt'`) instead of collapsing to the top-level segment, and the display strings differ from the old developer text. `validateFields` additionally returns the raw `issues` array for widget kits that translate themselves.
|
|
27
|
-
|
|
28
|
-
**`voltro update` carries you across this** — codemod `0.53.0/03_validation-messages-are-ids`. If you pin versions by hand and never run it, print the notes without changing anything: `voltro update --codemods-only --from <your current version> --dry-run` (this one ships in 0.53.0).
|
|
29
|
-
|
|
30
|
-
### Added
|
|
31
|
-
|
|
32
|
-
- **@voltro/plugin-comments, @voltro/ui, @voltro/devtools-ui** — `@voltro/plugin-comments` — comment threads anchored to anything the app can name (an order, a document, a section anchor), live over the existing reactive engine: `comments.list` declares a `reactivityChannel` as its `source:`, every write publishes it, and a second client sees a new comment without a reload. No second push mechanism.
|
|
33
|
-
|
|
34
|
-
Access FOLLOWS THE ANCHOR, fail-closed: the app declares `access.viaEntity` (guard delegation — receives anchor, subject and the bound store, so the rule reads the entity's own row) or `access.scope`; with neither declared, every read and write refuses by name — a comments surface nobody opened serves nobody rather than everybody. A soft-deleted anchor is the same door: the guard cannot approve what it cannot read, so a stale mention notification finds "no longer available", not a leak.
|
|
35
|
-
|
|
36
|
-
Mentions are tenant-safe BY CONSTRUCTION, twice: the `resolveMentions` seam requires the calling subject in its signature and the plugin re-filters the result to the caller's tenant (opt-out `crossTenant`), and every mention is RE-validated at create time against the same resolver — a hand-crafted mention on a foreign tenant is dropped, never delivered. A validated mention sends through plugin-notifications when configured (preferences, quiet hours and digests apply — ten mentions in one window roll into ONE delivery, tested); without it, a log note and nothing else.
|
|
37
|
-
|
|
38
|
-
Also in the box: replies (anchor-pinned — a reply cannot smuggle into a thread on a different anchor than the access check ran for), resolve/reopen, author-only edit, delete with a `comments:moderate` scope override cascading reactions, per-emoji reactions aggregated with `count` + `mine`, per-subject thread unread (`markRead`; your own comments are never unread for you), `useComments`/`useThread`/`useMentionSearch` hooks, the ejectable unstyled `<CommentsThread>` in `@voltro/ui`, and a Comments panel in both dashboards. Moderation is honestly opt-in (one plugin-moderation rule, documented — no "automatic" claim).
|
|
39
|
-
|
|
40
|
-
Proven over the real wire (`scripts/comments-e2e.mjs`, real `voltro serve` + postgres + signed session subjects): A comments → B's ALREADY-OPEN subscription receives the new snapshot live; B's mention lands in the inbox (no self-notification); resolve at A arrives live at B; soft-deleted and missing anchors refuse; a cross-tenant mention delivers nothing. The docs site carries a LIVE demo (the real plugin against the docs demo backend). The declared limits are documented: attachments = storage-grant + URL, and the channel-wide reactivity granularity with the read-set work as the named narrowing.
|
|
41
|
-
- **@voltro/content, @voltro/cli, @voltro/changelog** — Content collections (plan 03): `@voltro/content` — file-based, schema-typed markdown content without installing a markdown dependency.
|
|
42
|
-
|
|
43
|
-
`defineCollection` declares a folder (`content/<name>/**/*.md`) with an `effect/Schema` frontmatter schema in a `*.collection.ts` file. The isomorphic `getCollection`/`getEntry`: at build/SSR time the server reads the filesystem, decodes frontmatter (a violation FAILS the build naming the file), and renders markdown with dual-theme shiki; the build emits JSON artifacts under `dist/assets/content/…` that the CLIENT branch fetches on SPA navigations — no markdown engine, no highlighter, no content bodies in the browser bundle (proven by the fixture e2e's budget checks: 402 chunks → 9 after the split). Slugs come from the relative path; duplicates are build errors. Locale trees (`i18n: { locales, defaultLocale, missing }`) serve `de/` mirrors with per-collection fallback-or-404 policy. Rendered entries carry `headings[]` (depth/slug/text — the SAME ids stamped on the HTML, via one shared `extractHeadings`). `kind: 'data'` decodes `.json` files (authors.json). `reference('<collection>')` fields are validated by the build — a dangling reference names collection, entry, field and target. `config.feeds` builds RSS from a collection next to sitemap.xml and serves the same XML as a live dev route. `voltro dev` serves artifact shapes on demand and invalidates on `content/**` edits.
|
|
44
|
-
|
|
45
|
-
`@voltro/changelog` now CONSUMES the seam (frontmatter + render via `@voltro/content/markdown`, `renderReleaseRss` via the generic `buildFeed`); unlabelled fences render plain instead of guessing `ts`. An unexpected static-loader throw at build time now FAILS the build instead of shipping an empty page (it shipped a whole docs site as 646 empty pages under exit 0). The blog/docs/changelog templates run on collections; the docs site migrated with a script-proven equivalence over all 646 pages × both locales.
|
|
46
|
-
- **@voltro/local-first, @voltro/database, @voltro/runtime, @voltro/protocol, @voltro/client, @voltro/cli, @voltro/plugin-row-history, @voltro/voltro** — CRDT beyond text: `crdtDoc()` stores a whole collaborative document as a column (same storage and doc-agnostic authoritative server merge as `crdtText()`, which stays as the plain-text specialisation), and `@voltro/local-first/editor` ships `useCrdtEditor` — a Tiptap binding (StarterKit + Collaboration + CollaborationCaret, all MIT, fully self-hosted; the paid Tiptap Cloud features are deliberately unused) over the new `CrdtDocHandle` (`createDoc`: the raw Y.Doc for the binding, `stateVector`/`encodeUpdateSince`, `onUpdate`, and `encodeAnchor`/`resolveAnchor` — the stable-position primitive inline comments pin threads with).
|
|
47
|
-
|
|
48
|
-
Wire amplification is fixed in BOTH directions. Upstream, offline edits coalesce per cell in the durable queue (1000 keystrokes drain as O(1) pushes) and a client push is an incremental update. Downstream, the new `mergeCells` patch op carries per-column incremental updates: the dispatcher diffs CRDT cells against the subscriber's previous state vector and the client folds them through `crdtMergeCell` — a one-character edit against a 100 KB document measured under 1 KB on the subscription wire, no op in the delta carrying the full blob.
|
|
49
|
-
|
|
50
|
-
Fold atomicity is pinned in layers in the one store wrapper: a per-row in-process mutex serialises concurrent folds completely on a single node, a verify-and-refold pass heals cross-replica interleaves, and the residual multi-replica window is a stated limit (descriptor-level `FOR UPDATE` is the named next step). Storage stays bounded: a fold's result soft-compacts past `VOLTRO_CRDT_COMPACT_MAX_BYTES` (default 512 KiB) without breaking the merge lineage; `rebaseText` is the explicit hard reset — a new epoch subscribers receive as a fresh snapshot.
|
|
51
|
-
|
|
52
|
-
The capture paths know CRDT columns now: undo capture strips them from update images and skips crdt-only updates entirely (client-side doc undo is the editor's Y.UndoManager), row history excludes them the same way (document version history is named snapshots taken BEFORE compaction), and both exclusions keep whole images on DELETE. Exposure rules are declaration rules: `.serverOnly()` on a CRDT column throws (a doc clients write but never read cannot be collaborated on); `.encrypted()` is the documented online-only decision. Carets ride a `delivery: 'latest'` event via `attachAwarenessBridge` — one member's state per envelope, measured far inside the event cap, never the aggregated room — deliberately NOT presence metadata, whose value-compare push would make every caret move a "real" change.
|
|
53
|
-
- **@voltro/protocol, @voltro/runtime** — A write target declares its many-to-many relations now — and the framework writes the junction in the SAME transaction:
|
|
18
|
+
**Migration — `relations:` values are objects now:**
|
|
54
19
|
|
|
55
20
|
```ts
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
relations: { assignedStores: 'employee_assigned_stores' }
|
|
59
|
-
|
|
21
|
+
// before
|
|
22
|
+
target: { table: 'employees', op: 'update',
|
|
23
|
+
relations: { assignedStores: 'employee_assigned_stores' } }
|
|
24
|
+
|
|
25
|
+
// after
|
|
26
|
+
target: { table: 'employees', op: 'update',
|
|
27
|
+
relations: { assignedStores: {
|
|
28
|
+
junction: 'employee_assigned_stores',
|
|
29
|
+
anchorColumn: 'employeeId', // the junction reference() pointing at `employees`
|
|
30
|
+
targetColumn: 'storeId', // the junction's other reference()
|
|
31
|
+
} } }
|
|
60
32
|
```
|
|
61
33
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
Underneath sits the new `store.relationLinks(junction, table, id)` — the existing `links()` with its anchor COLUMN derived from the junction's `reference()` targets; a self-junction is refused by name, never guessed.
|
|
65
|
-
- **@voltro/runtime, @voltro/protocol, @voltro/client, @voltro/cli, @voltro/voltro** — Delta-resume for subscriptions: a client that reconnects inside the resume window no longer pays for a full snapshot per query. The re-subscribe presents the last materialised revision in the per-call `voltro-resume-from` header (the same surface the idempotency key rides, read by the ONE shared auth-middleware builder so both boot paths agree), and the server — which keeps a resumable subscription alive server-side for the window after a disconnect, its emits recorded into a bounded per-identity delta ring — replays only the missed deltas and re-attaches the stream on the SAME revision line. The wire signal is the first event's tag: `delta` means resumed, `snapshot` means reset — no schema change.
|
|
66
|
-
|
|
67
|
-
The failure direction is fixed everywhere: a wrong snapshot costs bytes, a wrong replay would leak rows, so every doubtful case answers with a fresh snapshot. Concretely: the ring is keyed by query + canonical input + subject + tenant (a login/logout/tenant-switch between disconnect and resume simply never finds it); the per-delivery guard re-check keeps running on the detached subscription and a revocation while offline drops the retained history (plus one more re-check at the adoption boundary); row-filtered apps and computed queries are excluded from resume entirely; a non-chaining or out-of-window revision falls back to snapshot. Replayed deltas may coalesce exactly as slow-consumer updates do.
|
|
68
|
-
|
|
69
|
-
`@voltro/client` participates automatically: the reconnect-seeded cache keeps its rows AND revision, sends the header on the re-subscribe, applies a resumed delta onto the held base with no snapshot round-trip, and treats a snapshot-first stream as the reset it already knew. Tunables ride the shared resolver both boot paths call: `reactive.resume.windowMs` (default 60 s, env `VOLTRO_REACTIVE_RESUME_WINDOW_MS`) and `reactive.resume.maxDeltas` (default 256, env `VOLTRO_REACTIVE_RESUME_MAX_DELTAS`). Proven end-to-end against a real `voltro serve`: kill a live subscriber mid-stream, write while it is gone, resume — first event is a delta past the held revision, a post-resume write reaches the adopted stream live, a headerless control gets a snapshot, and past the window the same header gets a snapshot again.
|
|
70
|
-
- **@voltro/cli, @voltro/web** — Font pipeline (plan 12). Declare local font files once (`fonts:` in the web `app.config.ts`) and get content-hashed self-hosting, `@font-face` with `font-display`, a SIZE-ADJUSTED fallback face (real metrics read via fontkit, capsize formula against Arial/Times — the swap moves nothing, CLS ≈ 0), a `<link rel="preload">` in the shell head, and opt-in unicode-range subsetting (`subsets: ['latin', 'latin-ext']` via subset-font, declared with matching `unicode-range`). Multiple weights/styles per family and variable ranges (`weight: '100 900'`) are first-class. `localFont('Inter')` in `@voltro/web` maps the declared family to its CSS variable/stack.
|
|
71
|
-
|
|
72
|
-
ONE memoized build feeds every surface: `writeEntryFiles` bakes CSS + preloads into the generated shell (served identically by dev, static prerender, SSR streaming and `voltro start`), the dev server answers the hashed files from the same memo, `voltro build` writes them into `dist/assets/fonts` — the shell's URLs and the files cannot disagree.
|
|
34
|
+
The columns are declaration data because the optimistic patch runs in the BROWSER, which has no table registry to derive them from — `@voltro/database` is server-only by construction, and guessing a column from a table name is exactly what `store.relationLinks` refuses to do. They are not taken on trust: before it writes, the server compares the declaration against the junction's real reference columns and refuses, naming the correct pair, if they disagree. A wrong declaration is a loud error carrying its own fix, never a client that patches one column while the server writes another.
|
|
73
35
|
|
|
74
|
-
|
|
36
|
+
Semantics unchanged and now shared by both sides: an absent input field touches nothing (absent ≠ empty), an empty array is the explicit clear. The client uses `input.id` for an update and, for an insert, the same optimistic id it stamped on the new row — the server's `output.id` is not knowable before the response.
|
|
75
37
|
|
|
76
|
-
|
|
77
|
-
- **@voltro/client, @voltro/ui** —
|
|
38
|
+
**`voltro update` carries you across this** — codemod `0.55.0/01_target-relations-declare-columns`. If you pin versions by hand and never run it, print the notes without changing anything: `voltro update --codemods-only --from <your current version> --dry-run` (this one ships in 0.55.0).
|
|
39
|
+
- **@voltro/client, @voltro/ui, @voltro/ui-shadcn, @voltro/web, @voltro/cli** — **A rich-text field, and the sanitizing contract is the point of it.** `RichTextDocument` (`@voltro/client`) is the value; `widget: 'rich-text'` renders it; `<RichTextView>` (`@voltro/ui`, re-exported by `@voltro/web`) displays it.
|
|
78
40
|
|
|
79
|
-
|
|
80
|
-
- **@voltro/cli, @voltro/runtime** — The opt-in gRPC surface — an external client generated from the framework-emitted `.proto` calls a named Voltro procedure: unary for mutations/actions, server-streaming (live current-snapshot frames) for queries. Nothing re-implements the wire semantics: a gRPC call runs the SAME bound runner every other surface uses, so guards, the plugin interceptor chain (order proven side-by-side against a socket call in the e2e) and typed errors behave identically.
|
|
41
|
+
The contract, stated plainly because the alternatives all look reasonable until you name who the attacker is:
|
|
81
42
|
|
|
82
|
-
|
|
43
|
+
- **The value is a closed document tree, not an HTML string.** There is no `html` node, no raw-markup escape hatch, no attribute bag. Anything that is not one of the declared node types fails to decode. - **The boundary is the `Schema` decode**, which is the server's existing, non-bypassable input boundary — the same one every mutation input already passes through. So the guarantee is not "somebody remembered to sanitize this"; it is that a document which reached the database is one of these shapes. - **A link's `href` is the one field that points outward, and it is allowlisted** — `http(s)`, `mailto:`, a `#fragment`, a `/path`; nothing else. Control characters and whitespace are stripped before the check, because `java\tscript:` navigates exactly like `javascript:` and a check on the raw string passes it. - **Client-side is not a boundary and is not treated as one.** The widget's parser runs in the browser for the editing experience; every property it maintains is re-established by the decode on the server. - **Rendering never uses `dangerouslySetInnerHTML`.** Nodes become React elements, text becomes React children — so markup typed into the box is markup the reader SEES. `<RichTextView>` also drops an href that would not survive a decode, for the value that never went through one.
|
|
83
44
|
|
|
84
|
-
|
|
45
|
+
Rejected, for the record: sanitizing an HTML string on write (ships an HTML parser and the mXSS surface that comes with it), escaping at render (makes safety a property of every read site), and declaring an unenforced boundary (a convention, not a guarantee).
|
|
85
46
|
|
|
86
|
-
|
|
47
|
+
The built-in widget is a `<textarea>` over a small, CLOSED markdown subset — headings, `**bold**`, `*italic*`, `` `code` ``, `[text](href)`, lists, blockquote, fenced code — with everything unrecognised left as literal text. That also closes the no-JS loop: the textarea posts source, `/form/*` parses it, and the same decode validates it. A WYSIWYG belongs at rung 2 (register a `rich-text` widget); the stored value shape does not change.
|
|
87
48
|
|
|
88
|
-
|
|
89
|
-
- **@voltro/cli, @voltro/web** — Build-time image pipeline (plan 11). `import hero from './hero.jpg?image'` turns a static asset into an `OptimizedImageAsset`: every ladder width up to the intrinsic width encoded as AVIF + WebP plus a same-family fallback, hashed into `dist/assets/`, with intrinsic width/height and a 16px blur data URI. `<Image src={hero}>` renders a `<picture>` with per-format sources — dimensions and blur inferred, `placeholder="blur"` the default. The suffix is an explicit opt-in: bare image imports keep Vite's URL semantics untouched.
|
|
49
|
+
**Not** the collaborative case: `crdtDoc()` + `useCrdtEditor` remains the multi-writer path (a CRDT bytes column, a sync lane, Tiptap). This is one column, one writer, ordinary JSON the server can validate and diff, and no new dependency in the default kit.
|
|
90
50
|
|
|
91
|
-
|
|
51
|
+
**Migration:** `WidgetKind` gained `'rich-text'`. Only a registry typed as a TOTAL map (`Record<WidgetKind, Widget>`) notices — add one entry pointing at the exported `RichTextWidget`. Partial registries need nothing.
|
|
92
52
|
|
|
93
|
-
|
|
53
|
+
Also fixed alongside: the capability manifest's `MANIFEST_WIDGET_KINDS` is a hand copy of `WidgetKind` (a CLI module cannot import `@voltro/client`) and had silently drifted by two kinds since 0.53.0, telling coding agents a smaller set than the renderer accepts. It is complete again and pinned by a test that reads the union out of the client's source.
|
|
94
54
|
|
|
95
|
-
|
|
96
|
-
- **@voltro/web** — Intercepting routes (plan 22): the modal-with-URL pattern. A page exporting `intercept: { from: '/photos' }` renders as an OVERLAY above the still-mounted origin page on a soft navigation from a `from` route, standalone on a hard load (and on soft navigation from anywhere else), and closes on Back — with the background's mounted state, scroll and subscriptions untouched.
|
|
55
|
+
**`voltro update` carries you across this** — codemod `0.55.0/02_widget-kind-gained-rich-text`. If you pin versions by hand and never run it, print the notes without changing anything: `voltro update --codemods-only --from <your current version> --dry-run` (this one ships in 0.55.0).
|
|
97
56
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
Behaviour changes that ship with it: `useBlocker` now guards POPSTATE — the Back gesture is a modal's primary close, and it previously bypassed every blocker (the router reverts the moved URL via an entry-index delta and offers retry/reset; ESC in the overlay routes through the same path). `useSearchParams`/`useSetSearchParams` read and write the CALLING TREE's query — a background component can no longer decode the modal's query against its own schema or write onto the modal's URL. Navigation scrolling moved to the visual commit, so an overlay open never scrolls the background. The overlay slot is ALWAYS rendered (null when closed) through the shared provider tree, keeping server/client fiber arity identical (the useId class). The overlay chrome is a native `<dialog>` via `showModal()` — platform focus trap, backdrop and focus restoration; body scroll locked while open; deliberately unstyled (`dialog[data-vweb-overlay]`).
|
|
101
|
-
|
|
102
|
-
Declared non-goal: Next's parallel `@slot` routes — split panes are components in a layout, not a routing concept. Islands/zero-JS pages don't intercept (no client router). Proven by 6 jsdom router tests plus `scripts/intercept-e2e.mjs` on an ssr fixture: standalone SSR HTML with per-photo title and zero hydration warnings, overlay over a mounted background (typed input + mount counter survive open AND close), per-tree search params, nested modals with topmost-only Back, popstate blocking with discard, reload-renders-standalone, and a dev-parity smoke.
|
|
103
|
-
|
|
104
|
-
Measured price: the router group grew 1.5 KB gz (10.5 -> 12.0 KB, the whole first-load delta of this change) -- the overlay stack, popstate blocking and per-tree search params; every other bundle group moved by noise only. The bundle budget is re-pinned to that number.
|
|
105
|
-
- **@voltro/local-first, @voltro/client, @voltro/cli, @voltro/plugin-presence** — The local-first sync engine. A `localFirst()` table's data is now offline readable, editable and convergently resynchronised — through the primitives apps already use, not a second data API.
|
|
106
|
-
|
|
107
|
-
READS: the subscription cache accepts a mirror; every base movement of every subscribed query persists (rows + revision) into a subject+tenant-PARTITIONED IndexedDB store, a cold start seeds `useSubscription` from it (offline reload renders the last materialised rows), and the next connect presents the mirrored revision as `voltro-resume-from` — composing with delta-resume. Deliberately NOT a browser SQL engine: the client's query surface is `(tag, input)`, predicates never exist client-side, so the mirror stores materialised results per query behind a `KvStore` seam (a SQLite backing stays possible without touching a consumer). Soundness is structural: partitioning lives in the KEY (a new subject never finds the predecessor's rows; `purge()` on logout/revocation), `.encrypted()` columns are stripped before every save via codegen-emitted metadata (`voltro dev` writes a zero-import `.framework/localFirst.generated.ts`), a snapshot save REPLACES the row set (revoked/deleted rows evict by construction), and entries gate on a build `schemaFingerprint` — a new build's load is a visible cold start, never a mixed-shape render.
|
|
108
|
-
|
|
109
|
-
WRITES: `useOutbox` gains durability (`persistence:` seam; the one real implementation is `outboxPersistence()` over the same `PersistenceAdapter` the sync queue drains — one durable queue per device) and conflict resolution: `resolveConflict(id, input)` returns a conflicted entry to pending with the resolved input, typically computed by `resolveWithPolicy()` — `crdtText()` columns MERGE, scalars follow the declared `conflictPolicy()`, convergence proven side-symmetric. Multi-tab safety via `withDrainLock` (an exclusive per-partition Web Lock; a host without the API drains unlocked and reports it). Presence rides ONE wire: `usePresenceChannel` in plugin-presence/web adapts the local-first `PresenceChannel` onto the framework's existing presence lane instead of a second transport.
|
|
110
|
-
|
|
111
|
-
Proven end-to-end in a REAL chromium against a real `voltro serve` (`scripts/browser-local-first.mjs`): online seed → 5 offline edits → page RELOAD (queue survives in real IndexedDB, order preserved, mirrored rows render) → drain under the real Web Lock → server and a second browser context converge on all 6 rows; two tabs race the lock and exactly one drains; a foreign subject's binding reads nothing and purge empties exactly one partition.
|
|
112
|
-
- **@voltro/cli** — OG-image generation (plan 13). A page declares its `og:image` as a satori JSX template (`export const ogImage = ({ params, loaderData, locale }) => …`); `static` pages bake the PNG at build time — hashed into `dist/assets/og/`, `og:image`/`twitter:image`/`twitter:card` injected with the absolute `seo.siteUrl`, the page's own `og:image` meta winning over the generated tag — and `ssr` pages serve it on demand over `/_voltro/og`, ONE builder mounted by `voltro dev` AND `voltro start` (head injection lives in the SHARED head builder, so the streamed arm a plain ssr page takes cannot drift from the buffered one — it did, for one commit, and the parity e2e is what caught it).
|
|
113
|
-
|
|
114
|
-
The on-demand URL is signed: HMAC-SHA256 (timing-safe compare) over route + params + tenant + locale — tampering answers 403, tenant/locale ride the signature AND the cache key, and the PNG caches in the same IsrCache backend as the page cache. Secret handling is conditional by design: a single process mints a per-boot secret (sign and verify happen in the same process); a DEPLOY boot with ssr `ogImage` pages and no `VOLTRO_OG_SECRET` refuses loudly — behind a load balancer the signing and the fetching replica differ, and a per-boot secret would 403 every cross-replica fetch. Never a default value.
|
|
115
|
-
|
|
116
|
-
Preconditions are decided, not improvised: a declared `fonts:` family is REQUIRED (the renderer reads the ORIGINAL un-subsetted files; no bundled default font — that would ship a license artifact) with a named error naming the fix; emoji are a declared limit (satori's emoji path is a per-glyph CDN fetch — use an image/data-URI in the template). satori (pinned to an aged release — the workspace's minimumReleaseAge gate is policy) and @resvg/resvg-js ship as script-free optionalDependencies.
|
|
117
|
-
|
|
118
|
-
Proven: renderer core 4/4 (real PNG, size flow-through, distinct-template proof, font refusal), signed route 4/4 (cache HIT, 403, tenant variants render DIFFERENT images, signature-is-not-access), and the `font-pipeline-e2e` extension — build §1b (PNG in dist + absolute tags), dev §2b and `voltro start` §5 (signed URL in the ssr head, PNG, HIT, 403) all green.
|
|
119
|
-
- **@voltro/web, @voltro/cli** — Partial prerendering (plan 20): an isr page that exports `ppr = true` combines a cached, ANONYMOUS shell with per-request dynamic holes on the same response.
|
|
120
|
-
|
|
121
|
-
The design decision, made against React's actual capabilities: a cached stream prefix cannot be RESUMED in stable React (postponed state is experimental), so ppr is client composition on the existing `defer()` seam. The shell is the normal buffered isr render — eager fields in the HTML, each hole as its `<Await>` fallback, cached as a plain IsrCacheEntry (no cache shape change, same revalidate/CDC/on-demand invalidation). On every serve (hit, stale, miss) the response stays open after the shell bytes: the page loader runs again with the FULL request context and each deferred field is appended as a registry settle script the moment it resolves. The client reveals holes through hydration.
|
|
122
|
-
|
|
123
|
-
The shell render is fail-closed, not merely stripped: its loader context and `useServerRequest()` snapshot THROW by name on credential access (cookie, authorization, x-voltro-*; any cookie but voltro:locale) — the first request answers with an error naming the read and the fix ("move it into a deferred hole"), instead of baking silently-empty subject data into an artefact served to everyone. Holes are async functions; their credential reads happen inside the promise and see the real request only on the hole pass.
|
|
124
|
-
|
|
125
|
-
Static + ppr stays refused (a static file host cannot append anything — isr with a long revalidate is that page); ppr requires `interactive: 'full'`; layout loaders cannot defer on a ppr page (v1); csp nonces are refused as on isr. `voltro dev` mirrors the whole behaviour through the shared `pprRender.ts`. Proven end-to-end by `scripts/ppr-e2e.mjs` with a FILE-GATED hole (deterministic, no timing waits): shell chunk received while the hole is provably open, settle script after the gate opens, per-subject hole content with a byte-identical subject-free shell prefix across subjects, cache HIT on the second request, the named 500 for an eager credential read, dev parity, and a browser client-navigation rendering the hole via the client defer path.
|
|
126
|
-
- **@voltro/plugin-presence** — `presencePlugin({ resolveMember })` — resolve the fields other channel members see about a caller (display name, avatar URL) server-side, from the authenticated subject. `meta` is client-supplied and handed to every channel member verbatim, which is the right contract for a cursor and the wrong one for identity: any member could present any name and any `<img src>` to everyone else. The resolver runs on every heartbeat and its result merges OVER the caller's `meta`, so a client cannot override what the server says about them; returning `undefined` declines and leaves `meta` untouched. The docs and the package description now say plainly that `meta` is unvalidated and relayed verbatim — identity does not belong in it.
|
|
127
|
-
- **@voltro/plugin-queue, @voltro/cli, @voltro/devtools-ui, @voltro/plugin-cdc-out, @voltro/sql-postgres, @voltro/sql-mysql, @voltro/sql-mssql, @voltro/sql-sqlite** — `@voltro/plugin-queue` — interop with a Kafka an adopter already runs, as the door to foreign queues (the outbox stays the path for your OWN durable side-effects, workflows for your own orchestration). Kafka first; the `QueueProvider` contract is cut so SQS/RabbitMQ can be later implementations.
|
|
57
|
+
### Added
|
|
128
58
|
|
|
129
|
-
|
|
59
|
+
- **@voltro/database, @voltro/data-transfer, @voltro/cli, @voltro/voltro** — A staged `--mode replace` now RECORDS the scratch tables it creates, and a boot collects the ones nobody is coming back for.
|
|
130
60
|
|
|
131
|
-
|
|
61
|
+
Staging tables are `_voltro_staging_`-prefixed, so the differ correctly ignores them — and nothing else mentioned them either. A run that died between the load and the swap left a full copy of a bundle that only a hand-written introspection could find, on a database whose boot said nothing. The marker row now names the set, carries a heartbeat the run refreshes while rows land, and records whether the run was started `--no-atomic`. That is what lets a boot tell the three cases apart: silent past the threshold and not resumable → the tables are dropped; still beating → an import is loading into them, here or on another replica; resumable → its staging IS the resume point and is left alone however stale. `VOLTRO_STAGING_STALE_MINUTES` moves the threshold (default 30). A staging record is reported, never a refusal — a staged replace destroys nothing until one short server-side swap, so refusing a boot over it would be an alarm on a healthy database. `voltro data clear-staging` reads the same records and labels each table with what its own run says, instead of listing them flat under a warning that it could not tell a leftover from an import in flight.
|
|
132
62
|
|
|
133
|
-
|
|
63
|
+
Two smaller things came with it. The raw-SQL seam the staged swap runs on (`DataStore.run`) is a DECLARED optional capability now, in the shape `emptyTables` established, with a parity assertion across the four dialect stores — it was duck-typed against an interface that never mentioned it, so a store that dropped it would have fallen out of the feature detection and taken the slower path forever, on that one dialect, in silence. And a registered staging clone can no longer reach the differ's DECLARED side: `--mode replace` registers each staging table as a clone of its target for the length of the load (a typed write resolves its columns by name), and a plan computed while an import was in flight proposed `create-table _voltro_staging_notes`.
|
|
134
64
|
|
|
135
|
-
|
|
136
|
-
- **@voltro/client, @voltro/ui, @voltro/
|
|
65
|
+
Also measured rather than assumed: the staging table `createStagingSql` builds on **sqlite** (`CREATE TABLE … AS SELECT * FROM t WHERE 0`) and on **SQL Server** (`SELECT * INTO … WHERE 1 = 0`) carries the target's columns and ZERO foreign keys, which is what the load needs. Those were the two dialects the postgres and mysql-family measurements had not covered.
|
|
66
|
+
- **@voltro/client, @voltro/ui, @voltro/web** — **`useFormField(path)` finds its binding.** `<FormBindingProvider binding={form}>` (mounted for you by `<AutoForm>`) makes the narrow per-field subscription reachable without threading the binding down to every field component as a prop. That thread was blocking incremental adoption: a codebase moving a hundred-plus forms one at a time keeps its own field context and swaps engines per form, and being asked to prop-drill to ~30 field components at once meant taking the binding and declining the optimisation they had the most to gain from.
|
|
137
67
|
|
|
138
|
-
|
|
139
|
-
- **@voltro/web, @voltro/cli** — `<Script>` — third-party scripts with a declared loading strategy (plan 23). `afterInteractive` (default, injected after hydration, never render-blocking) and `lazyOnload` (browser idle via requestIdleCallback with the setTimeout fallback). Inline variant with a REQUIRED `id` as its dedupe key. Deliberately absent: `beforeInteractive` (the honest answer for a must-run-first script is a literal tag in the shell head — a preload link fetches but never executes) and `worker` (Partytown-class, its own decision).
|
|
68
|
+
**Message ids a real catalogue needed**, each because the generic answer is worse at the point of use:
|
|
140
69
|
|
|
141
|
-
|
|
70
|
+
- `betweenLength {min,max}` when a field carries BOTH bounds — "at least 2" is a half-truth for a rule that is "between 2 and 50" — and `exactLength {amount}` when they are equal. Read from the schema, not the failing issue: piping nests the later refinement outermost, so the sibling bound is not reachable from the issue that failed. - `invalidEmail` / `invalidUrl` / `invalidUuid` when the refinement declares a JSON-Schema `format`. A bare regex cannot name its own rule, and "Invalid format" beside an email box tells nobody anything. - `minDate` / `maxDate`, because a date bound rendered as a number bound reads "must be at least 2026-01-01". - `invalidFileType` / `fileTooLarge` — not produced by any refinement, carried so an app's own `ctx.validation.fail('doc', 'validation.fileTooLarge')` renders a sentence rather than an id.
|
|
142
71
|
|
|
143
|
-
|
|
72
|
+
`apiSurface: compatible` — `useFormField` goes from a const arrow to an overloaded function so it can take `(path)` as well as `(binding, path)`. The golden line for the old signature is replaced rather than removed: every existing `useFormField(form, path)` call compiles unchanged, because that overload is still declared first-class. Only code capturing the function's exact TYPE (rather than calling it) sees a difference.
|
|
144
73
|
|
|
145
|
-
|
|
146
|
-
- **@voltro/
|
|
74
|
+
**Counting rules pass `count`.** `minItems` / `maxItems` carry `{ count }` beside `{min}`/`{max}`: i18next selects a plural form on a parameter named exactly `count`, so ids passing only `{min}` could not be pluralised at all.
|
|
75
|
+
- **@voltro/runtime, @voltro/cli** — **The resume census — `/_voltro/inspect/subscriptions` now carries `resume`.** Per query label: how many subscriptions recorded a delta-resume ring, and how many were excluded, counted per reason (`computed`, `row-filter`, `eager-load`, `uncanonical-input`, `not-offered`). `voltro dev` also logs each verdict once per label under the `voltro:resume` scope — debug is the default level outside production, so it is already on where the tuning happens and off where it is served.
|
|
147
76
|
|
|
148
|
-
|
|
149
|
-
if (await emailTaken(input.email)) {
|
|
150
|
-
return yield* ctx.validation.fail('email', 'validation.emailTaken')
|
|
151
|
-
}
|
|
152
|
-
yield* ctx.validation.require(input.startsAt < input.endsAt, 'endsAt', 'validation.beforeStart')
|
|
153
|
-
```
|
|
77
|
+
It exists because the two failure shapes are indistinguishable from outside. A subscription excluded by a row filter and one whose executor returns a **value** rather than a descriptor both reconnect with a fresh snapshot and rows on the screen, so an app measuring its own reconnects cannot tell which of its queries a `tables:` declaration is even capable of helping. The answer is which of the two bind paths the executor took, and nothing on the wire carries it.
|
|
154
78
|
|
|
155
|
-
`
|
|
156
|
-
- **@voltro/client** — `useFormBinding` closes two gaps between the docs' promise and the hook. `asyncFields` puts `useAsyncValidation` INTO the submit path: in-flight checks are awaited (bounded, default 5s, fail-closed to `validation.checking`), an `invalid` verdict blocks the submit with the message on ITS field — the uniqueness probe no longer runs beside the form while `submit` ignores it. And `createHooks` now types the binding: `useFormBinding('employees.update', …)` takes the tag as a literal, `values`/`defaults` and the submit output infer from the generated descriptor — same treatment `useSubscription`/`useMutation`/`useAction` already had.
|
|
157
|
-
- **@voltro/runtime, @voltro/cli, @voltro/voltro** — Subscription socket backpressure (plan 01 phase 1). Measured first: a consumer that stopped reading retained EVERY event — 300 changes, 300 retained events, the producer never slowed — so one dead dashboard tab grew the process without bound.
|
|
79
|
+
The reasons are the load-bearing part, not the counts: `computed` means no declaration can ever change this query, `row-filter` means the filter narrows its source and the exclusion is the point, and `eager-load` is reported ONLY when the base table is not itself narrowed — so that verdict always means "drop the `.with(...)` and this one resumes". Counts rather than one verdict per label, because resumability is not purely a property of the label: an input that does not canonicalise is a property of the value, so one query can be resumable for one subscriber and excluded for the next. A label nobody has subscribed to is ABSENT rather than reported as zero — "nothing has subscribed yet" and "every query is excluded" must not read the same.
|
|
158
80
|
|
|
159
|
-
|
|
81
|
+
**And a correction to what `tables:` was documented to buy.** The 0.54.0 notes, the `tables:` doc comment and the row-level-security page all said one registration cost delta-resume on every query descriptor in an app, with a count beside it. The count was real; the sentence around it claimed those descriptors would have HAD the feature, and that was never measured. A query only has a delta chain when its executor returns a descriptor — one that maps its rows or wraps them in a page envelope re-runs an opaque handler and emits snapshots, filter or no filter. So the number a declaration gives back is the number of descriptor-returning subscriptions, not the number of queries. The docs now say that where the decision is made, and the census is how you find out which shape each of yours took.
|
|
160
82
|
|
|
161
|
-
|
|
83
|
+
### Changed
|
|
162
84
|
|
|
163
|
-
|
|
85
|
+
- `dataTransfer.stagingStaleMinutes` in `app.config.ts` — how long a staged import's silence has to last before a boot treats its scratch tables as abandoned. Previously `VOLTRO_STAGING_STALE_MINUTES` only; the env var still overrides the declaration, on the rule every other tunable here follows.
|
|
164
86
|
|
|
165
|
-
|
|
166
|
-
- **@voltro/plugin-notifications, @voltro/cli, @voltro/env, @voltro/protocol, @voltro/devtools-ui** — Web Push (VAPID) as a notification channel — `webPushChannel()` in `@voltro/plugin-notifications`: a browser subscribes once (`useWebPush()` + the shipped `sw.js`) and receives notifications with the tab closed.
|
|
87
|
+
The reason this was not already a field was recorded as "the boot check runs off the store alone, before the app config is threaded to it". That described the function's signature, not the boot: both paths already held the config three lines above the call. Resolution lives inside `stagingLeftoversAtBoot` rather than at either call site, so the two cannot disagree about what a declared value means, and a source-reading assertion fails if either path stops handing the config over.
|
|
167
88
|
|
|
168
|
-
|
|
89
|
+
### Fixed
|
|
169
90
|
|
|
170
|
-
|
|
91
|
+
- **@voltro/protocol, @voltro/cli, @voltro/client, @voltro/plugin-notifications, @voltro/plugin-comments, @voltro/plugin-presence, @voltro/plugin-search, @voltro/plugin-flags** — **Installing a plugin under an `alias` now moves its client too.** `alias` exists for one problem — your app already publishes `notifications.*` and cannot install a plugin that wants the same namespace — and it has to move four surfaces or it is worse than not existing. It moved three.
|
|
171
92
|
|
|
172
|
-
|
|
93
|
+
The two that did not:
|
|
173
94
|
|
|
174
|
-
|
|
95
|
+
- **The generated client sent the tag the PLUGIN authored.** Every lifter is `Rpc.make(descriptor.name, …)`, so the wire tag comes from the descriptor, not from the tag the codegen computed. Under `alias: 'inbox'` the exported identifier became `inboxInboxRpc`, the `appDescriptors` key became `inbox.inbox`, the type key became `inbox.inbox` — and the browser still asked a server that had stopped serving it for `notifications.inbox`. The codegen now lifts every plugin descriptor through `withRpcTag(…)`, unconditionally, so the aliased and un-aliased cases are one code path rather than a branch nothing exercises. - **The plugin's own hooks spelled their namespace as a literal.** `useInbox()`, `useUpload()`, `useComments()`, `usePresence()`, `useFlag()`, `useSearch()` all carried strings like `'notifications.inbox'`, which no alias could reach. `voltro dev` now writes a `registerPluginAliases({ … })` declaration into `rpcGroup.generated.ts` — the module the web client already loads value-level — and every plugin hook resolves its tag through `pluginTag(baseName, route)` from `@voltro/protocol` at call time, not at module load. Aliasing a plugin needs no change at any call site.
|
|
175
96
|
|
|
176
|
-
|
|
97
|
+
Two installs of one plugin (`name: 'ops'`) with no un-suffixed primary make `pluginTag` **refuse** rather than pick: a hook has no way to name an install, and guessing would address the wrong one silently. The error names both candidates and points at the full-tag call that says which you mean.
|
|
177
98
|
|
|
178
|
-
|
|
99
|
+
`pluginAlias` / `pluginSlug` moved from the CLI into `@voltro/protocol` (and are re-exported from their old path) because the browser has to derive the same namespace the server registered, and a second copy on the client would be a second definition of the rule with nothing comparing them.
|
|
179
100
|
|
|
180
|
-
|
|
101
|
+
Also corrected, in the same seam: the list of plugins that deliberately do NOT accept `tables: false` read as exhaustive and omitted `_voltro_storage_grants`, which decides who may read an object. It is named now — along with the reason the option would not have reached it anyway (storage's tables are framework tables, not `extendSchema` contributions).
|
|
102
|
+
- `voltro build` could not produce an api serve bundle on 0.53.0 or 0.54.0.
|
|
181
103
|
|
|
182
|
-
|
|
104
|
+
`@voltro/content`'s `get.ts` reaches its render pipeline through a dynamic `import('./serverLoad')`. That is deliberate — an unresolvable-at-build-time specifier is what keeps marked and the shiki grammars out of a consumer's client chunk graph. But `serverLoad` was not in the package's entry map, so nothing emitted `dist/serverLoad.js`, and `dist/index.js` shipped an import of a file beside it that was not there.
|
|
183
105
|
|
|
184
|
-
|
|
106
|
+
It resolved in this repo every time, because the workspace `exports` point at `src/` and `serverLoad.ts` sits next to `get.ts`. A consumer resolves `publishConfig.exports` to `dist/index.js`, and the same line cannot resolve. esbuild does not honour `@vite-ignore` — that is a vite directive — so the serve bundle refused to ship. The refusal was right; nothing had ever triggered it.
|
|
185
107
|
|
|
186
|
-
|
|
108
|
+
Two things worth knowing, both measured rather than reasoned:
|
|
187
109
|
|
|
188
|
-
|
|
110
|
+
- **The build was stopped by a dependency that contributes nothing to it.** An api serve bundle reaches `@voltro/content` through `serveCommand → dev → webDev → contentWiring`, and esbuild resolves before it tree-shakes. After shaking, the content pipeline is **0 bytes** of a 14.42 MB bundle. So an api app with no markdown anywhere was blocked by a markdown loader whose code it would never have carried. - **Shipping the file does not bloat anything.** Same measurement with the fixed package resolved as a consumer resolves it: 14.42 MB, content still 0 bytes. The dynamic import stays shaken away.
|
|
189
111
|
|
|
190
|
-
-
|
|
112
|
+
`scripts/check-dist-internal-specifiers.mjs` now bundles every emitted file of every publishable package — from `.publish/`, the tree users receive — with bare specifiers external, and fails if any relative specifier does not resolve. GATE-2 (`publint`) answers "does a declared subpath resolve"; this is one level below it, where `./serverLoad` lives.
|
|
113
|
+
- **@voltro/client** — Three places still taught the pre-fix contract for a cold-start failure.
|
|
191
114
|
|
|
192
|
-
|
|
193
|
-
- **@voltro/runtime, @voltro/workflow, @voltro/plugin-ratelimit, @voltro/cli** — Two more ways a production stream got bare text between its JSON records, both now closed:
|
|
115
|
+
`SubscriptionFailed` gives it its own state — `loading: false`, `failed: true`, `error` non-optional — precisely so a component branching on `loading` alone cannot render a skeleton forever. But `SubscriptionMeta.error`'s doc comment and two docs pages still said the opposite ("leaves `loading` TRUE … check `error` to break out of it"), which is the sentence a deployment quoted back at us as evidence for the defect that had already been fixed.
|
|
194
116
|
|
|
195
|
-
|
|
117
|
+
A comment that predicts a trap the code no longer has is worse than no comment: it teaches the defensive shape as if it were still required, and it invites the reading that `loading` is unreliable. All four now describe the state that exists, with the old behaviour kept only as the history that explains why the field is there.
|
|
118
|
+
- **@voltro/client, @voltro/web, @voltro/cli, @voltro/ui** — Four defects a real migration found, all of which passed `tsc` and a full test suite and only showed up against a running system.
|
|
196
119
|
|
|
197
|
-
|
|
198
|
-
- **@voltro/cli** — `voltro serve`'s `/_voltro/inspect/data/tables` now serves the MERGED table set (app entities + framework-assembled tables + analytics), as `voltro dev` always has. It served the app's entities alone, so `voltro check --url` — which reads that endpoint as its idea of which tables exist — reported a query whose `source:` names a framework table (`_voltro_agent_messages`, say) as a dangling-source ERROR against a live server, exit 1, while `--offline` said OK about the same declaration: the offline manifest assembles the framework tables itself, and the two modes contradicted each other. The full set was already computed a few lines above (the stale-`source:` audit refuses to run without it, for this exact reason) — the inspect surface just never received it. The masked data browser gains the same tables.
|
|
199
|
-
- **@voltro/client** — Auto-optimistic `op: 'update'` now merges into a SINGLE-OBJECT cache entry — a `*.getById` read — exactly as it merges a list row, and `op: 'delete'` empties one to `null`. The reducer's list handling fell through `Array.isArray(current) ? current : []` for a single object, so a two-field PATCH replaced the whole row until the server snapshot arrived: for ~400ms a detail page rendered only the patched fields — no assignee, a `createdAt` of "Invalid Date", nothing the input did not carry. The doc sentence "update merges by id" now holds for both shapes it covers; a patch whose id does not match the cached object leaves it untouched, and the same rule applies to a single object at a nested target path.
|
|
200
|
-
- **@voltro/runtime, @voltro/cli, @voltro/voltro** — Two optional-parameter declarations that were not optional enough.
|
|
120
|
+
**A server render derived a different form than the browser.** Nothing mounts a runtimes provider during SSR, so `useFormBinding` resolved its input schema from an EMPTY descriptor map: no fields, `required: false`. The browser then rendered the real ones and React discarded the subtree — "Hydration failed" on every server-rendered page carrying a bound form, with the diff pointing at a `Mui-required` class. `@voltro/client` keeps a process-global SSR descriptor registry now, and both boot paths fill it before rendering (`voltro dev` and `voltro start`, pinned as a parity test — a CLI module cannot import `@voltro/client`, so the call goes through `@voltro/web/ssr`, which both already load).
|
|
201
121
|
|
|
202
|
-
|
|
122
|
+
**A rejected `submit()` had nowhere to go.** A form calls it from an `onSubmit` handler that cannot await it, so the rejection surfaced as `Uncaught (in promise)` while the form sat there looking saved. `submit()` resolves `undefined` now and the failure is state: `state.submitError`, plus an optional `onError`. That covers a composed `onSubmit` whose follow-up write fails on its OWN mutation handle — a failure the binding never saw, and the common shape (create the row, then its first child).
|
|
203
123
|
|
|
204
|
-
|
|
124
|
+
**Undeclared fields went on the wire.** The binding validated the mapped input and then sent the object unchanged; the client decode ignores excess properties while the server has refused them since 0.37, so a form carrying anything beyond the mutation's input passed validation and was rejected on the wire with a message pointing at no field. The payload is restricted to the declared keys now — the rule the no-JS path already followed, so the two submits agree — with a dev warning naming what was dropped.
|
|
205
125
|
|
|
206
|
-
|
|
207
|
-
-
|
|
208
|
-
- **@voltro/logger, @voltro/cli** — In a `json`-format log stream (the default off a TTY, so every pod), EVERY line the framework emits is now a parseable record. Previously a production tail mixed JSON records with bare text from three sources: five subsystems whose serve-path wiring handed them hand-rolled `process.stdout.write('[tag] …')` adapters instead of the logger (schedule, broadcast, workflow ×2, flow-control — dev handed the real logger through, so every dev terminal looked right); the boot banner and app surface, which stripped their colours off a TTY and printed the multi-line layout anyway; and `voltro db apply`'s plan summary and refusal detail, rendered as terminal tables into a migrate job's stream.
|
|
126
|
+
**`setValue` with an unchanged value produced a new `values`.** Every React state source is expected to no-op on that; this one did not, so an effect depending on `values` that re-set a field to the value it already held never settled ("Maximum update depth exceeded" on a form mirroring toggles out of a multi-select).
|
|
127
|
+
- A plugin's dashboard panel no longer disappears when the app aliases the plugin.
|
|
209
128
|
|
|
210
|
-
|
|
211
|
-
- **@voltro/cli** — **An unmatched path is a server-rendered 404 now, in both boot paths.** The best-matching `not-found.tsx` (deepest owning directory, group segments excluded — the same pick the client router makes) renders through the ssr arm with status 404 and `x-voltro-rendered-by: ssr-not-found`; an app without one gets a plain-text 404. Previously `voltro dev` served a 200 client shell for any unknown path — so crawlers indexed error pages, link checkers needed a browser to see failures, monitoring read "fine", and the user saw a shell and then the client-side not-found jump — and `voltro start` answered a bare text 404 without the app's page. One shared predicate (`bestNotFoundDir`), so dev and start cannot disagree about which file answers a miss.
|
|
129
|
+
`alias` moves `plugin.name`, and the inspect mount is derived from it, so `alias: 'inbox'` on notifications moved its panel to `/_voltro/inspect/plugins/inbox/...` while both dashboards ask for `/plugins/notifications/...` with the path compiled in. They live in other repositories and cannot follow. The field's own doc comment stated this as a cost you accept — in nine plugins, the protocol helper and the docs.
|
|
212
130
|
|
|
213
|
-
|
|
131
|
+
`makePluginInspectRegistry` now mounts each plugin's `inspectEndpoints` under its CANONICAL slug as well, in a second pass so an effective mount always wins the path. Added only where unambiguous: a base name carried by more than one installed plugin gets no shared mount, because showing either install under it would hand a dashboard the other one's rows under a name that looks right — the hazard `pluginTag` refuses rather than guesses. `/_voltro/inspect/plugins` now reports `baseName` and `inspectSlug` per plugin, which is how a caller reaches a specific install.
|
|
132
|
+
- **@voltro/cli, @voltro/data-transfer** — **`voltro data restore --drill` failed every healthy backup of a real app.** It compared the restored schema's fingerprint against the backup stamp's `schemaFingerprint`, which records the SOURCE database's whole live schema — and the artifact never carries that schema. `pg_dump` / `mariadb-dump` exclude `_voltro_replace_in_progress` and `_voltro_data_transfers` on purpose, and the backup command opens a run row in the second one before it dumps, so on any database the framework has run against, the artifact is two tables short of the value it was being measured against. The drill answered:
|
|
214
133
|
|
|
215
|
-
|
|
216
|
-
- **@voltro/cli** — `voltro update` can cross a package rename. The pin sweep bumped the OLD package name to the target version — a version never published under that name — so the install failed with `NO_MATCHING_VERSION`, and the codemod that performs exactly this rename sat inside the target version the failed install never put on disk. "Fix the install" was the rename; the user did the circle by hand.
|
|
134
|
+
FAIL — restored N table(s), but the schema fingerprint (…) does NOT match the backup's stamp (…). The restore did not reproduce the schema that was backed up — the artifact is inconsistent.
|
|
217
135
|
|
|
218
|
-
|
|
219
|
-
- **@voltro/cli** — `restRoutes` declared on a `type:'web'` app now REFUSES the boot (`voltro dev` and `voltro start`, one shared predicate) instead of being silently ignored. The silent form was the worst available behaviour: a readiness probe hitting a declared `/api/health` got the SPA shell with a 200 — green probe, handler never reached — a POST got 404, and call sites ran against a dead same-origin path with nothing anywhere saying the config was inert. The refusal names the fix: REST routes mount on the API process; same-origin paths belong to the ingress/proxy.
|
|
136
|
+
about an artifact that was exactly right. A drill exists to be wired to a CI cron, and one that is red on every healthy input gets switched off — taking its two real failures with it.
|
|
220
137
|
|
|
221
|
-
|
|
138
|
+
The stamp now carries a second value, `dumpFingerprint`: the same snapshot minus `dumpExcludedTables(dialect)` — what a faithful restore must reproduce. The drill compares against that. A stamp written before this field degrades to a PARTIAL pass that says so, rather than falling back to the value that produces the false failure. `dumpExcludedTables` is per-dialect because only two of the five backup paths carry an exclusion flag at all: the sqlite/turso copy and the mssql export carry everything, and subtracting a set from those would invent the same bug in the other direction.
|
|
222
139
|
|
|
223
|
-
|
|
140
|
+
**The drill also checks the one boot-fatal condition a schema comparison cannot see.** `voltro serve`'s boot gate reads the newest `_voltro_migration_plans` row and refuses with `prod-mismatch` when there is none — so a ledger table that restores with exactly the right columns and zero rows is a database no source tree can boot, and its fingerprint is identical to a healthy one's. That is now a FAIL with the reason named. A restored database with no ledger table at all is not a voltro-managed schema and is reported as such, not failed.
|
|
224
141
|
|
|
225
|
-
|
|
142
|
+
There is deliberately no app boot in the drill. The boot gate is a comparison, not a startup sequence, so the part that generalises is reachable with a SELECT; booting a fixture app instead would prove something about our fixture rather than about your backup.
|
|
@@ -12,12 +12,12 @@
|
|
|
12
12
|
"dependencies": {
|
|
13
13
|
"@effect/platform": "^0.97.0",
|
|
14
14
|
"@effect/rpc": "^0.76.0",
|
|
15
|
-
"@voltro/ai": "0.
|
|
16
|
-
"@voltro/cli": "0.
|
|
17
|
-
"@voltro/database": "0.
|
|
18
|
-
"@voltro/env": "0.
|
|
19
|
-
"@voltro/protocol": "0.
|
|
20
|
-
"@voltro/runtime": "0.
|
|
15
|
+
"@voltro/ai": "0.55.0",
|
|
16
|
+
"@voltro/cli": "0.55.0",
|
|
17
|
+
"@voltro/database": "0.55.0",
|
|
18
|
+
"@voltro/env": "0.55.0",
|
|
19
|
+
"@voltro/protocol": "0.55.0",
|
|
20
|
+
"@voltro/runtime": "0.55.0",
|
|
21
21
|
"effect": "^3.22.0"
|
|
22
22
|
},
|
|
23
23
|
"devDependencies": {
|