@shivaedev/react 0.22.1 → 0.23.1

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 CHANGED
@@ -1,5 +1,41 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.23.1 - 2026-10-30
4
+
5
+ ### Changed
6
+
7
+ - Release with updated workspace dependencies.
8
+
9
+ ## 0.23.0 - 2026-10-30
10
+
11
+ ### Added
12
+
13
+ - Expose the native submission `cause` on `useEditor`, `useCreate` and `SaveState` so applications can render failures that include defects.
14
+ - Export modules under `internal/` in `contract` and `react` and `server/internal/` in `jobs` through the existing `.ts` wildcard, so consumers can import those modules directly by their file paths.
15
+ - Access exports the `Permission` Schema and derives its type from it. Role resolution and identity session decoding use that Schema.
16
+ - Access exports `forbidden(permission)` and `unauthorized()` from `refusal.ts` for consistent permission refusals.
17
+
18
+ ### Changed
19
+
20
+ - Generated applications import React test helpers from their existing `@shivaedev/test-story` development dependency.
21
+
22
+ ### Fixed
23
+
24
+ - Work Board reads draft timestamps and expiry through its form client’s runtime Clock.
25
+ - Ignore repeated sign-out dispatch while sign-out is pending.
26
+ - Generated apps send an explicitly signed-out visitor to sign-in when the replacement session receives `Unauthorized` from the notes route.
27
+
28
+ ### Breaking
29
+
30
+ - Persisted drafts require a `now: () => number` callback in core forms, `usePersistedDraft`, `useEditor` and `useCreate`. Capture `Clock.Clock` in the application’s Effect setup and pass `now: () => clock.currentTimeMillisUnsafe()` so timestamps and expiry follow that clock, including TestClock in stories.
31
+ - Form no longer exports the identity cast helpers `holder`, `fieldOf`, `choicesOf` or `checkOf` from `shape.ts`, or `keySelector` from `paths.ts`. Use declared reference, field, check and selector types directly; get field refs and choices through the form’s typed `field` and `choices` methods.
32
+ - `useSignOut` stops follow-up navigation on component unmount or registry disposal. Keep the component mounted until the request completes, or let the replacement session's route policy navigate to sign-in when a session refresh unmounts it first.
33
+ - Move the React test helpers to `@shivaedev/test-story/client/react.ts`. Replace imports from `@shivaedev/react/client/testing.ts` with this path for `render`, `eventually`, `captureReactWarnings`, `expectNoReactWarnings` and their types. Add `@shivaedev/test-story` as a development dependency and install its React helpers' optional peers `react`, `react-dom` and `@effect/atom-react` when using them. React no longer brings `@shivaedev/test` into applications as a runtime dependency.
34
+ - Access, identity and React now share access's grant evaluator: an `any` grant covers an `own` check for the same permission name. An `own` grant still cannot cover an `any` check. Applications that need separate capabilities must declare separate permission names; record ownership checks still belong in `canAccess` or `requireAccess`.
35
+ - Replace `Access.requirePermission(user, permission)` with `requirePermission(yield* Access.resolve(user.role), permission)` from `@shivaedev/access/requirePermission.ts`. The new function takes a signed-in user; reject an absent session with `unauthorized()` from `@shivaedev/access/refusal.ts` before resolving its role. `requireAccess` continues to handle signed-out users itself.
36
+ - Replace identity's `requirePermission(permission)` with access's `requirePermission(yield* SessionUser, permission)`. There is no identity re-export. Admin's built-in handlers use this shared enforcement.
37
+ - Access's `requireAccess` now uses the shared permission refusal message, `Permission required: <name> (<strength>)`, instead of `Access denied: <name>`. Consumers should branch on the `Forbidden` tag.
38
+
3
39
  ## 0.22.1 - 2026-10-30
4
40
 
5
41
  ### Changed
package/README.md CHANGED
@@ -30,7 +30,7 @@ An **editor** joins a query atom, a form field schema and a save Effect. The que
30
30
 
31
31
  A **session generation** is the string returned by `identify(session)`. `SessionBoundary` owns its client, registry and React subtree. Your auth owner supplies the session and decides when to replace or end it. The boundary automatically mounts live connections on bound contract APIs returned by `connect`, directly or as client properties such as `api`. Use a Platform database host and the default shared WebSocket client; the application needs no subscribe handler or `live()` call. Live changed hints coalesce over 20 ms by default. Set `liveCoalesceWindow` on `SessionBoundary` to a native Duration to choose the window for that session, or `0` for immediate per-hint refreshes. A bound query can override that default with `useQuery(atom, { coalesceWindow: "2 seconds" })`. Changing this option applies to subsequent windows without reconnecting or refetching any query; a window already started keeps its deadline. A refetch in flight runs once more after settling when further hints arrive. Cache policy, RPC declarations and transport stay with native Effect and the application; this package supplies their React state and lifecycle boundary.
32
32
 
33
- `useEditor` and `useCreate` opt into persisted input with `persistedDraft: { key }`. Choose a stable key for the record or create request, independent of its server revision. The adapters use form's browser storage and inherit ownership from `SessionBoundary`: reload and query refresh preserve edits, successful submission clears accepted input, and a committed session replacement drops tracked drafts. A suspended replacement that is canceled keeps the visible session's draft and future writes. Later edits made during a save remain persisted. Optional `storage`, `maxBytes` and `maxAgeMs` customize the provider and bounds; defaults are `localStorage`, 256 KiB of UTF-8 JSON and seven days. See [form's persisted input API](https://github.com/ShivaeDev/platform/tree/main/packages/form#persist-input-across-reloads) for direct form hooks and limits.
33
+ `useEditor` and `useCreate` opt into persisted input with `persistedDraft: { key, now }`. Choose a stable key for the record or create request, independent of its server revision. Supply `now: () => clock.currentTimeMillisUnsafe()` using `Clock.Clock` captured in the application’s Effect setup; draft timestamps and expiry use that clock. The adapters use form's browser storage and inherit ownership from `SessionBoundary`: reload and query refresh preserve edits, successful submission clears accepted input, and a committed session replacement drops tracked drafts. A suspended replacement that is canceled keeps the visible session's draft and future writes. Later edits made during a save remain persisted. Optional `storage`, `maxBytes` and `maxAgeMs` customize the provider and bounds; defaults are `localStorage`, 256 KiB of UTF-8 JSON and seven days. See [form's persisted input API](https://github.com/ShivaeDev/platform/tree/main/packages/form#persist-input-across-reloads) for direct form hooks and limits.
34
34
 
35
35
  ### 1. Once per application
36
36
 
@@ -313,7 +313,7 @@ export function InvoiceLineEditor({ id }: { readonly id: number }) {
313
313
  }}
314
314
  >
315
315
  <Inputs form={editor.form} />
316
- {Option.isSome(editor.failure) && <p role="alert">Could not save</p>}
316
+ {Option.isSome(editor.cause) && <p role="alert">Could not save</p>}
317
317
  <button type="submit" disabled={editor.saving}>
318
318
  Save
319
319
  </button>
@@ -346,7 +346,7 @@ export function InvoiceLineCreate() {
346
346
  }}
347
347
  >
348
348
  <Inputs form={create.form} />
349
- {Option.isSome(create.failure) && <p role="alert">Could not create</p>}
349
+ {Option.isSome(create.cause) && <p role="alert">Could not create</p>}
350
350
  <button type="submit" disabled={create.saving}>
351
351
  Create
352
352
  </button>
@@ -423,7 +423,7 @@ Use `useEditor` for a continuing editor that subscribes to a record, merges refr
423
423
 
424
424
  #### Field rejections and other failures
425
425
 
426
- A tagged error with string `_tag` and `message`, and a `field` naming a form field, becomes that field's message by default. Other save failures appear in `failure`. Use `rejectField` when the server uses a different shape or field name:
426
+ A tagged error with string `_tag` and `message`, and a `field` naming a form field, becomes that field's message by default. Other typed save failures appear in `failure`. The native `cause` also exposes defects, interruptions and form validation failures; use it for a general failure alert so a save that dies is visible. Use `rejectField` when the server uses a different shape or field name:
427
427
 
