@voltro/cli 0.3.0 → 0.5.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 (72) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/dist/{apiBuild-DdgYydVJ.js → apiBuild-CZugTK46.js} +2 -5
  3. package/dist/apiBuild-D-iBYKa3.js +2 -0
  4. package/dist/bin.js +2 -2
  5. package/dist/{commands-CXESev-z.js → commands-CJheDVOr.js} +2435 -2000
  6. package/dist/{dev-x_VqbV_8.js → dev-B71ICpXJ.js} +1993 -1823
  7. package/dist/dev-DNUetyG2.js +2 -0
  8. package/dist/index.js +1 -1
  9. package/dist/serveCommand-C0WFA396.js +1084 -0
  10. package/dist/serveEntry.js +2 -2
  11. package/package.json +17 -17
  12. package/templates/AGENTS.core.md +131 -0
  13. package/templates/AGENTS.md +131 -0
  14. package/templates/agent-docs/_manifest.json +4 -4
  15. package/templates/agent-docs/ai.md +34 -2
  16. package/templates/agent-docs/authentication.md +74 -2
  17. package/templates/agent-docs/cli.md +115 -0
  18. package/templates/agent-docs/data.md +302 -7
  19. package/templates/agent-docs/database/advancedqueries.md +25 -2
  20. package/templates/agent-docs/database/querying.md +17 -5
  21. package/templates/agent-docs/database/schema.md +5 -2
  22. package/templates/agent-docs/internationalization.md +237 -2
  23. package/templates/agent-docs/observability.md +9 -0
  24. package/templates/agent-docs/plugins.md +2 -2
  25. package/templates/agent-docs/reference.md +110 -0
  26. package/templates/agent-docs/schema-driven-ui.md +551 -2
  27. package/templates/agent-docs/templates/apibackends.md +10 -8
  28. package/templates/agent-docs/templates/overview.md +1 -1
  29. package/templates/agent-docs/testing.md +177 -3
  30. package/templates/agent-docs/workflows.md +11 -8
  31. package/templates/apps/api-ai/package.json +7 -7
  32. package/templates/apps/api-auth/package.json +8 -8
  33. package/templates/apps/api-backend/package.json +7 -7
  34. package/templates/apps/api-backend-deactivation/package.json +7 -7
  35. package/templates/apps/api-backend-mail/package.json +8 -8
  36. package/templates/apps/api-backend-mariadb/package.json +9 -9
  37. package/templates/apps/api-backend-storage/package.json +8 -8
  38. package/templates/apps/api-data-advanced/package.json +8 -8
  39. package/templates/apps/api-durable/package.json +8 -8
  40. package/templates/apps/api-feature-flags/package.json +9 -9
  41. package/templates/apps/api-governance/package.json +8 -8
  42. package/templates/apps/api-kv/package.json +8 -8
  43. package/templates/apps/api-moderation/package.json +8 -8
  44. package/templates/apps/api-observability/package.json +8 -8
  45. package/templates/apps/api-ratelimit/package.json +8 -8
  46. package/templates/apps/api-rbac/README.md +9 -4
  47. package/templates/apps/api-rbac/mutations/notes.create.mutation.server.ts +1 -1
  48. package/templates/apps/api-rbac/mutations/notes.create.mutation.ts +6 -6
  49. package/templates/apps/api-rbac/package.json +8 -8
  50. package/templates/apps/api-rbac/tests/notes.create.test.ts +8 -8
  51. package/templates/apps/api-rest/package.json +7 -7
  52. package/templates/apps/api-saas/package.json +11 -11
  53. package/templates/apps/api-search/package.json +8 -8
  54. package/templates/apps/api-versioning/package.json +8 -8
  55. package/templates/apps/api-webhooks/package.json +8 -8
  56. package/templates/apps/changelog/package.json +6 -6
  57. package/templates/apps/edge-functions/package.json +2 -2
  58. package/templates/apps/frontend-admin/package.json +8 -8
  59. package/templates/apps/frontend-app/package.json +8 -8
  60. package/templates/apps/frontend-blank/package.json +7 -7
  61. package/templates/apps/frontend-contact/package.json +7 -7
  62. package/templates/apps/frontend-dashboard/package.json +7 -7
  63. package/templates/apps/frontend-docs/package.json +7 -7
  64. package/templates/apps/frontend-i18n/package.json +6 -6
  65. package/templates/apps/frontend-landing/package.json +7 -7
  66. package/templates/apps/frontend-spa/package.json +7 -7
  67. package/templates/apps/frontend-ssr/package.json +7 -7
  68. package/templates/apps/frontend-ssr-api/package.json +8 -8
  69. package/templates/apps/frontend-static-blog/package.json +6 -6
  70. package/dist/apiBuild-cadmH8ca.js +0 -2
  71. package/dist/dev-DemiMSSl.js +0 -2
  72. package/dist/serveCommand-Dttqe5Ms.js +0 -1077
