@voltro/cli 0.2.2 → 0.4.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 (77) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/THIRD-PARTY-NOTICES.md +234 -1
  3. package/bin/voltro.mjs +71 -1
  4. package/dist/apiBuild-CvtQeBMs.js +190 -0
  5. package/dist/apiBuild-DQBNqNZ8.js +2 -0
  6. package/dist/bin.js +2 -2
  7. package/dist/{commands-DQy4812j.js → commands-DhyBIs1O.js} +2381 -1768
  8. package/dist/{dev--jHe1vcu.js → dev-CQxbrpDz.js} +1677 -1626
  9. package/dist/dev-DYjGqPGD.js +2 -0
  10. package/dist/index.d.ts +2 -0
  11. package/dist/index.js +1 -1
  12. package/dist/serveCommand-BZzUJIyo.js +1077 -0
  13. package/dist/serveEntry.js +2 -2
  14. package/package.json +22 -19
  15. package/templates/AGENTS.core.md +61 -0
  16. package/templates/AGENTS.md +61 -0
  17. package/templates/agent-docs/_manifest.json +4 -4
  18. package/templates/agent-docs/ai.md +28 -0
  19. package/templates/agent-docs/authentication.md +5 -2
  20. package/templates/agent-docs/cli.md +98 -0
  21. package/templates/agent-docs/data.md +193 -3
  22. package/templates/agent-docs/database/advancedqueries.md +29 -0
  23. package/templates/agent-docs/database/querying.md +2 -0
  24. package/templates/agent-docs/deployment.md +3 -1
  25. package/templates/agent-docs/internationalization.md +237 -2
  26. package/templates/agent-docs/observability.md +9 -0
  27. package/templates/agent-docs/plugins.md +2 -2
  28. package/templates/agent-docs/reference.md +76 -0
  29. package/templates/agent-docs/schema-driven-ui.md +551 -2
  30. package/templates/agent-docs/templates/apibackends.md +10 -8
  31. package/templates/agent-docs/templates/overview.md +1 -1
  32. package/templates/agent-docs/testing.md +177 -3
  33. package/templates/apps/api-ai/package.json +7 -7
  34. package/templates/apps/api-auth/package.json +8 -8
  35. package/templates/apps/api-backend/package.json +7 -7
  36. package/templates/apps/api-backend-deactivation/package.json +7 -7
  37. package/templates/apps/api-backend-mail/package.json +8 -8
  38. package/templates/apps/api-backend-mariadb/package.json +9 -9
  39. package/templates/apps/api-backend-storage/package.json +8 -8
  40. package/templates/apps/api-data-advanced/package.json +8 -8
  41. package/templates/apps/api-durable/package.json +8 -8
  42. package/templates/apps/api-feature-flags/package.json +9 -9
  43. package/templates/apps/api-governance/package.json +8 -8
  44. package/templates/apps/api-kv/package.json +8 -8
  45. package/templates/apps/api-moderation/package.json +8 -8
  46. package/templates/apps/api-observability/package.json +8 -8
  47. package/templates/apps/api-ratelimit/package.json +8 -8
  48. package/templates/apps/api-rbac/README.md +9 -4
  49. package/templates/apps/api-rbac/mutations/notes.create.mutation.server.ts +1 -1
  50. package/templates/apps/api-rbac/mutations/notes.create.mutation.ts +6 -6
  51. package/templates/apps/api-rbac/package.json +8 -8
  52. package/templates/apps/api-rbac/tests/notes.create.test.ts +8 -8
  53. package/templates/apps/api-rest/package.json +7 -7
  54. package/templates/apps/api-saas/package.json +11 -11
  55. package/templates/apps/api-search/package.json +8 -8
  56. package/templates/apps/api-versioning/package.json +8 -8
  57. package/templates/apps/api-webhooks/package.json +8 -8
  58. package/templates/apps/changelog/package.json +6 -6
  59. package/templates/apps/edge-functions/package.json +2 -2
  60. package/templates/apps/frontend-admin/package.json +8 -8
  61. package/templates/apps/frontend-app/package.json +8 -8
  62. package/templates/apps/frontend-blank/package.json +7 -7
  63. package/templates/apps/frontend-contact/package.json +7 -7
  64. package/templates/apps/frontend-dashboard/package.json +7 -7
  65. package/templates/apps/frontend-docs/package.json +7 -7
  66. package/templates/apps/frontend-i18n/package.json +6 -6
  67. package/templates/apps/frontend-landing/package.json +7 -7
  68. package/templates/apps/frontend-spa/package.json +7 -7
  69. package/templates/apps/frontend-ssr/package.json +7 -7
  70. package/templates/apps/frontend-ssr-api/package.json +8 -8
  71. package/templates/apps/frontend-static-blog/package.json +6 -6
  72. package/templates/baselines/compose/docker/api.Dockerfile +10 -5
  73. package/templates/baselines/compose-mariadb/docker/api.Dockerfile +10 -5
  74. package/dist/apiBuild-OpZROja5.js +0 -2
  75. package/dist/apiBuild-o70rjpVJ.js +0 -184
  76. package/dist/dev-BKkZglQV.js +0 -2
  77. package/dist/serveCommand-93rRdEp0.js +0 -1077
@@ -315,6 +315,40 @@ Queries are streaming RPCs whose elements are **subscription events**: an initia
315
315
  - **Using a stream for durable data.** Streams are transient. Persist rows and expose them through a query when the UI should survive reloads or sync across tabs.
316
316
 
317
317
 
318
+ ## Loading vs empty — don't conflate them
319
+
320
+ `useSubscription` returns `loading` and `isEmpty` alongside `data`. They are
321
+ **different** states, and branching on `data === undefined` alone is what causes
322
+ a flash of empty-state before the first snapshot:
323
+
324
+ | State | Meaning | Render |
325
+ |---|---|---|
326
+ | `loading` | no snapshot has arrived yet | skeleton |
327
+ | `isEmpty` | snapshot arrived, zero rows (or a null value) | empty state |
328
+ | neither | rows present | the list |
329
+
330
+ ```tsx
331
+ const { data, loading, isEmpty } = useSubscription<Note[]>('app', 'notes.list', {})
332
+ if (loading) return <TableSkeleton/>
333
+ if (isEmpty) return <EmptyNotes/>
334
+ return <NotesTable notes={data!}/>
335
+ ```
336
+
337
+ `fallback` fills `data` while loading so a page can render its real (empty) shell
338
+ immediately — it never lies about `loading`:
339
+
340
+ ```ts
341
+ const { data, loading } = useSubscription('app', 'notes.list', {}, { fallback: [] })
342
+ // data is [] before the first snapshot; loading is still true
343
+ ```
344
+
345
+ **Errors.** `error` carries a **cold-start** stream failure (nothing ever
346
+ arrived) — check it to avoid an infinite skeleton. A failure AFTER data arrived
347
+ deliberately does NOT replace good data with an error banner (a transient
348
+ websocket hiccup would blank a working screen); those reach the api's error bus
349
+ instead — subscribe with `useOnRpcError` for connection-level UX.
350
+
351
+
318
352
 
319
353
  ---
320
354
 
@@ -389,6 +423,17 @@ The descriptor is the wire contract. The `.mutation.server.ts` file is the serve
389
423
  5. Commit the transaction.
390
424
  6. Drain the batched change events so matching query subscriptions receive new snapshots or deltas.
391
425
 
426
+ ## Partial updates: `ctx.store.applyDefined`
427
+
428
+ A partial-update mutation should set only the fields the caller actually sent — not overwrite an omitted field with `undefined`. Instead of hand-writing `if (input.x !== undefined) patch.x = input.x` per field, use `ctx.store.applyDefined(input, keys)`:
429
+
430
+ ```ts
431
+ const execute = async (input: UpdateNote, ctx: AppContext) =>
432
+ ctx.store.update('notes', input.id, ctx.store.applyDefined(input, ['title', 'body', 'dueAt']))
433
+ ```
434
+
435
+ It returns a patch containing only the listed keys whose value is not `undefined` (a defined falsy value like `0` / `''` / `false` IS kept). Also importable standalone (`import { applyDefined } from '@voltro/runtime'`) for seeds/tests.
436
+
392
437
  ## Calling From React
393
438
 
