@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.
- package/CHANGELOG.md +49 -0
- package/THIRD-PARTY-NOTICES.md +234 -1
- package/bin/voltro.mjs +71 -1
- package/dist/apiBuild-CvtQeBMs.js +190 -0
- package/dist/apiBuild-DQBNqNZ8.js +2 -0
- package/dist/bin.js +2 -2
- package/dist/{commands-DQy4812j.js → commands-DhyBIs1O.js} +2381 -1768
- package/dist/{dev--jHe1vcu.js → dev-CQxbrpDz.js} +1677 -1626
- package/dist/dev-DYjGqPGD.js +2 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -1
- package/dist/serveCommand-BZzUJIyo.js +1077 -0
- package/dist/serveEntry.js +2 -2
- package/package.json +22 -19
- package/templates/AGENTS.core.md +61 -0
- package/templates/AGENTS.md +61 -0
- package/templates/agent-docs/_manifest.json +4 -4
- package/templates/agent-docs/ai.md +28 -0
- package/templates/agent-docs/authentication.md +5 -2
- package/templates/agent-docs/cli.md +98 -0
- package/templates/agent-docs/data.md +193 -3
- package/templates/agent-docs/database/advancedqueries.md +29 -0
- package/templates/agent-docs/database/querying.md +2 -0
- package/templates/agent-docs/deployment.md +3 -1
- package/templates/agent-docs/internationalization.md +237 -2
- package/templates/agent-docs/observability.md +9 -0
- package/templates/agent-docs/plugins.md +2 -2
- package/templates/agent-docs/reference.md +76 -0
- package/templates/agent-docs/schema-driven-ui.md +551 -2
- package/templates/agent-docs/templates/apibackends.md +10 -8
- package/templates/agent-docs/templates/overview.md +1 -1
- package/templates/agent-docs/testing.md +177 -3
- package/templates/apps/api-ai/package.json +7 -7
- package/templates/apps/api-auth/package.json +8 -8
- package/templates/apps/api-backend/package.json +7 -7
- package/templates/apps/api-backend-deactivation/package.json +7 -7
- package/templates/apps/api-backend-mail/package.json +8 -8
- package/templates/apps/api-backend-mariadb/package.json +9 -9
- package/templates/apps/api-backend-storage/package.json +8 -8
- package/templates/apps/api-data-advanced/package.json +8 -8
- package/templates/apps/api-durable/package.json +8 -8
- package/templates/apps/api-feature-flags/package.json +9 -9
- package/templates/apps/api-governance/package.json +8 -8
- package/templates/apps/api-kv/package.json +8 -8
- package/templates/apps/api-moderation/package.json +8 -8
- package/templates/apps/api-observability/package.json +8 -8
- package/templates/apps/api-ratelimit/package.json +8 -8
- package/templates/apps/api-rbac/README.md +9 -4
- package/templates/apps/api-rbac/mutations/notes.create.mutation.server.ts +1 -1
- package/templates/apps/api-rbac/mutations/notes.create.mutation.ts +6 -6
- package/templates/apps/api-rbac/package.json +8 -8
- package/templates/apps/api-rbac/tests/notes.create.test.ts +8 -8
- package/templates/apps/api-rest/package.json +7 -7
- package/templates/apps/api-saas/package.json +11 -11
- package/templates/apps/api-search/package.json +8 -8
- package/templates/apps/api-versioning/package.json +8 -8
- package/templates/apps/api-webhooks/package.json +8 -8
- package/templates/apps/changelog/package.json +6 -6
- package/templates/apps/edge-functions/package.json +2 -2
- package/templates/apps/frontend-admin/package.json +8 -8
- package/templates/apps/frontend-app/package.json +8 -8
- package/templates/apps/frontend-blank/package.json +7 -7
- package/templates/apps/frontend-contact/package.json +7 -7
- package/templates/apps/frontend-dashboard/package.json +7 -7
- package/templates/apps/frontend-docs/package.json +7 -7
- package/templates/apps/frontend-i18n/package.json +6 -6
- package/templates/apps/frontend-landing/package.json +7 -7
- package/templates/apps/frontend-spa/package.json +7 -7
- package/templates/apps/frontend-ssr/package.json +7 -7
- package/templates/apps/frontend-ssr-api/package.json +8 -8
- package/templates/apps/frontend-static-blog/package.json +6 -6
- package/templates/baselines/compose/docker/api.Dockerfile +10 -5
- package/templates/baselines/compose-mariadb/docker/api.Dockerfile +10 -5
- package/dist/apiBuild-OpZROja5.js +0 -2
- package/dist/apiBuild-o70rjpVJ.js +0 -184
- package/dist/dev-BKkZglQV.js +0 -2
- 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
|
-
|
|
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.**
|
|
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.
|
|
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
|
-
|
|
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 `
|
|
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
|
|
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.
|