@@ -53,7 +53,7 @@ The resolved locale is **guaranteed** to be one of the codes in `locales`. Any u
53
53
 
54
54
  The client side mirrors #1 (cookie) and #3 (default) for hydration safety. `Accept-Language` is **server-only** because `navigator.languages` can diverge from what the server saw, which would cause a hydration mismatch.
55
55
 
56
- See [Catalogs](/docs/i18n/catalogs) for the type-safe catalog convention and the component hooks, and [URL strategies](/docs/i18n/url-strategies) for cookie-only vs URL-prefix routing.
56
+ See [Catalogs](/docs/i18n/catalogs) for the type-safe catalog convention and the component hooks, [Plurals & formatting](/docs/i18n/formatting) for CLDR plural selection and the `Intl`-backed date / number / relative-time hooks, and [URL strategies](/docs/i18n/url-strategies) for cookie-only vs URL-prefix routing.
57
57
 
58
58
 
59
59
 
@@ -62,7 +62,7 @@ See [Catalogs](/docs/i18n/catalogs) for the type-safe catalog convention and the
62
62
  <!-- source: en/i18n/catalogs.md -->
63
63
  ## Catalogs & hooks
64
64
 
65
- _Type-safe message catalogs with defineCatalog + defineLocale (parity-enforced), reading translations with useT / <T> / useLocale, ICU placeholders, and the react-intl escape hatch._
65
+ _Type-safe message catalogs with defineCatalog + defineLocale (parity-enforced), reading translations with useT / <T> / useLocale, ICU placeholders, code-splitting with defineCatalogs + LazyI18nProvider, and the react-intl escape hatch._
66
66
 
