@voltro/cli 0.52.0 → 0.53.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (195) hide show
  1. package/CHANGELOG.md +229 -0
  2. package/THIRD-PARTY-NOTICES.md +8311 -3318
  3. package/dist/{apiBuild-CSFI8QGq.js → apiBuild-CaPfoWku.js} +11 -5
  4. package/dist/apiBuild-DHtLXYx9.js +2 -0
  5. package/dist/bin.js +1 -1
  6. package/dist/build-D-OnvNMf.js +843 -0
  7. package/dist/{checkCommand-COmqc2cB.js → checkCommand-C5elt0tW.js} +6 -6
  8. package/dist/{checkCommand-2SbqzukH.js → checkCommand-D2ZduVlh.js} +1 -1
  9. package/dist/codegen-BWpt3VgF.js +2 -0
  10. package/dist/{codegen-VF479Cnb.js → codegen-FEk8AZHb.js} +2 -2
  11. package/dist/{codegenCommand-DCdG2JN-.js → codegenCommand-BOiWQ5hz.js} +4 -4
  12. package/dist/{codemodRunner-r7J9lIa7.js → codemodRunner-BjtB2lq6.js} +109 -11
  13. package/dist/{commands-Cc_nV8WI.js → commands-DyxAmhP0.js} +30 -30
  14. package/dist/{dashboardCommand-C-gKvwqh.js → dashboardCommand-BdKTyT13.js} +5 -5
  15. package/dist/{dataCommand-BgpBHnlB.js → dataCommand-Bab9X7s8.js} +2 -2
  16. package/dist/{dbCommand-sHedr-NJ.js → dbCommand-06O2finM.js} +277 -236
  17. package/dist/dbCommand-B1EXBC6f.js +2 -0
  18. package/dist/{dev-CRHoCEiy.js → dev-C6LGF4iY.js} +3006 -2426
  19. package/dist/{dev--A3nsxA3.js → dev-GjJWAYo2.js} +1 -1
  20. package/dist/doctorCommand-B0hX0tdz.js +2 -0
  21. package/dist/{doctorCommand-DtfJ3FA6.js → doctorCommand-etMkflRc.js} +101 -69
  22. package/dist/{dormancyCommand-Drn7o0No.js → dormancyCommand-UwZ1AZzB.js} +1 -1
  23. package/dist/{embeddingsCommand-Z-jO1fWN.js → embeddingsCommand-C70zWHwo.js} +1 -1
  24. package/dist/{envCommand-D4gCrrTZ.js → envCommand-dSyKvRkM.js} +18 -18
  25. package/dist/{evolveCommand-CMROeKeA.js → evolveCommand-CG0_ebO5.js} +2 -2
  26. package/dist/fileConventions-DASGEmj-.js +35 -0
  27. package/dist/{fileTaxonomy-DvDUV9wq.js → fileTaxonomy-B7uxipWS.js} +55 -55
  28. package/dist/fontPipeline-LxIHa1vo.js +2 -0
  29. package/dist/fontPipeline-Tsh8kZfA.js +152 -0
  30. package/dist/frameworkTableAssembly-C_7Z-rMs.js +2 -0
  31. package/dist/{frameworkTableAssembly-w-XnLa3q.js → frameworkTableAssembly-DKx3ba3S.js} +5 -5
  32. package/dist/imagePipeline-B_GVJgm6.js +2 -0
  33. package/dist/imagePipeline-CBZmjT4i.js +127 -0
  34. package/dist/index.js +1 -1
  35. package/dist/{infoCommand-DXM868o_.js → infoCommand-_53iOc_j.js} +1 -1
  36. package/dist/{inspect-CtL_xTbu.js → inspect-Bd8-9wsi.js} +1 -1
  37. package/dist/inspect-CuoDInfZ.js +2 -0
  38. package/dist/{inspectGateHint-BjnFubmH.js → inspectGateHint-4LxkNtrz.js} +1 -1
  39. package/dist/manifestBuild-C4-J1-m_.js +2 -0
  40. package/dist/{manifestBuild-CuU1VrSm.js → manifestBuild-Cqgsx2bM.js} +1 -1
  41. package/dist/{metaCommands-C6RFmF1r.js → metaCommands-Cn2oboG4.js} +8 -2
  42. package/dist/{migrate-D0F-eTlK.js → migrate-Cko9rswM.js} +2 -2
  43. package/dist/{pageConvention-CzUiSbtU.js → pageConvention-C938S8oC.js} +1 -1
  44. package/dist/{privacyCommand-DGdopOI6.js → privacyCommand-DWTQMC6R.js} +2 -2
  45. package/dist/{probeCommand-C9gazU0H.js → probeCommand-DkGGLknv.js} +83 -24
  46. package/dist/{renderModeScan-CcH2X1_D.js → renderModeScan-CUbOeOAg.js} +3 -1
  47. package/dist/{renderProfile-Ck32Fzxr.js → renderProfile-CskIgAfn.js} +2 -2
  48. package/dist/{runtimeTrace-BPQyCmC5.js → runtimeTrace-c0APJz7E.js} +1 -1
  49. package/dist/{sdkgen-Se88ifTd.js → sdkgen-BiQCgIEr.js} +1 -1
  50. package/dist/{serveCommand-DkP3OT0W.js → serveCommand-CueKQgzl.js} +869 -805
  51. package/dist/serveCommand-DsnrVN3U.js +2 -0
  52. package/dist/serveEntry.js +1 -1
  53. package/dist/start-BJzZLbt8.js +3 -0
  54. package/dist/start-ekPan8BT.js +1510 -0
  55. package/dist/startEntry.js +1 -1
  56. package/dist/{staticCommand-BwNEDlSU.js → staticCommand-xlSL-IWk.js} +1 -1
  57. package/dist/{test-f3amja6a.js → test-BWPQcRoB.js} +1 -1
  58. package/dist/updateCommand-Bqql_rsQ.js +2 -0
  59. package/dist/{updateCommand-BMk2e4ky.js → updateCommand-C_8I8Rzo.js} +139 -115
  60. package/dist/webDev-C7jWJ5dX.js +2 -0
  61. package/dist/{webDev-BgWL9gKV.js → webDev-oczpugbx.js} +1455 -922
  62. package/dist/{webhooksCommand-CoIO3jbj.js → webhooksCommand-4SVPDjKg.js} +1 -1
  63. package/package.json +60 -19
  64. package/templates/AGENTS.md +5 -3
  65. package/templates/agent-docs/_index.md +5 -3
  66. package/templates/agent-docs/_manifest.json +20 -4
  67. package/templates/agent-docs/ai.md +2 -2
  68. package/templates/agent-docs/authentication.md +1 -1
  69. package/templates/agent-docs/cli.md +2 -2
  70. package/templates/agent-docs/configuration.md +17 -0
  71. package/templates/agent-docs/data.md +470 -26
  72. package/templates/agent-docs/database/advancedqueries.md +7 -7
  73. package/templates/agent-docs/database/columntypes.md +2 -2
  74. package/templates/agent-docs/database/querying.md +1 -1
  75. package/templates/agent-docs/database/schema.md +1 -1
  76. package/templates/agent-docs/database/seedsdialects.md +1 -1
  77. package/templates/agent-docs/database/transactions.md +3 -3
  78. package/templates/agent-docs/deployment.md +8 -0
  79. package/templates/agent-docs/internationalization.md +2 -2
  80. package/templates/agent-docs/introduction.md +7 -1
  81. package/templates/agent-docs/local-first-mobile.md +102 -4
  82. package/templates/agent-docs/observability.md +2 -0
  83. package/templates/agent-docs/plugins/auth.md +1 -1
  84. package/templates/agent-docs/plugins/comments.md +142 -0
  85. package/templates/agent-docs/plugins/notifications.md +47 -4
  86. package/templates/agent-docs/plugins/presence.md +16 -3
  87. package/templates/agent-docs/plugins/prometheus.md +1 -1
  88. package/templates/agent-docs/plugins/queue.md +129 -0
  89. package/templates/agent-docs/plugins.md +11 -7
  90. package/templates/agent-docs/reference.md +16 -4
  91. package/templates/agent-docs/routing.md +527 -3
  92. package/templates/agent-docs/schema-driven-ui.md +214 -3
  93. package/templates/agent-docs/security.md +3 -3
  94. package/templates/agent-docs/whats-new.md +160 -66
  95. package/templates/apps/api-ai/package.json +6 -6
  96. package/templates/apps/api-auth/package.json +8 -8
  97. package/templates/apps/api-backend/package.json +7 -7
  98. package/templates/apps/api-backend-deactivation/package.json +7 -7
  99. package/templates/apps/api-backend-mail/package.json +8 -8
  100. package/templates/apps/api-backend-mariadb/package.json +9 -9
  101. package/templates/apps/api-backend-sqlite/package.json +8 -8
  102. package/templates/apps/api-backend-storage/package.json +8 -8
  103. package/templates/apps/api-cms/package.json +9 -9
  104. package/templates/apps/api-collab/package.json +8 -8
  105. package/templates/apps/api-data-advanced/package.json +8 -8
  106. package/templates/apps/api-durable/package.json +8 -8
  107. package/templates/apps/api-feature-flags/package.json +9 -9
  108. package/templates/apps/api-governance/package.json +8 -8
  109. package/templates/apps/api-kv/package.json +8 -8
  110. package/templates/apps/api-moderation/package.json +8 -8
  111. package/templates/apps/api-observability/package.json +8 -8
  112. package/templates/apps/api-ratelimit/package.json +8 -8
  113. package/templates/apps/api-rbac/package.json +8 -8
  114. package/templates/apps/api-rest/package.json +7 -7
  115. package/templates/apps/api-row-history/package.json +8 -8
  116. package/templates/apps/api-saas/package.json +10 -10
  117. package/templates/apps/api-saas-starter/package.json +10 -10
  118. package/templates/apps/api-search/package.json +8 -8
  119. package/templates/apps/api-status/package.json +8 -8
  120. package/templates/apps/api-webhooks/package.json +9 -9
  121. package/templates/apps/changelog/app.config.ts +26 -2
  122. package/templates/apps/changelog/content/releases/{0.1.0.mdx → v0-1-0.mdx} +0 -1
  123. package/templates/apps/changelog/content/releases/{0.2.0.mdx → v0-2-0.mdx} +0 -1
  124. package/templates/apps/changelog/package.json +8 -8
  125. package/templates/apps/changelog/src/collections/releases.collection.ts +48 -0
  126. package/templates/apps/changelog/src/globals.d.ts +1 -1
  127. package/templates/apps/changelog/src/locales/de.ts +1 -1
  128. package/templates/apps/changelog/src/locales/en.ts +1 -1
  129. package/templates/apps/changelog/src/pages/[locale]/[slug]/page.tsx +7 -4
  130. package/templates/apps/changelog/src/pages/[locale]/mirrors.test.tsx +13 -41
  131. package/templates/apps/changelog/src/pages/[slug]/page.test.tsx +26 -51
  132. package/templates/apps/changelog/src/pages/[slug]/page.tsx +21 -19
  133. package/templates/apps/changelog/src/pages/page.test.tsx +14 -37
  134. package/templates/apps/changelog/src/pages/page.tsx +18 -12
  135. package/templates/apps/edge-functions/package.json +2 -2
  136. package/templates/apps/frontend-admin/package.json +8 -8
  137. package/templates/apps/frontend-app/package.json +9 -9
  138. package/templates/apps/frontend-auth/package.json +8 -8
  139. package/templates/apps/frontend-blank/package.json +7 -7
  140. package/templates/apps/frontend-cms/package.json +9 -9
  141. package/templates/apps/frontend-collab/package.json +10 -10
  142. package/templates/apps/frontend-contact/package.json +7 -7
  143. package/templates/apps/frontend-dashboard/package.json +7 -7
  144. package/templates/apps/frontend-docs/content/docs/de/guides/first-page.md +4 -0
  145. package/templates/apps/frontend-docs/content/docs/de/intro/getting-started.md +4 -0
  146. package/templates/apps/frontend-docs/content/docs/en/guides/first-page.md +4 -0
  147. package/templates/apps/frontend-docs/content/docs/en/intro/getting-started.md +4 -0
  148. package/templates/apps/frontend-docs/package.json +8 -6
  149. package/templates/apps/frontend-docs/src/collections/docs.collection.ts +21 -0
  150. package/templates/apps/frontend-docs/src/locales/de.ts +0 -5
  151. package/templates/apps/frontend-docs/src/locales/en.ts +0 -5
  152. package/templates/apps/frontend-docs/src/pages/[locale]/docs/[...slug]/page.tsx +17 -9
  153. package/templates/apps/frontend-docs/src/pages/[locale]/mirrors.test.tsx +10 -5
  154. package/templates/apps/frontend-docs/src/pages/[locale]/page.tsx +8 -0
  155. package/templates/apps/frontend-docs/src/pages/docs/[...slug]/page.test.tsx +53 -15
  156. package/templates/apps/frontend-docs/src/pages/docs/[...slug]/page.tsx +27 -33
  157. package/templates/apps/frontend-docs/src/pages/page.test.tsx +17 -3
  158. package/templates/apps/frontend-docs/src/pages/page.tsx +16 -12
  159. package/templates/apps/frontend-i18n/package.json +6 -6
  160. package/templates/apps/frontend-landing/package.json +6 -6
  161. package/templates/apps/frontend-portal/package.json +8 -8
  162. package/templates/apps/frontend-saas/package.json +8 -8
  163. package/templates/apps/frontend-spa/package.json +7 -7
  164. package/templates/apps/frontend-ssr/package.json +7 -7
  165. package/templates/apps/frontend-ssr-api/package.json +8 -8
  166. package/templates/apps/frontend-static-blog/content/posts/cms-to-ssg.md +12 -0
  167. package/templates/apps/frontend-static-blog/content/posts/hello-static.md +12 -0
  168. package/templates/apps/frontend-static-blog/content/posts/islands-not-hydration.md +14 -0
  169. package/templates/apps/frontend-static-blog/package.json +8 -6
  170. package/templates/apps/frontend-static-blog/src/collections/posts.collection.ts +27 -0
  171. package/templates/apps/frontend-static-blog/src/pages/[locale]/blog/[slug]/page.tsx +7 -4
  172. package/templates/apps/frontend-static-blog/src/pages/[locale]/mirrors.test.tsx +7 -1
  173. package/templates/apps/frontend-static-blog/src/pages/blog/[slug]/page.test.tsx +19 -9
  174. package/templates/apps/frontend-static-blog/src/pages/blog/[slug]/page.tsx +19 -21
  175. package/templates/apps/frontend-static-blog/src/pages/page.test.tsx +18 -5
  176. package/templates/apps/frontend-static-blog/src/pages/page.tsx +21 -14
  177. package/templates/apps/frontend-status/package.json +8 -8
  178. package/templates/apps/mobile-app/package.json +4 -4
  179. package/dist/apiBuild-BYBpL7Pz.js +0 -2
  180. package/dist/build-CPgcMQug.js +0 -793
  181. package/dist/codegen-CctkDO-1.js +0 -2
  182. package/dist/dbCommand-DNb6yeOG.js +0 -2
  183. package/dist/doctorCommand-CqoWA2p5.js +0 -2
  184. package/dist/fileConventions-DOqD3lPS.js +0 -34
  185. package/dist/frameworkTableAssembly-C6ETawPR.js +0 -2
  186. package/dist/inspect-CuGDYES0.js +0 -2
  187. package/dist/manifestBuild-CPjhvM62.js +0 -2
  188. package/dist/serveCommand-DLc-BznW.js +0 -2
  189. package/dist/start-DfL3fOiN.js +0 -3
  190. package/dist/start-jw89Xbqy.js +0 -1339
  191. package/dist/updateCommand-5gFVfK5q.js +0 -2
  192. package/dist/webDev-CZbTsDcH.js +0 -2
  193. package/templates/apps/changelog/scripts/generate-rss.mjs +0 -38
  194. package/templates/apps/changelog/src/lib/releases.ts +0 -21
  195. package/templates/apps/frontend-static-blog/src/content/posts.ts +0 -64
