@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
|
@@ -378,6 +378,37 @@ permissions). Don't hand-wire `useResumableAgentStream` + a bubble list for a
|
|
|
378
378
|
standard chat — `<AgentChat>` is the supported path; reach for the resumable
|
|
379
379
|
hook directly only for a TRANSIENT (non-persisted) run over a `*.stream.ts`.
|
|
380
380
|
|
|
381
|
+
### `useAgentChat(apiName, agent, { threadId })`
|
|
382
|
+
|
|
383
|
+
The headless core `<AgentChat>` renders over. It opens no transport of its own —
|
|
384
|
+
it composes the agent's SYNTHESIZED pair: a `useSubscription` on
|
|
385
|
+
`<agent>.messages` (the persisted thread, including the live `streaming: true`
|
|
386
|
+
row) plus a `useAction` on `<agent>.send`. Because the feed is a reactive query,
|
|
387
|
+
reconnect- and reload-survival come for free.
|
|
388
|
+
|
|
389
|
+
```tsx
|
|
390
|
+
const chat = useAgentChat('app', 'support', { threadId })
|
|
391
|
+
|
|
392
|
+
await chat.send('Where is my order?')
|
|
393
|
+
await chat.send('Retry', { locale: 'en' }) // extra fields merge into the send input
|
|
394
|
+
chat.regenerate() // re-run the last user prompt
|
|
395
|
+
```
|
|
396
|
+
|
|
397
|
+
| Field | Meaning |
|
|
398
|
+
|---|---|
|
|
399
|
+
| `messages` | The thread, sorted by `order` then `stepOrder`. Each message has `id`, `role`, `content`, `streaming`, and the persisted `parts`. |
|
|
400
|
+
| `streaming` | `true` while any message row is still being written. |
|
|
401
|
+
| `loading` | `true` until the first snapshot arrives. |
|
|
402
|
+
| `error` | The subscription error, or the last send failure. |
|
|
403
|
+
| `send(prompt, extra?)` | Appends the user turn and drives the assistant turn. |
|
|
404
|
+
| `regenerate()` | Re-sends the last user prompt as a fresh turn; `undefined` if there is none. |
|
|
405
|
+
| `sending` | `true` while a `send` is in flight. |
|
|
406
|
+
|
|
407
|
+
`threadId` is required — mint one per chat (a `useRef`'d uuid) and keep it stable
|
|
408
|
+
across renders, since it is the subscription key for the whole thread. Render
|
|
409
|
+
from `message.parts` (text / reasoning / tool / source / file) rather than
|
|
410
|
+
`content` so a reloaded thread and a live one look identical.
|
|
411
|
+
|
|
381
412
|
|
|
382
413
|
|
|
383
414
|
---
|
|
@@ -392,7 +423,7 @@ source of truth, no separate frontend permission list. Feed the subject's scopes
|
|
|
392
423
|
(from your session query) to `<PermissionProvider>`; `useCan` reads them.
|
|
393
424
|
|
|
394
425
|
```tsx
|
|
395
|
-
import { PermissionProvider, useCan } from '@voltro/
|
|
426
|
+
import { PermissionProvider, useCan } from '@voltro/client'
|
|
396
427
|
|
|
397
428
|
<PermissionProvider scopes={session.scopes}>…</PermissionProvider>
|
|
398
429
|
const canDelete = useCan('todos:delete') // ADMIN bypasses; deny-by-default
|
|
@@ -401,6 +432,10 @@ const canDelete = useCan('todos:delete') // ADMIN bypasses; deny-by-default
|
|
|
401
432
|
UX only — the server still enforces. Pairs with `<AutoForm>` / `<DataTable>` to
|
|
402
433
|
auto-hide actions the subject can't perform.
|
|
403
434
|
|
|
435
|
+
The scope set itself, the `canCall` matcher, the OR variant (`useCanAny` /
|
|
436
|
+
`canCallAny`), and the per-RESOURCE gates (`useResourceCan` / `useResourceCans`)
|
|
437
|
+
are in [usePermissions](/docs/ui/client-utilities/use-permissions).
|
|
438
|
+
|
|
404
439
|
|
|
405
440
|
|
|
406
441
|
---
|
|
@@ -469,7 +504,7 @@ _Live read-only detail view for one record plus its eager-loaded relations._
|
|
|
469
504
|
|
|
470
505
|
The read counterpart to `<AutoForm>`: a live detail view for one record + its
|
|
471
506
|
eager-loaded relations (scalars → a definition list; arrays → nested tables).
|
|
472
|
-
`useRecord(
|
|
507
|
+
[`useRecord`](/docs/ui/client-utilities/use-record) is the headless core.
|
|
473
508
|
|
|
474
509
|
```tsx
|
|
475
510
|
import { RecordView } from '@voltro/web'
|
|
@@ -605,3 +640,517 @@ const w = useWindowedSubscription('app', 'rows.list', { rowHeight: 32, viewportH
|
|
|
605
640
|
<div style={{ height: w.bottomSpacer }} />
|
|
606
641
|
</div>
|
|
607
642
|
```
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
|
|
646
|
+
---
|
|
647
|
+
|
|
648
|
+
<!-- source: en/ui/client-utilities/use-theme.md -->
|
|
649
|
+
## useTheme
|
|
650
|
+
|
|
651
|
+
_Read and set the app theme through the same cookie and class the pre-paint script owns._
|
|
652
|
+
|
|
653
|
+
With `theme: 'system'` the framework already emits a `<head>` script that reads
|
|
654
|
+
the `voltro:theme` cookie (falling back to `prefers-color-scheme`) and toggles
|
|
655
|
+
`.dark` on `<html>` before first paint. What was missing was the in-app
|
|
656
|
+
switcher — so apps layered a second theme system on top, and then two writers
|
|
657
|
+
raced on one `class` attribute: a flash on navigation, and a stored preference
|
|
658
|
+
that disagreed with the rendered class. `useTheme` is that switcher, reading and
|
|
659
|
+
writing exactly the cookie and class the pre-paint script already uses.
|
|
660
|
+
|
|
661
|
+
```tsx
|
|
662
|
+
import { useTheme } from '@voltro/web'
|
|
663
|
+
|
|
664
|
+
const { theme, resolvedTheme, setTheme } = useTheme()
|
|
665
|
+
<button onClick={() => setTheme(resolvedTheme === 'dark' ? 'light' : 'dark')}>
|
|
666
|
+
```
|
|
667
|
+
|
|
668
|
+
`theme` is the stored preference — `'light' | 'dark' | 'system'`, where
|
|
669
|
+
`'system'` means no explicit choice. `resolvedTheme` is what is actually applied
|
|
670
|
+
right now (`'light' | 'dark'`), resolving `'system'` against `matchMedia`, and
|
|
671
|
+
it stays live when the OS flips. `setTheme` persists the cookie and applies the
|
|
672
|
+
class immediately; `setTheme('system')` clears the cookie and falls back to the
|
|
673
|
+
OS.
|
|
674
|
+
|
|
675
|
+
Requires `theme: 'system'` in the web `app.config.ts` — that is what emits the
|
|
676
|
+
pre-paint script. Do not also install a third-party theme provider.
|
|
677
|
+
|
|
678
|
+
|
|
679
|
+
|
|
680
|
+
---
|
|
681
|
+
|
|
682
|
+
<!-- source: en/ui/client-utilities/use-connection-status.md -->
|
|
683
|
+
## useConnectionStatus
|
|
684
|
+
|
|
685
|
+
_One honest signal for whether the server is reachable — derived, never polled._
|
|
686
|
+
|
|
687
|
+
Connection health from the two signals the client genuinely has, so apps stop
|
|
688
|
+
hand-rolling a degraded-connection state machine in an auth provider.
|
|
689
|
+
|
|
690
|
+
```tsx
|
|
691
|
+
import { useConnectionStatus } from '@voltro/client'
|
|
692
|
+
|
|
693
|
+
const { status, reportSuccess } = useConnectionStatus('app')
|
|
694
|
+
{status !== 'connected' && <OfflineBanner status={status} />}
|
|
695
|
+
```
|
|
696
|
+
|
|
697
|
+
`status` is `'connected' | 'degraded' | 'offline'`. `offline` is browser-reported
|
|
698
|
+
(`navigator.onLine` plus its `online` / `offline` events) — reliable for "the
|
|
699
|
+
network is gone", and it wins over `degraded`. `degraded` means an rpc on that
|
|
700
|
+
api failed and nothing has succeeded since; it is real evidence, not a guess.
|
|
701
|
+
Coming back online clears the failure count, since failures counted during an
|
|
702
|
+
offline window *are* that window. `reportSuccess()` clears it eagerly from a
|
|
703
|
+
place that knows a call went through.
|
|
704
|
+
|
|
705
|
+
Also returned: `online`, `failureCount`, `lastFailureAt`. There is deliberately
|
|
706
|
+
no polling ping — the framework does not call the server just to colour an
|
|
707
|
+
indicator, so `connected` means "nothing has failed", not "just verified".
|
|
708
|
+
|
|
709
|
+
|
|
710
|
+
|
|
711
|
+
---
|
|
712
|
+
|
|
713
|
+
<!-- source: en/ui/client-utilities/use-debounced.md -->
|
|
714
|
+
## useDebounced
|
|
715
|
+
|
|
716
|
+
_Delay a value until typing stops — the one debounce the framework's own bindings use._
|
|
717
|
+
|
|
718
|
+
Every app that has a search box eventually writes the same six lines: a
|
|
719
|
+
`useState`, a `useEffect`, a `setTimeout`, and a cleanup that clears it. Get the
|
|
720
|
+
cleanup wrong and stale timers fire after unmount; get the dependency array
|
|
721
|
+
wrong and the timer never restarts. `useDebounced` is that snippet, written
|
|
722
|
+
once. `useAsyncValidation` and `useQueryField` are built on it, so a debounced
|
|
723
|
+
subscription in your code behaves exactly like the framework's own.
|
|
724
|
+
|
|
725
|
+
```tsx
|
|
726
|
+
import { useDebounced } from '@voltro/client'
|
|
727
|
+
|
|
728
|
+
const [term, setTerm] = useState('')
|
|
729
|
+
const debouncedTerm = useDebounced(term, 300)
|
|
730
|
+
const results = useSubscription('app', 'todos.search', { term: debouncedTerm })
|
|
731
|
+
```
|
|
732
|
+
|
|
733
|
+
The whole API is `useDebounced(value, ms = 300)`. There is no options object —
|
|
734
|
+
no leading edge, no `maxWait`, no `flush()` or `cancel()`. It is a trailing-edge
|
|
735
|
+
debounce and nothing else. If you need one of those, you need a different
|
|
736
|
+
primitive, not a flag on this one.
|
|
737
|
+
|
|
738
|
+
It is generic over the value, not limited to strings: `useDebounced(filters)`
|
|
739
|
+
over an object works the same way.
|
|
740
|
+
|
|
741
|
+
## Two behaviours worth knowing before you use it
|
|
742
|
+
|
|
743
|
+
**The first value is not delayed.** The internal state is seeded with `value` on
|
|
744
|
+
the very first render, so the initial value is returned synchronously — including
|
|
745
|
+
during SSR, where no timer ever fires. Only *changes* wait for the quiet window.
|
|
746
|
+
That is what you want (no empty first paint), but it means you cannot use the
|
|
747
|
+
"debounced value hasn't caught up yet" trick to detect the initial render.
|
|
748
|
+
|
|
749
|
+
Comparing the two is how you detect *pending* input, which is exactly what
|
|
750
|
+
`useAsyncValidation` does internally to show its `checking` state:
|
|
751
|
+
|
|
752
|
+
```tsx
|
|
753
|
+
const settling = term !== debouncedTerm
|
|
754
|
+
```
|
|
755
|
+
|
|
756
|
+
**The value is compared by identity.** `value` and `ms` are the effect's
|
|
757
|
+
dependencies, so a value that is a fresh object or array on every render
|
|
758
|
+
restarts the timer on every render. Pass primitives, or memoize:
|
|
759
|
+
|
|
760
|
+
```tsx
|
|
761
|
+
const filters = useMemo(() => ({ status, assignee }), [status, assignee])
|
|
762
|
+
const debouncedFilters = useDebounced(filters, 300)
|
|
763
|
+
```
|
|
764
|
+
|
|
765
|
+
Changing `ms` restarts the quiet window too, for the same reason — so derive the
|
|
766
|
+
delay from something stable rather than recomputing it inline.
|
|
767
|
+
|
|
768
|
+
|
|
769
|
+
|
|
770
|
+
---
|
|
771
|
+
|
|
772
|
+
<!-- source: en/ui/client-utilities/use-skeletons.md -->
|
|
773
|
+
## useFormSkeleton & useTableSkeleton
|
|
774
|
+
|
|
775
|
+
_Loading placeholders shaped like the real data — field and column descriptors from the same Schema the real UI uses._
|
|
776
|
+
|
|
777
|
+
A generic spinner tells the user nothing and reserves no space, so the layout
|
|
778
|
+
jumps the moment data arrives. The usual fix is to hand-write a placeholder with
|
|
779
|
+
"about four grey bars" — which then silently drifts from the form it is standing
|
|
780
|
+
in for. These two hooks remove that guesswork: the descriptor's Schema already
|
|
781
|
+
knows exactly how many fields the form will render and which columns the table
|
|
782
|
+
will have, *before* any data is fetched. Same source as `<AutoForm>` and
|
|
783
|
+
`<DataTable>`, so the placeholder and the real UI cannot disagree.
|
|
784
|
+
|
|
785
|
+
```tsx
|
|
786
|
+
import { useFormSkeleton, useTableSkeleton } from '@voltro/client'
|
|
787
|
+
|
|
788
|
+
const fields = useFormSkeleton('app', 'todos.create') // mutation INPUT Schema
|
|
789
|
+
const columns = useTableSkeleton('app', 'todos.list') // query OUTPUT Schema
|
|
790
|
+
```
|
|
791
|
+
|
|
792
|
+
Both take `(apiName, tag)` and return a `ReadonlyArray<FieldDescriptor>`. That
|
|
793
|
+
is the whole signature — there are no options.
|
|
794
|
+
|
|
795
|
+
## Reach for the component first
|
|
796
|
+
|
|
797
|
+
Most of the time you do not need these hooks at all — `@voltro/ui` ships
|
|
798
|
+
`<FormSkeleton>` and `<TableSkeleton>`, which call them for you:
|
|
799
|
+
|
|
800
|
+
```tsx
|
|
801
|
+
import { FormSkeleton, TableSkeleton } from '@voltro/ui'
|
|
802
|
+
|
|
803
|
+
{loading ? <FormSkeleton api="app" mutation="todos.create" /> : <AutoForm api="app" mutation="todos.create" />}
|
|
804
|
+
{loading ? <TableSkeleton api="app" query="todos.list" rows={5} /> : <DataTable api="app" query="todos.list" />}
|
|
805
|
+
```
|
|
806
|
+
|
|
807
|
+
Both accept a fallback count for the moment the descriptor is not resolvable yet
|
|
808
|
+
(`fallbackFields`, default 3; `fallbackColumns`, default 4), and both mark
|
|
809
|
+
themselves `aria-busy` + `aria-hidden` so a screen reader never announces the
|
|
810
|
+
placeholder bars.
|
|
811
|
+
|
|
812
|
+
The hooks are the headless layer underneath. Reach for them when the shipped
|
|
813
|
+
markup does not fit your design system and you want to render the placeholder
|
|
814
|
+
yourself:
|
|
815
|
+
|
|
816
|
+
```tsx
|
|
817
|
+
{loading
|
|
818
|
+
? fields.map((f) => <div key={f.name} className="h-10 animate-pulse rounded bg-muted" />)
|
|
819
|
+
: <AutoForm api="app" mutation="todos.create" />}
|
|
820
|
+
```
|
|
821
|
+
|
|
822
|
+
Each `FieldDescriptor` carries `name`, `label` (the humanised property name),
|
|
823
|
+
`widget` (`'text' | 'textarea' | 'number' | 'checkbox' | 'switch' | 'select' |
|
|
824
|
+
'radio' | 'async-select' | 'multi-select' | 'date' | 'datetime' | 'daterange' |
|
|
825
|
+
…`), `required`, `nullable`, an optional `options` array for closed value sets,
|
|
826
|
+
and the resolved `jsonSchema` node. `widget` is what makes the placeholder
|
|
827
|
+
faithful — a `textarea` deserves a taller bar than a `checkbox`.
|
|
828
|
+
|
|
829
|
+
## Three things that will surprise you
|
|
830
|
+
|
|
831
|
+
**They know nothing about loading.** Neither hook subscribes to anything or
|
|
832
|
+
tracks a request; they read a Schema. The `loading` flag in the example above is
|
|
833
|
+
yours — from the `useSubscription` / `useMutation` whose data you are waiting
|
|
834
|
+
on. They are cheap and synchronous, and they work during SSR, which is why the
|
|
835
|
+
shape is correct on the very first paint.
|
|
836
|
+
|
|
837
|
+
**An unknown tag returns `[]`, silently.** A typo in the api name or the tag
|
|
838
|
+
produces an empty array, not an error — so a skeleton that renders nothing is
|
|
839
|
+
almost always a misspelled tag, not a Schema the hooks failed to read.
|
|
840
|
+
|
|
841
|
+
**Neither hook checks the descriptor's kind.** `useFormSkeleton` reads the
|
|
842
|
+
descriptor's `input`; `useTableSkeleton` reads its `output`. Pass a query tag to
|
|
843
|
+
`useFormSkeleton` and you get that query's input fields — useful for a filter
|
|
844
|
+
panel's skeleton, but it means nothing stops you from pairing the wrong hook
|
|
845
|
+
with the wrong tag. `useTableSkeleton` additionally returns `[]` unless the
|
|
846
|
+
output Schema is an array of objects.
|
|
847
|
+
|
|
848
|
+
The labels are humanised property names, not the Schema `title` annotation —
|
|
849
|
+
built-in refinements carry a type-name title (`nonEmptyString`) that would leak
|
|
850
|
+
into the UI. That matches what `<AutoForm>` renders, so the placeholder's label
|
|
851
|
+
widths line up with the real ones.
|
|
852
|
+
|
|
853
|
+
|
|
854
|
+
|
|
855
|
+
---
|
|
856
|
+
|
|
857
|
+
<!-- source: en/ui/client-utilities/use-capability-manifest.md -->
|
|
858
|
+
## useCapabilityManifest
|
|
859
|
+
|
|
860
|
+
_Read the api's capability manifest (procedures + tables + schemas) from the browser, without codegen._
|
|
861
|
+
|
|
862
|
+
What does this api actually expose? `useCapabilityManifest` answers that at
|
|
863
|
+
runtime: a **one-shot fetch** of `/_voltro/inspect/manifest` — the same inspect
|
|
864
|
+
surface [`useProvenance`](/docs/ui/client-utilities/use-provenance) rides. It is
|
|
865
|
+
a structural lookup, not a live subscription, so it does not re-fetch on data
|
|
866
|
+
changes.
|
|
867
|
+
|
|
868
|
+
```tsx
|
|
869
|
+
import { useCapabilityManifest } from '@voltro/client'
|
|
870
|
+
|
|
871
|
+
const { manifest, loading, error } = useCapabilityManifest('app')
|
|
872
|
+
```
|
|
873
|
+
|
|
874
|
+
The manifest carries:
|
|
875
|
+
|
|
876
|
+
| Field | Contents |
|
|
877
|
+
|---|---|
|
|
878
|
+
| `procedures` | Every procedure's `tag`, `kind`, `input`/`output` Schema, plus the `source` table a query reads and the `targets` (`{ table, op }`) a mutation writes. |
|
|
879
|
+
| `tables` | The user tables — `name`, `columns` (`type`, `nullable`, `refersTo`, `enum`), and whether the table is `reactive`. Framework `_voltro_*` tables are flagged `framework`. |
|
|
880
|
+
| `workflows` | The discovered workflow names. |
|
|
881
|
+
| `widgets` | The registered widget ids. |
|
|
882
|
+
| `version` | The manifest format version. |
|
|
883
|
+
|
|
884
|
+
## Deriving an admin surface
|
|
885
|
+
|
|
886
|
+
`deriveEntityAdmins(manifest)` is the pure projection the [admin
|
|
887
|
+
template](/docs/templates/admin) is built on. It joins each user table to the
|
|
888
|
+
procedures that read and write it, so a generated back-office binds to tags that
|
|
889
|
+
**actually exist** instead of guessing them from a naming convention:
|
|
890
|
+
|
|
891
|
+
```tsx
|
|
892
|
+
import { useCapabilityManifest, deriveEntityAdmins } from '@voltro/client'
|
|
893
|
+
|
|
894
|
+
const { manifest } = useCapabilityManifest('app')
|
|
895
|
+
const entities = manifest ? deriveEntityAdmins(manifest) : []
|
|
896
|
+
// each: { table, columns, reactive, listTag?, createTag?, updateTag?, deleteTag?,
|
|
897
|
+
// createScope, writeScope, deleteScope }
|
|
898
|
+
```
|
|
899
|
+
|
|
900
|
+
A tag is `undefined` when the app exposes no procedure for that operation — render
|
|
901
|
+
that affordance read-only rather than binding to a tag that does not resolve. The
|
|
902
|
+
`*Scope` strings are the conventional names the UI gates writes on via
|
|
903
|
+
[`useCan`](/docs/ui/client-utilities/use-can); map them to your app's real RBAC
|
|
904
|
+
scopes.
|
|
905
|
+
|
|
906
|
+
The inspect surface is open in dev. When a deploy sets an inspect token the
|
|
907
|
+
manifest GET is bearer-gated, so an admin UI pointed at a locked-down api has to
|
|
908
|
+
supply that token — a deployment concern, not something this hook handles.
|
|
909
|
+
|
|
910
|
+
|
|
911
|
+
|
|
912
|
+
---
|
|
913
|
+
|
|
914
|
+
<!-- source: en/ui/client-utilities/use-permissions.md -->
|
|
915
|
+
## usePermissions
|
|
916
|
+
|
|
917
|
+
_The scope set useCan reads, plus the reactive per-resource gates scopes cannot express._
|
|
918
|
+
|
|
919
|
+
[`useCan`](/docs/ui/client-utilities/use-can) is only a matcher — it needs a
|
|
920
|
+
scope set to match against. `<PermissionProvider>` supplies it and
|
|
921
|
+
`usePermissions` reads it, so the whole app gates on one reactive source instead
|
|
922
|
+
of each component fetching the session again.
|
|
923
|
+
|
|
924
|
+
## PermissionProvider / usePermissions
|
|
925
|
+
|
|
926
|
+
```tsx
|
|
927
|
+
import { PermissionProvider, usePermissions, canCall, ADMIN_SCOPE } from '@voltro/client'
|
|
928
|
+
|
|
929
|
+
<PermissionProvider scopes={session.scopes}>…</PermissionProvider>
|
|
930
|
+
|
|
931
|
+
const { scopes } = usePermissions() // ReadonlyArray<string>
|
|
932
|
+
```
|
|
933
|
+
|
|
934
|
+
`<PermissionProvider>` takes exactly `scopes` and `children`. Mount it once high
|
|
935
|
+
in the tree, fed by your session subscription — scopes are ordinary reactive
|
|
936
|
+
data, so a role change re-renders every gate.
|
|
937
|
+
|
|
938
|
+
With no provider above it, `usePermissions()` returns `{ scopes: [] }`, which
|
|
939
|
+
makes `useCan` deny. That is the deliberate default: a missing provider hides
|
|
940
|
+
affordances rather than revealing them.
|
|
941
|
+
|
|
942
|
+
`canCall(subjectScopes, required)` is the same matcher as a plain function, for
|
|
943
|
+
loaders and route guards that are not components. `required` as an array means
|
|
944
|
+
ALL of them (AND); `ADMIN_SCOPE` (`'admin:full'`) satisfies anything; an empty
|
|
945
|
+
requirement always passes.
|
|
946
|
+
|
|
947
|
+
## useCanAny / canCallAny
|
|
948
|
+
|
|
949
|
+
`useCan` with an array demands ALL of the scopes. `useCanAny` is the OR variant —
|
|
950
|
+
true when the subject holds AT LEAST ONE. Use it for "this section is visible to
|
|
951
|
+
editors OR reviewers" affordances.
|
|
952
|
+
|
|
953
|
+
```tsx
|
|
954
|
+
import { useCanAny, canCallAny } from '@voltro/client'
|
|
955
|
+
|
|
956
|
+
const canReview = useCanAny(['notes:edit', 'notes:review'])
|
|
957
|
+
```
|
|
958
|
+
|
|
959
|
+
`canCallAny(subjectScopes, required)` is the same OR check as a plain function,
|
|
960
|
+
matching `canCall`. `ADMIN_SCOPE` satisfies it, and an empty requirement passes —
|
|
961
|
+
nothing is being demanded.
|
|
962
|
+
|
|
963
|
+
`@voltro/client` is the one place these live. Scopes are a framework concept —
|
|
964
|
+
`@voltro/protocol` owns `ScopeError`, `guards: [{ scope }]` and `ctx.access` —
|
|
965
|
+
and [rbac](/docs/plugins/rbac) is only ONE way to produce them; an app can
|
|
966
|
+
register its own `setResourceScopeResolver` over its own tables instead. These
|
|
967
|
+
hooks CONSUME scopes, so they must not drag in a plugin that PRODUCES them.
|
|
968
|
+
|
|
969
|
+
## useResourceCan / useResourceCans
|
|
970
|
+
|
|
971
|
+
Scopes answer "may this subject delete todos". They cannot answer "may this
|
|
972
|
+
subject delete **todo 42**" — that depends on relation tuples only the server
|
|
973
|
+
holds. These two hooks ask the server, reactively.
|
|
974
|
+
|
|
975
|
+
```tsx
|
|
976
|
+
import { useResourceCan, useResourceCans } from '@voltro/client'
|
|
977
|
+
|
|
978
|
+
const { allowed, pending } = useResourceCan('app', 'docs.can', {
|
|
979
|
+
action: 'write', resourceType: 'doc', resourceId: id,
|
|
980
|
+
})
|
|
981
|
+
|
|
982
|
+
const { allowedIds } = useResourceCans('app', 'docs.canMany', {
|
|
983
|
+
action: 'delete', resourceType: 'doc', resourceIds,
|
|
984
|
+
})
|
|
985
|
+
<DataTable … rowActions={(r) => allowedIds.has(r.id) ? <DeleteBtn id={r.id} /> : null} />
|
|
986
|
+
```
|
|
987
|
+
|
|
988
|
+
Both take `(apiName, rpcTag, input, options?)`, where `options` is the ordinary
|
|
989
|
+
[`SubscriptionOptions`](/docs/reference/hooks-data) (`skip`, `fallback`) — they are thin
|
|
990
|
+
projections over `useSubscription`. You bind them to a server query whose source
|
|
991
|
+
is the ReBAC tuple table, returning `{ allowed }` or `{ allowedIds }`; because
|
|
992
|
+
that source is reactive, a revoke re-runs `can()` server-side and the button
|
|
993
|
+
disables with no refetch.
|
|
994
|
+
|
|
995
|
+
`useResourceCan` returns `{ allowed: boolean, pending: boolean }` —
|
|
996
|
+
`allowed` is a real boolean that starts `false` and stays `false` while
|
|
997
|
+
`pending`, never `undefined`. `useResourceCans` returns
|
|
998
|
+
`{ allowedIds: ReadonlySet<string>, pending: boolean }`; an id absent from the
|
|
999
|
+
set is denied. Both fail closed.
|
|
1000
|
+
|
|
1001
|
+
All of this hides affordances. The server still enforces every call through the
|
|
1002
|
+
same `can()` engine — see
|
|
1003
|
+
[Authorization](/docs/authentication/authorization).
|
|
1004
|
+
|
|
1005
|
+
|
|
1006
|
+
|
|
1007
|
+
---
|
|
1008
|
+
|
|
1009
|
+
<!-- source: en/ui/client-utilities/use-on-rpc-error.md -->
|
|
1010
|
+
## useOnRpcError
|
|
1011
|
+
|
|
1012
|
+
_One place to react to rpc failures that are nobody's local problem — auth loss, telemetry, toasts._
|
|
1013
|
+
|
|
1014
|
+
Some failures do not belong to the component that triggered them. An
|
|
1015
|
+
`Unauthenticated` error means the session is gone, whatever screen you happen to
|
|
1016
|
+
be on. Rather than repeat that check in every `useMutation` call site, each api's
|
|
1017
|
+
runtime carries an error bus; this hook subscribes to it with React lifecycle.
|
|
1018
|
+
|
|
1019
|
+
```tsx
|
|
1020
|
+
import { useOnRpcError, errorTag } from '@voltro/client'
|
|
1021
|
+
|
|
1022
|
+
useOnRpcError('app', useCallback((e) => {
|
|
1023
|
+
if (errorTag(e.error) === 'Unauthenticated') redirectToSignIn()
|
|
1024
|
+
}, []))
|
|
1025
|
+
```
|
|
1026
|
+
|
|
1027
|
+
The event is `{ source, tag, error, traceId? }`. `source` is
|
|
1028
|
+
`'mutation' | 'action' | 'subscription'`, so both unary calls and stream
|
|
1029
|
+
failures arrive here. `error` is the raw value — a `Schema.TaggedError`, a plain
|
|
1030
|
+
`Error`, or anything else that was thrown; `errorTag(err)` reads `_tag` off
|
|
1031
|
+
tagged errors and returns `undefined` otherwise. `traceId` is the same id the
|
|
1032
|
+
server logged, so a handler can point at `voltro logs --trace <id>`.
|
|
1033
|
+
|
|
1034
|
+
Two things to know. The hook re-subscribes whenever the listener **reference**
|
|
1035
|
+
changes, so define it with `useCallback` or at module scope unless you want that.
|
|
1036
|
+
And the bus is per api runtime — an app talking to several apis subscribes once
|
|
1037
|
+
per api name.
|
|
1038
|
+
|
|
1039
|
+
This is the read side of failures that already happened on the wire. To push a
|
|
1040
|
+
client-side error the server never saw, call `reportClientError(error, context)`
|
|
1041
|
+
instead. Note also that a subscription failure *after* data arrived reaches only
|
|
1042
|
+
this bus, never the hook's `error` field — see
|
|
1043
|
+
[Queries](/docs/data/queries).
|
|
1044
|
+
|
|
1045
|
+
|
|
1046
|
+
|
|
1047
|
+
---
|
|
1048
|
+
|
|
1049
|
+
<!-- source: en/ui/client-utilities/use-refresh-subscriptions.md -->
|
|
1050
|
+
## useRefreshSubscriptions
|
|
1051
|
+
|
|
1052
|
+
_Re-issue every live subscription after the connection's subject changes — no reload, no reconnect._
|
|
1053
|
+
|
|
1054
|
+
When a user signs in, the server rebinds the subject on the *existing*
|
|
1055
|
+
connection. Every open subscription is now snapshotting against the wrong
|
|
1056
|
+
subject's tenant scope. Reloading the page would fix it and lose everything else;
|
|
1057
|
+
this hook is the surgical version.
|
|
1058
|
+
|
|
1059
|
+
```tsx
|
|
1060
|
+
import { useRefreshSubscriptions } from '@voltro/client'
|
|
1061
|
+
|
|
1062
|
+
const refresh = useRefreshSubscriptions('app')
|
|
1063
|
+
await signIn(...)
|
|
1064
|
+
refresh()
|
|
1065
|
+
```
|
|
1066
|
+
|
|
1067
|
+
It returns a stable zero-argument function. Calling it interrupts each active
|
|
1068
|
+
subscription's fiber and re-forks it over the **same** WebSocket — no reconnect.
|
|
1069
|
+
Server-side each one is a fresh subscription, so it flows through the auth
|
|
1070
|
+
middleware again and resolves the new subject.
|
|
1071
|
+
|
|
1072
|
+
Optimistic patches survive the refresh: they are tied to mutation lifecycles, not
|
|
1073
|
+
subscription lifecycles, and the new snapshots land underneath them.
|
|
1074
|
+
|
|
1075
|
+
This is not a cache-invalidation tool. Subscriptions are already live, so a
|
|
1076
|
+
normal write needs no refresh — reach for this only when the *connection's
|
|
1077
|
+
identity* changed underneath them.
|
|
1078
|
+
|
|
1079
|
+
|
|
1080
|
+
|
|
1081
|
+
---
|
|
1082
|
+
|
|
1083
|
+
<!-- source: en/ui/client-utilities/use-tracking.md -->
|
|
1084
|
+
## useTracking
|
|
1085
|
+
|
|
1086
|
+
_A declarative event map next to a component instead of track() calls sprinkled through handlers._
|
|
1087
|
+
|
|
1088
|
+
Imperative `track()` calls rot: they live inside handlers, get forgotten on the
|
|
1089
|
+
new code path, and vanish in a refactor. `useTracking` moves the instrumentation
|
|
1090
|
+
into one declarative map beside the component, so what a component reports is
|
|
1091
|
+
readable in a single place.
|
|
1092
|
+
|
|
1093
|
+
```tsx
|
|
1094
|
+
import { defineTracking, useTracking } from '@voltro/client'
|
|
1095
|
+
|
|
1096
|
+
const spec = defineTracking('CheckoutButton', {
|
|
1097
|
+
onMount: 'checkout.viewed',
|
|
1098
|
+
onClick: (props) => ({ event: 'checkout.started', plan: props.plan }),
|
|
1099
|
+
})
|
|
1100
|
+
|
|
1101
|
+
const CheckoutButton = (props) => {
|
|
1102
|
+
const tracked = useTracking(spec, props, sink)
|
|
1103
|
+
return <button {...tracked}>Checkout</button>
|
|
1104
|
+
}
|
|
1105
|
+
```
|
|
1106
|
+
|
|
1107
|
+
`useTracking(spec, props, sink)` returns a **copy of your props** with every
|
|
1108
|
+
callback the map names wrapped, so invoking `onClick` fires its event and then
|
|
1109
|
+
calls your original handler. Spread the returned props; nothing is mutated.
|
|
1110
|
+
|
|
1111
|
+
In the map, `onMount` and `onUnmount` are lifecycle — they fire from an effect,
|
|
1112
|
+
once per mount, and are never wrapped as props. Every other key is the name of a
|
|
1113
|
+
callback prop to wrap. An entry is either a bare event name or a function of the
|
|
1114
|
+
props returning `{ event, ...payload }`.
|
|
1115
|
+
|
|
1116
|
+
The `sink` is yours to provide: `(event: TrackingEvent) => void`. The kernel is
|
|
1117
|
+
transport-agnostic — it does not know where events go, which is also what makes
|
|
1118
|
+
it testable with a recorder.
|
|
1119
|
+
|
|
1120
|
+
`resolveTrackingEvent(entry, props)` and `wrapTrackedCallbacks(spec, props, sink)`
|
|
1121
|
+
are exported as pure functions if you need the behaviour outside a component.
|
|
1122
|
+
|
|
1123
|
+
For a typed, validated event taxonomy on top of the same sink, see the
|
|
1124
|
+
[Analytics catalog](/docs/ui/client-utilities/analytics-catalog).
|
|
1125
|
+
|
|
1126
|
+
|
|
1127
|
+
|
|
1128
|
+
---
|
|
1129
|
+
|
|
1130
|
+
<!-- source: en/ui/client-utilities/use-record.md -->
|
|
1131
|
+
## useRecord
|
|
1132
|
+
|
|
1133
|
+
_One live record from a "get" query, normalized — the headless core of RecordView._
|
|
1134
|
+
|
|
1135
|
+
A detail view wants *one* record, but a "get" query may return a row array or a
|
|
1136
|
+
single object depending on how it was written. `useRecord` subscribes and
|
|
1137
|
+
normalizes that away, so the component never branches on the shape.
|
|
1138
|
+
|
|
1139
|
+
```tsx
|
|
1140
|
+
import { useRecord } from '@voltro/client'
|
|
1141
|
+
|
|
1142
|
+
const { record, loading, error } = useRecord('app', 'users.get', { id })
|
|
1143
|
+
```
|
|
1144
|
+
|
|
1145
|
+
An array result yields its **first** row; an object result is the record itself;
|
|
1146
|
+
anything else is `undefined`. `loading` is true exactly while no snapshot has
|
|
1147
|
+
arrived, and `error` is the underlying subscription's cold-start error.
|
|
1148
|
+
|
|
1149
|
+
It is a thin projection over `useSubscription` — same live semantics, so the
|
|
1150
|
+
detail view updates on any write with no refetch — with the single difference
|
|
1151
|
+
that it takes no options argument, only `(apiName, queryTag, input?)`. Eager
|
|
1152
|
+
loaded relations arrive as nested arrays on the record.
|
|
1153
|
+
|
|
1154
|
+
Reach for it when you want the data but not the markup.
|
|
1155
|
+
[`<RecordView>`](/docs/ui/client-utilities/record-view) is this hook plus a
|
|
1156
|
+
rendered definition list and sub-tables.
|
|
@@ -2604,9 +2604,9 @@ done
|
|
|
2604
2604
|
<!-- source: en/templates/api-rbac.md -->
|
|
2605
2605
|
## API · RBAC
|
|
2606
2606
|
|
|
2607
|
-
_Role-based access control with @voltro/plugin-rbac — a role→scope map, an interceptor that resolves the caller's roles to scopes, and an in-handler permission(ctx, 'notes:write') guard that fails typed
|
|
2607
|
+
_Role-based access control with @voltro/plugin-rbac — a role→scope map, an interceptor that resolves the caller's roles to scopes, and an in-handler permission(ctx, 'notes:write') guard that fails typed ScopeError. admin:full bypasses. Config-only, zero infra; useCan() for web UI._
|
|
2608
2608
|
|
|
2609
|
-
Roles → scopes → guards. `@voltro/plugin-rbac` compiles a role map and an rpc interceptor resolves each caller's **roles** to **scopes**, stamped on the subject. Guard handlers with `permission(ctx, 'notes:write')`, the Effect-native guard that fails typed `
|
|
2609
|
+
Roles → scopes → guards. `@voltro/plugin-rbac` compiles a role map and an rpc interceptor resolves each caller's **roles** to **scopes**, stamped on the subject. Guard handlers with `permission(ctx, 'notes:write')`, the Effect-native guard that fails typed `ScopeError`. The `admin:full` scope is a blanket bypass. Config-only, zero infra. Template id: **`api-rbac`**.
|
|
2610
2610
|
|
|
2611
2611
|
## Scaffold
|
|
2612
2612
|
|
|
@@ -2643,13 +2643,13 @@ rbacPlugin({
|
|
|
2643
2643
|
import { permission } from '@voltro/plugin-rbac'
|
|
2644
2644
|
|
|
2645
2645
|
export default (input, ctx) => Effect.gen(function* () {
|
|
2646
|
-
yield* permission(ctx, 'notes:write') // fails typed
|
|
2646
|
+
yield* permission(ctx, 'notes:write') // fails typed ScopeError for viewer
|
|
2647
2647
|
const store = yield* EffectStore
|
|
2648
2648
|
return yield* store.insert('notes', { title: input.title, body: input.body })
|
|
2649
2649
|
})
|
|
2650
2650
|
```
|
|
2651
2651
|
|
|
2652
|
-
Declare `error:
|
|
2652
|
+
Declare `error: ScopeError` (from `@voltro/protocol`). Companions: `anyPermission(ctx, [...])` (OR), `assertPermission` (sync throw), `can(ctx, scope)` (boolean).
|
|
2653
2653
|
|
|
2654
2654
|
## Try it
|
|
2655
2655
|
|
|
@@ -2659,21 +2659,23 @@ curl -s localhost:4000/_voltro/inspect/invoke -H 'content-type: application/json
|
|
|
2659
2659
|
-d '{"tag":"notes.create","input":{"title":"hi","body":"x"}}'
|
|
2660
2660
|
# → { ok:true, … }
|
|
2661
2661
|
|
|
2662
|
-
# readers tenant → viewer → notes:write missing → typed
|
|
2662
|
+
# readers tenant → viewer → notes:write missing → typed ScopeError:
|
|
2663
2663
|
curl -s localhost:4000/_voltro/inspect/invoke -H 'content-type: application/json' -H 'x-tenant: readers' \
|
|
2664
2664
|
-d '{"tag":"notes.create","input":{"title":"hi","body":"x"}}'
|
|
2665
|
-
# → { ok:false, error:{ _tag:"
|
|
2665
|
+
# → { ok:false, error:{ _tag:"ScopeError", required:"notes:write" } }
|
|
2666
2666
|
```
|
|
2667
2667
|
|
|
2668
2668
|
## On the web side
|
|
2669
2669
|
|
|
2670
2670
|
```tsx
|
|
2671
|
-
import { useCan } from '@voltro/
|
|
2671
|
+
import { PermissionProvider, useCan } from '@voltro/client'
|
|
2672
|
+
|
|
2673
|
+
<PermissionProvider scopes={session.scopes}>…</PermissionProvider>
|
|
2672
2674
|
const canWrite = useCan('notes:write')
|
|
2673
2675
|
{canWrite && <NewNoteButton />} // hide affordances the user can't use
|
|
2674
2676
|
```
|
|
2675
2677
|
|
|
2676
|
-
`useCan` is a UI affordance only — the server `permission()` guard is the enforcement.
|
|
2678
|
+
The UI gate ships in [`@voltro/client`](/docs/ui/client-utilities/use-can), not in the plugin: scopes are a framework concept, so gating a button must not require an rbac dependency. `useCan` is a UI affordance only — the server `permission()` guard is the enforcement.
|
|
2677
2679
|
|
|
2678
2680
|
## Persisted roles
|
|
2679
2681
|
|
|
@@ -47,7 +47,7 @@ The four `api-backend*` shapes share the same minimal `notes` domain and differ
|
|
|
47
47
|
| [`api-search`](/docs/templates/api-search) | **Full-text search in sync** — `@voltro/plugin-search` mirrors every table write into an index via the post-commit change tap; the synthesized `search.query` rpc returns tenant-scoped hits. Memory backend (zero infra); one line to Typesense/Meilisearch/Algolia. |
|
|
48
48
|
| [`api-feature-flags`](/docs/templates/api-feature-flags) | **Feature flags as code** — `@voltro/plugin-flags`: kill-switch / %-rollout / targeting, a declarative `gatedBy` map + an in-handler `requireFlag` guard (both fail typed `FlagDisabled`), `useFlag()` on the web. Memory store. |
|
|
49
49
|
| [`api-ratelimit`](/docs/templates/api-ratelimit) | **Per-endpoint rate limiting** — `@voltro/plugin-ratelimit` intercepts rpc: a default + rules (sliding-window / token-bucket, by subject/tenant/…), typed `RateLimited` auto-merged into the error union. Memory store. |
|
|
50
|
-
| [`api-rbac`](/docs/templates/api-rbac) | **Role → scope access control** — `@voltro/plugin-rbac`: a role map, an interceptor resolving roles to scopes, and an in-handler `permission(ctx, 'notes:write')` guard (typed `
|
|
50
|
+
| [`api-rbac`](/docs/templates/api-rbac) | **Role → scope access control** — `@voltro/plugin-rbac`: a role map, an interceptor resolving roles to scopes, and an in-handler `permission(ctx, 'notes:write')` guard (typed `ScopeError`). `admin:full` bypasses. Config-only. |
|
|
51
51
|
| [`api-moderation`](/docs/templates/api-moderation) | **Pre-commit content moderation** — `@voltro/plugin-moderation` checks fields before the handler: a `block` rule rejects with typed `ContentRejected`, a `flag` rule queues for review. keywordProvider / aiProvider. |
|
|
52
52
|
| [`api-versioning`](/docs/templates/api-versioning) | **Row history + time-travel** — `@voltro/plugin-versioning` snapshots every write via the change tap; `rowHistory()` (every version) + `rowAsOf()` (value at a past instant), read through handlers. Memory store. |
|
|
53
53
|
| [`api-backend-deactivation`](/docs/templates/api-backend-deactivation) | **The `deactivation()` mixin** — lock a user out (`deactivatedAt`) while the row stays VISIBLE/queryable — the deliberate opposite of `softDelete()`'s hide+anonymise. Pure schema. |
|