67
67
  A **catalog** is a flat `Record<string, string>` of message ID → ICU MessageFormat template. Voltro picks flat-string format (instead of react-intl's `{ defaultMessage, description }` objects) because translation tools (Crowdin / Lokalise / Phrase) import flat string maps natively, the format diffs cleanly in code review, and the base catalog *is* the source of truth — `defaultMessage` becomes redundant.
68
68
 
@@ -168,6 +168,62 @@ export const meta = ({ locale }: { readonly locale: string }): PageMeta => ({
168
168
 
169
169
  `pickCatalog(catalogs, locale, defaultLocale)` returns the concrete catalog type, so a known-key lookup is `string` (not `string | undefined`) — exactly what `PageMeta.title` needs. An unknown or `undefined` locale falls back to `defaultLocale`.
170
170
 
171
+ ## Code-splitting catalogs — `defineCatalogs`
172
+
173
+ ### Why `pickCatalog` cannot split
174
+
175
+ `pickCatalog({ en, de, fr }, locale)` is a **static import map**. Every catalog is a value-level `import` of the module that builds the map, so the bundler has no choice but to put all of them in one chunk — a visitor who will only ever see German downloads English and French too. At real catalog sizes that becomes the single largest client chunk in the app, and it **grows linearly with every locale you add**.
176
+
177
+ No provider-side change can fix this. The cost is paid at *import* time, before any React code runs — by the time a provider knows which locale is active, all of them are already in the bundle.
178
+
179
+ The only thing a bundler treats as a chunk boundary is a **dynamic `import()`**. So the catalog map becomes a map of *loaders*:
180
+
181
+ ```ts
182
+ // src/locales/index.ts
183
+ import { defineCatalogs } from '@voltro/i18n'
184
+
185
+ export const catalogs = defineCatalogs({
186
+ en: () => import('./en'),
187
+ de: () => import('./de'),
188
+ }, 'en')
189
+ ```
190
+
191
+ Each arrow is its own chunk; a session fetches exactly the locales it uses. Loaders accept either an `export default` catalog or a bare map, so the catalog files from the top of this page work unchanged.
192
+
193
+ `defineCatalogs(loaders, defaultLocale)` returns:
194
+
195
+ - **`locales`** — the declared locales, in declaration order.
196
+ - **`defaultLocale`** — the base locale. Its type is constrained to the loader keys, so a `defaultLocale` you never declared a loader for is a **compile error**, not a runtime blank page.
197
+ - **`load(locale)`** — resolves the catalog, importing its chunk on first use. Concurrent callers share one import: two components mounting in the same tick will not race two fetches.
198
+ - **`peek(locale)`** — the catalog *if already loaded*, else `undefined`. Never triggers a fetch. This is what lets a provider render synchronously.
199
+ - **`preload(locale)`** — fire-and-forget cache warming (server boot, hover intent, a route transition that is about to switch locale).
200
+
201
+ An unknown locale resolves to `defaultLocale` rather than throwing — a stale cookie or a hand-typed URL prefix degrades to the base language instead of blanking the app. A **failed** load is not cached, so the next attempt retries: a 404'd chunk on a flaky network is recoverable.
202
+
203
+ ### `<LazyI18nProvider>`
204
+
205
+ ```tsx
206
+ import { LazyI18nProvider } from '@voltro/i18n'
207
+ import { catalogs } from './locales'
208
+
209
+ <LazyI18nProvider catalogs={catalogs} locale={locale} fallback={<AppSkeleton />}>
210
+ <App />
211
+ </LazyI18nProvider>
212
+ ```
213
+
214
+ A resolved catalog is cached per loader map, so switching back to a locale is synchronous and re-renders never re-import.
215
+
216
+ ### The trade-off, stated honestly
217
+
218
+ The active catalog is now **asynchronous**, and first paint needs it resolved. There are two ways to keep that from becoming a flash of untranslated UI, and you should pick one deliberately:
219
+
220
+ - **`catalogs.preload(locale)` on the server, or before `hydrateRoot`.** The catalog is already in the cache, `peek()` hits, and the provider renders in the same tick as a static catalog would — no suspense boundary, no flash. This is the **right default**, and it costs no waterfall: the locale is known from the cookie or the URL prefix before React starts.
221
+ - **`fallback`.** Rendered for the one tick it takes to load a catalog that genuinely isn't in memory. It defaults to `null` — deliberately blank rather than a screen of untranslated message IDs.
222
+
223
+ So: **`fallback` is for a locale SWITCH, not for first paint.** On a switch the user already has a rendered page and a brief placeholder is fine. If your `fallback` is showing on first load, the preload is missing — fix the preload, don't dress up the fallback.
224
+
225
+ `meta({ locale })` is synchronous and runs outside React, so it still needs the static `pickCatalog` form. Keep that map in a module the client bundle doesn't import, or the static graph pulls every locale back into the browser chunk and undoes the split.
226
+
171
227
  ## The react-intl escape hatch
172
228
 
173
229
  For features the wrap doesn't expose — custom formatters, `Intl` options, rich-text with React-element values — import from `react-intl` directly:
@@ -382,3 +438,182 @@ For theme the parallel cookie is **`voltro:theme`** (values `'system' | 'light'
382
438
  - **Don't mix strategies within one app.** Pick cookie-only *or* URL-prefix per app; mixing them produces ambiguous canonical URLs and broken language switching.
383
439
  - **Don't read `Accept-Language` on the client.** It's server-only — `navigator.languages` can diverge from what the server saw and cause a hydration mismatch.
384
440
  - **Don't invent your own cookie name.** The kit and the resolver only agree on `voltro:lang` / `voltro:theme`.
441
+
442
+
443
+
444
+ ---
445
+
446
+ <!-- source: en/i18n/formatting.md -->
447
+ ## Plurals & formatting
448
+
449
+ _Locale-aware plural selection (CLDR via Intl.PluralRules) and Intl-backed formatters — plural, usePlural, useFormatDate, useRelativeTime, useFormatNumber, useFormatCurrency, useFormatters._
450
+
451
+ Two things go wrong in every app that ships `useLocale()` but no formatters.
452
+
453
+ The first is pluralization by string surgery: `` `${count} epic(s)` ``. That literal `(s)` is a guess that only reads as acceptable in English — and it isn't even correct there ("1 epic(s)"). Outside English and German it is simply wrong: Polish needs three forms for what English does with two, and no amount of parentheses expresses that.
454
+
455
+ The second is relative time. "3 minutes ago" looks trivial, so it gets written inline — and then again in another component, and again with a date library, until one app carries four divergent helpers, one of them hardcoded German. They disagree on rounding, on the sub-second case, and on the language.
456
+
457
+ `@voltro/i18n` closes both with `Intl`-backed primitives that resolve the **active** locale from the provider. Nothing to pin, nothing to hand-roll, and no dependency — `Intl.PluralRules` / `DateTimeFormat` / `NumberFormat` / `RelativeTimeFormat` are in every runtime the framework targets.
458
+
459
+ ## `plural` — the pure core
460
+
461
+ ```ts
462
+ import { plural } from '@voltro/i18n'
463
+
464
+ plural('en', 1, { one: '{count} epic', other: '{count} epics' }) // "1 epic"
465
+ plural('en', 3, { one: '{count} epic', other: '{count} epics' }) // "3 epics"
466
+ plural('en', 0, { one: '{count} epic', other: '{count} epics' }) // "0 epics"
467
+ ```
468
+
469
+ `plural(locale, count, forms, options?)` selects the form using the locale's **real CLDR rules** via `Intl.PluralRules`, then substitutes every `{count}` occurrence. It takes the locale as an argument and touches no React, so it works in `meta({ locale })`, in a server handler, or in a test — the hook below is a thin binding of it.
470
+
471
+ `forms` accepts `zero`, `one`, `two`, `few`, `many` and `other`. **Only `other` is required**: it is the fallback for every category the caller didn't supply and for every category a locale doesn't distinguish.
472
+
473
+ ### One/other is not enough — the Polish proof
474
+
475
+ ```ts
476
+ const files = {
477
+ one: '{count} plik',
478
+ few: '{count} pliki',
479
+ many: '{count} plików',
480
+ other: '{count} pliku',
481
+ }
482
+
483
+ plural('pl', 1, files) // "1 plik"
484
+ plural('pl', 3, files) // "3 pliki" → few
485
+ plural('pl', 7, files) // "7 plików" → many
486
+ ```
487
+
488
+ Polish distinguishes `few` (2–4) from `many` (5+). This is the exact case a hardcoded `(s)` or a hand-written `count === 1 ? a : b` cannot express — and it is not an exotic edge case, it is a language with 40 million speakers. Supply the categories the locale needs; the ones you omit fall through to `other`:
489
+
490
+ ```ts
491
+ plural('pl', 3, { one: '{count} epic', other: '{count} epics' }) // "3 epics" — no `few` given
492
+ ```
493
+
494
+ ### Explicit zero
495
+
496
+ ```ts
497
+ plural('en', 0, { one: '{count} epic', other: '{count} epics', zero: 'no epics' }) // "no epics"
498
+ plural('en', 1, { one: '{count} epic', other: '{count} epics', zero: 'no epics' }) // "1 epic"
499
+ ```
500
+
501
+ `zero` is honoured for an **exact 0** even in locales whose CLDR category for 0 is `other` (English). Apps overwhelmingly want "no items" there rather than "0 items", and opting out is just omitting the key.
502
+
503
+ ### Ordinals
504
+
505
+ Pass `Intl.PluralRules` options through as the fourth argument:
506
+
507
+ ```ts
508
+ const ord = { one: '{count}st', two: '{count}nd', few: '{count}rd', other: '{count}th' }
509
+
510
+ plural('en', 1, ord, { type: 'ordinal' }) // "1st"
511
+ plural('en', 2, ord, { type: 'ordinal' }) // "2nd"
512
+ plural('en', 3, ord, { type: 'ordinal' }) // "3rd"
513
+ plural('en', 4, ord, { type: 'ordinal' }) // "4th"
514
+ ```
515
+
516
+ An unknown locale tag falls back to `other` instead of throwing — a stale cookie renders English-ish output, not a crash.
517
+
518
+ ## The hooks
519
+
520
+ Every hook below reads the active locale from the provider via `useLocale()` and returns a stable callback.
521
+
522
+ ### `usePlural`
523
+
524
+ `plural` bound to the active locale — same `(count, forms, options?)` signature minus the leading locale:
525
+
526
+ ```tsx
527
+ import { usePlural } from '@voltro/i18n'
528
+
529
+ function EpicCount({ count }: { readonly count: number }) {
530
+ const plural = usePlural()
531
+ return <span>{plural(count, { one: '{count} epic', other: '{count} epics', zero: 'no epics' })}</span>
532
+ }
533
+ ```
534
+
535
+ ### `useFormatDate`
536
+
537
+ ```tsx
538
+ const formatDate = useFormatDate()
539
+
540
+ formatDate(order.createdAt, { dateStyle: 'medium' })
541
+ formatDate(order.createdAt, { dateStyle: 'medium', timeStyle: 'short', timeZone: 'Europe/Berlin' })
542
+ ```
543
+
544
+ `(value, options?) => string`, where `value` is a `Date`, a timestamp number, or a date string, and `options` is `Intl.DateTimeFormatOptions`. **Omit `timeZone` and the viewer's own zone is used** — which is what a multi-timezone app wants. Pass one only when the value genuinely belongs to a fixed zone (a store's opening hours, a scheduled broadcast). Pinning a global zone across the whole app is the anti-pattern this replaces.
545
+
546
+ ### `useRelativeTime`
547
+
548
+ ```tsx
549
+ const relativeTime = useRelativeTime()
550
+
551
+ relativeTime(comment.postedAt) // "3 minutes ago" / "vor 3 Minuten"
552
+ relativeTime(job.runsAt) // "in 2 days"
553
+ relativeTime(comment.postedAt, { numeric: 'always' }) // "1 day ago" instead of "yesterday"
554
+ relativeTime(comment.postedAt, { now: renderedAt }) // measure against a fixed base
555
+ ```
556
+
557
+ `(value, options?) => string`. Options are `Intl.RelativeTimeFormatOptions` plus a `now` override (a `Date`, number, or string) for deterministic rendering and tests; the default base is `Date.now()`.
558
+
559
+ It picks the **largest unit that fits**, so a 90-minute delta reads "1 hour ago", not "90 minutes ago". Anything under a second renders through the `second` unit at 0 — "now" — which avoids the "0 seconds ago" flicker hand-rolled versions produce. `numeric: 'auto'` is the default, so English gets "yesterday" rather than "1 day ago".
560
+
561
+ ### `useFormatNumber` and `useFormatCurrency`
562
+
563
+ ```tsx
564
+ const formatNumber = useFormatNumber()
565
+
566
+ formatNumber(1234.5) // "1,234.5" / "1.234,5"
567
+ formatNumber(0.42, { style: 'percent' }) // "42%"
568
+ formatNumber(1_200_000, { notation: 'compact' }) // "1.2M"
569
+
570
+ const formatEur = useFormatCurrency('EUR')
571
+
572
+ formatEur(19.9) // "€19.90" / "19,90 €"
573
+ formatEur(19.9, { maximumFractionDigits: 0 }) // options merge over the currency defaults
574
+ ```
575
+
576
+ `useFormatNumber()` is `(value, options?) => string` over `Intl.NumberFormatOptions`. `useFormatCurrency(currency)` takes the ISO code up front and applies `{ style: 'currency', currency }`; any options you pass are merged on top, so you can still override fraction digits or notation.
577
+
578
+ Note that the currency **code** is not the locale — `useFormatCurrency('EUR')` renders `€19.90` for an English viewer and `19,90 €` for a German one. The amount's currency and the viewer's language are independent, and this keeps them that way.
579
+
580
+ ### `useFormatters`
581
+
582
+ For a component that needs several at once, without stacking five hook calls:
583
+
584
+ ```tsx
585
+ import { useFormatters } from '@voltro/i18n'
586
+
587
+ function ActivityRow({ entry }: { readonly entry: Entry }) {
588
+ const { locale, formatDate, relativeTime, formatNumber, plural } = useFormatters()
589
+
590
+ return (
591
+ <li lang={locale}>
592
+ <time dateTime={entry.at.toISOString()} title={formatDate(entry.at, { dateStyle: 'full' })}>
593
+ {relativeTime(entry.at)}
594
+ </time>
595
+ {plural(entry.changes, { one: '{count} change', other: '{count} changes' })}
596
+ <span>{formatNumber(entry.score)}</span>
597
+ </li>
598
+ )
599
+ }
600
+ ```
601
+
602
+ It returns the active `locale` plus `formatDate`, `relativeTime`, `formatNumber` and `plural` — memoized together. Currency is not in the bundle because it needs its ISO code up front; call `useFormatCurrency(code)` alongside it when you need one.
603
+
604
+ ## Formatters vs. ICU in the catalog
605
+
606
+ Both can pluralize, and they are not competitors — pick by where the string lives:
607
+
608
+ - **ICU in the catalog** (`'{count, plural, one {# item} other {# items}}'`) is right when the whole sentence is translator-owned. Translators see the plural structure in their tool and can add the categories their language needs without a code change. This is the default for user-facing prose.
609
+ - **`plural` / `usePlural`** is right when the forms are decided in code — a pure helper outside React, a `meta({ locale })` title, a test asserting CLDR behaviour, or a count rendered next to non-string content.
610
+
611
+ For dates, numbers and relative time the hooks are the blessed path; reach for `react-intl`'s `<FormattedDate>` / `<FormattedNumber>` only when you want the JSX form.
612
+
613
+ ## Anti-patterns
614
+
615
+ - **Don't write `(s)`, `count === 1 ? 'x' : 'xs'`, or a `+ 's'` suffix.** It is wrong in most languages and cannot be fixed by a translator. Use `plural` / `usePlural` or ICU in the catalog.
616
+ - **Don't hand-roll "X minutes ago".** `useRelativeTime` is one hook, is localized, and handles the sub-second and unit-selection cases that inline versions get wrong.
617
+ - **Don't pin a global `timeZone` / locale for the whole app.** The formatters resolve the active locale from the provider; a pin makes every viewer read the app in one user's settings.
618
+ - **Don't pass a locale-formatted string to a machine consumer.** Formatted output is presentation — send ISO strings and raw numbers to APIs, `dateTime` attributes and sort keys.
619
+ - **Don't format inside a `.map()` by constructing `Intl` objects yourself.** The hooks memoize per locale; a fresh `new Intl.NumberFormat(...)` per row is the slow path.
@@ -205,6 +205,15 @@ The same trace continuity flows to a vendor APM when you install a deep-observab
205
205
 
206
206
  See [Observability › Routing traces to a vendor](/docs/observability/overview#routing-traces-to-a-vendor) for the contribution surface + the sampler caveat.
207
207
 
208
+ ### "Missing peer" warnings on install (benign)
209
+
210
+ `pnpm install` may print missing-peer warnings for `@opentelemetry/sdk-logs` and `@opentelemetry/sdk-trace-web` — these are **optional peers of `@effect/opentelemetry`** (pulled in transitively), used only if you export logs/browser traces to OTLP. Boot and the in-memory trace ring work **without** them, so the warnings are safe to ignore. To silence them, either add the two packages to your app, or add a pnpm rule:
211
+
212
+ ```json
213
+ // package.json
214
+ "pnpm": { "peerDependencyRules": { "ignoreMissing": ["@opentelemetry/sdk-logs", "@opentelemetry/sdk-trace-web"] } }
215
+ ```
216
+
208
217
 
209
218
 
210
219
  ---
@@ -63,7 +63,7 @@ Status legend: ✓ shipped · ◐ partial · — planned.
63
63
  | `@voltro/plugin-auth` | ✓ | Full auth suite via `authRoutesPlugin()`: password (rehash-on-verify), sessions (multi-key rotation + sliding-window), magic-link + password-reset, passkeys/WebAuthn (atomic clone detection, BYO multi-replica challenge store), CSRF, session enumeration + revocation, memberships + switch-tenant, TOTP/MFA (sign-in enforcement + recovery codes); `authTables` schemas |
64
64
  | `@voltro/plugin-multitenancy` | ✓ | `tenant()` schema mixin (read-scope + write-fill) + `assertOwnTenant` guard + typed `TenantMismatch` |
65
65
  | `@voltro/plugin-soft-delete` | ✓ | `softDelete()` schema mixin — `deletedAt` / `deletedBy`; `delete` → UPDATE, `hardDelete()` bypass |
66
- | `@voltro/plugin-rbac` | ✓ | Roles compile to scopes + the `permission()` handler guard + typed `Forbidden` |
66
+ | `@voltro/plugin-rbac` | ✓ | Roles compile to scopes + the `permission()` handler guard + typed `ScopeError` |
67
67
  | `@voltro/plugin-ratelimit` | ✓ | Per-endpoint / per-subject / per-tenant limits; sliding-window / fixed-window / token-bucket; memory / postgres / redis stores |
68
68
  | `@voltro/plugin-billing` | ✓ | Subscriptions, plans, entitlements + usage metering over a pluggable provider (Stripe + mock); seat-based billing + mid-cycle proration + dunning (failed-payment retries); `requireEntitlement()` guard + `enforce` interceptor; `/billing/webhook` via plugin-webhooks; money as integer minor units |
69
69
  | `@voltro/plugin-mail` | ✓ | Transactional email — Resend / Postmark / SendGrid / SES / Mailgun / SMTP, *.email.tsx templates, per-tenant suppression, send-time scheduling, bulk/batch send, per-send idempotency, durable via workflows |
@@ -214,7 +214,7 @@ error to the rpc layer.
214
214
  // Pre-only: short-circuit before the executor runs.
215
215
  const guard: RpcInterceptor = (next, ctx) =>
216
216
  ctx.tag.startsWith('admin.') && ctx.subject.type !== 'user'
217
- ? Effect.fail(new Forbidden({ tag: ctx.tag }))
217
+ ? Effect.fail(new ScopeError({ required: 'user', message: ` is admin-only` }))
218
218
  : next
219
219
 
220
220
  // Post-only: tap the success/failure channels.
@@ -21,10 +21,15 @@ The web side of a Voltro app talks to API apps through React hooks. Data hooks a
21
21
  | [`useMutation`](/docs/reference/hooks-data) | Run an atomic write (`*.mutation.ts`). |
22
22
  | [`useAction`](/docs/reference/hooks-data) | Run a unary non-transactional action (`*.action.ts`). |
23
23
  | [`useWorkflow`](/docs/reference/hooks-data) | Start, cancel, resume, or signal a durable workflow (`*.workflow.tsx`). |
24
+ | [`useWorkflowSignal`](/docs/reference/hooks-data) | Focused signal sender, for approval buttons that don't also start workflows. |
25
+ | [`useWorkflowUpdate`](/docs/reference/hooks-data) | Focused tracked update — waits for the workflow handler's result. |
24
26
  | [`useWorkflowRun`](/docs/reference/hooks-data) | Subscribe to one workflow run's reactive status row. |
25
27
  | [`useWorkflowRuns`](/docs/reference/hooks-data) | Subscribe to a bounded/filterable workflow run list. |
26
28
  | [`useWorkflowRunSteps`](/docs/reference/hooks-data) | Subscribe to one run's checkpointed step timeline. |
27
29
  | [`useWorkflowRunEvents`](/docs/reference/hooks-data) | Subscribe to one run's lifecycle/timer/signal event timeline. |
30
+ | [`useWorkflowDomainEvents`](/docs/reference/hooks-data) | Subscribe to the domain events a workflow emitted (the business-event timeline). |
31
+ | [`useWorkflowEventDeliveries`](/docs/reference/hooks-data) | Subscribe to delivery attempts/outcomes for those emitted events. |
32
+ | [`useWorkflowRunState`](/docs/ui/reactive-components) | Aggregates run + steps + events into one `{ status, currentStep, waitingFor, … }` plus cancel/resume/signal/update. |
28
33
  | [`useAgentStream`](/docs/reference/hooks-data) | Consume a one-shot element stream (`*.stream.ts`). |
29
34
  | [`useAgent`](/docs/reference/hooks-data) | Convenience wrapper for transient AI streams. |
30
35
 
@@ -44,6 +49,51 @@ The web side of a Voltro app talks to API apps through React hooks. Data hooks a
44
49
  |---|---|
45
50
  | [`useServerRequest`](/docs/reference/hooks-server) | Request snapshot during SSR and hydration-sensitive client code. |
46
51
 
52
+ ## Schema-driven UI Hooks
53
+
54
+ The headless primitives that derive UI from a descriptor's Schema. **Reach for
55
+ these before hand-rolling a form, a table, or a picker** — full guide in
56
+ [Schema-driven UI](/docs/ui/overview).
57
+
58
+ | Hook | Purpose |
59
+ |---|---|
60
+ | [`useFormBinding`](/docs/ui/forms-and-tables) | Bind a form to a MUTATION — fields + validation from its input Schema; a server `ValidationError({ field })` routes to that field. |
61
+ | [`useDataTable`](/docs/ui/forms-and-tables) | Bind a table to a QUERY — live rows, columns derived from the output Schema, sort/filter/pagination. |
62
+ | [`useQueryFilters`](/docs/ui/forms-and-tables) | Filter controls derived from a query's INPUT Schema (the read-side mirror of a form). |
63
+ | [`useQueryField`](/docs/ui/forms-and-tables) | Query-bound picker — a debounced search term drives a live subscription. |
64
+ | [`useFormSkeleton`](/docs/ui/client-utilities/use-skeletons) / [`useTableSkeleton`](/docs/ui/client-utilities/use-skeletons) | Placeholders shaped like the REAL data, from the same Schema. |
65
+ | [`useAsyncValidation`](/docs/ui/client-utilities/use-async-validation) | Live server-side validation (uniqueness, cross-row) over a query binding. |
66
+ | [`useDebounced`](/docs/ui/client-utilities/use-debounced) | Debounce a value (search, filter, validation input). |
67
+ | [`useRecord`](/docs/ui/client-utilities/use-record) | One live record from a "get" query, normalized (array → first row). |
68
+
69
+ ## Files, Permissions, and Client Utilities
70
+
71
+ | Hook | Purpose |
72
+ |---|---|
73
+ | [`useUpload`](/docs/plugins/storage) | File upload with progress + cancel, on every storage provider. Not base64 → action. |
74
+ | [`useCan`](/docs/ui/client-utilities/use-can) | Scope/RBAC UI gate, over `<PermissionProvider>`. Lives in `@voltro/client` — scopes are a framework concept, so gating a button needs no rbac dependency. |
75
+ | [`useCanAny`](/docs/ui/client-utilities/use-permissions) | OR variant of `useCan` — true when the subject holds AT LEAST ONE of the required scopes. |
76
+ | [`usePermissions`](/docs/ui/client-utilities/use-permissions) / `<PermissionProvider>` | The current subject's scope set, fed once from your session query — the source `useCan` reads. Gates UI on the SAME scope strings the server checks. |
77
+ | [`useResourceCan`](/docs/ui/client-utilities/use-permissions) / `useResourceCans` | Per-RESOURCE (ReBAC) gate, reactive — one resource or many in a single subscription. |
78
+ | [`useDerived`](/docs/ui/client-utilities/use-derived) | Dependency-tracked derived value from reactive sources. Replaces hand-maintained `useMemo` dep arrays. |
79
+ | [`useWindowedSubscription`](/docs/ui/client-utilities/use-windowed-subscription) | Subscribe to the VISIBLE window of a huge list, not the whole table. |
80
+ | [`useOutbox`](/docs/ui/client-utilities/use-outbox) | Queue mutations offline, replay in order on reconnect. |
81
+ | [`useUndoLog`](/docs/ui/client-utilities/use-undo) / `useUndo` | Client controller for the server-persisted undo stack. |
82
+ | [`usePreview`](/docs/ui/client-utilities/use-preview) | Mutation dry-run — run the real handler in a rolled-back transaction. |
83
+ | [`useProvenance`](/docs/ui/client-utilities/use-provenance) | "Why is this value here?" — lineage lookup for a field. |
84
+ | [`useOnRpcError`](/docs/ui/client-utilities/use-on-rpc-error) / `reportClientError` | Subscribe to the rpc error bus; report a client error to the server. |
85
+ | [`useTracking`](/docs/ui/client-utilities/use-tracking) | Fire mount/unmount + interaction tracking events. |
86
+ | [`useCapabilityManifest`](/docs/ui/client-utilities/use-capability-manifest) | The api's capability manifest (procedures + tables + schemas), fetched once. |
87
+ | [`useRefreshSubscriptions`](/docs/ui/client-utilities/use-refresh-subscriptions) | Force-refresh live subscriptions (e.g. after an out-of-band change). |
88
+
89
+ ## AI Hooks
90
+
91
+ | Hook | Purpose |
92
+ |---|---|
93
+ | [`useAgentChat`](/docs/ui/reactive-components) | Full chat surface over an `*.agent.tsx` (messages + send + streaming). |
94
+ | [`useResumableAgentStream`](/docs/ai/streaming) | Agent stream that survives reload/reconnect. |
95
+ | [`useDataCopilot`](/docs/ai/data-copilot) | Bind a data-copilot action by api name + tag. |
96
+
47
97
  ## Where To Read Next
48
98
 
49
99
  - [Data hooks](/docs/reference/hooks-data)
@@ -255,6 +305,32 @@ Events include lifecycle changes, timers, signals, and updates recorded by the w
255
305
 
256
306
  `useWorkflowEvents(apiName, runId)` is a shorter alias for the same hook.
257
307
 
308
+ ## `useWorkflowDomainEvents(apiName, filters?, options?)`
309
+
310
+ Subscribes to the **domain events** an app emitted through `ctx.events.emit(...)` — the business-event log behind [event triggers](/docs/workflows/event-triggers), not one run's internal timeline.
311
+
312
+ ```tsx
313
+ const { events } = useWorkflowDomainEvents('app', { name: 'order.paid', limit: 50 })
314
+ ```
315
+
316
+ Both filters are optional: `name` narrows to one event name, `limit` defaults to `100` and is clamped to `500` by the server. Rows arrive newest-first (by `occurredAt`), and each carries `id`, `name`, `payload`, `source`, `subject`, `traceId`, and `occurredAt`. The third argument is the standard `SubscriptionOptions` (e.g. `{ skip }`).
317
+
318
+ Alongside `events`, the hook returns the normal subscription fields (`data`, `error`, `revision`, …).
319
+
320
+ ## `useWorkflowEventDeliveries(apiName, eventId)`
321
+
322
+ Subscribes to the **fan-out** of one emitted domain event: one row per trigger the event was routed to, so you can see which workflows a single `emit(...)` actually started.
323
+
324
+ ```tsx
325
+ const { deliveries } = useWorkflowEventDeliveries('app', selectedEvent?.id)
326
+
327
+ deliveries.map((d) => `${d.workflowName}: ${d.status}`)
328
+ ```
329
+
330
+ The subscription is skipped while `eventId` is `undefined`, so it pairs directly with a row selected out of `useWorkflowDomainEvents`. Rows are ordered oldest-first and carry `eventId`, `eventName`, `triggerId`, `workflowName`, `executionId`, `idempotencyKey`, `skipped`, `errorMessage`, `createdAt`, `completedAt`, and a `status` of `'starting'`, `'started'`, `'skipped'`, or `'failed'`.
331
+
332
+ A `skipped` delivery is the normal outcome when a trigger's `filter` returned false or its `idempotencyKey` had already been seen — it is not a failure. `errorMessage` is set only on `'failed'`.
333
+
258
334
  ## `useAgentStream(apiName, rpcTag)`
259
335
 
260
336
  Consumes a `defineStream` RPC. Despite the name, this hook is not limited to AI agents; it handles any one-shot element stream.
@@ -735,8 +811,42 @@ await ctx.store.delete('notes', noteId)
735
811
  const rows = await ctx.store.query(database.notes.descriptor)
736
812
  ```
737
813
 
814
+ `ctx.store.query` returns the row type the builder already knew — `database.notes`
815
+ resolves to that table's row, so `rows[0].title` is a `string` with no cast. A
816
+ hand-built descriptor still resolves to the untyped `Row`.
817
+
738
818
  Mutations receive a transactional store view. Actions and streams receive a normal store view; writes from them are not automatically rolled back as one unit.
739
819
 
820
+ ## `ctx.load` / `ctx.loadMany` — request-scoped batching
821
+
822
+ `relations()` + `.with()` is the right answer whenever the shape of the related
823
+ data is known statically: it compiles to ONE query. Reach for it first.
824
+
825
+ This is for the case it cannot express — assembly whose shape depends on the
826
+ DATA. A breadth-first walk over a tree is the canonical example: each level's
827
+ ids come from the level above, so no declarative relation spec covers it, and
828
+ the natural code is one query per node.
829
+
830
+ ```ts
831
+ let level = [await ctx.load('nodes', rootId)]
832
+ while (level.length > 0) {
833
+ const childIds = level.flatMap((n) => n?.childIds ?? [])
834
+ if (childIds.length === 0) break
835
+ level = [...await ctx.loadMany('nodes', childIds)]
836
+ }
837
+ ```
838
+
839
+ Every `load` issued in the same tick for the same table is coalesced into one
840
+ `WHERE id IN (...)`, so that walk costs one query per LEVEL, not per node.
841
+ Repeated ids — a diamond where two parents share a child — are fetched once.
842
+ A missing row is `null` rather than a throw, because a dangling edge in a graph
843
+ walk is usually data; use `.one()` when absence is an error.
844
+
845
+ The cache lives exactly as long as the request. That is a correctness
846
+ requirement, not a tuning choice: a longer-lived cache would serve one
847
+ subject's rows to another (a data-isolation bug on a tenant-scoped store) and
848
+ would go stale across a mutation in the same request.
849
+
740
850
  ## `ctx.cache`
741
851
 
742
852
  Async cache facade for request handlers: