@voltro/cli 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/dist/{apiBuild-DdgYydVJ.js → apiBuild-CZugTK46.js} +2 -5
  3. package/dist/apiBuild-D-iBYKa3.js +2 -0
  4. package/dist/bin.js +2 -2
  5. package/dist/{commands-CXESev-z.js → commands-CJheDVOr.js} +2435 -2000
  6. package/dist/{dev-x_VqbV_8.js → dev-B71ICpXJ.js} +1993 -1823
  7. package/dist/dev-DNUetyG2.js +2 -0
  8. package/dist/index.js +1 -1
  9. package/dist/serveCommand-C0WFA396.js +1084 -0
  10. package/dist/serveEntry.js +2 -2
  11. package/package.json +17 -17
  12. package/templates/AGENTS.core.md +131 -0
  13. package/templates/AGENTS.md +131 -0
  14. package/templates/agent-docs/_manifest.json +4 -4
  15. package/templates/agent-docs/ai.md +34 -2
  16. package/templates/agent-docs/authentication.md +74 -2
  17. package/templates/agent-docs/cli.md +115 -0
  18. package/templates/agent-docs/data.md +302 -7
  19. package/templates/agent-docs/database/advancedqueries.md +25 -2
  20. package/templates/agent-docs/database/querying.md +17 -5
  21. package/templates/agent-docs/database/schema.md +5 -2
  22. package/templates/agent-docs/internationalization.md +237 -2
  23. package/templates/agent-docs/observability.md +9 -0
  24. package/templates/agent-docs/plugins.md +2 -2
  25. package/templates/agent-docs/reference.md +110 -0
  26. package/templates/agent-docs/schema-driven-ui.md +551 -2
  27. package/templates/agent-docs/templates/apibackends.md +10 -8
  28. package/templates/agent-docs/templates/overview.md +1 -1
  29. package/templates/agent-docs/testing.md +177 -3
  30. package/templates/agent-docs/workflows.md +11 -8
  31. package/templates/apps/api-ai/package.json +7 -7
  32. package/templates/apps/api-auth/package.json +8 -8
  33. package/templates/apps/api-backend/package.json +7 -7
  34. package/templates/apps/api-backend-deactivation/package.json +7 -7
  35. package/templates/apps/api-backend-mail/package.json +8 -8
  36. package/templates/apps/api-backend-mariadb/package.json +9 -9
  37. package/templates/apps/api-backend-storage/package.json +8 -8
  38. package/templates/apps/api-data-advanced/package.json +8 -8
  39. package/templates/apps/api-durable/package.json +8 -8
  40. package/templates/apps/api-feature-flags/package.json +9 -9
  41. package/templates/apps/api-governance/package.json +8 -8
  42. package/templates/apps/api-kv/package.json +8 -8
  43. package/templates/apps/api-moderation/package.json +8 -8
  44. package/templates/apps/api-observability/package.json +8 -8
  45. package/templates/apps/api-ratelimit/package.json +8 -8
  46. package/templates/apps/api-rbac/README.md +9 -4
  47. package/templates/apps/api-rbac/mutations/notes.create.mutation.server.ts +1 -1
  48. package/templates/apps/api-rbac/mutations/notes.create.mutation.ts +6 -6
  49. package/templates/apps/api-rbac/package.json +8 -8
  50. package/templates/apps/api-rbac/tests/notes.create.test.ts +8 -8
  51. package/templates/apps/api-rest/package.json +7 -7
  52. package/templates/apps/api-saas/package.json +11 -11
  53. package/templates/apps/api-search/package.json +8 -8
  54. package/templates/apps/api-versioning/package.json +8 -8
  55. package/templates/apps/api-webhooks/package.json +8 -8
  56. package/templates/apps/changelog/package.json +6 -6
  57. package/templates/apps/edge-functions/package.json +2 -2
  58. package/templates/apps/frontend-admin/package.json +8 -8
  59. package/templates/apps/frontend-app/package.json +8 -8
  60. package/templates/apps/frontend-blank/package.json +7 -7
  61. package/templates/apps/frontend-contact/package.json +7 -7
  62. package/templates/apps/frontend-dashboard/package.json +7 -7
  63. package/templates/apps/frontend-docs/package.json +7 -7
  64. package/templates/apps/frontend-i18n/package.json +6 -6
  65. package/templates/apps/frontend-landing/package.json +7 -7
  66. package/templates/apps/frontend-spa/package.json +7 -7
  67. package/templates/apps/frontend-ssr/package.json +7 -7
  68. package/templates/apps/frontend-ssr-api/package.json +8 -8
  69. package/templates/apps/frontend-static-blog/package.json +6 -6
  70. package/dist/apiBuild-cadmH8ca.js +0 -2
  71. package/dist/dev-DemiMSSl.js +0 -2
  72. package/dist/serveCommand-Dttqe5Ms.js +0 -1077
@@ -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/web'
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('app', 'users.get', { id })` is the headless core.
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 Forbidden. admin:full bypasses. Config-only, zero infra; useCan() for web UI._
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 `Forbidden`. The `admin:full` scope is a blanket bypass. Config-only, zero infra. Template id: **`api-rbac`**.
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 Forbidden for viewer
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: Forbidden` (from the browser-safe `@voltro/plugin-rbac/errors`). Companions: `anyPermission(ctx, [...])` (OR), `assertPermission` (sync throw), `can(ctx, scope)` (boolean).
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 Forbidden:
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:"Forbidden", required:"notes:write" } }
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/plugin-rbac/web'
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 `Forbidden`). `admin:full` bypasses. Config-only. |
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. |