@@ -173,8 +173,10 @@ A combined create-or-edit screen is a three-line wrapper:
173
173
  ### The customization ladder
174
174
 
175
175
  - **Rung 0 — zero config.** Fields render from the schema: a `Schema.Literal`
176
- union → `select`; string → text; boolean → checkbox; `Date` → date; nested
177
- object → a `custom` placeholder asking for a render-prop.
176
+ union → `select`; string → text; boolean → checkbox; `Schema.Date` → date;
177
+ a nested struct → a real `<fieldset>` SECTION with its legend and dotted
178
+ fields; widgets receive `onBlur`, so errors reveal on leave-field exactly
179
+ like the headless binding.
178
180
  - **Rung 1 — one custom widget** via a `<Field>` render-prop:
179
181
  ```tsx
180
182
  import { AutoForm, Field, AsyncSelect } from '@voltro/ui'
@@ -214,6 +216,189 @@ Schema.Struct({
214
216
  })
215
217
  ```
216
218
 
219
+ ### Validation — one schema, translated messages, server field errors
220
+
221
+ Client and server validate the SAME input schema, and the messages a user
222
+ sees are structured, not developer text. Every failed check maps to a stable
223
+ message id with params — `validation.required`, `validation.minLength {min}`,
224
+ `validation.minValue {min}` — rendered through a built-in en/de catalog. The
225
+ locale follows `<html lang>` (the framework's locale contract); override it
226
+ per form with `locale:`, or wire your own catalog in one line:
227
+
228
+ ```tsx
229
+ // ONCE, at the app root — every form under it resolves ids through your catalog
230
+ <ValidationMessagesProvider messages={(id, params) => t(id, params)}>
231
+ <App />
232
+ </ValidationMessagesProvider>
233
+ ```
234
+
235
+ A per-form `messages:` option still exists and wins over the provider; a
236
+ resolver returning `undefined` falls through to the built-ins per id, so a
237
+ partial catalog costs nothing.
238
+
239
+ A widget kit that resolves ids itself reads the same resolver with
240
+ `useValidationMessages()`. It returns the provider's function, or
241
+ `undefined` when no provider is mounted — so a kit can fall through to the
242
+ built-in catalog instead of shipping its own.
243
+
244
+ Errors key the FULL field path (`address.city`, `entries.0.startsAt`), and a
245
+ `message` annotation on a schema may BE an id with params — as may the issues
246
+ a struct-level `filter` returns, which land at THEIR field:
247
+
248
+ ```ts
249
+ const Input = Schema.Struct({
250
+ name: Schema.String.pipe(Schema.minLength(2)),
251
+ startsAt: Schema.Number,
252
+ endsAt: Schema.Number,
253
+ }).pipe(
254
+ Schema.filter((v) =>
255
+ v.startsAt < v.endsAt ? undefined : [{ path: ['endsAt'], message: 'validation.beforeStart' }],
256
+ ),
257
+ )
258
+ ```
259
+
260
+ **Server-side rules route to their field too.** An executor raises a typed
261
+ field error through the always-present `ctx.validation` — no declaration
262
+ needed, `ValidationError` is auto-merged into every mutation's and action's
263
+ wire error union exactly like `ScopeError`:
264
+
265
+ ```ts
266
+ // executor
267
+ if (await emailTaken(input.email)) {
268
+ return yield* ctx.validation.fail('email', 'validation.emailTaken')
269
+ }
270
+ yield* ctx.validation.require(input.startsAt < input.endsAt, 'endsAt', 'validation.beforeStart')
271
+ // several at once: ctx.validation.failAll([{ field, message }, …])
272
+ ```
273
+
274
+ The binding routes it: the message lands in `errors.email` (translated
275
+ through the same catalog), the form stays editable, and `submitError` only
276
+ carries what NO field can — a `BusinessRuleViolation` whose rule pinpointed
277
+ a `field` routes the same way. Custom widget kits get the same judgement via
278
+ `fieldIssuesOf(error)` from `@voltro/protocol`.
279
+
280
+ **Async checks gate the submit.** Bind a uniqueness probe to its field and
281
+ `submit` waits for it — an `invalid` verdict blocks with the message on that
282
+ field, an unsettled check fails closed after 5s:
283
+
284
+ ```tsx
285
+ const email = useAsyncValidation('app', 'users.emailAvailable', values.email ?? '', {
286
+ interpret: (r) => ({ valid: (r as { available: boolean }).available, message: 'Email taken' }),
287
+ })
288
+ const form = useFormBinding('app', 'users.create', { asyncFields: { email } })
289
+ ```
290
+
291
+ **The binding is typed — `toInput` included.** `createHooks<AppProcedures>('app')`
292
+ returns `useFormBinding` beside the other hooks — tag as a literal type,
293
+ `values`/`defaults` and the submit output inferred from the descriptor. With
294
+ `toInput`, the form gets its OWN `Values` shape and the mapper's return is
295
+ checked against the mutation's input by the compiler — a mapping that stops
296
+ producing the wire shape is a type error, not a runtime refusal.
297
+
298
+ ### The full binding — nested values, arrays, timing, one state
299
+
300
+ `useFormBinding` carries a complete form, not just flat fields. Nested structs
301
+ flatten into SECTIONS (`address.city`, grouped under `section('address')`),
302
+ arrays of structs become field arrays, and every field is reachable as a
303
+ bound handle a widget kit spreads onto its input:
304
+
305
+ ```tsx
306
+ const form = useFormBinding('app', 'employees.update', {
307
+ defaults: fromRow(employee),
308
+ toInput: (values) => ({ id: employee.id, ...employeePatch(values) }),
309
+ errorPath: { fullName: 'name' },
310
+ })
311
+
312
+ const city = form.field('address.city') // { value, setValue, onBlur, error, required, label, a11y, … }
313
+ form.array('entries').push({ startsAt: '' })
314
+ form.section('address') // the section's descriptors
315
+ form.state // { isDirty, canSubmit, isSubmitting, isSubmitSuccessful, submissionAttempts, errorCount, pending, … }
316
+ form.reset(nextDefaults) // switch the edited record without a remount
317
+ form.focusFirstInvalid()
318
+ ```
319
+
320
+ **Error timing has defaults a form can trust:** a form NEVER opens with
321
+ errors — a field reveals its error after ITS blur or after the first submit
322
+ attempt, then live (`validate: { onChange: 'afterTouched' }`; `'always'` and
323
+ `'never'` exist). `isValid`/`canSubmit` always tell the truth underneath, so
324
+ the save button disables correctly while the user is not yet being scolded.
325
+
326
+ **Values ≠ mutation input:** `toInput` maps form values to the wire input
327
+ BEFORE validation; input-schema issues route back to form fields via
328
+ `errorPath` (same-name fields map automatically). For composed saves,
329
+ `onSubmit: async ({ input, values, mutate }) => …` owns the write and keeps
330
+ optimistic + error routing.
331
+
332
+ **Per-field rendering:** with `subscribe: 'fields'` the binding only
333
+ re-renders on submission-level changes, and each field component subscribes
334
+ narrowly:
335
+
336
+ ```tsx
337
+ const BoundField = ({ form, path }: { form: FormBinding<Record<string, unknown>, unknown>; path: string }) => {
338
+ const f = useFormField(form, path)
339
+ return <input id={f.a11y.id} value={String(f.value ?? '')} onChange={(e) => f.setValue(e.target.value)} onBlur={f.onBlur} aria-invalid={f.a11y['aria-invalid']} />
340
+ }
341
+ ```
342
+
343
+ Annotate structure on the schema itself: `formField({ section, order, label,
344
+ widget })` rides an annotation, `description` becomes help text, and
345
+ `Schema.Date` / `Schema.DateTimeUtc` map to date/datetime widgets. The engine
346
+ underneath is an implementation detail — no engine type appears in the public
347
+ API, and production builds stub its devtools channel automatically.
348
+
349
+ ### Reference fields, uploads, and testing the form
350
+
351
+ **Reference fields.** Mark a schema field as a table reference and it renders
352
+ as a picker-shaped field whose VALUE stays the id (or id list — which is
353
+ exactly what a target's declared `relations:` consumes):
354
+
355
+ ```ts
356
+ const EmployeesUpdateInput = Schema.Struct({
357
+ id: Schema.String,
358
+ storeId: Schema.String.annotations(formField({ reference: 'stores' })),
359
+ assignedStores: Schema.Array(Schema.String).annotations(formField({ reference: 'stores' })),
360
+ })
361
+ ```
362
+
363
+ The descriptor carries `widget: 'reference'` + `reference: 'stores'`; bind
364
+ the shipped `<AsyncSelect>` (or your own picker) to it via a render-prop —
365
+ the default registry deliberately renders the render-prop note, because a
366
+ live picker needs a query binding only the app can name.
367
+
368
+ **Uploads as field values.** `useUpload` returns a `fileId`; hold it in a
369
+ field and LINK it in the submit — the composed save is what `onSubmit` is
370
+ for, so the upload that never got attached cannot happen silently:
371
+
372
+ ```tsx
373
+ const form = useFormBinding('app', 'tasks.create', {
374
+ onSubmit: async ({ input, values, mutate }) => {
375
+ const row = await mutate(input)
376
+ // attachments: [...existing, ...uploaded fileIds] — linked HERE, not forgotten
377
+ return row
378
+ },
379
+ })
380
+ ```
381
+
382
+ **Leaving a dirty form** is guarded by the router: `useBlocker(form.state.isDirty)`
383
+ holds SPA navigations (Back button included) and arms the native
384
+ `beforeunload` prompt — see the routing docs.
385
+
386
+ **Testing.** `renderFormBinding` (from `@voltro/testing/client`) drives the
387
+ REAL binding against a fake api — fill, blur, submit, read the visible
388
+ errors; a mutation handler that throws `ValidationError({ field })`
389
+ exercises the same routing path a server refusal takes:
390
+
391
+ ```ts
392
+ const form = await renderFormBinding('users.create', {
393
+ binding: { schema: UsersCreateInput },
394
+ mutation: () => { throw new ValidationError({ field: 'email', message: 'validation.emailTaken' }) },
395
+ })
396
+ await form.submit()
397
+ expect(form.errors()['email']).toBe('validation.emailTaken')
398
+ ```
399
+
400
+ Runs under jsdom (`// @vitest-environment jsdom`).
401
+
217
402
  ### Forms without JavaScript
218
403
 
219
404
  On a server-rendered page, `<AutoForm>` works with JavaScript disabled — or not
@@ -720,7 +905,33 @@ multi-device CRDT (single-user/device, honest about its limits).
720
905
 
721
906
  ```tsx
722
907
  const outbox = useOutbox({ send })
723
- // → { queue, enqueue, replay, online, pending, conflicts }
908
+ // → { queue, enqueue, replay, online, pending, conflicts, resolveConflict }
909
+ ```
910
+
911
+ **Durable across reloads.** Pass `persistence` and the queue survives:
912
+ entries rehydrate on mount, every transition persists (delivered entries are
913
+ compacted away), and a failing backing degrades to the in-memory behaviour
914
+ instead of breaking the outbox. The one real implementation is
915
+ `outboxPersistence()` from `@voltro/local-first`, backed by the same
916
+ `PersistenceAdapter` the sync engine drains — one durable queue per device:
917
+
918
+ ```tsx
919
+ import { createIndexedDbPersistence, outboxPersistence } from '@voltro/local-first'
920
+
921
+ const adapter = await createIndexedDbPersistence()
922
+ const outbox = useOutbox({ send, persistence: outboxPersistence(adapter) })
923
+ ```
924
+
925
+ **Resolving a conflict.** A conflicted entry blocks everything behind it
926
+ (causality). `resolveConflict(id, input)` replaces its input with the RESOLVED
927
+ value, returns it to pending and replays — typically computed with
928
+ `resolveWithPolicy()` from `@voltro/local-first`, where `crdtText()` columns
929
+ merge and scalars follow the declared `conflictPolicy()`:
930
+
931
+ ```tsx
932
+ outbox.resolveConflict(entry.id, resolveWithPolicy(policy, local, remote, {
933
+ crdtColumns: ['body'],
934
+ }))
724
935
  ```
725
936
 
726
937
 
@@ -195,7 +195,7 @@ would break every link into your api.
195
195
 
196
196
  ### Raw WebSocket gateways are guarded before the upgrade
197
197
 
198
- A [`defineWebSocket` gateway](/docs/data/subscriptions#raw-websocket-gateways--definewebsocket)
198
+ A [`defineWebSocket` gateway](/docs/data/subscriptions#raw-websocket-gateways-definewebsocket)
199
199
  mounts its own upgrade path beside the rpc socket, and the listener treats it
200
200
  exactly like the rpc upgrade:
201
201
 
@@ -479,7 +479,7 @@ deferred registry, the shell's bundle tags, and React's own bootstrap/settle
479
479
  scripts — while the policy header itself stays the middleware's to set via
480
480
  `responseHeaders`, carrying the same nonce. An `isr` page combined with
481
481
  `cspNonce` refuses the render: a cached nonce is a lie the browser enforces.
482
- Mechanics + examples: [Middleware → cspNonce](/docs/routing/middleware#a-per-request-csp-nonce--cspnonce).
482
+ Mechanics + examples: [Middleware → cspNonce](/docs/routing/middleware#a-per-request-csp-nonce-cspnonce).
483
483
 
484
484
  ## Incoming webhooks must verify their caller
485
485
 
@@ -582,7 +582,7 @@ dependency, like a rate-limit counter in Redis — catches internally and
582
582
  degrades **loudly**. `@voltro/plugin-ratelimit`'s `httpShield` does exactly
583
583
  that: a Redis outage means unlimited-with-a-warning, never a self-inflicted API
584
584
  outage. The full authoring guidance is in the
585
- [plugin contract](/docs/plugins/contract#onhttprequest-httprequestinterceptor--pre-auth-http-pipeline-hook).
585
+ [plugin contract](/docs/plugins/contract#onhttprequest-httprequestinterceptor-pre-auth-http-pipeline-hook).
586
586
 
587
587
  ## Response compression — and where BREACH sits
588
588