394
439
  ```tsx
@@ -421,6 +466,35 @@ export default function NewNote() {
421
466
 
422
467
  `useMutation` returns `mutate`, `pending`, `error`, `data`, plus the chainable optimistic helpers.
423
468
 
469
+ ### Handling the result — `onSuccess` / `onError` / `notify`
470
+
471
+ Pass a result handler to `mutate` instead of wrapping every call in
472
+ `try/catch/finally` + toasts. `pending` already replaces the `finally`:
473
+
474
+ ```ts
475
+ const create = useMutation('app', 'teams.create')
476
+
477
+ await create.mutate(input, {
478
+ onSuccess: (team) => setOpen(false),
479
+ notify: { success: t('teams.created'), error: (e) => messageFor(e) },
480
+ })
481
+ ```
482
+
483
+ **The load-bearing rule:** supplying an error handler (`onError` **or**
484
+ `notify.error`) marks the failure **handled** — `mutate` then resolves with
485
+ `undefined` instead of rejecting, which is what removes the `try/catch`. With no
486
+ error handler it rejects exactly as before, so an unhandled failure stays loud.
487
+ You opt in per call.
488
+
489
+ `notify` routes to an app-wide sink you register once — the framework is not
490
+ bound to any toast library:
491
+
492
+ ```ts
493
+ import { setMutationNotifier } from '@voltro/client'
494
+ setMutationNotifier({ success: (m) => toast.success(m), error: (m) => toast.error(m) })
495
+ ```
496
+
497
+
424
498
  ## Auto-Optimistic
425
499
 
426
500
  The default path is declarative:
@@ -465,6 +539,58 @@ const create = useMutation('app', 'notes.create').withOptimistic((cache, input)
465
539
 
466
540
  Use `.withoutOptimistic()` for effects that should not preview locally.
467
541
 
542
+ ### Nested / path-targeted optimistic
543
+
544
+ By default a `target` patches the **flat top-level row array** a query returns, keyed by `id`. When a query returns a **nested array** — a JSON array column (`snapshot.projects`) or a computed/shaped value — add `path` (and, if the item key isn't `id`, `by`) to patch at **item** granularity, with no hand-written `.withOptimistic` reducer:
545
+
546
+ ```ts
547
+ target: {
548
+ table: 'projectRoadmaps', op: 'update',
549
+ path: 'snapshot.projects', // dot-path to the nested array in the value
550
+ identify: (input) => input.projectId, // which item to patch (default input.id)
551
+ }
552
+ ```
553
+
554
+ - `op: 'insert'` appends (or `order: 'prepend'`) a new item into the nested array — safe even on a computed query (a path insert targets a KNOWN document, not a blind top-level add).
555
+ - `op: 'delete'` filters the item out by its key.
556
+ - `by` overrides the item-key field (default `'id'`).
557
+
558
+ **Shape the item with `shapeItem` (not `shape`).** For a nested target, build/patch the item with `shapeItem` — it is typed to the **item** of the nested array, not the mutation's output, so `current` needs no cast:
559
+
560
+ ```ts
561
+ target: {
562
+ table: 'projectRoadmaps', op: 'update', path: 'snapshot.projects',
563
+ identify: (input) => input.projectId,
564
+ shapeItem: (input, current) => ({ ...current, startDate: input.startDate }), // `current` IS the item
565
+ }
566
+ ```
567
+
568
+ (The flat `shape` stays bound to the output row — a single field can't be both, so the nested shaper is its own.)
569
+
570
+ **Bulk (multi-item) patches.** `identify` may return an **array** of ids to patch or delete **many** items in one mutation — exactly the group-drag / batch-edit where per-item parallel writes used to race:
571
+
572
+ ```ts
573
+ target: {
574
+ table: 'projectRoadmaps', op: 'update', path: 'snapshot.projects',
575
+ identify: (input) => input.projectIds, // ← ARRAY: patch them all
576
+ shapeItem: (input, current) => ({ ...current, shiftedBy: input.delta }), // each keeps its own key
577
+ }
578
+ ```
579
+
580
+ (This works for flat top-level targets too — `identify` returning an array patches/deletes every matching row.)
581
+
582
+ Add `match` to patch **only** the entries whose current value satisfies a predicate — the guard that stops a patch bleeding across sibling subscriptions sharing a source table:
583
+
584
+ ```ts
585
+ target: {
586
+ table: 'projectRoadmaps', op: 'update', path: 'snapshot.projects',
587
+ identify: (i) => i.projectId,
588
+ match: (value, input) => value.id === input.roadmapId, // only THIS roadmap's subscription
589
+ }
590
+ ```
591
+
592
+ `path`, `by`, `match`, and `shapeItem` are browser-safe descriptor data (a dot-path string + pure functions) — the same discipline as `identify`/`shape`.
593
+
468
594
  ## Typed Errors
469
595
 
470
596
  ```ts