428
428
  ```ts
429
429
  const TitleFields = Schema.Struct({ title: Schema.String });
@@ -654,7 +654,7 @@ For a loader that coordinates more work, use `loadQuery(registry, atom, loaderCo
654
654
 
655
655
  #### Session permissions and guarded routes
656
656
 
657
- Permissions come from the session payload's `user.permissions`, populated by `@shivaedev/access` through the server's role policy. `can(session, permission)` reads exact name and strength grants without making a request. `useCan()` supplies the same check from the nearest `PermissionBoundary` for component controls. A signed-out session has no grants. These checks control visibility; the server still authorizes every request.
657
+ Permissions come from the session payload's `user.permissions`, populated by `@shivaedev/access` through the server's role policy. `can(session, permission)` uses access's grant evaluator without making a request: names match exactly, and an `any` grant covers an `own` check for that name. An `own` grant does not cover an `any` check. `useCan()` supplies the same check from the nearest `PermissionBoundary` for component controls. A signed-out session has no grants. These checks control visibility; the server still authorizes every request.
658
658
 
659
659
  Declare a route's requirement in TanStack's native `staticData.permission`. Install `permissionGuard` once as the root route's `beforeLoad`; it checks the matched routes before their loaders run. Signed-out visitors go to the app's `signIn` path with the full requested location in the `returnTo` search field. A signed-in session without a required grant throws the shared `Forbidden` failure.
660
660
 
@@ -770,7 +770,7 @@ function Account() {
770
770
 
771
771
  The example uses the application's Better Auth client. Reads preserve returned auth errors and use the same `AuthActionUnavailable` failure as actions for rejected promises. Releasing a pending read interrupts its Effect and aborts the supplied signal.
772
772
 
773
- For an explicit sign-out in a TanStack route, use `useSignOut(signOut, { signIn: "/sign-in" })` from `@shivaedev/react/client/useSignOut.ts` in place of `useAction`. It exposes the same state and a `dispatch()` handler. The hook records sign-out intent before the request and navigates to sign-in after success. `permissionGuard` and `signInSearch(location)` omit `returnTo` for that intent, including when the auth owner's session refresh reruns guards first. Use `signInSearch` for module sign-in links too; ordinary visits retain the full requested location. Applications do not need history markers or Better Auth completion timing hooks.
773
+ For an explicit sign-out in a TanStack route, use `useSignOut(signOut, { signIn: "/sign-in" })` from `@shivaedev/react/client/useSignOut.ts` in place of `useAction`. It exposes the same state and a `dispatch()` handler. The hook records sign-out intent before the request and navigates to sign-in after success. Repeated clicks while pending share one request and follow-up. Unmounting the component or disposing its registry stops the follow-up navigation. If a session refresh replaces the component before the response, the replacement session’s route policy owns navigation to sign-in. `permissionGuard` and `signInSearch(location)` omit `returnTo` for that intent, including when the auth owner's session refresh reruns guards first. Use `signInSearch` for module sign-in links too; ordinary visits retain the full requested location. Applications do not need history markers or Better Auth completion timing hooks.
774
774
 
775
775
  ### 3. Every day
776
776
 
@@ -826,11 +826,11 @@ Without a runtime it uses a native empty atom runtime. Stories supply a runtime
826
826
 
827
827
  #### Render tests in happy-dom
828
828
 
829
- Use `@shivaedev/react/client/testing.ts` with Vitest's `happy-dom` environment. Install contract and changes for the example's query, command and key declarations, and the testing peers and environment in your app's dev dependencies:
829
+ Use `@shivaedev/test-story/client/react.ts` with Vitest's `happy-dom` environment. Install contract and changes for the example's query, command and key declarations, and the testing peers and environment in your app's dev dependencies:
830
830
 
831
831
  ```sh
832
832
  pnpm add @shivaedev/changes @shivaedev/contract
833
- pnpm add -D @shivaedev/test @effect/vitest vitest happy-dom react-dom @types/react-dom
833
+ pnpm add -D @shivaedev/test-story @effect/platform-node @effect/vitest vitest happy-dom react-dom @types/react-dom
834
834
  ```
835
835
 
836
836
  Configure Vitest with `test: { environment: "happy-dom" }`, or select that environment for your DOM test project. This complete `notes.dom.test.tsx` renders a component that runs a declared query and command against native RPC test handlers:
@@ -846,7 +846,7 @@ import { contract } from "@shivaedev/contract/contract.ts";
846
846
  import { collection } from "@shivaedev/changes/keys.ts";
847
847
  import { command, query } from "@shivaedev/contract/operation.ts";
848
848
  import { useAction, useQuery } from "@shivaedev/react/client/resultState.ts";
849
- import { eventually, expectNoReactWarnings, render } from "@shivaedev/react/client/testing.ts";
849
+ import { eventually, expectNoReactWarnings, render } from "@shivaedev/test-story/client/react.ts";
850
850
  import { it } from "@shivaedev/test/it.ts";
851
851
 
852
852
  Object.assign(globalThis, { "IS_REACT_ACT_ENVIRONMENT": true });
@@ -893,22 +893,21 @@ function NotesView() {
893
893
  }
894
894
 
895
895
  it.live("renders a query and refetches it after a command through a test RPC layer", function* () {
896
- const view = yield* Effect.promise(() => render(createElement(NotesView)));
897
- try {
898
- yield* eventually(() => expect(view.container.querySelector("button")?.disabled).toBe(false));
899
- expect(view.container.querySelector("output")?.textContent).toBe("");
900
- yield* Effect.promise(() => act(async () => view.container.querySelector("button")?.click()));
901
- yield* eventually(() => {
902
- expect(view.container.querySelector("output")?.textContent).toBe("Morning walk");
903
- expect(view.container.querySelector("button")?.disabled).toBe(false);
904
- });
905
- } finally {
906
- yield* Effect.promise(view.unmount);
907
- }
896
+ const view = yield* Effect.acquireRelease(
897
+ Effect.promise(() => render(createElement(NotesView))),
898
+ (view) => Effect.promise(view.unmount),
899
+ );
900
+ yield* eventually(() => expect(view.container.querySelector("button")?.disabled).toBe(false));
901
+ expect(view.container.querySelector("output")?.textContent).toBe("");
902
+ yield* Effect.promise(() => act(async () => view.container.querySelector("button")?.click()));
903
+ yield* eventually(() => {
904
+ expect(view.container.querySelector("output")?.textContent).toBe("Morning walk");
905
+ expect(view.container.querySelector("button")?.disabled).toBe(false);
906
+ });
908
907
  });
909
908
  ```
910
909
 
911
- `render(node, options?)` returns the attached `container`, its fresh native `registry`, `render(nextNode)` to update the same root and registry, and `unmount()` to release the root, registry and container. The options are React's own `RootOptions`, passed to `createRoot`, including `onCaughtError`, `onUncaughtError` and `onRecoverableError`. Always unmount in `finally`. Keep component interactions inside React's `act`. `eventually(assert)` returns an Effect: yield it directly in a story or Effect test, keeping the expectation in the spec. It retries for up to three seconds in live time, allowing pending responses to settle inside `act` between attempts without advancing the story’s TestClock. A timeout fails with an Error whose message includes the elapsed milliseconds and last assertion message, and whose `cause` is that assertion error. In an async test, use `await Effect.runPromise(eventually(assert))`. `expectNoReactWarnings()` registers a Vitest hook that fails a test if it logged to `console.error`.
910
+ `render(node, options?)` returns the attached `container`, its fresh native `registry`, `render(nextNode)` to update the same root and registry, and `unmount()` to release the root, registry and container. The options are React's own `RootOptions`, passed to `createRoot`, including `onCaughtError`, `onUncaughtError` and `onRecoverableError`. In Effect tests, acquire the view with `Effect.acquireRelease` so its scope releases it on success, failure or interruption. In async tests, unmount in `finally`. Keep component interactions inside React's `act`. `eventually(assert)` returns an Effect: yield it directly in a story or Effect test, keeping the expectation in the spec. It retries for up to three seconds in live time, allowing pending responses to settle inside `act` between attempts without advancing the story’s TestClock. A timeout fails with an Error whose message includes the elapsed milliseconds and last assertion message, and whose `cause` is that assertion error. In an async test, use `await Effect.runPromise(eventually(assert))`. `expectNoReactWarnings()` registers a Vitest hook that fails a test if it logged to `console.error`.
912
911
 
913
912
  The RPC layer stays application-owned: replace these in-memory handlers with the service Layer your component needs.
914
913
 
@@ -928,7 +927,7 @@ screenStories.it("shows the route failure", [], async () => {
928
927
  });
929
928
  ```
930
929
 
931
- Story kits can acquire `captureReactWarnings` from `client/testing.ts` in their `create` effect with `const warnings = yield* captureReactWarnings`. The scope owns the capture and restores `console.error` when it closes, including after a failed story. Pass `warnings.onCaughtError` to `render` to capture boundary errors, or `warnings.expectCaughtError(predicate)` to exclude a declared error. Both handlers silence caught errors; the capture also silences and records `console.error` calls. `warnings.read()` returns `{ consoleErrors, caughtErrors }`, with each console call's arguments and the unexpected caught errors. It asserts nothing, leaving the story spec to state its expectation. The Vitest guard uses the same capture mechanism and additionally checks that every declared error occurred.
930
+ Story kits can acquire `captureReactWarnings` from `@shivaedev/test-story/client/react.ts` in their `create` effect with `const warnings = yield* captureReactWarnings`. The scope owns the capture and restores `console.error` when it closes, including after a failed story. Pass `warnings.onCaughtError` to `render` to capture boundary errors, or `warnings.expectCaughtError(predicate)` to exclude a declared error. Both handlers silence caught errors; the capture also silences and records `console.error` calls. `warnings.read()` returns `{ consoleErrors, caughtErrors }`, with each console call's arguments and the unexpected caught errors. It asserts nothing, leaving the story spec to state its expectation. The Vitest guard uses the same capture mechanism and additionally checks that every declared error occurred.
932
931
 
933
932
  ### API
934
933
 
@@ -1,6 +1,7 @@
1
- import { Option } from "effect";
2
- import { FieldFailure, type Fields, type Form } from "@shivaedev/form/shape.ts";
1
+ import { type Cause, Option } from "effect";
2
+ import { FieldFailure, type Fields, type Form, Invalid } from "@shivaedev/form/shape.ts";
3
3
  export interface SaveState<E> {
4
+ readonly cause: Option.Option<Cause.Cause<E | FieldFailure | Invalid>>;
4
5
  readonly dirty: boolean;
5
6
  readonly failure: Option.Option<E>;
6
7
  readonly revert: () => void;
@@ -1 +1 @@
1
- {"version":3,"file":"saveState.d.ts","sourceRoot":"","sources":["../../src/client/saveState.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAIhC,OAAO,EAAE,YAAY,EAAE,KAAK,MAAM,EAAE,KAAK,IAAI,EAAW,MAAM,0BAA0B,CAAC;AAMzF,MAAM,WAAW,SAAS,CAAC,CAAC;IAC3B,QAAQ,CAAC,KAAK,EAAE,OAAO,CAAC;IACxB,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC;IACnC,QAAQ,CAAC,MAAM,EAAE,MAAM,IAAI,CAAC;IAC5B,QAAQ,CAAC,IAAI,EAAE,MAAM,IAAI,CAAC;IAC1B,QAAQ,CAAC,MAAM,EAAE,OAAO,CAAC;CACzB;AACD,wBAAgB,YAAY,CAAC,OAAO,SAAS,MAAM,EAAE,CAAC,EAAE,CAAC,EAAE,aAAa,EACvE,IAAI,EAAE,IAAI,CAAC,OAAO,EAAE,CAAC,EAAE,CAAC,GAAG,YAAY,EAAE,aAAa,CAAC,GAAG,SAAS,EACnE,OAAO,EAAE,CAAC,MAAM,EAAE,CAAC,KAAK,IAAI,GAC1B,SAAS,CAAC,CAAC,GAAG,aAAa,CAAC,CAgC9B"}
1
+ {"version":3,"file":"saveState.d.ts","sourceRoot":"","sources":["../../src/client/saveState.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,KAAK,KAAK,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAI5C,OAAO,EAAE,YAAY,EAAE,KAAK,MAAM,EAAE,KAAK,IAAI,EAAE,OAAO,EAAE,MAAM,0BAA0B,CAAC;AAMzF,MAAM,WAAW,SAAS,CAAC,CAAC;IAC3B,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC,MAAM,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC,GAAG,YAAY,GAAG,OAAO,CAAC,CAAC,CAAC;IACvE,QAAQ,CAAC,KAAK,EAAE,OAAO,CAAC;IACxB,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC;IACnC,QAAQ,CAAC,MAAM,EAAE,MAAM,IAAI,CAAC;IAC5B,QAAQ,CAAC,IAAI,EAAE,MAAM,IAAI,CAAC;IAC1B,QAAQ,CAAC,MAAM,EAAE,OAAO,CAAC;CACzB;AACD,wBAAgB,YAAY,CAAC,OAAO,SAAS,MAAM,EAAE,CAAC,EAAE,CAAC,EAAE,aAAa,EACvE,IAAI,EAAE,IAAI,CAAC,OAAO,EAAE,CAAC,EAAE,CAAC,GAAG,YAAY,EAAE,aAAa,CAAC,GAAG,SAAS,EACnE,OAAO,EAAE,CAAC,MAAM,EAAE,CAAC,KAAK,IAAI,GAC1B,SAAS,CAAC,CAAC,GAAG,aAAa,CAAC,CAgC9B"}
@@ -32,6 +32,6 @@ export function useSaveState(form, settled) {
32
32
  }, [form, registry]);
33
33
  const revert = useCallback(() => form?.revert(), [form]);
34
34
  const failure = Option.filter(AsyncResult.error(result), (error) => !(error instanceof FieldFailure || error instanceof Invalid));
35
- return { dirty, failure, revert, save, saving: result.waiting };
35
+ return { cause: AsyncResult.cause(result), dirty, failure, revert, save, saving: result.waiting };
36
36
  }
37
37
  //# sourceMappingURL=saveState.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"saveState.js","sourceRoot":"","sources":["../../src/client/saveState.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,eAAe,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AACnE,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAChC,OAAO,KAAK,WAAW,MAAM,+BAA+B,CAAC;AAC7D,OAAO,KAAK,IAAI,MAAM,wBAAwB,CAAC;AAC/C,OAAO,EAAE,WAAW,EAAE,UAAU,EAAE,SAAS,EAAE,MAAM,OAAO,CAAC;AAC3D,OAAO,EAAE,YAAY,EAA0B,OAAO,EAAE,MAAM,0BAA0B,CAAC;AACzF,OAAO,EAAE,cAAc,EAAE,MAAM,kBAAkB,CAAC;AAClD,OAAO,EAAE,iBAAiB,EAAE,MAAM,sBAAsB,CAAC;AAEzD,MAAM,IAAI,GAAG,IAAI,CAAC,IAAI,CAAC,WAAW,CAAC,OAAO,EAAE,CAAC,CAAC;AAC9C,MAAM,KAAK,GAAG,IAAI,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;AAQ/B,MAAM,UAAU,YAAY,CAC3B,IAAmE,EACnE,OAA4B;IAE5B,MAAM,QAAQ,GAAG,UAAU,CAAC,eAAe,CAAC,CAAC;IAC7C,MAAM,OAAO,GAAG,iBAAiB,EAAE,CAAC;IACpC,MAAM,MAAM,GAA2E,YAAY,CAAC,IAAI,EAAE,MAAM,IAAI,IAAI,CAAC,CAAC;IAC1H,MAAM,KAAK,GAAG,YAAY,CAAC,IAAI,EAAE,KAAK,IAAI,KAAK,CAAC,CAAC;IACjD,SAAS,CAAC,GAAG,EAAE;QACd,IAAI,WAAW,CAAC,SAAS,CAAC,MAAM,CAAC,IAAI,cAAc,CAAC,MAAM,CAAC,KAAK,CAAC,EAAE,CAAC;YACnE,OAAO,EAAE,CAAC;QACX,CAAC;IACF,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,CAAC,CAAC,CAAC;IACtB,SAAS,CACR,GAAG,EAAE,CACJ,IAAI;WACD,QAAQ,CAAC,SAAS,CAAC,IAAI,CAAC,MAAM,EAAE,CAAC,IAAI,EAAE,EAAE;YAC3C,IAAI,WAAW,CAAC,SAAS,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,OAAO,EAAE,CAAC;gBAClD,OAAO,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;YACrB,CAAC;QACF,CAAC,CAAC,EACH,CAAC,IAAI,EAAE,QAAQ,EAAE,OAAO,CAAC,CACzB,CAAC;IACF,MAAM,IAAI,GAAG,WAAW,CAAC,GAAG,EAAE;QAC7B,IAAI,IAAI,KAAK,SAAS,IAAI,QAAQ,CAAC,GAAG,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC,OAAO,EAAE,CAAC;YAC7D,OAAO;QACR,CAAC;QACD,QAAQ,CAAC,GAAG,CAAC,IAAI,CAAC,MAAM,EAAE,SAAS,CAAC,CAAC;IACtC,CAAC,EAAE,CAAC,IAAI,EAAE,QAAQ,CAAC,CAAC,CAAC;IACrB,MAAM,MAAM,GAAG,WAAW,CAAC,GAAG,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,EAAE,CAAC,IAAI,CAAC,CAAC,CAAC;IACzD,MAAM,OAAO,GAAG,MAAM,CAAC,MAAM,CAC5B,WAAW,CAAC,KAAK,CAAC,MAAM,CAAC,EACzB,CAAC,KAAK,EAA8B,EAAE,CAAC,CAAC,CAAC,KAAK,YAAY,YAAY,IAAI,KAAK,YAAY,OAAO,CAAC,CACnG,CAAC;IACF,OAAO,EAAE,KAAK,EAAE,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,MAAM,CAAC,OAAO,EAAE,CAAC;AACjE,CAAC"}
1
+ {"version":3,"file":"saveState.js","sourceRoot":"","sources":["../../src/client/saveState.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,eAAe,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AACnE,OAAO,EAAc,MAAM,EAAE,MAAM,QAAQ,CAAC;AAC5C,OAAO,KAAK,WAAW,MAAM,+BAA+B,CAAC;AAC7D,OAAO,KAAK,IAAI,MAAM,wBAAwB,CAAC;AAC/C,OAAO,EAAE,WAAW,EAAE,UAAU,EAAE,SAAS,EAAE,MAAM,OAAO,CAAC;AAC3D,OAAO,EAAE,YAAY,EAA0B,OAAO,EAAE,MAAM,0BAA0B,CAAC;AACzF,OAAO,EAAE,cAAc,EAAE,MAAM,kBAAkB,CAAC;AAClD,OAAO,EAAE,iBAAiB,EAAE,MAAM,sBAAsB,CAAC;AAEzD,MAAM,IAAI,GAAG,IAAI,CAAC,IAAI,CAAC,WAAW,CAAC,OAAO,EAAE,CAAC,CAAC;AAC9C,MAAM,KAAK,GAAG,IAAI,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;AAS/B,MAAM,UAAU,YAAY,CAC3B,IAAmE,EACnE,OAA4B;IAE5B,MAAM,QAAQ,GAAG,UAAU,CAAC,eAAe,CAAC,CAAC;IAC7C,MAAM,OAAO,GAAG,iBAAiB,EAAE,CAAC;IACpC,MAAM,MAAM,GAA2E,YAAY,CAAC,IAAI,EAAE,MAAM,IAAI,IAAI,CAAC,CAAC;IAC1H,MAAM,KAAK,GAAG,YAAY,CAAC,IAAI,EAAE,KAAK,IAAI,KAAK,CAAC,CAAC;IACjD,SAAS,CAAC,GAAG,EAAE;QACd,IAAI,WAAW,CAAC,SAAS,CAAC,MAAM,CAAC,IAAI,cAAc,CAAC,MAAM,CAAC,KAAK,CAAC,EAAE,CAAC;YACnE,OAAO,EAAE,CAAC;QACX,CAAC;IACF,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,CAAC,CAAC,CAAC;IACtB,SAAS,CACR,GAAG,EAAE,CACJ,IAAI;WACD,QAAQ,CAAC,SAAS,CAAC,IAAI,CAAC,MAAM,EAAE,CAAC,IAAI,EAAE,EAAE;YAC3C,IAAI,WAAW,CAAC,SAAS,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,OAAO,EAAE,CAAC;gBAClD,OAAO,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;YACrB,CAAC;QACF,CAAC,CAAC,EACH,CAAC,IAAI,EAAE,QAAQ,EAAE,OAAO,CAAC,CACzB,CAAC;IACF,MAAM,IAAI,GAAG,WAAW,CAAC,GAAG,EAAE;QAC7B,IAAI,IAAI,KAAK,SAAS,IAAI,QAAQ,CAAC,GAAG,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC,OAAO,EAAE,CAAC;YAC7D,OAAO;QACR,CAAC;QACD,QAAQ,CAAC,GAAG,CAAC,IAAI,CAAC,MAAM,EAAE,SAAS,CAAC,CAAC;IACtC,CAAC,EAAE,CAAC,IAAI,EAAE,QAAQ,CAAC,CAAC,CAAC;IACrB,MAAM,MAAM,GAAG,WAAW,CAAC,GAAG,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,EAAE,CAAC,IAAI,CAAC,CAAC,CAAC;IACzD,MAAM,OAAO,GAAG,MAAM,CAAC,MAAM,CAC5B,WAAW,CAAC,KAAK,CAAC,MAAM,CAAC,EACzB,CAAC,KAAK,EAA8B,EAAE,CAAC,CAAC,CAAC,KAAK,YAAY,YAAY,IAAI,KAAK,YAAY,OAAO,CAAC,CACnG,CAAC;IACF,OAAO,EAAE,KAAK,EAAE,WAAW,CAAC,KAAK,CAAC,MAAM,CAAC,EAAE,KAAK,EAAE,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,MAAM,CAAC,OAAO,EAAE,CAAC;AACnG,CAAC"}
@@ -1 +1 @@
1
- {"version":3,"file":"useSignOut.d.ts","sourceRoot":"","sources":["../../src/client/useSignOut.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,IAAI,MAAM,wBAAwB,CAAC;AAM/C,OAAO,QAAQ,wBAAwB,CAAC;IACvC,UAAU,YAAY;QACrB,QAAQ,CAAC,iBAAiB,CAAC,EAAE,OAAO,CAAC;KACrC;CACD;AAED,wBAAgB,UAAU,CAAC,CAAC,EAAE,CAAC,EAAE,MAAM,EAAE,IAAI,CAAC,YAAY,CAAC,IAAI,EAAE,CAAC,EAAE,CAAC,CAAC,EAAE,OAAO,EAAE;IAAE,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;CAAE;;;;;;;EAsC3G"}
1
+ {"version":3,"file":"useSignOut.d.ts","sourceRoot":"","sources":["../../src/client/useSignOut.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,IAAI,MAAM,wBAAwB,CAAC;AAM/C,OAAO,QAAQ,wBAAwB,CAAC;IACvC,UAAU,YAAY;QACrB,QAAQ,CAAC,iBAAiB,CAAC,EAAE,OAAO,CAAC;KACrC;CACD;AAED,wBAAgB,UAAU,CAAC,CAAC,EAAE,CAAC,EAAE,MAAM,EAAE,IAAI,CAAC,YAAY,CAAC,IAAI,EAAE,CAAC,EAAE,CAAC,CAAC,EAAE,OAAO,EAAE;IAAE,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;CAAE;;;;;;;EA8C3G"}
@@ -1,9 +1,9 @@
1
- import { RegistryContext } from "@effect/atom-react";
1
+ import { RegistryContext, useAtomMount } from "@effect/atom-react";
2
2
  import { useRouter } from "@tanstack/react-router";
3
3
  import { Effect } from "effect";
4
4
  import * as Atom from "effect/reactivity/Atom";
5
5
  import * as AtomRegistry from "effect/reactivity/AtomRegistry";
6
- import { useContext, useMemo } from "react";
6
+ import { useContext, useEffect, useMemo } from "react";
7
7
  import { SignOutRegistryContext } from "#internal/SignOutRegistryContext.ts";
8
8
  import { useAction } from "./resultState.js";
9
9
  export function useSignOut(action, options) {
@@ -14,25 +14,33 @@ export function useSignOut(action, options) {
14
14
  get.addFinalizer(registry.subscribe(action, (result) => get.setSelf(result)));
15
15
  return registry.get(action);
16
16
  }, (_, input) => registry.set(action, input)), [registry, action]);
17
- const state = useAction(source);
18
- function failed() {
19
- if (router) {
20
- router.history.replace(router.history.location.href, { ...router.history.location.state, __platformSignOut: false });
17
+ const followUp = useMemo(() => {
18
+ function failed() {
19
+ if (router) {
20
+ router.history.replace(router.history.location.href, { ...router.history.location.state, __platformSignOut: false });
21
+ }
21
22
  }
22
- }
23
- function completed() {
24
- if (router) {
25
- void router.navigate({ replace: true, state: { __platformSignOut: true }, to: options.signIn });
23
+ function completed() {
24
+ if (router) {
25
+ void router.navigate({ replace: true, state: { __platformSignOut: true }, to: options.signIn });
26
+ }
26
27
  }
27
- }
28
+ return Atom.fn()(() => AtomRegistry.getResult(registry, action, { suspendOnWaiting: true }).pipe(Effect.match({ onFailure: failed, onSuccess: completed })));
29
+ }, [registry, action, router, options.signIn]);
30
+ useAtomMount(followUp);
31
+ useEffect(() => () => local.set(followUp, Atom.Interrupt), [local, followUp]);
32
+ const state = useAction(source);
28
33
  return {
29
34
  ...state,
30
35
  dispatch: () => {
36
+ if (registry.get(action).waiting || local.get(followUp).waiting) {
37
+ return;
38
+ }
31
39
  if (router) {
32
40
  router.history.replace(router.history.location.href, { ...router.history.location.state, __platformSignOut: true });
33
41
  }
34
42
  state.dispatch(undefined);
35
- Effect.runFork(AtomRegistry.getResult(registry, action, { suspendOnWaiting: true }).pipe(Effect.match({ onFailure: failed, onSuccess: completed })));
43
+ local.set(followUp, undefined);
36
44
  },
37
45
  };
38
46
  }
@@ -1 +1 @@
1
- {"version":3,"file":"useSignOut.js","sourceRoot":"","sources":["../../src/client/useSignOut.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,eAAe,EAAE,MAAM,oBAAoB,CAAC;AACrD,OAAO,EAAE,SAAS,EAAE,MAAM,wBAAwB,CAAC;AACnD,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAChC,OAAO,KAAK,IAAI,MAAM,wBAAwB,CAAC;AAC/C,OAAO,KAAK,YAAY,MAAM,gCAAgC,CAAC;AAC/D,OAAO,EAAE,UAAU,EAAE,OAAO,EAAE,MAAM,OAAO,CAAC;AAC5C,OAAO,EAAE,sBAAsB,EAAE,MAAM,qCAAqC,CAAC;AAC7E,OAAO,EAAE,SAAS,EAAE,MAAM,kBAAkB,CAAC;AAQ7C,MAAM,UAAU,UAAU,CAAO,MAAqC,EAAE,OAAoC;IAC3G,MAAM,MAAM,GAAG,SAAS,CAAC,EAAE,IAAI,EAAE,KAAK,EAAE,CAAC,CAAC;IAC1C,MAAM,KAAK,GAAG,UAAU,CAAC,eAAe,CAAC,CAAC;IAC1C,MAAM,QAAQ,GAAG,UAAU,CAAC,sBAAsB,CAAC,IAAI,KAAK,CAAC;IAC7D,MAAM,MAAM,GAAG,OAAO,CACrB,GAAG,EAAE,CACJ,IAAI,CAAC,QAAQ,CACZ,CAAC,GAAG,EAAE,EAAE;QACP,GAAG,CAAC,YAAY,CAAC,QAAQ,CAAC,SAAS,CAAC,MAAM,EAAE,CAAC,MAAM,EAAE,EAAE,CAAC,GAAG,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC;QAC9E,OAAO,QAAQ,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC;IAC7B,CAAC,EACD,CAAC,CAAC,EAAE,KAA8C,EAAE,EAAE,CAAC,QAAQ,CAAC,GAAG,CAAC,MAAM,EAAE,KAAK,CAAC,CAClF,EACF,CAAC,QAAQ,EAAE,MAAM,CAAC,CAClB,CAAC;IACF,MAAM,KAAK,GAAG,SAAS,CAAC,MAAM,CAAC,CAAC;IAChC,SAAS,MAAM;QACd,IAAI,MAAM,EAAE,CAAC;YACZ,MAAM,CAAC,OAAO,CAAC,OAAO,CAAC,MAAM,CAAC,OAAO,CAAC,QAAQ,CAAC,IAAI,EAAE,EAAE,GAAG,MAAM,CAAC,OAAO,CAAC,QAAQ,CAAC,KAAK,EAAE,iBAAiB,EAAE,KAAK,EAAE,CAAC,CAAC;QACtH,CAAC;IACF,CAAC;IACD,SAAS,SAAS;QACjB,IAAI,MAAM,EAAE,CAAC;YACZ,KAAK,MAAM,CAAC,QAAQ,CAAC,EAAE,OAAO,EAAE,IAAI,EAAE,KAAK,EAAE,EAAE,iBAAiB,EAAE,IAAI,EAAE,EAAE,EAAE,EAAE,OAAO,CAAC,MAAM,EAAE,CAAC,CAAC;QACjG,CAAC;IACF,CAAC;IACD,OAAO;QACN,GAAG,KAAK;QACR,QAAQ,EAAE,GAAG,EAAE;YACd,IAAI,MAAM,EAAE,CAAC;gBACZ,MAAM,CAAC,OAAO,CAAC,OAAO,CAAC,MAAM,CAAC,OAAO,CAAC,QAAQ,CAAC,IAAI,EAAE,EAAE,GAAG,MAAM,CAAC,OAAO,CAAC,QAAQ,CAAC,KAAK,EAAE,iBAAiB,EAAE,IAAI,EAAE,CAAC,CAAC;YACrH,CAAC;YACD,KAAK,CAAC,QAAQ,CAAC,SAAS,CAAC,CAAC;YAC1B,MAAM,CAAC,OAAO,CACb,YAAY,CAAC,SAAS,CAAC,QAAQ,EAAE,MAAM,EAAE,EAAE,gBAAgB,EAAE,IAAI,EAAE,CAAC,CAAC,IAAI,CAAC,MAAM,CAAC,KAAK,CAAC,EAAE,SAAS,EAAE,MAAM,EAAE,SAAS,EAAE,SAAS,EAAE,CAAC,CAAC,CACpI,CAAC;QACH,CAAC;KACD,CAAC;AACH,CAAC"}
1
+ {"version":3,"file":"useSignOut.js","sourceRoot":"","sources":["../../src/client/useSignOut.ts"],"names":[],"mappings":"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"}
@@ -1,5 +1,5 @@
1
1
  import { redirect } from "@tanstack/react-router";
2
- import { Forbidden } from "@shivaedev/errors/taxonomy.ts";
2
+ import { forbidden } from "@shivaedev/access/refusal.ts";
3
3
  import { can } from "./permissions.js";
4
4
  import { signInSearch } from "./signInSearch.js";
5
5
  export function permissionGuard({ session, signIn }) {
@@ -14,7 +14,7 @@ export function permissionGuard({ session, signIn }) {
14
14
  }
15
15
  for (const permission of required) {
16
16
  if (!can(current, permission)) {
17
- throw new Forbidden({ message: `Permission required: ${permission.name} (${permission.strength})` });
17
+ throw forbidden(permission);
18
18
  }
19
19
  }
20
20
  };
@@ -1 +1 @@
1
- {"version":3,"file":"permissionGuard.js","sourceRoot":"","sources":["../src/permissionGuard.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,MAAM,wBAAwB,CAAC;AAElD,OAAO,EAAE,SAAS,EAAE,MAAM,+BAA+B,CAAC;AAC1D,OAAO,EAAE,GAAG,EAA0B,MAAM,kBAAkB,CAAC;AAC/D,OAAO,EAAuB,YAAY,EAAE,MAAM,mBAAmB,CAAC;AAkBtE,MAAM,UAAU,eAAe,CAAC,EAAE,OAAO,EAAE,MAAM,EAA0B;IAC1E,OAAO,CAAC,EAAE,OAAO,EAAE,QAAQ,EAAE,EAAE,EAAE;QAChC,MAAM,QAAQ,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,CAAC,KAAK,CAAC,UAAU,CAAC,UAAU,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,UAAU,CAAC,UAAU,CAAC,CAAC,CAAC,CAAC;QAC9H,IAAI,QAAQ,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;YAC3B,OAAO;QACR,CAAC;QACD,MAAM,OAAO,GAAG,OAAO,EAAE,CAAC;QAC1B,IAAI,OAAO,KAAK,IAAI,IAAI,OAAO,KAAK,SAAS,EAAE,CAAC;YAC/C,MAAM,QAAQ,CAAC,EAAE,MAAM,EAAE,YAAY,CAAC,QAAQ,CAAC,EAAE,EAAE,EAAE,MAAM,EAAE,CAAC,CAAC;QAChE,CAAC;QACD,KAAK,MAAM,UAAU,IAAI,QAAQ,EAAE,CAAC;YACnC,IAAI,CAAC,GAAG,CAAC,OAAO,EAAE,UAAU,CAAC,EAAE,CAAC;gBAC/B,MAAM,IAAI,SAAS,CAAC,EAAE,OAAO,EAAE,wBAAwB,UAAU,CAAC,IAAI,KAAK,UAAU,CAAC,QAAQ,GAAG,EAAE,CAAC,CAAC;YACtG,CAAC;QACF,CAAC;IACF,CAAC,CAAC;AACH,CAAC"}
1
+ {"version":3,"file":"permissionGuard.js","sourceRoot":"","sources":["../src/permissionGuard.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,MAAM,wBAAwB,CAAC;AAElD,OAAO,EAAE,SAAS,EAAE,MAAM,8BAA8B,CAAC;AACzD,OAAO,EAAE,GAAG,EAA0B,MAAM,kBAAkB,CAAC;AAC/D,OAAO,EAAuB,YAAY,EAAE,MAAM,mBAAmB,CAAC;AAkBtE,MAAM,UAAU,eAAe,CAAC,EAAE,OAAO,EAAE,MAAM,EAA0B;IAC1E,OAAO,CAAC,EAAE,OAAO,EAAE,QAAQ,EAAE,EAAE,EAAE;QAChC,MAAM,QAAQ,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,CAAC,KAAK,CAAC,UAAU,CAAC,UAAU,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,UAAU,CAAC,UAAU,CAAC,CAAC,CAAC,CAAC;QAC9H,IAAI,QAAQ,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;YAC3B,OAAO;QACR,CAAC;QACD,MAAM,OAAO,GAAG,OAAO,EAAE,CAAC;QAC1B,IAAI,OAAO,KAAK,IAAI,IAAI,OAAO,KAAK,SAAS,EAAE,CAAC;YAC/C,MAAM,QAAQ,CAAC,EAAE,MAAM,EAAE,YAAY,CAAC,QAAQ,CAAC,EAAE,EAAE,EAAE,MAAM,EAAE,CAAC,CAAC;QAChE,CAAC;QACD,KAAK,MAAM,UAAU,IAAI,QAAQ,EAAE,CAAC;YACnC,IAAI,CAAC,GAAG,CAAC,OAAO,EAAE,UAAU,CAAC,EAAE,CAAC;gBAC/B,MAAM,SAAS,CAAC,UAAU,CAAC,CAAC;YAC7B,CAAC;QACF,CAAC;IACF,CAAC,CAAC;AACH,CAAC"}
@@ -1,4 +1,4 @@
1
- import type { Permission } from "@shivaedev/access/permission.ts";
1
+ import { type Permission } from "@shivaedev/access/permission.ts";
2
2
  export interface PermissionSession {
3
3
  readonly user: {
4
4
  readonly permissions?: readonly Permission[];
@@ -1 +1 @@
1
- {"version":3,"file":"permissions.d.ts","sourceRoot":"","sources":["../src/permissions.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,iCAAiC,CAAC;AAElE,MAAM,WAAW,iBAAiB;IACjC,QAAQ,CAAC,IAAI,EAAE;QACd,QAAQ,CAAC,WAAW,CAAC,EAAE,SAAS,UAAU,EAAE,CAAC;KAC7C,CAAC;CACF;AAED,wBAAgB,GAAG,CAAC,OAAO,EAAE,iBAAiB,GAAG,IAAI,GAAG,SAAS,EAAE,UAAU,EAAE,UAAU,GAAG,OAAO,CAElG"}
1
+ {"version":3,"file":"permissions.d.ts","sourceRoot":"","sources":["../src/permissions.ts"],"names":[],"mappings":"AAAA,OAAO,EAAiB,KAAK,UAAU,EAAE,MAAM,iCAAiC,CAAC;AAEjF,MAAM,WAAW,iBAAiB;IACjC,QAAQ,CAAC,IAAI,EAAE;QACd,QAAQ,CAAC,WAAW,CAAC,EAAE,SAAS,UAAU,EAAE,CAAC;KAC7C,CAAC;CACF;AAED,wBAAgB,GAAG,CAAC,OAAO,EAAE,iBAAiB,GAAG,IAAI,GAAG,SAAS,EAAE,UAAU,EAAE,UAAU,GAAG,OAAO,CAElG"}
@@ -1,4 +1,5 @@
1
+ import { hasPermission } from "@shivaedev/access/permission.ts";
1
2
  export function can(session, permission) {
2
- return session?.user.permissions?.some((grant) => grant.name === permission.name && grant.strength === permission.strength) ?? false;
3
+ return hasPermission(session?.user.permissions, permission);
3
4
  }
4
5
  //# sourceMappingURL=permissions.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"permissions.js","sourceRoot":"","sources":["../src/permissions.ts"],"names":[],"mappings":"AAQA,MAAM,UAAU,GAAG,CAAC,OAA6C,EAAE,UAAsB;IACxF,OAAO,OAAO,EAAE,IAAI,CAAC,WAAW,EAAE,IAAI,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,KAAK,CAAC,IAAI,KAAK,UAAU,CAAC,IAAI,IAAI,KAAK,CAAC,QAAQ,KAAK,UAAU,CAAC,QAAQ,CAAC,IAAI,KAAK,CAAC;AACtI,CAAC"}
1
+ {"version":3,"file":"permissions.js","sourceRoot":"","sources":["../src/permissions.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAmB,MAAM,iCAAiC,CAAC;AAQjF,MAAM,UAAU,GAAG,CAAC,OAA6C,EAAE,UAAsB;IACxF,OAAO,aAAa,CAAC,OAAO,EAAE,IAAI,CAAC,WAAW,EAAE,UAAU,CAAC,CAAC;AAC7D,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shivaedev/react",
3
- "version": "0.22.1",
3
+ "version": "0.23.1",
4
4
  "description": "React query, action, editor and session hooks for native Effect atoms",
5
5
  "keywords": [
6
6
  "effect"
@@ -31,7 +31,6 @@
31
31
  "types": "./dist/*.d.ts",
32
32
  "default": "./dist/*.js"
33
33
  },
34
- "./internal/*": null,
35
34
  "./package.json": "./package.json"
36
35
  },
37
36
  "files": [
@@ -46,11 +45,10 @@
46
45
  "README.md"
47
46
  ],
48
47
  "dependencies": {
49
- "@shivaedev/access": "0.1.2",
50
- "@shivaedev/contract": "0.21.0",
51
- "@shivaedev/errors": "0.1.1",
52
- "@shivaedev/form": "0.7.1",
53
- "@shivaedev/test": "0.10.1"
48
+ "@shivaedev/access": "0.2.0",
49
+ "@shivaedev/contract": "0.23.0",
50
+ "@shivaedev/errors": "0.1.2",
51
+ "@shivaedev/form": "0.8.0"
54
52
  },
55
53
  "devDependencies": {
56
54
  "@effect/atom-react": "4.0.6",
@@ -58,15 +56,16 @@
58
56
  "@effect/sql-pg": "4.0.6",
59
57
  "@effect/sql-sqlite-node": "4.0.6",
60
58
  "@shivaedev/changes": "0.5.1",
61
- "@shivaedev/identity": "0.4.0",
62
- "@shivaedev/jobs": "0.22.1",
59
+ "@shivaedev/identity": "0.7.0",
60
+ "@shivaedev/jobs": "0.23.1",
63
61
  "@shivaedev/local-postgres": "0.7.0",
64
- "@shivaedev/quality": "0.45.0",
65
- "@shivaedev/server": "0.5.0",
66
- "@shivaedev/service": "0.5.1",
67
- "@shivaedev/sql": "0.25.0",
68
- "@shivaedev/test-sql": "0.11.0",
69
- "@shivaedev/test-story": "0.24.0",
62
+ "@shivaedev/quality": "0.47.0",
63
+ "@shivaedev/server": "0.8.0",
64
+ "@shivaedev/service": "0.6.0",
65
+ "@shivaedev/sql": "0.26.1",
66
+ "@shivaedev/test": "0.11.0",
67
+ "@shivaedev/test-sql": "0.13.0",
68
+ "@shivaedev/test-story": "0.25.1",
70
69
  "@tanstack/react-router": "1.170.46",
71
70
  "@types/node": "24.19.2",
72
71
  "@types/react": "19.3.0",
@@ -82,19 +81,11 @@
82
81
  "@effect/atom-react": "4.0.6",
83
82
  "@tanstack/react-router": "1.170.46",
84
83
  "effect": "4.0.6",
85
- "react": "19.3.1",
86
- "react-dom": "19.3.1",
87
- "vitest": "5.0.5"
84
+ "react": "19.3.1"
88
85
  },
89
86
  "peerDependenciesMeta": {
90
87
  "@tanstack/react-router": {
91
88
  "optional": true
92
- },
93
- "react-dom": {
94
- "optional": true
95
- },
96
- "vitest": {
97
- "optional": true
98
89
  }
99
90
  },
100
91
  "engines": {
@@ -1,5 +1,5 @@
1
1
  import { RegistryContext, useAtomValue } from "@effect/atom-react";
2
- import { Option } from "effect";
2
+ import { type Cause, Option } from "effect";
3
3
  import * as AsyncResult from "effect/reactivity/AsyncResult";
4
4
  import * as Atom from "effect/reactivity/Atom";
5
5
  import { useCallback, useContext, useEffect } from "react";
@@ -10,6 +10,7 @@ import { useSessionRecheck } from "./sessionBoundary.ts";
10
10
  const idle = Atom.make(AsyncResult.initial());
11
11
  const clean = Atom.make(false);
12
12
  export interface SaveState<E> {
13
+ readonly cause: Option.Option<Cause.Cause<E | FieldFailure | Invalid>>;
13
14
  readonly dirty: boolean;
14
15
  readonly failure: Option.Option<E>;
15
16
  readonly revert: () => void;
@@ -50,5 +51,5 @@ export function useSaveState<TFields extends Fields, A, E, TRuntimeError>(
50
51
  AsyncResult.error(result),
51
52
  (error): error is E | TRuntimeError => !(error instanceof FieldFailure || error instanceof Invalid),
52
53
  );
53
- return { dirty, failure, revert, save, saving: result.waiting };
54
+ return { cause: AsyncResult.cause(result), dirty, failure, revert, save, saving: result.waiting };
54
55
  }
@@ -1,9 +1,9 @@
1
- import { RegistryContext } from "@effect/atom-react";
1
+ import { RegistryContext, useAtomMount } from "@effect/atom-react";
2
2
  import { useRouter } from "@tanstack/react-router";
3
3
  import { Effect } from "effect";
4
4
  import * as Atom from "effect/reactivity/Atom";
5
5
  import * as AtomRegistry from "effect/reactivity/AtomRegistry";
6
- import { useContext, useMemo } from "react";
6
+ import { useContext, useEffect, useMemo } from "react";
7
7
  import { SignOutRegistryContext } from "#internal/SignOutRegistryContext.ts";
8
8
  import { useAction } from "./resultState.ts";
9
9
 
@@ -28,27 +28,35 @@ export function useSignOut<A, E>(action: Atom.AtomResultFn<void, A, E>, options:
28
28
  ),
29
29
  [registry, action],
30
30
  );
31
- const state = useAction(source);
32
- function failed() {
33
- if (router) {
34
- router.history.replace(router.history.location.href, { ...router.history.location.state, __platformSignOut: false });
31
+ const followUp = useMemo(() => {
32
+ function failed() {
33
+ if (router) {
34
+ router.history.replace(router.history.location.href, { ...router.history.location.state, __platformSignOut: false });
35
+ }
35
36
  }
36
- }
37
- function completed() {
38
- if (router) {
39
- void router.navigate({ replace: true, state: { __platformSignOut: true }, to: options.signIn });
37
+ function completed() {
38
+ if (router) {
39
+ void router.navigate({ replace: true, state: { __platformSignOut: true }, to: options.signIn });
40
+ }
40
41
  }
41
- }
42
+ return Atom.fn<void>()(() =>
43
+ AtomRegistry.getResult(registry, action, { suspendOnWaiting: true }).pipe(Effect.match({ onFailure: failed, onSuccess: completed })),
44
+ );
45
+ }, [registry, action, router, options.signIn]);
46
+ useAtomMount(followUp);
47
+ useEffect(() => () => local.set(followUp, Atom.Interrupt), [local, followUp]);
48
+ const state = useAction(source);
42
49
  return {
43
50
  ...state,
44
51
  dispatch: () => {
52
+ if (registry.get(action).waiting || local.get(followUp).waiting) {
53
+ return;
54
+ }
45
55
  if (router) {
46
56
  router.history.replace(router.history.location.href, { ...router.history.location.state, __platformSignOut: true });
47
57
  }
48
58
  state.dispatch(undefined);
49
- Effect.runFork(
50
- AtomRegistry.getResult(registry, action, { suspendOnWaiting: true }).pipe(Effect.match({ onFailure: failed, onSuccess: completed })),
51
- );
59
+ local.set(followUp, undefined);
52
60
  },
53
61
  };
54
62
  }
@@ -1,6 +1,6 @@
1
1
  import { redirect } from "@tanstack/react-router";
2
2
  import type { Permission } from "@shivaedev/access/permission.ts";
3
- import { Forbidden } from "@shivaedev/errors/taxonomy.ts";
3
+ import { forbidden } from "@shivaedev/access/refusal.ts";
4
4
  import { can, type PermissionSession } from "./permissions.ts";
5
5
  import { type SignInLocation, signInSearch } from "./signInSearch.ts";
6
6
 
@@ -32,7 +32,7 @@ export function permissionGuard({ session, signIn }: PermissionGuardOptions): (c
32
32
  }
33
33
  for (const permission of required) {
34
34
  if (!can(current, permission)) {
35
- throw new Forbidden({ message: `Permission required: ${permission.name} (${permission.strength})` });
35
+ throw forbidden(permission);
36
36
  }
37
37
  }
38
38
  };
@@ -1,4 +1,4 @@
1
- import type { Permission } from "@shivaedev/access/permission.ts";
1
+ import { hasPermission, type Permission } from "@shivaedev/access/permission.ts";
2
2
 
3
3
  export interface PermissionSession {
4
4
  readonly user: {
@@ -7,5 +7,5 @@ export interface PermissionSession {
7
7
  }
8
8
 
9
9
  export function can(session: PermissionSession | null | undefined, permission: Permission): boolean {
10
- return session?.user.permissions?.some((grant) => grant.name === permission.name && grant.strength === permission.strength) ?? false;
10
+ return hasPermission(session?.user.permissions, permission);
11
11
  }
@@ -1,28 +0,0 @@
1
- import { Effect, Scope } from "effect";
2
- import * as AtomRegistry from "effect/reactivity/AtomRegistry";
3
- import { type ReactNode } from "react";
4
- import { type RootOptions } from "react-dom/client";
5
- type CaughtErrorHandler = NonNullable<RootOptions["onCaughtError"]>;
6
- export interface ReactWarningsReadout {
7
- readonly caughtErrors: readonly unknown[];
8
- readonly consoleErrors: readonly (readonly unknown[])[];
9
- }
10
- export interface ReactWarnings {
11
- readonly expectCaughtError: (matches: (error: unknown) => boolean) => CaughtErrorHandler;
12
- readonly onCaughtError: CaughtErrorHandler;
13
- readonly read: () => ReactWarningsReadout;
14
- }
15
- export declare const captureReactWarnings: Effect.Effect<ReactWarnings, never, Scope.Scope>;
16
- export declare function expectNoReactWarnings(): {
17
- expectCaughtError(matches: (error: unknown) => boolean): NonNullable<RootOptions["onCaughtError"]>;
18
- };
19
- export declare function eventually(assert: () => void): Effect.Effect<void, Error, never>;
20
- export interface Rendered {
21
- readonly container: HTMLDivElement;
22
- readonly registry: AtomRegistry.AtomRegistry;
23
- readonly render: (node: ReactNode) => Promise<void>;
24
- readonly unmount: () => Promise<void>;
25
- }
26
- export declare function render(node: ReactNode, options?: RootOptions): Promise<Rendered>;
27
- export {};
28
- //# sourceMappingURL=testing.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"testing.d.ts","sourceRoot":"","sources":["../../src/client/testing.ts"],"names":[],"mappings":"AACA,OAAO,EAAS,MAAM,EAAQ,KAAK,EAAE,MAAM,QAAQ,CAAC;AACpD,OAAO,KAAK,YAAY,MAAM,gCAAgC,CAAC;AAC/D,OAAO,EAAsB,KAAK,SAAS,EAAE,MAAM,OAAO,CAAC;AAC3D,OAAO,EAAc,KAAK,WAAW,EAAE,MAAM,kBAAkB,CAAC;AAKhE,KAAK,kBAAkB,GAAG,WAAW,CAAC,WAAW,CAAC,eAAe,CAAC,CAAC,CAAC;AAEpE,MAAM,WAAW,oBAAoB;IACpC,QAAQ,CAAC,YAAY,EAAE,SAAS,OAAO,EAAE,CAAC;IAC1C,QAAQ,CAAC,aAAa,EAAE,SAAS,CAAC,SAAS,OAAO,EAAE,CAAC,EAAE,CAAC;CACxD;AAED,MAAM,WAAW,aAAa;IAC7B,QAAQ,CAAC,iBAAiB,EAAE,CAAC,OAAO,EAAE,CAAC,KAAK,EAAE,OAAO,KAAK,OAAO,KAAK,kBAAkB,CAAC;IACzF,QAAQ,CAAC,aAAa,EAAE,kBAAkB,CAAC;IAC3C,QAAQ,CAAC,IAAI,EAAE,MAAM,oBAAoB,CAAC;CAC1C;AAqCD,eAAO,MAAM,oBAAoB,kDAA6D,CAAC;AAE/F,wBAAgB,qBAAqB;IAkBnC,iBAAiB,UAAU,CAAC,KAAK,EAAE,OAAO,KAAK,OAAO,GAAG,WAAW,CAAC,WAAW,CAAC,eAAe,CAAC,CAAC;EAInG;AACD,wBAAgB,UAAU,CAAC,MAAM,EAAE,MAAM,IAAI,qCAwB5C;AACD,MAAM,WAAW,QAAQ;IACxB,QAAQ,CAAC,SAAS,EAAE,cAAc,CAAC;IACnC,QAAQ,CAAC,QAAQ,EAAE,YAAY,CAAC,YAAY,CAAC;IAC7C,QAAQ,CAAC,MAAM,EAAE,CAAC,IAAI,EAAE,SAAS,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IACpD,QAAQ,CAAC,OAAO,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;CACtC;AACD,wBAAsB,MAAM,CAAC,IAAI,EAAE,SAAS,EAAE,OAAO,CAAC,EAAE,WAAW,GAAG,OAAO,CAAC,QAAQ,CAAC,CA4BtF"}
@@ -1,108 +0,0 @@
1
- import { RegistryContext } from "@effect/atom-react";
2
- import { Clock, Effect, Exit, Scope } from "effect";
3
- import * as AtomRegistry from "effect/reactivity/AtomRegistry";
4
- import { act, createElement } from "react";
5
- import { createRoot } from "react-dom/client";
6
- import { beforeEach, expect, vi } from "vitest";
7
- import { eventually as retry } from "@shivaedev/test/eventually.ts";
8
- import { withLive } from "@shivaedev/test/withLive.ts";
9
- const acquireReactWarnings = Effect.acquireRelease(Effect.sync(() => {
10
- const errors = vi.spyOn(console, "error").mockImplementation(() => undefined);
11
- let consoleErrors;
12
- const caughtErrors = [];
13
- const expectations = [];
14
- const capture = {
15
- expectCaughtError: (matches) => {
16
- const expectation = { caught: false };
17
- expectations.push(expectation);
18
- return (error) => {
19
- if (matches(error)) {
20
- expectation.caught = true;
21
- }
22
- else {
23
- caughtErrors.push(error);
24
- }
25
- };
26
- },
27
- onCaughtError: (error) => {
28
- caughtErrors.push(error);
29
- },
30
- read: () => ({ caughtErrors: [...caughtErrors], consoleErrors: consoleErrors ?? errors.mock.calls.map((args) => [...args]) }),
31
- };
32
- return {
33
- capture,
34
- expectations,
35
- restore: () => {
36
- consoleErrors = capture.read().consoleErrors;
37
- errors.mockRestore();
38
- },
39
- };
40
- }), ({ restore }) => Effect.sync(restore));
41
- export const captureReactWarnings = Effect.map(acquireReactWarnings, ({ capture }) => capture);
42
- export function expectNoReactWarnings() {
43
- let current;
44
- beforeEach(({ onTestFinished }) => {
45
- const scope = Effect.runSync(Scope.make());
46
- current = Effect.runSync(Effect.provideService(acquireReactWarnings, Scope.Scope, scope));
47
- const { capture, expectations } = current;
48
- onTestFinished(() => {
49
- Effect.runSync(Scope.close(scope, Exit.void));
50
- const { consoleErrors, caughtErrors } = capture.read();
51
- expect(consoleErrors).toEqual([]);
52
- expect(caughtErrors, "Unexpected caught React errors").toEqual([]);
53
- expect(expectations.every(({ caught }) => caught), "Expected caught React error did not occur").toBe(true);
54
- });
55
- });
56
- return {
57
- expectCaughtError(matches) {
58
- return current.capture.expectCaughtError(matches);
59
- },
60
- };
61
- }
62
- export function eventually(assert) {
63
- return withLive(Effect.gen(function* () {
64
- const started = yield* Clock.currentTimeMillis;
65
- return yield* retry(Effect.try({ catch: (error) => error, try: assert }), {
66
- timeout: "3 seconds",
67
- // Responses and React updates must settle together inside act.
68
- wait: Effect.promise(() => act(async () => {
69
- await new Promise((resolve) => setTimeout(resolve, 10));
70
- })),
71
- }).pipe(Effect.catch((error) => Effect.gen(function* () {
72
- const elapsed = (yield* Clock.currentTimeMillis) - started;
73
- return yield* Effect.fail(new Error(`Assertion did not pass after ${elapsed}ms: ${error instanceof Error ? error.message : String(error)}`, { cause: error }));
74
- })));
75
- }));
76
- }
77
- export async function render(node, options) {
78
- const registry = AtomRegistry.make();
79
- const container = document.createElement("div");
80
- document.body.append(container);
81
- const root = createRoot(container, options);
82
- let mounted = true;
83
- async function unmount() {
84
- if (!mounted) {
85
- return;
86
- }
87
- mounted = false;
88
- try {
89
- await act(async () => root.unmount());
90
- }
91
- finally {
92
- registry.dispose();
93
- container.remove();
94
- }
95
- }
96
- async function update(next) {
97
- await act(async () => root.render(createElement(RegistryContext.Provider, { value: registry }, next)));
98
- }
99
- try {
100
- await update(node);
101
- }
102
- catch (error) {
103
- await unmount();
104
- throw error;
105
- }
106
- return { container, registry, render: update, unmount };
107
- }
108
- //# sourceMappingURL=testing.js.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"testing.js","sourceRoot":"","sources":["../../src/client/testing.ts"],"names":[],"mappings":"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"}
@@ -1,142 +0,0 @@
1
- import { RegistryContext } from "@effect/atom-react";
2
- import { Clock, Effect, Exit, Scope } from "effect";
3
- import * as AtomRegistry from "effect/reactivity/AtomRegistry";
4
- import { act, createElement, type ReactNode } from "react";
5
- import { createRoot, type RootOptions } from "react-dom/client";
6
- import { beforeEach, expect, vi } from "vitest";
7
- import { eventually as retry } from "@shivaedev/test/eventually.ts";
8
- import { withLive } from "@shivaedev/test/withLive.ts";
9
-
10
- type CaughtErrorHandler = NonNullable<RootOptions["onCaughtError"]>;
11
-
12
- export interface ReactWarningsReadout {
13
- readonly caughtErrors: readonly unknown[];
14
- readonly consoleErrors: readonly (readonly unknown[])[];
15
- }
16
-
17
- export interface ReactWarnings {
18
- readonly expectCaughtError: (matches: (error: unknown) => boolean) => CaughtErrorHandler;
19
- readonly onCaughtError: CaughtErrorHandler;
20
- readonly read: () => ReactWarningsReadout;
21
- }
22
-
23
- const acquireReactWarnings = Effect.acquireRelease(
24
- Effect.sync(() => {
25
- const errors = vi.spyOn(console, "error").mockImplementation(() => undefined);
26
- let consoleErrors: readonly (readonly unknown[])[] | undefined;
27
- const caughtErrors: unknown[] = [];
28
- const expectations: { caught: boolean }[] = [];
29
- const capture: ReactWarnings = {
30
- expectCaughtError: (matches) => {
31
- const expectation = { caught: false };
32
- expectations.push(expectation);
33
- return (error) => {
34
- if (matches(error)) {
35
- expectation.caught = true;
36
- } else {
37
- caughtErrors.push(error);
38
- }
39
- };
40
- },
41
- onCaughtError: (error) => {
42
- caughtErrors.push(error);
43
- },
44
- read: () => ({ caughtErrors: [...caughtErrors], consoleErrors: consoleErrors ?? errors.mock.calls.map((args) => [...args]) }),
45
- };
46
- return {
47
- capture,
48
- expectations,
49
- restore: () => {
50
- consoleErrors = capture.read().consoleErrors;
51
- errors.mockRestore();
52
- },
53
- };
54
- }),
55
- ({ restore }) => Effect.sync(restore),
56
- );
57
-
58
- export const captureReactWarnings = Effect.map(acquireReactWarnings, ({ capture }) => capture);
59
-
60
- export function expectNoReactWarnings() {
61
- let current: Effect.Success<typeof acquireReactWarnings>;
62
- beforeEach(({ onTestFinished }) => {
63
- const scope = Effect.runSync(Scope.make());
64
- current = Effect.runSync(Effect.provideService(acquireReactWarnings, Scope.Scope, scope));
65
- const { capture, expectations } = current;
66
- onTestFinished(() => {
67
- Effect.runSync(Scope.close(scope, Exit.void));
68
- const { consoleErrors, caughtErrors } = capture.read();
69
- expect(consoleErrors).toEqual([]);
70
- expect(caughtErrors, "Unexpected caught React errors").toEqual([]);
71
- expect(
72
- expectations.every(({ caught }) => caught),
73
- "Expected caught React error did not occur",
74
- ).toBe(true);
75
- });
76
- });
77
- return {
78
- expectCaughtError(matches: (error: unknown) => boolean): NonNullable<RootOptions["onCaughtError"]> {
79
- return current.capture.expectCaughtError(matches);
80
- },
81
- };
82
- }
83
- export function eventually(assert: () => void) {
84
- return withLive(
85
- Effect.gen(function* () {
86
- const started = yield* Clock.currentTimeMillis;
87
- return yield* retry(Effect.try({ catch: (error) => error, try: assert }), {
88
- timeout: "3 seconds",
89
- // Responses and React updates must settle together inside act.
90
- wait: Effect.promise(() =>
91
- act(async () => {
92
- await new Promise<void>((resolve) => setTimeout(resolve, 10));
93
- }),
94
- ),
95
- }).pipe(
96
- Effect.catch((error) =>
97
- Effect.gen(function* () {
98
- const elapsed = (yield* Clock.currentTimeMillis) - started;
99
- return yield* Effect.fail(
100
- new Error(`Assertion did not pass after ${elapsed}ms: ${error instanceof Error ? error.message : String(error)}`, { cause: error }),
101
- );
102
- }),
103
- ),
104
- );
105
- }),
106
- );
107
- }
108
- export interface Rendered {
109
- readonly container: HTMLDivElement;
110
- readonly registry: AtomRegistry.AtomRegistry;
111
- readonly render: (node: ReactNode) => Promise<void>;
112
- readonly unmount: () => Promise<void>;
113
- }
114
- export async function render(node: ReactNode, options?: RootOptions): Promise<Rendered> {
115
- const registry = AtomRegistry.make();
116
- const container = document.createElement("div");
117
- document.body.append(container);
118
- const root = createRoot(container, options);
119
- let mounted = true;
120
- async function unmount() {
121
- if (!mounted) {
122
- return;
123
- }
124
- mounted = false;
125
- try {
126
- await act(async () => root.unmount());
127
- } finally {
128
- registry.dispose();
129
- container.remove();
130
- }
131
- }
132
- async function update(next: ReactNode) {
133
- await act(async () => root.render(createElement(RegistryContext.Provider, { value: registry }, next)));
134
- }
135
- try {
136
- await update(node);
137
- } catch (error) {
138
- await unmount();
139
- throw error;
140
- }
141
- return { container, registry, render: update, unmount };
142
- }