march-hare 0.6.1 → 0.7.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.
@@ -6,24 +6,28 @@ export { getActionSymbol, getLifecycleType, isBroadcastAction, isMulticastAction
6
6
  type ActionFactory = {
7
7
  /**
8
8
  * Creates a new unicast action with the given name.
9
+ *
10
+ * `K` is the literal type of the action name and is captured as a phantom
11
+ * brand so `Action("A")` and `Action("B")` produce structurally-distinct
12
+ * types. **Note:** when the caller supplies any explicit generic
13
+ * (`Action<P>("Name")`), TypeScript fills `K` from its default and the
14
+ * literal is lost. The Name brand still helps for `Action("Name")` calls
15
+ * (e.g. lifecycle / no-payload actions) which is the most common source of
16
+ * foreign-class collisions.
17
+ *
9
18
  * @template P The payload type for the action.
10
- * @template C The channel type for channeled dispatches (defaults to never).
11
- * @param name The action name, used for debugging purposes.
12
- * @returns A typed action object.
19
+ * @template C The channel type for channeled dispatches.
20
+ * @template K The literal type of the action name (inferred when no other
21
+ * generics are supplied; defaults to `string` otherwise).
13
22
  */
14
- <P = never, C extends Filter = never>(name: string): HandlerPayload<P, C>;
23
+ <P = never, C extends Filter = never, K extends string = string>(name: K): HandlerPayload<P, C, K>;
15
24
  /**
16
25
  * Creates a new action with the specified distribution mode.
17
- * @template P The payload type for the action.
18
- * @template C The channel type for channeled dispatches (defaults to never).
19
- * @param name The action name, used for debugging purposes.
20
- * @param distribution The distribution mode (Unicast, Broadcast, or Multicast).
21
- * @returns A typed action object (BroadcastPayload if Broadcast, MulticastPayload if Multicast).
22
26
  */
23
- <P = never, C extends Filter = never>(name: string, distribution: Distribution.Broadcast): BroadcastPayload<P, C>;
24
- <P = never, C extends Filter = never>(name: string, distribution: Distribution.Multicast): MulticastPayload<P, C>;
25
- <P = never, C extends Filter = never>(name: string, distribution: Distribution.Unicast): HandlerPayload<P, C>;
26
- <P = never, C extends Filter = never>(name: string, distribution: Distribution): HandlerPayload<P, C> | BroadcastPayload<P, C> | MulticastPayload<P, C>;
27
+ <P = never, C extends Filter = never, K extends string = string>(name: K, distribution: Distribution.Broadcast): BroadcastPayload<P, C, K>;
28
+ <P = never, C extends Filter = never, K extends string = string>(name: K, distribution: Distribution.Multicast): MulticastPayload<P, C, K>;
29
+ <P = never, C extends Filter = never, K extends string = string>(name: K, distribution: Distribution.Unicast): HandlerPayload<P, C, K>;
30
+ <P = never, C extends Filter = never, K extends string = string>(name: K, distribution: Distribution): HandlerPayload<P, C, K> | BroadcastPayload<P, C, K> | MulticastPayload<P, C, K>;
27
31
  };
28
32
  /**
29
33
  * Creates a new action with a given payload type, optional channel type, and optional distribution mode.
@@ -0,0 +1,41 @@
1
+ import { Props } from './types.ts';
2
+ import * as React from "react";
3
+ export { useStore } from './utils.ts';
4
+ /**
5
+ * App-wide store of cross-cutting, mutable state. The interface is
6
+ * declared empty here and **augmented** by consumer code via module
7
+ * augmentation:
8
+ *
9
+ * @example
10
+ * ```ts
11
+ * declare module "march-hare" {
12
+ * interface Store {
13
+ * session: Session | null;
14
+ * locale: string;
15
+ * featureFlags: Record<string, boolean>;
16
+ * }
17
+ * }
18
+ * ```
19
+ *
20
+ * Every key declared here flows into:
21
+ *
22
+ * - `useStore()` &mdash; the per-`<Boundary>` handle for reads and writes.
23
+ * - `context.store` inside `useActions` handlers.
24
+ * - The `store` field on every `Resource` fetcher's args object.
25
+ *
26
+ * The Store is **not** reactive. Mutating it does not re-render. Drive
27
+ * view state through the model; use the Store for cross-handler
28
+ * coordination, session tokens, locale, feature flags, etc.
29
+ */
30
+ export interface Store {
31
+ }
32
+ /**
33
+ * Provides a per-Boundary {@link Store} value to every component inside
34
+ * the boundary. Usually wired in via the `<Boundary store={initial}>`
35
+ * prop rather than used directly.
36
+ *
37
+ * The Store is **not** reactive. Mutating it does not trigger a
38
+ * re-render. Drive view state through the model; use the Store for
39
+ * cross-handler coordination.
40
+ */
41
+ export declare function Store({ initial, children }: Props): React.ReactNode;
@@ -0,0 +1,11 @@
1
+ import { ReactNode } from 'react';
2
+ import { Store } from './index.tsx';
3
+ export type { Store } from './index.tsx';
4
+ /**
5
+ * Props for the Store provider component. Accepts the initial Store
6
+ * value that satisfies the augmented {@link Store} interface.
7
+ */
8
+ export type Props = {
9
+ initial: Store;
10
+ children: ReactNode;
11
+ };
@@ -0,0 +1,64 @@
1
+ import { RefObject } from 'react';
2
+ import { Store } from './index.tsx';
3
+ import * as React from "react";
4
+ /**
5
+ * React context exposing the per-Boundary Store ref. The ref itself is
6
+ * stable across renders &mdash; readers grab `.current` at call time.
7
+ *
8
+ * @internal
9
+ */
10
+ export declare const Context: React.Context<React.RefObject<Store>>;
11
+ /**
12
+ * Hook that returns a read-only handle to the per-Boundary {@link Store}.
13
+ * Reads use plain dot notation (`store.session`) and always reflect the
14
+ * latest value, even after `await` boundaries &mdash; the handle is a
15
+ * `Proxy` that delegates property access to the live ref.
16
+ *
17
+ * Writes are not exposed here. Mutate the Store inside an action handler
18
+ * via `context.actions.produce(({ model, store }) => { store.x = ... })`
19
+ * &mdash; the same Immer-style recipe used for the model. Mutations do
20
+ * **not** trigger a re-render; drive view state through the model.
21
+ *
22
+ * The Store's shape is declared via module augmentation on the library's
23
+ * {@link Store} interface, so dot reads are fully typed at every call
24
+ * site.
25
+ *
26
+ * @example
27
+ * ```ts
28
+ * declare module "march-hare" {
29
+ * interface Store {
30
+ * session: Session | null;
31
+ * locale: string;
32
+ * }
33
+ * }
34
+ *
35
+ * function useAuthActions() {
36
+ * const store = useStore();
37
+ * const actions = useActions<void, typeof Actions>();
38
+ *
39
+ * actions.useAction(Actions.SignIn, async (context, credentials) => {
40
+ * const result = await context.actions.resource(signIn(credentials));
41
+ * context.actions.produce(({ store }) => {
42
+ * store.session = result;
43
+ * });
44
+ * });
45
+ *
46
+ * actions.useAction(Actions.Refresh, async (context) => {
47
+ * if (store.session === null) return;
48
+ * // ...
49
+ * });
50
+ *
51
+ * return actions;
52
+ * }
53
+ * ```
54
+ */
55
+ export declare function useStore(): Store;
56
+ /**
57
+ * Internal accessor for the per-Boundary Store ref &mdash; used by the
58
+ * Resource layer to pass a fresh snapshot to each fetcher invocation
59
+ * and by the action layer to write through `context.actions.produce`.
60
+ * Not exported from the library.
61
+ *
62
+ * @internal
63
+ */
64
+ export declare function useStoreRef(): RefObject<Store>;
@@ -27,9 +27,9 @@ export type ActionId = symbol | string;
27
27
  * ```
28
28
  */
29
29
  export type Task<P = unknown> = {
30
- controller: AbortController;
31
- action: ActionId;
32
- payload: P;
30
+ readonly controller: AbortController;
31
+ readonly action: ActionId;
32
+ readonly payload: P;
33
33
  };
34
34
  /**
35
35
  * A set of running tasks ordered by creation time (oldest first).
@@ -2,19 +2,20 @@ import { Props } from './types.ts';
2
2
  import * as React from "react";
3
3
  /**
4
4
  * Creates a unified context boundary for all March Hare features.
5
- * Wraps children with Broadcaster, Mode, and Tasks providers.
5
+ * Wraps children with Broadcaster, Store, and Tasks providers.
6
6
  *
7
- * Use this at the root of your application or to create isolated context boundaries
8
- * for libraries that need their own March Hare context.
7
+ * Use this at the root of your application or to create isolated context
8
+ * boundaries for libraries that need their own March Hare context.
9
9
  *
10
- * @param props.children - The children to render within the boundary.
11
- * @returns The children wrapped in all required context providers.
10
+ * Pass the `store` prop with the initial Store value (session, locale,
11
+ * feature flags, etc.) &mdash; the shape is determined by module
12
+ * augmentation on the library's `Store` interface.
12
13
  *
13
14
  * @example
14
15
  * ```tsx
15
- * <Boundary>
16
+ * <Boundary store={{ session: null, locale: "en-GB" }}>
16
17
  * <App />
17
18
  * </Boundary>
18
19
  * ```
19
20
  */
20
- export declare function Boundary({ children }: Props): React.ReactNode;
21
+ export declare function Boundary({ store, children }: Props): React.ReactNode;
@@ -1,4 +1,22 @@
1
+ import { Store } from './components/store/types.ts';
1
2
  import type * as React from "react";
2
3
  export type Props = {
4
+ /**
5
+ * Initial value of the per-Boundary {@link Store}. The shape is
6
+ * derived from module augmentation &mdash; declare the keys your
7
+ * application needs once via:
8
+ *
9
+ * ```ts
10
+ * declare module "march-hare" {
11
+ * interface Store {
12
+ * session: Session | null;
13
+ * locale: string;
14
+ * }
15
+ * }
16
+ * ```
17
+ *
18
+ * Optional only when the augmented Store has no required keys.
19
+ */
20
+ store?: Store;
3
21
  children: React.ReactNode;
4
22
  };
@@ -0,0 +1,44 @@
1
+ import { Adapter, Stored } from './types.ts';
2
+ export type { Adapter, Encoded } from './types.ts';
3
+ /**
4
+ * Persistence-aware cache for a single {@link Resource}. Wraps a
5
+ * synchronous {@link Adapter} (localStorage, MMKV, chrome.storage with a
6
+ * sync facade, etc.) and traffics in {@link Stored} envelopes &mdash;
7
+ * storage entries serialise as {@link Encoded}`<T>` so the
8
+ * `Temporal.Instant` timestamp survives the string round-trip and
9
+ * `.exceeds({...})` can short-circuit on the persisted timestamp after
10
+ * a reload.
11
+ *
12
+ * Call with no arguments for an in-memory cache scoped to this
13
+ * instance &mdash; useful for tests, ephemeral state, or when you want a
14
+ * first-class cache object to share between Resources without
15
+ * persistence. Pass an {@link Adapter} to back the cache with a
16
+ * persistent store.
17
+ *
18
+ * @example
19
+ * ```ts
20
+ * // In-memory, scoped to this instance.
21
+ * const cache = Cache();
22
+ *
23
+ * // Persisted via localStorage.
24
+ * const cache = Cache({
25
+ * get: (key) => localStorage.getItem(key),
26
+ * set: (key, value) => localStorage.setItem(key, value),
27
+ * remove: (key) => localStorage.removeItem(key),
28
+ * clear: () => localStorage.clear(),
29
+ * });
30
+ *
31
+ * // Wired into a Resource — successful runs write through automatically.
32
+ * export const cat = Resource(
33
+ * async ({ controller }) => fetchCat(controller.signal),
34
+ * cache,
35
+ * );
36
+ * ```
37
+ */
38
+ export type Cache = {
39
+ get<T>(key: string): Stored<T>;
40
+ set<T>(key: string, value: Stored<T>): boolean;
41
+ remove(key: string): void;
42
+ clear(): void;
43
+ };
44
+ export declare function Cache(adapter?: Adapter): Cache;
@@ -0,0 +1,54 @@
1
+ export type { Stored } from '../utils/types.ts';
2
+ /**
3
+ * On-disk JSON shape of a `Stored` envelope. The Cache wrapper
4
+ * encodes a populated Stored as `{ data, at: at.toString() }` so the
5
+ * `Temporal.Instant` survives the string round-trip, and decodes via
6
+ * `Temporal.Instant.from(...)` on read. Adapters never see this shape
7
+ * directly &mdash; they shuttle the already-stringified JSON.
8
+ *
9
+ * @template T The payload type carried by the matching {@link Stored}.
10
+ */
11
+ export type Encoded<T> = {
12
+ readonly data: T;
13
+ readonly at: string;
14
+ };
15
+ /**
16
+ * Adapter contract for synchronous key/value storage. Implement once per
17
+ * backend (localStorage, MMKV on React Native, chrome.storage with a sync
18
+ * facade, etc.) and pass to {@link Cache}. The adapter shuttles raw
19
+ * strings; JSON encoding and `Temporal.Instant` round-tripping happen
20
+ * inside the Cache wrapper, so adapters stay trivial.
21
+ */
22
+ export type Adapter = {
23
+ /**
24
+ * Return the raw string stored under `key`, or `null` when no entry
25
+ * exists. The Cache wrapper handles JSON parsing and `Temporal.Instant`
26
+ * round-tripping, so this stays a plain string getter. Treat any
27
+ * read-time error (decryption, IPC, etc.) as "not found" and return
28
+ * `null` &mdash; the Cache falls through to its next fallback rather
29
+ * than crashing the render.
30
+ */
31
+ readonly get: (key: string) => string | null;
32
+ /**
33
+ * Persist the raw string `value` under `key`. The Cache guarantees
34
+ * `value` is a JSON-encoded `{ data, at }` envelope produced by a
35
+ * resolved snapshot &mdash; never a placeholder. Throwing is fine on
36
+ * quota, private mode, sandboxed iframes, etc.; the Cache catches and
37
+ * swallows so a write failure can't poison an already-resolved fetch.
38
+ */
39
+ readonly set: (key: string, value: string) => void;
40
+ /**
41
+ * Drop the entry at `key`. Idempotent &mdash; calling `remove` for a
42
+ * key that isn't present must not throw.
43
+ */
44
+ readonly remove: (key: string) => void;
45
+ /**
46
+ * Wipe every entry this adapter can see. On a shared backend such as
47
+ * `localStorage` this means the whole origin &mdash; third-party SDK
48
+ * state, dismissed banners, route hints, etc. all go with it. Adapter
49
+ * authors should either delegate to the backend's native clear
50
+ * (accepting that scope) or namespace by key prefix and remove only
51
+ * their own.
52
+ */
53
+ readonly clear: () => void;
54
+ };
@@ -5,13 +5,15 @@ export { Operation, Op, State } from 'immertation';
5
5
  export { annotate } from './annotate/index.ts';
6
6
  export { Boundary } from './boundary/index.tsx';
7
7
  export { withScope } from './boundary/components/scope/index.tsx';
8
- export { useMode } from './boundary/components/mode/index.tsx';
9
- export type { ModeHandle } from './boundary/components/mode/index.tsx';
8
+ export { useStore } from './boundary/components/store/index.tsx';
9
+ export type { Store } from './boundary/components/store/index.tsx';
10
10
  export { useActions, With } from './hooks/index.ts';
11
- export { Resource, useResource } from './resource/index.ts';
12
- export type { ResourceHandle, ResourceFetcher, BoundResourceHandle, IfOptions, } from './resource/index.ts';
11
+ export { Resource } from './resource/index.ts';
12
+ export type { Fetcher } from './resource/index.ts';
13
+ export { Cache } from './cache/index.ts';
14
+ export type { Adapter, Encoded } from './cache/index.ts';
13
15
  export * as utils from './utils/index.ts';
14
- export type { Stored, Unset, Adapter, Store } from './utils/index.ts';
16
+ export type { Stored, Unset } from './utils/index.ts';
15
17
  export type { Box } from 'immertation';
16
18
  export type { Fault } from './error/index.ts';
17
- export type { Pk, Task, Tasks, Handlers } from './types/index.ts';
19
+ export type { Pk, Task, Tasks, Handlers, Handler, LeafActions, Dispatchable, Subscribable, } from './types/index.ts';
@@ -1,65 +1,102 @@
1
- import { ResourceFetcher, ResourceHandle, BoundResourceHandle } from './types.ts';
2
- export type { IfOptions, ResourceFetcher, ResourceHandle, BoundResourceHandle, } from './types.ts';
1
+ import { Fetcher } from './types.ts';
2
+ import { Cache } from './utils.ts';
3
+ import { Store } from '../boundary/components/store/index.tsx';
4
+ export type { Fetcher } from './types.ts';
3
5
  /**
4
- * Defines a remote resource &mdash; declared at module scope and
5
- * consumed via {@link useResource}.
6
+ * Snapshot of the most recent resource invocation. `cat(params)` writes
7
+ * one of these into a module-scope slot; the next
8
+ * `context.actions.resource(...)` / `.set(...)` call consumes it via
9
+ * {@link consumePending}.
6
10
  *
7
- * The fetcher receives the optional `AbortSignal` first and the
8
- * `params` object second (defaults to `{}`). Resources do **not**
9
- * carry any callbacks &ndash; side-effects (broadcasting, logging,
10
- * model updates) belong in the `useAction` handler that called
11
- * `await handle(...)`.
11
+ * @internal
12
+ */
13
+ export type PendingCall = {
14
+ readonly run: (store: Store, controller: AbortController, params: object) => Promise<unknown>;
15
+ readonly read: (params: object) => {
16
+ data: unknown;
17
+ at: Temporal.Instant | null;
18
+ };
19
+ readonly seed: (params: object, data: unknown, at: Temporal.Instant) => void;
20
+ readonly params: object;
21
+ };
22
+ /**
23
+ * Reads and clears the slot populated by the most recent resource
24
+ * invocation. Throws when the slot is empty &mdash; the public
25
+ * `.resource(...)` shape requires a fresh `cat(params)` call as its
26
+ * argument.
12
27
  *
13
- * Every call fires its own request. The most recent successful
14
- * payload is cached in a module-level `WeakMap` keyed by the fetcher,
15
- * so `.if(...)` and `.else(...)` on the bound handle behave
16
- * consistently across all components that share the same Resource.
28
+ * @internal
29
+ */
30
+ export declare function consumePending(): PendingCall;
31
+ /**
32
+ * Resource handle returned by `Resource(...)`. Call it with `params` to
33
+ * read the per-params cache slot synchronously and prime the slot
34
+ * consumed by `context.actions.resource(...)` for a follow-up fetch or
35
+ * `context.actions.resource.set(...)` for an out-of-band write.
17
36
  *
18
- * @example
19
37
  * ```ts
20
- * import { Resource } from "march-hare";
38
+ * // Sync cache read in a model literal.
39
+ * { cat: cat({ id: 5 }) }
21
40
  *
22
- * // `T` is inferred from the fetcher's return type.
23
- * export const user = Resource((signal) =>
24
- * ky.get("user", { signal }).json<User>(),
25
- * );
41
+ * // Fetch with `.exceeds(...)` for cache-aware refresh.
42
+ * await context.actions.resource(cat({ id: 5 })).exceeds({ minutes: 5 });
26
43
  *
27
- * // Annotate `params` when destructuring so `P` is inferred.
28
- * export const updateUser = Resource(
29
- * (signal, { id, body }: { id: number; body: { name: string } }) =>
30
- * ky.patch(`users/${id}`, { json: body, signal }).json<User>(),
31
- * );
44
+ * // Write through to the per-params cache slot.
45
+ * context.actions.resource.set(cat({ id: 5 }), data);
32
46
  * ```
33
47
  */
34
- export declare function Resource<T, P extends object = Record<never, never>>(fetcher: ResourceFetcher<T, P>): ResourceHandle<T, P>;
48
+ export type Resource<T, P extends object = Record<never, never>> = [
49
+ keyof P
50
+ ] extends [never] ? (params?: P) => T | null : (params: P) => T | null;
35
51
  /**
36
- * Binds a module-scope {@link ResourceHandle} to the component, returning
37
- * the fetch callable with `.if`, `.else`, `.snapshot`, `.data`, and `.at`
38
- * attached. The hook is standalone &ndash; call it *before* `useActions`
39
- * when you want to seed the initial model from the cache via
40
- * `.else(fallback)`.
52
+ * Defines a remote resource &mdash; declared at module scope and used
53
+ * directly. Calling the returned handle with `params` returns the sync
54
+ * cache value (`T | null`) and primes the slot consumed by
55
+ * `context.actions.resource(...)` / `.set(...)` for fetch and write
56
+ * paths.
57
+ *
58
+ * The fetcher receives a single args object `{ store, controller, params }`:
59
+ *
60
+ * - `store` &ndash; snapshot of the per-`<Boundary>` Store (session,
61
+ * locale, feature flags, etc.). Reads only; writes go through
62
+ * `context.actions.produce(({ store }) => ...)` in handlers.
63
+ * - `controller` &ndash; the `AbortController` auto-threaded from the
64
+ * calling handler's `context.task.controller`. Pass `controller.signal`
65
+ * to `fetch`/`ky`, or call `controller.abort()` to fail fast.
66
+ * - `params` &ndash; the call-site params object (defaults to `{}`).
41
67
  *
42
- * Pass `context.task.controller.signal` as the first argument to thread
43
- * cancellation from the surrounding action handler through to the
44
- * fetcher. For parameterised resources, pass `null` as the first arg
45
- * when you have params but no signal.
68
+ * Resources do **not** carry any callbacks &ndash; side-effects
69
+ * (broadcasting, logging, model updates) belong in the `useAction`
70
+ * handler that awaited `context.actions.resource(...)`.
71
+ *
72
+ * Every successful fetch writes through to the per-fetcher {@link Cache}
73
+ * (in-memory by default, persistent when an adapter is supplied via the
74
+ * second argument).
46
75
  *
47
76
  * @example
48
77
  * ```ts
49
- * const cat = useResource(resources.cat);
50
- * const actions = useActions<Model, typeof Actions, Data>(
51
- * { cat: cat.else(store.get(Snapshots.Cat)).else(null) },
52
- * () => ({ index, router }),
78
+ * import { Resource, Cache } from "march-hare";
79
+ *
80
+ * export const user = Resource<User, { id: number }>(
81
+ * ({ store, controller, params }) =>
82
+ * ky.get(`users/${params.id}`, {
83
+ * headers: store.session
84
+ * ? { Authorization: `Bearer ${store.session.accessToken}` }
85
+ * : {},
86
+ * signal: controller.signal,
87
+ * }).json<User>(),
53
88
  * );
54
89
  *
90
+ * // Sync cache read at module scope or in the model literal.
91
+ * const cached: User | null = user({ id: 5 });
92
+ *
93
+ * // Fetch inside a handler — controller and Store auto-threaded.
55
94
  * actions.useAction(Actions.Mount, async (context) => {
56
- * const fresh = await cat.if(
57
- * { over: { minutes: 5 } },
58
- * context.task.controller.signal,
59
- * );
60
- * store.set(Snapshots.Cat, cat.snapshot());
61
- * context.actions.produce(({ model }) => void (model.cat = fresh));
95
+ * const data = await context.actions
96
+ * .resource(user({ id: 5 }))
97
+ * .exceeds({ minutes: 5 });
98
+ * context.actions.produce(({ model }) => void (model.user = data));
62
99
  * });
63
100
  * ```
64
101
  */
65
- export declare function useResource<T, P extends object>(resource: ResourceHandle<T, P>): BoundResourceHandle<T, P>;
102
+ export declare function Resource<T, P extends object = Record<never, never>>(fetcher: Fetcher<T, P>, cache?: Cache): Resource<T, P>;