@voltro/cli 0.54.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 +142 -2
- package/dist/{apiBuild-DTWp0S_q.js → apiBuild-CMvLJM_K.js} +2 -2
- package/dist/apiBuild-Cl0IDx8c.js +2 -0
- package/dist/bin.js +1 -1
- package/dist/{build-D4ygSbnV.js → build-S0QOzqPT.js} +10 -10
- package/dist/{checkCommand-L7DTlpIF.js → checkCommand-DNkY5kwF.js} +1 -1
- package/dist/{checkCommand-Dg1G7Gwd.js → checkCommand-fbj9GDjN.js} +5 -5
- package/dist/codegen-CN6vMM4J.js +2 -0
- package/dist/{codegen-DSLM8Su9.js → codegen-SIepQtUl.js} +74 -63
- package/dist/{codegenCommand-CG_Vx4lc.js → codegenCommand-3TDJezom.js} +10 -9
- package/dist/{codemodRunner-Cd4xkC6u.js → codemodRunner-C2zxZUIw.js} +55 -0
- package/dist/{commands-6Kzi92Np.js → commands-BBYJ7Q3B.js} +24 -24
- package/dist/{dashboardCommand-Cq1PWvI1.js → dashboardCommand-D2kmyCLL.js} +3 -3
- package/dist/{dataCommand-DYzW8vkv.js → dataCommand-BEPPQiTl.js} +266 -194
- package/dist/dbCommand-BTyBGhIA.js +2 -0
- package/dist/{dbCommand-B4NWZtGL.js → dbCommand-DZTmOFT4.js} +1 -1
- package/dist/{dev-CmuvUKRq.js → dev-Ca_A_S9v.js} +2182 -2157
- package/dist/{dev-cKUiZZsB.js → dev-DfVZaoys.js} +1 -1
- package/dist/{doctorCommand-DCiFVMtZ.js → doctorCommand-CGZJK_4o.js} +15 -15
- package/dist/doctorCommand-djmqEcDC.js +2 -0
- package/dist/{dormancyCommand-w1TrmgYP.js → dormancyCommand-DY2rYpTa.js} +1 -1
- package/dist/{embeddingsCommand-CMgPyRTr.js → embeddingsCommand-BoCqZsgp.js} +1 -1
- package/dist/{envCommand-Cyynmcfa.js → envCommand-Bxy2fOjc.js} +2 -2
- package/dist/{evolveCommand-BwvQ8dVH.js → evolveCommand-BsbZ-XDg.js} +2 -2
- package/dist/frameworkTableAssembly-Df2Ymp2f.js +2 -0
- package/dist/{frameworkTableAssembly-D7LJuALW.js → frameworkTableAssembly-Do-cf6RJ.js} +92 -98
- package/dist/index.js +1 -1
- package/dist/{infoCommand-DlYlUPqs.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/{migrate-BK_Bbx-_.js → migrate-CGFZS-1a.js} +2 -2
- package/dist/{probeCommand-_C0YU207.js → probeCommand-Bs3iVBSL.js} +1 -1
- package/dist/{runtimeTrace-CGWx1Q6l.js → runtimeTrace-C1BTpHGQ.js} +1 -1
- package/dist/{sdkgen-CDGHQUFj.js → sdkgen-CXMwLg9n.js} +1 -1
- package/dist/{serveCommand-Bje09q1v.js → serveCommand-C7IrCD58.js} +840 -838
- package/dist/serveCommand-Cjt5S9hD.js +2 -0
- package/dist/serveEntry.js +1 -1
- package/dist/{start-B0bnJgxI.js → start-DH7cat4-.js} +1 -1
- package/dist/{start-Clz-1BHB.js → start-EOV7s1NZ.js} +486 -476
- package/dist/startEntry.js +1 -1
- package/dist/{test-D_kW4KMj.js → test-DO27-x2P.js} +1 -1
- package/dist/{updateCommand-CRJlAOaM.js → updateCommand-C_jN1w18.js} +1 -1
- package/dist/updateCommand-nnFjDbl4.js +2 -0
- package/dist/{webDev-DlvZO30c.js → webDev-1XpVnYkW.js} +1 -1
- package/dist/{webDev-DSI9SOhs.js → webDev-B7vNj4Bq.js} +502 -494
- package/dist/{webhooksCommand-BvzXNHji.js → webhooksCommand-B1LVcyO3.js} +1 -1
- package/package.json +31 -19
- package/templates/AGENTS.md +1 -1
- package/templates/agent-docs/_index.md +1 -1
- package/templates/agent-docs/authentication.md +49 -6
- package/templates/agent-docs/cli.md +94 -11
- package/templates/agent-docs/data.md +64 -13
- package/templates/agent-docs/plugins.md +46 -8
- package/templates/agent-docs/reference.md +5 -3
- package/templates/agent-docs/routing.md +18 -0
- package/templates/agent-docs/schema-driven-ui.md +125 -0
- package/templates/agent-docs/whats-new.md +76 -133
- package/templates/apps/api-ai/package.json +6 -6
- package/templates/apps/api-auth/package.json +8 -8
- package/templates/apps/api-backend/package.json +7 -7
- package/templates/apps/api-backend-deactivation/package.json +7 -7
- package/templates/apps/api-backend-mail/package.json +8 -8
- package/templates/apps/api-backend-mariadb/package.json +9 -9
- package/templates/apps/api-backend-sqlite/package.json +8 -8
- package/templates/apps/api-backend-storage/package.json +8 -8
- package/templates/apps/api-cms/package.json +9 -9
- package/templates/apps/api-collab/package.json +8 -8
- package/templates/apps/api-data-advanced/package.json +8 -8
- package/templates/apps/api-durable/package.json +8 -8
- package/templates/apps/api-feature-flags/package.json +9 -9
- package/templates/apps/api-governance/package.json +8 -8
- package/templates/apps/api-kv/package.json +8 -8
- package/templates/apps/api-moderation/package.json +8 -8
- package/templates/apps/api-observability/package.json +8 -8
- package/templates/apps/api-ratelimit/package.json +8 -8
- package/templates/apps/api-rbac/package.json +8 -8
- package/templates/apps/api-rest/package.json +7 -7
- package/templates/apps/api-row-history/package.json +8 -8
- package/templates/apps/api-saas/package.json +11 -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/package.json +7 -7
- package/templates/apps/edge-functions/package.json +2 -2
- package/templates/apps/frontend-admin/package.json +8 -8
- package/templates/apps/frontend-app/package.json +9 -9
- package/templates/apps/frontend-auth/package.json +8 -8
- package/templates/apps/frontend-blank/package.json +7 -7
- package/templates/apps/frontend-cms/package.json +9 -9
- package/templates/apps/frontend-collab/package.json +10 -10
- package/templates/apps/frontend-contact/package.json +7 -7
- package/templates/apps/frontend-dashboard/package.json +7 -7
- package/templates/apps/frontend-docs/package.json +8 -8
- package/templates/apps/frontend-i18n/package.json +6 -6
- package/templates/apps/frontend-landing/package.json +7 -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/package.json +8 -8
- package/templates/apps/frontend-status/package.json +8 -8
- package/templates/apps/mobile-app/package.json +4 -4
- package/dist/apiBuild-CeUN55uk.js +0 -2
- package/dist/codegen-DjgxEOnD.js +0 -2
- package/dist/dbCommand-CSFWs9ev.js +0 -2
- package/dist/doctorCommand-J3qu4E0Y.js +0 -2
- package/dist/frameworkTableAssembly-IPD1pUnZ.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/serveCommand-BiPe8BJm.js +0 -2
- package/dist/updateCommand-CIoVDKnj.js +0 -2
|
@@ -257,6 +257,30 @@ const Input = Schema.Struct({
|
|
|
257
257
|
)
|
|
258
258
|
```
|
|
259
259
|
|
|
260
|
+
**The ids, and the shapes worth knowing.** `required`, `minLength {min}`,
|
|
261
|
+
`maxLength {max}`, `betweenLength {min,max}`, `exactLength {amount}`,
|
|
262
|
+
`pattern`, `invalidEmail` / `invalidUrl` / `invalidUuid`, `minValue {min}`,
|
|
263
|
+
`maxValue {max}`, `minDate {min}` / `maxDate {max}`, `minItems` / `maxItems`,
|
|
264
|
+
`integer`, `invalid`, `checking`, `invalidFileType` / `fileTooLarge`.
|
|
265
|
+
|
|
266
|
+
Three of those exist because the generic answer is worse at the point of use:
|
|
267
|
+
|
|
268
|
+
- **Two length bounds on one field are ONE statement.** `minLength(2)` +
|
|
269
|
+
`maxLength(50)` produce `betweenLength {min,max}` — not "at least 2" for a
|
|
270
|
+
field whose rule is "between 2 and 50" — and `length(4)` produces
|
|
271
|
+
`exactLength {amount}`.
|
|
272
|
+
- **A declared `format` names the rule.** A regex never does: "Invalid format"
|
|
273
|
+
beside an email box tells nobody anything. Annotate the format and the id
|
|
274
|
+
gets specific — `Schema.String.pipe(Schema.pattern(EMAIL))
|
|
275
|
+
.annotations({ jsonSchema: { format: 'email' } })` → `validation.invalidEmail`.
|
|
276
|
+
- **A date bound is not a number bound.** `minDate` / `maxDate` rather than
|
|
277
|
+
`minValue` reading "must be at least 2026-01-01".
|
|
278
|
+
|
|
279
|
+
**Counting rules pass `count`.** `minItems` / `maxItems` carry `{ count }`
|
|
280
|
+
(alongside `min`/`max`) because that is the parameter an i18n layer selects a
|
|
281
|
+
plural form on — i18next keys pluralisation on a parameter named exactly
|
|
282
|
+
`count`, so a message carrying only `{min}` cannot be pluralised at all.
|
|
283
|
+
|
|
260
284
|
**Server-side rules route to their field too.** An executor raises a typed
|
|
261
285
|
field error through the always-present `ctx.validation` — no declaration
|
|
262
286
|
needed, `ValidationError` is auto-merged into every mutation's and action's
|
|
@@ -383,6 +407,61 @@ const form = useFormBinding('app', 'tasks.create', {
|
|
|
383
407
|
holds SPA navigations (Back button included) and arms the native
|
|
384
408
|
`beforeunload` prompt — see the routing docs.
|
|
385
409
|
|
|
410
|
+
### Rich text — and who sanitizes it
|
|
411
|
+
|
|
412
|
+
A rich-text field is `RichTextDocument`. Use it in the mutation input and the
|
|
413
|
+
form renders an editor with no further annotation:
|
|
414
|
+
|
|
415
|
+
```ts
|
|
416
|
+
import { RichTextDocument } from '@voltro/web'
|
|
417
|
+
|
|
418
|
+
const ArticleUpdateInput = Schema.Struct({
|
|
419
|
+
id: Schema.String,
|
|
420
|
+
body: RichTextDocument,
|
|
421
|
+
})
|
|
422
|
+
```
|
|
423
|
+
|
|
424
|
+
Display it with `<RichTextView doc={article.body} />`.
|
|
425
|
+
|
|
426
|
+
**The contract, because "who sanitizes" is the whole question.** The value is
|
|
427
|
+
**not an HTML string** — it is a closed document tree with a fixed set of node
|
|
428
|
+
types. There is no `html` node, no raw-markup escape hatch, no attribute bag,
|
|
429
|
+
so there is nothing to sanitize: anything that is not one of the declared nodes
|
|
430
|
+
simply fails to decode.
|
|
431
|
+
|
|
432
|
+
That makes the **`Schema` decode the boundary** — the server's existing,
|
|
433
|
+
non-bypassable input check, the same one every mutation input already passes
|
|
434
|
+
through. The guarantee is therefore not "somebody remembered to sanitize this
|
|
435
|
+
one"; it is that a document which reached your database is one of these shapes.
|
|
436
|
+
|
|
437
|
+
The rest follows from that:
|
|
438
|
+
|
|
439
|
+
- **A link's `href` is the one field pointing outward, and it is allowlisted**:
|
|
440
|
+
`http(s)`, `mailto:`, a `#fragment`, a `/path`. Nothing else — `javascript:`
|
|
441
|
+
and `data:` are refused by the decode, and control characters/whitespace are
|
|
442
|
+
stripped before the check, because `java\tscript:` navigates exactly like
|
|
443
|
+
`javascript:`.
|
|
444
|
+
- **Client-side sanitizing is not a security boundary and is not treated as
|
|
445
|
+
one.** The widget's parser runs in the browser for the editing experience;
|
|
446
|
+
the browser is where an attacker sits, so every property it maintains is
|
|
447
|
+
re-established by the decode on the server.
|
|
448
|
+
- **Rendering never uses `dangerouslySetInnerHTML`.** `<RichTextView>` maps
|
|
449
|
+
nodes to React elements and text to React children, so markup someone typed
|
|
450
|
+
into the box is markup the reader SEES. It also drops an href that would not
|
|
451
|
+
survive a decode — for the value that never went through one.
|
|
452
|
+
|
|
453
|
+
**The built-in editor** is a `<textarea>` over a small, closed markdown subset:
|
|
454
|
+
headings, `**bold**`, `*italic*`, `` `code` ``, `[text](href)`, `-` lists,
|
|
455
|
+
`>` quotes and fenced code. Everything it does not recognise stays literal text.
|
|
456
|
+
That is also what makes the field work with JavaScript off — the textarea posts
|
|
457
|
+
source, `/form/*` parses it, the same decode validates it. Register your own
|
|
458
|
+
`rich-text` widget (rung 2) for a WYSIWYG; the stored value shape is unchanged.
|
|
459
|
+
|
|
460
|
+
**Not the collaborative case.** Concurrent, multi-writer editing is
|
|
461
|
+
`crdtDoc()` + `useCrdtEditor` (`@voltro/local-first`) — a CRDT bytes column, a
|
|
462
|
+
sync lane, Tiptap. This is the single-editor field: one column, one writer,
|
|
463
|
+
ordinary JSON your server can validate, index and diff.
|
|
464
|
+
|
|
386
465
|
**Testing.** `renderFormBinding` (from `@voltro/testing/client`) drives the
|
|
387
466
|
REAL binding against a fake api — fill, blur, submit, read the visible
|
|
388
467
|
errors; a mutation handler that throws `ValidationError({ field })`
|
|
@@ -399,6 +478,52 @@ expect(form.errors()['email']).toBe('validation.emailTaken')
|
|
|
399
478
|
|
|
400
479
|
Runs under jsdom (`// @vitest-environment jsdom`).
|
|
401
480
|
|
|
481
|
+
### What submit does with a failure, and what never reaches the wire
|
|
482
|
+
|
|
483
|
+
**`submit()` does not reject.** A form calls it from an `onSubmit` handler that
|
|
484
|
+
cannot await it, so a rejection has nowhere to go but the console — the form
|
|
485
|
+
sits there looking saved while the failure is invisible. It resolves
|
|
486
|
+
`undefined` instead, and the failure is state: field-routable errors land on
|
|
487
|
+
their field, everything else in `state.submitError`, with an optional
|
|
488
|
+
`onError` for a toast. That covers a composed `onSubmit` too — a follow-up
|
|
489
|
+
write failing on its OWN mutation handle is a failure the binding never saw
|
|
490
|
+
before, and it is the common shape (create the row, then its first child).
|
|
491
|
+
|
|
492
|
+
**Only declared keys are sent.** A form almost always carries more than the
|
|
493
|
+
mutation declares — a display toggle, a repeat control, a file held before
|
|
494
|
+
upload — and the server has refused undeclared input fields since 0.37. The
|
|
495
|
+
binding restricts the payload to the keys the input schema declares, which is
|
|
496
|
+
the rule the no-JS path already followed (`unknown keys are dropped`), so the
|
|
497
|
+
two submit paths agree. In development it warns once, naming what it dropped,
|
|
498
|
+
because a genuinely misplaced field should still be visible. `toInput` remains
|
|
499
|
+
the place to say what the write actually takes.
|
|
500
|
+
|
|
501
|
+
**`setValue` with an unchanged value is a no-op.** Every React state source is
|
|
502
|
+
expected to behave that way, and this one did not: each call produced a fresh
|
|
503
|
+
`values` object, so an effect depending on `values` that re-set a field to the
|
|
504
|
+
value it already held never settled.
|
|
505
|
+
|
|
506
|
+
A widget kit that needs the whole form rather than one field reads it with
|
|
507
|
+
`useFormBindingContext()` — the same provider, one level up.
|
|
508
|
+
|
|
509
|
+
**Server and browser derive the same form.** A page rendered on the server
|
|
510
|
+
resolves the mutation's input schema exactly as the browser will, so field
|
|
511
|
+
lists, labels and required marks match and hydration holds. (`voltro dev` and
|
|
512
|
+
`voltro start` both hand the descriptors over before rendering.)
|
|
513
|
+
|
|
514
|
+
```tsx
|
|
515
|
+
const form = useFormBinding('app', 'employees.update', {
|
|
516
|
+
toInput: (values) => ({ id, ...employeePatch(values) }),
|
|
517
|
+
onError: (error) => toast.error(String(error)), // optional; state.submitError always carries it
|
|
518
|
+
})
|
|
519
|
+
|
|
520
|
+
// A field component anywhere below — no binding threaded through as a prop
|
|
521
|
+
const City = () => {
|
|
522
|
+
const f = useFormField('address.city')
|
|
523
|
+
return <input value={String(f.value ?? '')} onChange={(e) => f.setValue(e.target.value)} onBlur={f.onBlur} />
|
|
524
|
+
}
|
|
525
|
+
```
|
|
526
|
+
|
|
402
527
|
### Forms without JavaScript
|
|
403
528
|
|
|
404
529
|
On a server-rendered page, `<AutoForm>` works with JavaScript disabled — or not
|
|
@@ -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,191 +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
|
-
|
|
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
|
-
The
|
|
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/local-first** — **`RUNTIME_SEAMS` lists ONE seam now, not three** — `['sync-transport-app-tags']`. `RuntimeSeam` narrows with it. The two removed entries left in opposite directions, and `seams.ts` was asserting both halves of the contradiction at once: its `DONE` prose said the presence broker binding shipped while the array beside it still named `presence-broker-binding` as open.
|
|
18
|
+
**Migration — `relations:` values are objects now:**
|
|
20
19
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
Absolute (`/…`) and remote sources are untouched; they are not the build's to move. Copy and content-hash only — build-time transformation (resize / format) remains a named non-goal for markdown content, because a markdown reference carries no width and no `sizes` to derive one from. The `?image` pipeline stays the answer where that matters.
|
|
36
|
-
|
|
37
|
-
Renderers outside the framework's build are unaffected: `@voltro/content` exposes this as a registered resolver (`setContentAssetResolver`), and with none registered a relative src is left exactly as written rather than rewritten to a path nothing serves.
|
|
38
|
-
- **@voltro/cli, @voltro/runtime** — **The server-side CRDT compaction threshold is an `app.config.ts` field now, not an environment variable only.** `crdt.compactMaxBytes` (default 512 KiB, `0` disables) decides when a merged `crdtText()` / `crdtDoc()` blob is soft-compacted — re-encoded through a live doc, same lineage, so every outstanding client update still merges. `VOLTRO_CRDT_COMPACT_MAX_BYTES` still wins over it: an operator acting on a running deployment outranks what the project declared.
|
|
39
|
-
|
|
40
|
-
This is the standing rule ("every number the framework picks on your behalf is a config field with a default, plus an env override"), applied to the one knob that had only the environment half. A threshold sized for a document shape is a property of the project, so it belongs in a file a reviewer reads and a deploy carries — not in whatever `env:` block somebody remembered to set.
|
|
41
|
-
|
|
42
|
-
Resolved by ONE builder both boot paths call (`wireCrdtTunables`), registered into the process slot the merge path reads — the `wireReactiveSocketTunables` shape, for the reason that shape exists: two paths resolving a value separately is how they come to disagree. `setCrdtCompactMaxBytes` was exported and called by nothing before this; the runtime kept a lazy env read for a process that never runs a boot path (a unit test), and that remains the fallback rather than a second answer.
|
|
43
|
-
|
|
44
|
-
Note `0` is a value here, not an absence — it means "do not compact" — so the resolver's floor is `>= 0` on both the config and the env side. The `> 0` floor the other tunables use would have silently dropped a declared zero.
|
|
45
|
-
- **@voltro/cli** — **The gRPC surface's shutdown drain budget is configurable — `grpc.drainMs` (default 5000, `0` forces immediately, env `VOLTRO_GRPC_DRAIN_MS`).** It was a `5_000` literal in the `stop()` closure.
|
|
20
|
+
```ts
|
|
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
|
+
} } }
|
|
32
|
+
```
|
|
46
33
|
|
|
47
|
-
|
|
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.
|
|
48
35
|
|
|
49
|
-
|
|
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.
|
|
50
37
|
|
|
51
|
-
|
|
52
|
-
- **@voltro/
|
|
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.
|
|
53
40
|
|
|
54
|
-
|
|
41
|
+
The contract, stated plainly because the alternatives all look reasonable until you name who the attacker is:
|
|
55
42
|
|
|
56
|
-
`
|
|
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.
|
|
57
44
|
|
|
58
|
-
|
|
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).
|
|
59
46
|
|
|
60
|
-
The `
|
|
61
|
-
- **@voltro/plugin-presence** — `resolveMember` now receives the app's `store`, and `identityFields:` closes the hole in the obvious resolver.
|
|
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.
|
|
62
48
|
|
|
63
|
-
|
|
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.
|
|
64
50
|
|
|
65
|
-
**
|
|
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.
|
|
66
52
|
|
|
67
|
-
|
|
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.
|
|
68
54
|
|
|
69
|
-
`
|
|
70
|
-
- **@voltro/plugin-queue** — **Queue consumers now export Prometheus series and open one tracing span per message, continuing the producer's trace.** Both were specified and neither was built: `voltro_queue_consumed_total` had zero occurrences anywhere in the repo, and `traceparent` was copied out of the message headers onto `ctx.traceparent` — a value a handler can forward by hand, not a trace. A Kafka hop was where every distributed trace ended.
|
|
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).
|
|
71
56
|
|
|
72
|
-
|
|
57
|
+
### Added
|
|
73
58
|
|
|
74
|
-
|
|
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.
|
|
75
60
|
|
|
76
|
-
|
|
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.
|
|
77
62
|
|
|
78
|
-
The
|
|
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`.
|
|
79
64
|
|
|
80
|
-
|
|
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.
|
|
81
67
|
|
|
82
|
-
|
|
83
|
-
- **@voltro/runtime, @voltro/voltro** — `setRowFilter({ …, tables: ['bookmarks', 'recentSearches'] })` — declare which tables your filter may narrow, and delta-resume survives everywhere else.
|
|
68
|
+
**Message ids a real catalogue needed**, each because the generic answer is worse at the point of use:
|
|
84
69
|
|
|
85
|
-
|
|
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.
|
|
86
71
|
|
|
87
|
-
|
|
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.
|
|
88
73
|
|
|
89
|
-
**
|
|
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.
|
|
90
76
|
|
|
91
|
-
|
|
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.
|
|
92
78
|
|
|
93
|
-
|
|
94
|
-
- **@voltro/local-first** — **`useCrdtDoc` — the transport half `useCrdtEditor` had no partner for.** `useCrdtEditor({ doc })` takes a `CrdtDocHandle` and owns the editor lifecycle; getting that handle wired to a server was app-level glue until now. Import it from `@voltro/local-first/react`:
|
|
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.
|
|
95
80
|
|
|
96
|
-
|
|
97
|
-
const shared = useCrdtDoc({
|
|
98
|
-
cell: { table: 'documents', id, column: 'body' },
|
|
99
|
-
remote: row.data?.body ?? null, // the reactive query streaming the row
|
|
100
|
-
push: (w) => save.mutate({ id: w.id, update: w.update }),
|
|
101
|
-
})
|
|
102
|
-
// in a CHILD component, so useCrdtEditor is never a conditional hook call:
|
|
103
|
-
const editor = useCrdtEditor({ doc })
|
|
104
|
-
```
|
|
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.
|
|
105
82
|
|
|
106
|
-
|
|
83
|
+
### Changed
|
|
107
84
|
|
|
108
|
-
|
|
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.
|
|
109
86
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
`useCrdtText` and `useCrdtDoc` now build their sync client through one shared internal seam rather than two copies of the same transport wiring.
|
|
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.
|
|
113
88
|
|
|
114
89
|
### Fixed
|
|
115
90
|
|
|
116
|
-
- **@voltro/
|
|
117
|
-
|
|
118
|
-
The filter is AND-merged onto a read's BASE table by the store middleware; an eager load is resolved BELOW that seam — the memory store recurses through its own raw read, the SQL stores fold the relation into one join — so the relation's rows never passed the code that would narrow them. Measured on one data set: a filter restricting `readers` to the caller returned exactly the caller's row on a direct read and BOTH rows through `.with({ readers: true })`.
|
|
119
|
-
|
|
120
|
-
Applying the filter inside eager compilation is the real fix and it is a per-dialect change. Until then the read REFUSES rather than serves, naming the table and both ways out (read it as its own query, or drop it from the `.with(...)`). Only relations reaching a table the filter actually narrows are affected; every other eager load is untouched, and an app with no filter pays nothing.
|
|
121
|
-
|
|
122
|
-
Silent exposure is the one outcome that must not survive the gap — the same reasoning that makes this module refuse to fail open when `load` fails.
|
|
123
|
-
- **@voltro/cli** — **The AGENTS.md seeder executed every app's `app.config.ts`, so seeding one app could stop another from booting.** It walked `apps/<proj>/<app>` for the whole workspace and `import()`ed each config to read its `plugins:` list. An `app.config.ts` is not inert: it imports the app's schema, which calls `databaseHandle(...)`, which REGISTERS every table. Two apps that each declare an `actors` table therefore collided —
|
|
124
|
-
|
|
125
|
-
```
|
|
126
|
-
duplicate table 'actors' registration: two different table descriptors claim the same name.
|
|
127
|
-
```
|
|
128
|
-
|
|
129
|
-
— and the app being booted was the one that failed. A reference project could not start at all.
|
|
130
|
-
|
|
131
|
-
The seeder parses the config now instead of importing it, resolving each plugin's package by pairing the identifiers called inside the `plugins: [...]` block with the import that introduced them (so a renamed import still lands on the right package), and reading the `{ name: '…' }` form directly. A plugin it cannot attribute is dropped rather than guessed: the index is a filter, and a wrong row is worse than a missing one.
|
|
132
|
-
|
|
133
|
-
The rule this encodes: **a step that writes documentation must not execute application modules.** Nothing about composing an index needs a config's runtime value, and the sibling app-discovery pass was already reading the same file as text for its `type:` check.
|
|
134
|
-
|
|
135
|
-
Worth knowing for the next diagnosis: two different descriptors for one name reads like one module evaluated twice, so the investigation went looking for split module identity (pnpm symlink vs real path, ESM cache keying). It was two DIFFERENT apps, pulled in by a documentation step — found by tracing what actually resolved, not by reasoning about what could.
|
|
136
|
-
- **@voltro/cli** — **`voltro build` read the PREVIOUS build's config, so every `app.config.ts` change landed one build late.** `loadConfig` prefers the precompiled `.framework/dist/server/appConfig.js` when it exists — correct for `serve` / `start`, which cannot read TypeScript, and exactly wrong for the command that WRITES that file. Anything consumed before the config is recompiled took the stale value: `fonts`, `images`, `seo`, `theme`, `locales`.
|
|
137
|
-
|
|
138
|
-
Silent in the worst way: the second build is always right, so the symptom is "my change did nothing" followed by "…and now it works", with no error either time. Measured by setting `title` to a probe value, building, and finding the old title in the generated shell.
|
|
139
|
-
|
|
140
|
-
`voltro dev` already carried this fix, with the reasoning written on the option itself. The build path is the one that makes the artefact, so it is the last place that should trust it.
|
|
141
|
-
- **@voltro/local-first** — **`useCrdtEditor` built its editor during RENDER, so a default page could not mount it and StrictMode leaked one.** The Tiptap instance was constructed inside a `useMemo`, which runs while rendering. Two consequences, both real:
|
|
142
|
-
|
|
143
|
-
- **Server render threw.** Tiptap needs `window`; a page mounting this hook failed prerender with `there is no window object available`. `renderMode` defaults to `'static'`, so that is the ordinary page, not an exotic one. - **React's double-invoked render built TWO editors** and the cleanup destroyed only the last, leaving the first alive holding its Yjs binding. `useCrdtText`'s own comment documents exactly this shape as a bug; the editor had it anyway.
|
|
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.
|
|
144
92
|
|
|
145
|
-
|
|
93
|
+
The two that did not:
|
|
146
94
|
|
|
147
|
-
`
|
|
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.
|
|
148
96
|
|
|
149
|
-
|
|
150
|
-
- **@voltro/cli** — `middleware.ts`'s `cspNonce` now reaches the scripts in a STREAMED response's `<head>`. Both boot paths handed the nonce to React — which stamps only the scripts React itself emits — and not to the driver that composes the head, so on the arm a plain `renderMode: 'ssr'` page takes, `renderDeferredRegistryScript()` (executable inline JS) and the `__voltro_state__` payload went out bare under the very `script-src 'nonce-…'` policy the same response set. A deferring page's registry was therefore the one script the browser refused to run. The `renderMode: 'spa'` layout-shell arms were unstamped end to end for the same reason and are fixed with them.
|
|
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.
|
|
151
98
|
|
|
152
|
-
`
|
|
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.
|
|
153
100
|
|
|
154
|
-
|
|
155
|
-
-
|
|
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.
|
|
156
103
|
|
|
157
|
-
|
|
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.
|
|
158
105
|
|
|
159
|
-
|
|
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.
|
|
160
107
|
|
|
161
|
-
|
|
162
|
-
- **@voltro/local-first** — **`useCrdtEditor` was unreachable from a published install.** `@voltro/local-first`'s source `exports` map carried `./editor`, its `publishConfig.exports` — the map an npm install actually resolves — carried only `.` and `./react`, and the build emitted no editor bundle. So the rich-text editor binding worked inside this monorepo (where `workspace:*` resolves the source map) and gave every user `ERR_PACKAGE_PATH_NOT_EXPORTED`, while the docs taught it.
|
|
108
|
+
Two things worth knowing, both measured rather than reasoned:
|
|
163
109
|
|
|
164
|
-
|
|
165
|
-
- **@voltro/web** — `responseHeaders` and `cspNonce` are on the type users actually write against.
|
|
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.
|
|
166
111
|
|
|
167
|
-
|
|
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.
|
|
168
114
|
|
|
169
|
-
|
|
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.
|
|
170
116
|
|
|
171
|
-
|
|
172
|
-
- **@voltro/cli** —
|
|
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.
|
|
173
119
|
|
|
174
|
-
|
|
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).
|
|
175
121
|
|
|
176
|
-
|
|
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).
|
|
177
123
|
|
|
178
|
-
|
|
179
|
-
- **@voltro/database** — Adding a `.default(…)` to an EXISTING `text()` column no longer kills the migration on MySQL. MySQL refuses a DEFAULT on a TEXT/BLOB column outright (`BLOB, TEXT, GEOMETRY or JSON column 'x' can't have a default value`), where MariaDB allows it — so the same declaration applied on one engine and failed mid-migration on the other. `CREATE TABLE` has always answered this by widening such a column to `VARCHAR(255)` (`NVARCHAR(450)` on SQL Server, where the reason is indexability); the ALTER path now applies that same answer, reshaping the column to the declared shape instead of setting a default on whatever the column happened to be. A column therefore ends up with the same type whether the default was declared before or after the table existed. Postgres (TEXT takes a DEFAULT) and SQLite (rebuilds to the declared shape) are unchanged. Note the narrowing: on mysql/mariadb the column becomes `VARCHAR(255)`, so the ALTER fails loudly if an existing row is longer — use `text().maxLength(n)` to choose the width. The same widening rule also reached `ADD COLUMN`, which on SQL Server had emitted `NVARCHAR(MAX)` for a column `CREATE TABLE` renders as `NVARCHAR(450)`.
|
|
180
|
-
- **@voltro/cli** — **Validation errors on the no-JavaScript form path rendered in English on every locale.** `validateFields` resolves message ids through a locale whose default is `documentLocale()` — it reads `<html lang>`, and outside a browser that is always `'en'`. The `/form/*` handler runs on the server, so a German page's 422 came back with English field errors while the same form with JavaScript rendered German; the flash carries the resolved strings, so hydration kept them.
|
|
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.
|
|
181
125
|
|
|
182
|
-
|
|
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.
|
|
183
128
|
|
|
184
|
-
|
|
185
|
-
- **@voltro/client** — `useOutbox`'s `resolveConflict(id, input)` now actually replays the entry it resolved. It replayed against the pre-resolution queue — `replay` reads the queue through a ref, and the `setQueue` beside it had not landed yet — so the entry it was handed was still `conflict`, `replayable()` stopped at it, and nothing was sent. The resolution then sat in the queue as `pending` with no further replay scheduled, and every write queued behind it stayed blocked with it: a conflict that could be resolved in the UI and never left the device.
|
|
186
|
-
- **@voltro/runtime, @voltro/cli** — **A subscription opened over SSE or gRPC ignored the registered row filter — every read, not just the first.** `dispatcher.subscribe` resolves row visibility itself and treats a missing `refilter` as "this app has no row filter", so it read the descriptor unnarrowed. The WebSocket path always supplied one (`bindSubscription`'s `defaultRefilter`); the SSE and gRPC projections go through `makeQuerySubscriber`, whose `subscribeDescriptor` callback passed three arguments and dropped the trailing pair. Both boot paths were affected identically, so nothing a dev/serve parity check looks at could see it.
|
|
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.
|
|
187
130
|
|
|
188
|
-
|
|
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:
|
|
189
133
|
|
|
190
|
-
|
|
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.
|
|
191
135
|
|
|
192
|
-
|
|
193
|
-
- **@voltro/cli** — **A template could not ship a font or an image — the scaffolder corrupted every binary file.** `scaffoldFromTemplate` read each file with `readFile(src, 'utf8')` and wrote the string back. That call does not throw on binary input: it substitutes U+FFFD for every undecodable sequence and returns a string, so a woff2 went in at 15 344 bytes and came out at 27 572, silently, in every scaffolded project.
|
|
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.
|
|
194
137
|
|
|
195
|
-
|
|
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.
|
|
196
139
|
|
|
197
|
-
The
|
|
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.
|
|
198
141
|
|
|
199
|
-
|
|
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.
|