@@ -485,6 +611,42 @@ export const createNote = defineMutation({
485
611
 
486
612
  Throw a matching error from the server file; the client can narrow on `_tag`.
487
613
 
614
+ ### Matching typed errors on the client
615
+
616
+ Tagged errors **round-trip structurally** over the wire — the caught value carries `_tag` plus every declared field as real properties (and `instanceof` works, same Schema class both ends). You do **not** need to parse the error message string.
617
+
618
+ Inside Effect, use `Effect.catchTag('NoteQuotaExceeded', …)`. In a React `try/catch` (outside Effect, where `catchTag` isn't available and the decoded value may be a plain object, not a class instance), match with **`errorTag(err)`** — the dependency-free tag reader `@voltro/client` ships:
619
+
620
+ ```ts
621
+ import { errorTag } from '@voltro/client'
622
+
623
+ try {
624
+ await createNote(input)
625
+ } catch (err) {
626
+ if (errorTag(err) === 'NoteQuotaExceeded') {
627
+ // err.limit is the declared field — read it directly, no regex
628
+ }
629
+ }
630
+ ```
631
+
632
+ **Trace id for debugging.** An error caught from `useMutation` / `useAction` carries a **non-enumerable `__voltroTraceId`** — the bridge to the server logs for that exact call:
633
+
634
+ ```ts
635
+ const traceId = (err as { __voltroTraceId?: string }).__voltroTraceId
636
+ // → `voltro logs --trace <traceId>` to see the server-side span
637
+ ```
638
+
639
+ **Exhaustive matching with the generated `matchError`.** Codegen emits a per-app `matchError` (plus `AppError` / `AppErrorTag`) into `rpcGroup.generated.ts`, derived by reference from every descriptor's `error:` schema + your plugins' cross-cutting errors — so there's no hand-maintained tag list to drift out of date (a dead/renamed tag is a compile error):
640
+
641
+ ```ts
642
+ import { matchError } from './rpcGroup.generated'
643
+
644
+ const message = matchError(err, {
645
+ NoteQuotaExceeded: (e) => `Limit ${e.limit} reached`, // e is typed
646
+ ScopeError: (e) => `Missing ${e.required}`,
647
+ }, () => 'Something went wrong')
648
+ ```
649
+
488
650
  ## When Not To Use A Mutation
489
651
 
490
652
  - **External I/O.** Use an action or workflow.
@@ -1077,6 +1239,7 @@ The reading API is **explicit + namespace-only** — `useAggregate(def).read(...
1077
1239
 
1078
1240
  ```ts
1079
1241
  handle.read({
1242
+ where: { teamId: 't1' }, // filter the materialised rows (see below)
1080
1243
  limit: 10, // pagination
1081
1244
  offset: 20,
1082
1245
  orderBy: 'rank', // any column in the output schema
@@ -1085,7 +1248,34 @@ handle.read({
1085
1248
  })
1086
1249
  ```
1087
1250
 
1088
- There's deliberately no `filter` / `where` / `join`. If you need a different cut of the data, define another aggregate.
1251
+ ### Parameterised reads `where`
1252
+
1253
+ Without a filter an aggregate can only ever be "the one global roll-up". Every tenant-, team- or period-scoped roll-up — which is most of the real ones — then has to read the *whole* aggregate and filter client-side: every row crosses the wire so the caller can throw most of them away. `where` moves that cut to the read.
1254
+
1255
+ ```ts
1256
+ // one team's rows, for one year
1257
+ const rows = yield* handle.read({ where: { teamId: 'team_7', year: 2026 } })
1258
+
1259
+ // an array is an IN set — status is 'open' OR 'blocked'
1260
+ const active = yield* handle.read({ where: { status: ['open', 'blocked'] } })
1261
+
1262
+ // composes with the other read options
1263
+ const top = yield* handle.read({
1264
+ where: { teamId: 'team_7', status: ['open', 'blocked'] },
1265
+ orderBy: 'rank',
1266
+ limit: 10,
1267
+ })
1268
+ ```
1269
+
1270
+ The semantics, exactly:
1271
+
1272
+ - **Entries are ANDed** — a row matches only when it satisfies *every* entry.
1273
+ - **A scalar value means strict equality** (`===`) against that field on the row.
1274
+ - **An array value means IN** — the row's value must be one of the array's entries.
1275
+ - An omitted `where`, or an empty `{}`, filters nothing.
1276
+ - Filtering happens **before** `orderBy` and `limit` / `offset`, so pagination paginates the filtered set.
1277
+
1278
+ `where` is deliberately **data, not a predicate function**. It is applied over the rows the aggregate has already materialised — the refresh still computes the full roll-up, and `where` cuts the result before it crosses the wire. Keeping it a serializable record of field → value (rather than a callback) is what leaves the door open to pushing the same filter down to the store later. A cut you can't express as equality / IN belongs in another aggregate rather than in the read.
1089
1279
 
1090
1280
  ### Metadata
1091
1281
 
@@ -1252,11 +1442,11 @@ Mental model: `'replace'` is "snapshot at time T"; `'merge'` is "incremental del
1252
1442
  The defining property of an aggregate is **the query is fixed in advance**. Treating it as a query-buildable virtual table (`database.topPlayers.where(...)`) opens four footguns:
1253
1443
 
1254
1444
  1. **Hidden staleness.** `database.topPlayers.where(...)` looks like a live query. Readers can't tell it's stale data.
1255
- 2. **Computation drift.** Adding `WHERE region='EU'` shifts the filter from refresh-time to read-time — the materialisation point IS the query; don't re-query it.
1445
+ 2. **Computation drift.** A full query builder shifts arbitrary computation from refresh-time to read-time — the materialisation point IS the query; don't re-query it. `read({ where })` is the bounded exception: an equality / IN cut of rows that are *already* materialised, not a new query.
1256
1446
  3. **Misleading expectations.** Users would reflexively try `database.topPlayers.insert(...)`. Framework would either silently do nothing or error with a cryptic message.
1257
1447
  4. **Cross-timeline joins.** Joining an aggregate with a live table mixes two timelines (refresh-time + now). Mostly a footgun.
1258
1448
 
1259
- The explicit namespace (`useAggregate(def).read(...)`) makes the materialisation explicit. Friction in the wrong direction (filtering, joining) is a feature it pushes you to either define another aggregate or do the work in app code with clear boundaries.
1449
+ The explicit namespace (`useAggregate(def).read(...)`) makes the materialisation explicit. `where` covers the one cut that genuinely belongs at read time — scoping a roll-up to a tenant, a team, a period. Everything past it (joins, aggregating over the aggregate, arbitrary predicates) keeps its friction on purpose: it pushes you to either define another aggregate or do the work in app code with clear boundaries.
1260
1450
 
1261
1451
  ## Decision: aggregate vs subscriber vs cron
1262
1452
 
@@ -219,6 +219,35 @@ text().unique() // single-column, on the column
219
219
 
220
220
  Multi-column uniqueness is declared at the table level with `.unique(name, [cols])` — see [Composite UNIQUE constraints](#composite-unique-constraints) below.
221
221
 
222
+ ## Unique among ACTIVE rows — `.uniqueActive([...])`
223
+
224
+ Enforce uniqueness only among the rows that aren't soft-deleted — "one active roadmap per (project, year)", where a soft-deleted roadmap frees the key for a new one:
225
+
226
+ ```ts
227
+ table('project_roadmaps', {
228
+ id: id(), projectId: text(), year: integer(), deletedAt: timestamp(),
229
+ })
230
+ .softDelete()
231
+ .uniqueActive(['projectId', 'year']) // partial UNIQUE among deletedAt IS NULL
232
+ ```
233
+
234
+ Emits a `CREATE UNIQUE INDEX … WHERE "deletedAt" IS NULL`, so a soft-deleted row leaves the active set and a NEW row with the same key inserts cleanly — no hand-written `generatedAs("CASE WHEN …")` column, and no [resurrection bug](/docs/database/migrations/troubleshooting) where a re-imported soft-deleted key collides. The predicate defaults to the `softDelete()` active set; override it for a custom one:
235
+
236
+ ```ts
237
+ .uniqueActive('byActiveSlug', ['orgId', 'slug'], { where: `"status" = 'open'` })
238
+ ```
239
+
240
+ Cross-dialect:
241
+
242
+ | Dialect | Support |
243
+ |---------------------|----------------------------------------------------------------|
244
+ | postgres / sqlite / mssql | native partial `CREATE UNIQUE INDEX … WHERE` |
245
+ | mysql / mariadb | lowered automatically to a generated STORED column per key column (NULL when soft-deleted) + a UNIQUE over them — NULL-distinct gives the same resurrection-safe semantics. Nothing to hand-write. |
246
+
247
+ **mysql / mariadb — how the emulation works.** Those engines have no partial index, so `.uniqueActive(['projectId', 'year'])` lowers to one `CASE WHEN <predicate> THEN CAST(<col> AS CHAR(255)) ELSE NULL END` STORED column per key column plus a `UNIQUE` over them. A soft-deleted row's generated columns are all NULL, and mysql/mariadb treat NULLs as DISTINCT in a unique index, so it never collides — re-creating the key just works, exactly like the partial index elsewhere. This round-trips through the declarative differ (the generated columns are part of the declared snapshot on those dialects, so `voltro dev` never re-plans them). You write the same `.uniqueActive([...])` on every dialect.
248
+
249
+ The predicate is emitted verbatim (ANSI double-quoted identifiers; on mysql/mariadb they are re-quoted with backticks inside the generated column).
250
+
222
251
  ## When NOT to index
223
252
 
224
253
  - Tables with <10k rows on a fast disk — the cost of maintaining the index outweighs the seq-scan cost.
@@ -100,6 +100,8 @@ ctx.store.select('notes').where(not(eq('archived', true)))
100
100
 
101
101
  `and(...)` is rarely needed because chained `.where()` calls are already AND'd; useful inside `or(...)` to nest.
102
102
 
103
+ `eq(col, val)` (and the other predicate helpers) is **callable without a row-type generic** — it defaults to a loose row shape — so in generic handler code you write `eq('teamId', id)` directly. There's no need for a `const ef = (c, v) => eq<Row, string>(c, v)` wrapper.
104
+
103
105
  ### JSON path filters
104
106
 
105
107
  For `json<T>()` columns:
@@ -411,7 +411,9 @@ voltro build ./apps/api # produces .framework/dist-api/serveBundle/
411
411
  voltro serve ./apps/api # boots from the bundle automatically
412
412
  ```
413
413
 
414
- There is nothing to configure and nothing to opt into: building an API app produces the bundle, and `voltro serve` prefers it whenever it is present. It is fully fallback-safe a missing, stale, or unreadable bundle silently degrades to the standard boot path, so a bad build can never stop the app from serving. A container image that runs `voltro build` at build time and `voltro serve` at start gets the fast boot for free.
414
+ Building an API app produces the bundle, and `voltro serve` boots from it. In **production** (`NODE_ENV=production`) the bundle is **required** — you run `voltro build` before `voltro serve`, and a bundle-build failure is fatal: production **never transpiles on demand**, so it fails loud rather than silently falling back to the slow tsx path. (`voltro dev` and a non-production local `voltro serve` still fall back to tsx as a convenience.) The generated Dockerfiles already do this: `voltro build` at build time, `voltro serve` at start.
415
+
416
+ Because production never transpiles, the serve image needs none of the build toolchain. The framework declares `tsx`, `esbuild`, `vite`, and Tailwind as **optional** dependencies of `@voltro/cli`, and the production Dockerfiles isolate the app with `pnpm --prod --no-optional deploy` — which drops that whole tree (and its native binaries) from the image. A serve image ships only what it runs at runtime: your app, the framework, and the one SQL driver you declared.
415
417
 
416
418
  ## Tiers (Voltro Cloud — coming soon)
417
419
 
@@ -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.