march-hare 0.12.1 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -28,7 +28,7 @@
28
28
  1. [Global data](#global-data)
29
29
  1. [Reusable components](#reusable-components)
30
30
 
31
- For advanced topics, see the [recipes directory](./recipes/).
31
+ For advanced topics, see the [recipes directory](./recipes/). For a worked end-to-end example with the FSD layout, see [`src/example/`](./src/example/README.md).
32
32
 
33
33
  ## Benefits
34
34
 
@@ -40,7 +40,7 @@ For advanced topics, see the [recipes directory](./recipes/).
40
40
  - Reduces context proliferation – events replace many contexts.
41
41
  - No need to memoize callbacks – handlers are stable references with fresh closure access.
42
42
  - Clear separation between business logic and markup.
43
- - Complements [Feature Slice Design](https://feature-sliced.design/) architecture.
43
+ - Complements [Feature Sliced Design](https://feature-sliced.design/) architecture — **App = host, Scope = feature**; see [Reusable components](#reusable-components).
44
44
  - Strongly typed dispatches, models, payloads, etc.
45
45
  - Built-in request cancellation with `AbortController`.
46
46
  - Granular async state tracking per model field.
@@ -393,7 +393,12 @@ Components that mount after a broadcast has already been dispatched automaticall
393
393
 
394
394
  ## Resource handling
395
395
 
396
- For remote data, declare an `app.Resource` at module scope. `resource.user(params)` is the unified call form &mdash; it returns the sync cache read (`User | null`) and primes a slot that `context.actions.resource(resource.user(params))` consumes for the fetch path (with auto-threaded abort controller and a live handle to the per-`<Boundary>` Env). Every successful fetch caches the response in a module-level slot keyed by the fetcher and the stringified params, so different param-sets are independent. Keep all resources in `resources.ts` and pull the whole module in as a namespace (`import * as resource from "./resources"`):
396
+ For remote data, declare an `app.Resource` at module scope. The resulting handle has two call forms:
397
+
398
+ - `resource.user.get(params)` &mdash; synchronous cache read, returns `User | null`. Use it in model literals, JSX, or anywhere you need the cached value without triggering a fetch.
399
+ - `resource.user(params)` &mdash; produces an `Invocation` you pass to `context.actions.resource(...)` for the fetch path (with auto-threaded abort controller and a live handle to the per-`<Boundary>` Env).
400
+
401
+ Every successful fetch caches the response in a module-level slot keyed by the fetcher and the stringified params, so different param-sets are independent. Keep all resources in `resources.ts` and pull the whole module in as a namespace (`import * as resource from "./resources"`):
397
402
 
398
403
  ```ts
399
404
  // resources.ts
@@ -430,7 +435,7 @@ function useActions() {
430
435
  const context = app.useContext<Model, typeof Actions>();
431
436
  const actions = context.useActions({
432
437
  // Sync cache read at the model literal — returns null when nothing is cached.
433
- user: resource.user(),
438
+ user: resource.user.get(),
434
439
  receipt: null,
435
440
  });
436
441
 
@@ -536,21 +541,28 @@ actions.useAction(Actions.Mount, async (context) => {
536
541
 
537
542
  See the [Resource recipe](./recipes/use-resource.md) for the three-tier error handling model, parameterised resources, and limitations.
538
543
 
539
- By default an `app.Resource`'s cache is in-memory only &ndash; it resets on every page load. To keep the most recent successful payload around between sessions, switch to `app.Resource.Cachable(cache, fetcher)`. The cache is the **first** argument &mdash; persistence is the headline of this form, the fetcher is the operation. Every successful fetch writes through to the Cache; first reads via the call form auto-seed from the Cache's adapter:
544
+ By default an `app.Resource`'s cache is in-memory only &ndash; it resets on every page load. To keep the most recent successful payload around between sessions, wire a `Cache` into `App({ cache })`. Every `app.Resource` declared on that App writes through to the shared Cache and seeds from it on the next reload; resources are namespaced internally so they don't collide on shared params keys:
540
545
 
541
546
  ```ts
542
- // resources.ts
543
- import { Cache } from "march-hare";
544
- import { app } from "./app";
547
+ // app.ts
548
+ import { App, Cache } from "march-hare";
545
549
 
546
- const cache = Cache({
547
- get: (key) => localStorage.getItem(key),
548
- set: (key, value) => localStorage.setItem(key, value),
549
- remove: (key) => localStorage.removeItem(key),
550
- clear: () => localStorage.clear(),
550
+ export const app = App({
551
+ env: { session: null as Session | null },
552
+ cache: Cache({
553
+ get: (key) => localStorage.getItem(key),
554
+ set: (key, value) => localStorage.setItem(key, value),
555
+ remove: (key) => localStorage.removeItem(key),
556
+ clear: () => localStorage.clear(),
557
+ }),
551
558
  });
559
+ ```
552
560
 
553
- export const cat = app.Resource.Cachable(cache, (context) =>
561
+ ```ts
562
+ // resources.ts
563
+ import { app } from "./app";
564
+
565
+ export const cat = app.Resource((context) =>
554
566
  fetchCat(context.controller.signal),
555
567
  );
556
568
  ```
@@ -571,7 +583,7 @@ function useActions() {
571
583
  const context = app.useContext<Model, typeof Actions>();
572
584
  const actions = context.useActions({
573
585
  // First render reads the Cache automatically.
574
- cat: resource.cat(),
586
+ cat: resource.cat.get(),
575
587
  });
576
588
 
577
589
  actions.useAction(Actions.Mount, async (context) => {
@@ -594,7 +606,9 @@ export default function CatCard(): React.ReactElement {
594
606
 
595
607
  `Cache()` with no adapter is an in-memory scope &ndash; useful in tests or when you want a holdable cache without persistence. Per-params keying via `JSON.stringify(params)` is automatic, so `user({ id: 5 })` and `user({ id: 6 })` are distinct slots.
596
608
 
597
- See the [storage recipe](./recipes/storage.md) for backend adapters (React Native MMKV, browser extension `chrome.storage`), sign-out purge, and the `unset` sentinel that keeps "nothing stored" distinct from "a legitimately stored null".
609
+ The adapter contract is **strictly synchronous** &ndash; `get` / `set` / `remove` / `clear` all return immediately, with no `Promise`. The model-literal read (`{ user: resource.user.get() }`) is evaluated during render and has no place to wait. React Native projects should use [`react-native-mmkv`](https://github.com/mrousavy/react-native-mmkv), which is sync out of the box and drops straight into the contract; `AsyncStorage` is incompatible. Truly async backends (IndexedDB, `chrome.storage.local`) need a sync facade hydrated at app entry &ndash; see the [storage recipe](./recipes/storage.md).
610
+
611
+ See the [storage recipe](./recipes/storage.md) for backend adapters (React Native `react-native-mmkv`, browser `localStorage`, browser extension `chrome.storage`), sign-out purge, and the `unset` sentinel that keeps "nothing stored" distinct from "a legitimately stored null".
598
612
 
599
613
  ## Channeled actions
600
614
 
@@ -703,7 +717,7 @@ A few rules worth knowing:
703
717
  - **No `scope.Scope()`.** The handle deliberately omits a nested factory. Open another scope by calling `app.Scope<...>()` again and rendering its `<Boundary>` &mdash; that way the multicast surface stays declared at the call site.
704
718
  - **Replay on late-mount is per-scope.** Like broadcast, multicast caches its most recent payload per action symbol; components that mount later inside the same boundary pick up the cached value through their `useAction` handler. See the [mount deduplication recipe](./recipes/mount-broadcast-deduplication.md) if you also fetch in `Lifecycle.Mount()`.
705
719
 
706
- See the [multicast recipe](./recipes/multicast-actions.md) for more details.
720
+ See the [multicast recipe](./recipes/multicast-actions.md) for more details. When the scope itself needs to be reusable across multiple hosts, reach for `shared.Scope<HostEnvs, typeof MulticastActions>()` &mdash; the standalone form covered in [Reusable components](#reusable-components). The rule of thumb: never reach for a second `App()` to get a private channel; that's what multicast scopes exist for.
707
721
 
708
722
  ## Global data
709
723
 
@@ -800,15 +814,16 @@ export const app = App();
800
814
 
801
815
  ## Reusable components
802
816
 
817
+ > **App = host, Scope = feature.** One `App<HostEnv>()` per deployable; everything inside it is a component. A component that needs a private channel reaches for `shared.Scope<HostEnvs, _>()`, never another `App()`. A component that runs under more than one `App` reaches for `shared.useContext<HostEnvs, M, A>()` instead of binding to a specific `app`. That one rule keeps the dependency graph acyclic, lets cross-cutting state (session, locale, permissions) live in a single Env, and gives [Feature Sliced Design](https://feature-sliced.design/) a 1:1 runtime expression &mdash; shared layer reuses `shared.X` against a `HostEnvs` union, features open `shared.Scope`s, hosts declare the App.
818
+
803
819
  Importing `app` from a single location is fine inside a feature, but it breaks when a component needs to run under **more than one** `App` &mdash; a shared `<Profile />` used by both a web app and a mobile shell, for example. For that case, every `app.X` factory has a **standalone counterpart** on the `shared` namespace that takes the Env shape `E` as its mandatory first generic:
804
820
 
805
- | Bound to an App | Standalone (`shared.X`) |
806
- | ---------------------------- | ---------------------------------------- |
807
- | `app.useContext<M, A, D>()` | `shared.useContext<E, M, A, D>()` |
808
- | `app.useEnv()` | `shared.useEnv<E>()` |
809
- | `app.Resource<T, P>(...)` | `shared.Resource<E, T, P>(...)` |
810
- | `app.Resource.Cachable(...)` | `shared.Resource.Cachable<E, T, P>(...)` |
811
- | `app.Scope<A>()` | `shared.Scope<E, A>()` |
821
+ | Bound to an App | Standalone (`shared.X`) |
822
+ | --------------------------- | --------------------------------- |
823
+ | `app.useContext<M, A, D>()` | `shared.useContext<E, M, A, D>()` |
824
+ | `app.useEnv()` | `shared.useEnv<E>()` |
825
+ | `app.Resource<T, P>(...)` | `shared.Resource<E, T, P>(...)` |
826
+ | `app.Scope<A>()` | `shared.Scope<E, A>()` |
812
827
 
813
828
  The standalone forms take the same runtime path as the App-bound ones &mdash; `E` is purely a type-level binding the caller supplies so reusable code stays App-agnostic.
814
829
 
@@ -876,6 +891,8 @@ function Where(): React.ReactElement {
876
891
  }
877
892
  ```
878
893
 
879
- `shared.Resource<E, T, P>` and `shared.Resource.Cachable<E, T, P>` are the same story for shared resources &mdash; declare them at module scope, pass the Env union as the first generic, and the fetcher's `context.env` is typed against it. `shared.Scope<E, A>()` opens a multicast scope without going through an App handle. See the [reusable components recipe](./recipes/reusable-components.md) for the full pattern including discriminator-keyed switches and the `App()`-with-no-env case.
894
+ `shared.Resource<E, T, P>` is the same story for shared resources &mdash; declare them at module scope, pass the Env union as the first generic, and the fetcher's `context.env` is typed against it. Shared resources always use an isolated in-memory cache; reach for `app.Resource` when persistence is required, since the cache is wired into the App via `App({ cache })`. `shared.Scope<E, A>()` opens a multicast scope without going through an App handle. See the [reusable components recipe](./recipes/reusable-components.md) for the full pattern including discriminator-keyed switches and the `App()`-with-no-env case.
895
+
896
+ When a reusable component or resource is genuinely Env-agnostic &mdash; the fetcher never touches `context.env`, the hook never calls `shared.useEnv` &mdash; pass `Envless` as `E` instead of spelling out `Record<never, never>`: `shared.Resource<Envless, T>`, `shared.useContext<Envless, M, A>()`. It's a named alias for the empty-record shape exported from `march-hare`, kept around purely for legibility at the call site.
880
897
 
881
898
  For one-line handler binding &mdash; flipping a boolean, assigning a payload to a leaf, pinning a field to a fixed value &mdash; reach for `context.with.{update,invert,always}`. See the [`With` helpers recipe](./recipes/with-helpers.md) for the full surface.
@@ -1,67 +1,13 @@
1
1
  import { Env } from '../boundary/components/env/index';
2
+ import { Cache } from '../cache/index';
2
3
  import { Actions, Model, Props } from '../types/index';
3
- import { Scope } from '../scope/index';
4
- import { AppContextHandle, AppResource } from './types';
4
+ import { AppHandle, AppContextHandle } from './types';
5
5
  import { Tap } from '../boundary/components/tap/types';
6
- import * as React from "react";
7
6
  export type { AppArgs, AppContextHandle, AppFetcher, AppResource, } from './types';
7
+ export type { AppHandle } from './types';
8
8
  /**
9
- * Returned from {@link App}. Bundles the Boundary, hooks, and Resource
10
- * factory bound to a single typed Env shape `S`.
11
- */
12
- export type App<S extends object> = {
13
- /**
14
- * Boundary component for this App. Wraps the subtree with the `env`
15
- * and `tap` declared on {@link App} &mdash; both are fixed at App
16
- * construction time and cannot be overridden at the render site.
17
- * Runtime mutations to the Env flow through
18
- * `context.actions.produce(({ env }) => { ... })`; if a test or
19
- * storybook needs a different initial Env, declare a separate `App`.
20
- */
21
- readonly Boundary: React.FC<{
22
- children: React.ReactNode;
23
- }>;
24
- /**
25
- * Hook returning a stable `Context` handle. The handle's
26
- * `context.useActions(model?, getData?)` materialises the
27
- * component's `[model, actions, data]` tuple. Every handler's
28
- * `context.env` is typed as `S`.
29
- */
30
- readonly useContext: <M extends Model | void = void, AC extends Actions | void = void, D extends Props = Props>() => AppContextHandle<M, AC, D, S>;
31
- /**
32
- * Read-only Proxy over the per-Boundary Env, typed as `S`. Reads use
33
- * dot notation (`env.session`) and always reflect the latest value
34
- * across `await` boundaries. Writes flow through
35
- * `context.actions.produce(({ env }) => { ... })`.
36
- */
37
- readonly useEnv: () => Readonly<S>;
38
- /**
39
- * `Resource` factory bound to this App's Env. Same shape as the
40
- * top-level `Resource`: call directly for an in-memory cache, or use
41
- * `app.Resource.Cachable(cache, fetcher)` for persistence.
42
- */
43
- readonly Resource: AppResource<S>;
44
- /**
45
- * Opens a typed multicast scope. The generic `MulticastActions` declares
46
- * the `Distribution.Multicast` action class (or union of classes)
47
- * whose dispatches are routed through this scope &mdash; the
48
- * returned handle mirrors the App surface but widens
49
- * `useContext().actions.dispatch` to accept actions from `MulticastActions`
50
- * on top of the local `AC` class.
51
- *
52
- * Render `<scope.Boundary>` to open the scope at runtime; nesting
53
- * multiple boundaries from different `app.Scope()` calls is fine,
54
- * each runs as an independent emitter shadowed for its subtree.
55
- *
56
- * The Scope handle deliberately does NOT expose a further `Scope`
57
- * method &mdash; the multicast surface must be declared at the
58
- * `app.Scope<MulticastActions>()` call site so the type union is explicit.
59
- */
60
- readonly Scope: <MulticastActions>() => Scope<S, MulticastActions>;
61
- };
62
- /**
63
- * Creates an `App` &mdash; the entrypoint for a typed Env shape `S`,
64
- * inferred from `config.env`. `App<S>` exposes `Boundary`, hooks, and
9
+ * Creates an `App` &mdash; the entrypoint for a typed Env shape `E`,
10
+ * inferred from `config.env`. `App<E>` exposes `Boundary`, hooks, and
65
11
  * a `Resource` factory all wired against the same shape.
66
12
  *
67
13
  * Each `<app.Boundary>` instance owns its own Env, so different `App`s
@@ -69,15 +15,22 @@ export type App<S extends object> = {
69
15
  *
70
16
  * Pass `tap` to subscribe to every action handler's dispatch / settle /
71
17
  * error inside the boundary &mdash; useful for analytics, audit logging,
72
- * Sentry breadcrumbs. See `recipes/tap.md`. Both `env` and `tap` are
73
- * fixed at `App()` time; `<app.Boundary>` does not accept overrides.
74
- * Mutate the live Env through `context.actions.produce(({ env }) => …)`,
75
- * and declare a separate `App` when a test or storybook needs a
76
- * different initial value.
18
+ * Sentry breadcrumbs. See `recipes/tap.md`. Pass `cache` to persist
19
+ * every `app.Resource(fetcher)` declaration through a single
20
+ * {@link Cache} &mdash; each resource is namespaced inside the cache by
21
+ * its declaration order, so reloads seed from storage automatically and
22
+ * resources do not collide on shared params keys. Omit `cache` to keep
23
+ * each resource's payloads in an isolated in-memory slot.
24
+ *
25
+ * `env`, `tap`, and `cache` are all fixed at `App()` time;
26
+ * `<app.Boundary>` does not accept overrides. Mutate the live Env
27
+ * through `context.actions.produce(({ env }) => …)`, and declare a
28
+ * separate `App` when a test or storybook needs a different initial
29
+ * value.
77
30
  *
78
31
  * @example
79
32
  * ```tsx
80
- * import { App, type Taps } from "march-hare";
33
+ * import { App, Cache, type Taps } from "march-hare";
81
34
  *
82
35
  * type Session = { accessToken: string };
83
36
  *
@@ -93,6 +46,12 @@ export type App<S extends object> = {
93
46
  * operating: "idle" as "idle" | "signing-out",
94
47
  * },
95
48
  * tap,
49
+ * cache: Cache({
50
+ * get: (key) => localStorage.getItem(key),
51
+ * set: (key, value) => localStorage.setItem(key, value),
52
+ * remove: (key) => localStorage.removeItem(key),
53
+ * clear: () => localStorage.clear(),
54
+ * }),
96
55
  * });
97
56
  *
98
57
  * // Root render.
@@ -100,21 +59,7 @@ export type App<S extends object> = {
100
59
  * <Root />
101
60
  * </app.Boundary>;
102
61
  *
103
- * // In a feature's actions.ts:
104
- * export function useAuthActions() {
105
- * const context = app.useContext<void, typeof Actions>();
106
- * const actions = context.useActions();
107
- *
108
- * actions.useAction(Actions.SignOut, async (context) => {
109
- * context.actions.produce(({ env }) => {
110
- * env.session = null;
111
- * });
112
- * });
113
- *
114
- * return actions;
115
- * }
116
- *
117
- * // In resources.ts:
62
+ * // In resources.ts &mdash; persisted via the App's cache.
118
63
  * export const user = app.Resource<User>((context) =>
119
64
  * ky
120
65
  * .get("/api/user", {
@@ -127,10 +72,11 @@ export type App<S extends object> = {
127
72
  * );
128
73
  * ```
129
74
  */
130
- export declare function App<S extends object = Env>(config?: {
131
- env?: S;
75
+ export declare function App<E extends object = Env>(config?: {
76
+ env?: E;
132
77
  tap?: Tap;
133
- }): App<S>;
78
+ cache?: Cache;
79
+ }): AppHandle<E>;
134
80
  /**
135
81
  * Standalone counterpart to `app.useContext`, exported as
136
82
  * `shared.useContext` &mdash; same call shape, but takes the **Env
@@ -1,47 +1,100 @@
1
1
  import { Args, ResourceHandle } from '../resource/types';
2
- import { Cache } from '../cache/index';
3
2
  import { Actions, Context, Model, Props, UseActions } from '../types/index';
4
3
  import { Data } from '../actions/types';
5
4
  import { Env } from '../boundary/components/env/index';
6
- import { WithHandle } from '../with/index';
5
+ import { WithHandle } from '../with/types';
7
6
  /**
8
7
  * Args object passed to an `app.Resource` fetcher. Same shape as the
9
- * base `Resource` fetcher's args but with `env` typed as `S`.
8
+ * base `Resource` fetcher's args but with `env` typed as `E`.
10
9
  */
11
- export type AppArgs<S, P extends object = Record<never, never>> = Omit<Args<P>, "env"> & {
12
- readonly env: Readonly<S>;
10
+ export type AppArgs<E, P extends object = Record<never, never>> = Omit<Args<P>, "env"> & {
11
+ readonly env: Readonly<E>;
13
12
  };
14
13
  /**
15
14
  * Fetcher signature for an `app.Resource` declaration. The fetcher's
16
- * `context.env` is typed against the App's inferred Env shape `S`.
15
+ * `context.env` is typed against the App's inferred Env shape `E`.
17
16
  */
18
- export type AppFetcher<S, T, P extends object = Record<never, never>> = (context: AppArgs<S, P>) => Promise<T>;
17
+ export type AppFetcher<E, T, P extends object = Record<never, never>> = (context: AppArgs<E, P>) => Promise<T>;
19
18
  /**
20
- * `app.Resource(fetcher)` &mdash; in-memory cache, no persistence.
21
- * `app.Resource.Cachable(cache, fetcher)` &mdash; persistent cache wired
22
- * to the supplied `Cache` adapter. Both forms type `context.env` as
23
- * the App's Env shape.
19
+ * `app.Resource(fetcher)` declares a remote interaction bound to the
20
+ * App's Env shape. Cache behaviour is decided at App construction:
21
+ * pass `App({ cache })` to share a single {@link Cache} (typically
22
+ * backed by `localStorage`/MMKV) across every resource on the App, or
23
+ * omit it to keep each resource's payloads in an isolated in-memory
24
+ * slot.
24
25
  */
25
- export type AppResource<S> = {
26
- <T, P extends object = Record<never, never>>(fetcher: AppFetcher<S, T, P>): ResourceHandle<T, P>;
27
- readonly Cachable: <T, P extends object = Record<never, never>>(cache: Cache, fetcher: AppFetcher<S, T, P>) => ResourceHandle<T, P>;
28
- };
26
+ export type AppResource<E> = <T, P extends object = Record<never, never>>(fetcher: AppFetcher<E, T, P>) => ResourceHandle<T, P>;
29
27
  /**
30
28
  * Tuple shape returned by `context.useActions(...)` on an App-bound
31
- * Context. Re-exports the base {@link UseActions} with the App's `S`
29
+ * Context. Re-exports the base {@link UseActions} with the App's `E`
32
30
  * threaded through every `HandlerContext` and produce draft.
33
31
  */
34
- type AppActionsResult<M, AC, D, S> = UseActions<M extends Model | void ? M : void, AC extends Actions | void ? AC : void, D extends Props ? D : Props, S extends Env ? S : Env>;
32
+ type AppActionsResult<M, AC, D, E> = UseActions<M extends Model | void ? M : void, AC extends Actions | void ? AC : void, D extends Props ? D : Props, E extends Env ? E : Env>;
35
33
  /**
36
34
  * `useActions(...)` signature on the App-bound Context. Has two forms:
37
35
  * void-model components omit the model argument entirely; everyone else
38
36
  * passes their initial model as the first argument and an optional data
39
37
  * callback as the second.
40
38
  */
41
- type AppUseActions<M, AC, D, S> = M extends void ? (getData?: Data<D & Props>) => AppActionsResult<M, AC, D, S> : (model: M, getData?: Data<D & Props>) => AppActionsResult<M, AC, D, S>;
39
+ type AppUseActions<M, AC, D, E> = M extends void ? (getData?: Data<D & Props>) => AppActionsResult<M, AC, D, E> : (model: M, getData?: Data<D & Props>) => AppActionsResult<M, AC, D, E>;
40
+ /**
41
+ * Returned from {@link App}. Bundles the Boundary, hooks, and Resource
42
+ * factory bound to a single typed Env shape `E`.
43
+ */
44
+ export type AppHandle<E extends object> = {
45
+ /**
46
+ * Boundary component for this App. Wraps the subtree with the `env`
47
+ * and `tap` declared on {@link App} &mdash; both are fixed at App
48
+ * construction time and cannot be overridden at the render site.
49
+ * Runtime mutations to the Env flow through
50
+ * `context.actions.produce(({ env }) => { ... })`; if a test or
51
+ * storybook needs a different initial Env, declare a separate `App`.
52
+ */
53
+ readonly Boundary: import('react').FC<{
54
+ children: import('react').ReactNode;
55
+ }>;
56
+ /**
57
+ * Hook returning a stable `Context` handle. The handle's
58
+ * `context.useActions(model?, getData?)` materialises the
59
+ * component's `[model, actions, data]` tuple. Every handler's
60
+ * `context.env` is typed as `E`.
61
+ */
62
+ readonly useContext: <M extends Model | void = void, AC extends Actions | void = void, D extends Props = Props>() => AppContextHandle<M, AC, D, E>;
63
+ /**
64
+ * Read-only Proxy over the per-Boundary Env, typed as `E`. Reads use
65
+ * dot notation (`env.session`) and always reflect the latest value
66
+ * across `await` boundaries. Writes flow through
67
+ * `context.actions.produce(({ env }) => { ... })`.
68
+ */
69
+ readonly useEnv: () => Readonly<E>;
70
+ /**
71
+ * `Resource` factory bound to this App's Env. Resources declared
72
+ * through this factory share the cache passed to `App({ cache })`
73
+ * &mdash; or fall back to a per-resource in-memory slot when no
74
+ * cache is configured on the App.
75
+ */
76
+ readonly Resource: AppResource<E>;
77
+ /**
78
+ * Opens a typed multicast scope. The generic `MulticastActions` declares
79
+ * the `Distribution.Multicast` action class (or union of classes)
80
+ * whose dispatches are routed through this scope &mdash; the
81
+ * returned handle mirrors the App surface but widens
82
+ * `useContext().actions.dispatch` to accept actions from `MulticastActions`
83
+ * on top of the local `AC` class.
84
+ *
85
+ * Render `<scope.Boundary>` to open the scope at runtime; nesting
86
+ * multiple boundaries from different `app.Scope()` calls is fine,
87
+ * each runs as an independent emitter shadowed for its subtree.
88
+ *
89
+ * The Scope handle deliberately does NOT expose a further `Scope`
90
+ * method &mdash; the multicast surface must be declared at the
91
+ * `app.Scope<MulticastActions>()` call site so the type union is explicit.
92
+ */
93
+ readonly Scope: <MulticastActions>() => import('../scope/types.ts').ScopeHandle<E, MulticastActions>;
94
+ };
42
95
  /**
43
96
  * `Context` handle returned by `app.useContext()`. Mirrors the base
44
- * {@link Context} but threads the App's Env shape `S` through every
97
+ * {@link Context} but threads the App's Env shape `E` through every
45
98
  * handler's `context.env` and produce draft.
46
99
  *
47
100
  * @template M The model type for the component's state, or `void`.
@@ -49,9 +102,9 @@ type AppUseActions<M, AC, D, S> = M extends void ? (getData?: Data<D & Props>) =
49
102
  * definitions, or `void` for actions-only consumers.
50
103
  * @template D The reactive data type returned from the `useActions(...)`
51
104
  * data callback.
52
- * @template S The App's Env shape, supplied at `App({env})` time.
105
+ * @template E The App's Env shape, supplied at `App({env})` time.
53
106
  */
54
- export type AppContextHandle<M, AC, D, S> = {
107
+ export type AppContextHandle<M, AC, D, E> = {
55
108
  /**
56
109
  * Stable dispatch surface available before `useActions(...)` runs.
57
110
  * Exposes only `dispatch(action, payload?)` &mdash; useful when an
@@ -77,6 +130,6 @@ export type AppContextHandle<M, AC, D, S> = {
77
130
  * second &mdash; the callback re-runs every render so handlers reading
78
131
  * `context.data` always see fresh values across `await` boundaries.
79
132
  */
80
- readonly useActions: AppUseActions<M, AC, D, S>;
133
+ readonly useActions: AppUseActions<M, AC, D, E>;
81
134
  };
82
135
  export {};
@@ -3,17 +3,28 @@ import * as React from "react";
3
3
  export { useEnv } from './utils';
4
4
  /**
5
5
  * Loose runtime shape for the per-`<Boundary>` Env. Each {@link App}
6
- * narrows this to its own typed env via `App<S>({ env })`; the
6
+ * narrows this to its own typed env via `App<E>({ env })`; the
7
7
  * loose type exists so the framework's internal plumbing
8
8
  * (`<Boundary>`, `useEnv`, handler `context.env`, Resource
9
- * fetcher `context.env`) does not need to be parametric over S.
9
+ * fetcher `context.env`) does not need to be parametric over E.
10
10
  *
11
11
  * Consumers should declare their Env shape inline via `App({ env })`
12
- * &mdash; the inferred `S` is what flows through `app.useContext`,
12
+ * &mdash; the inferred `E` is what flows through `app.useContext`,
13
13
  * `app.useEnv`, and `app.Resource`. Module augmentation of `Env`
14
14
  * is no longer required.
15
15
  */
16
16
  export type Env = Record<string, unknown>;
17
+ /**
18
+ * `E` generic for `shared.X<E, ...>` factories whose callers don't read
19
+ * anything off the Env. Equivalent to `Record<never, never>` &mdash; the
20
+ * named alias keeps consumer sites legible (`shared.Resource<Envless, T>`
21
+ * over `shared.Resource<Record<never, never>, T>`) and signals intent.
22
+ *
23
+ * Reach for `Envless` only when the component or resource is genuinely
24
+ * Env-agnostic. Anything that reads `context.env.x` should declare the
25
+ * required shape (or a union of host Envs) as `E` instead.
26
+ */
27
+ export type Envless = Record<never, never>;
17
28
  /**
18
29
  * Provides a per-Boundary {@link Env} value to every component inside
19
30
  * the boundary. Usually wired in via the `<Boundary env={initial}>`
@@ -1,8 +1,8 @@
1
- import { PendingCall } from '../../../resource/index';
1
+ import { Invocation } from '../../../resource/index';
2
2
  import * as React from "react";
3
3
  /**
4
4
  * Per-`<Boundary>` registry for `.coalesce(token)` sharing. Outer map
5
- * keys on the `PendingCall.run` function identity (stable per Resource
5
+ * keys on the `Invocation.run` function identity (stable per Resource
6
6
  * via the `build()` closure); inner map keys on
7
7
  * `${paramsKey}|${coalesceKey(token)}`. While an entry exists every
8
8
  * caller awaiting `.coalesce(token)` for the same Resource + params +
@@ -14,7 +14,7 @@ import * as React from "react";
14
14
  *
15
15
  * @internal
16
16
  */
17
- export type Sharing = WeakMap<PendingCall["run"], Map<string, Promise<unknown>>>;
17
+ export type Sharing = WeakMap<Invocation<unknown, object>["run"], Map<string, Promise<unknown>>>;
18
18
  /**
19
19
  * React context exposing the per-Boundary sharing registry. The
20
20
  * fallback is a fresh `WeakMap` used when `useSharing()` is read
@@ -5,9 +5,9 @@ import * as React from "react";
5
5
  * Env, and Tasks providers required by every March Hare hook.
6
6
  *
7
7
  * Most applications should reach for {@link App} instead &mdash;
8
- * `App<S>({ env })` returns a typed `app.Boundary` along with
8
+ * `App<E>({ env })` returns a typed `app.Boundary` along with
9
9
  * matching `useContext` / `useEnv` / `Resource` factories that all
10
- * close over the App's inferred env shape `S`. The bare `Boundary`
10
+ * close over the App's inferred env shape `E`. The bare `Boundary`
11
11
  * is exposed for advanced or library-internal use where the loose
12
12
  * Env record type is sufficient.
13
13
  *
@@ -18,7 +18,7 @@ import type * as React from "react";
18
18
  export type Props = {
19
19
  /**
20
20
  * Initial value of the per-Boundary {@link Env}. Prefer `App({ env })`
21
- * &mdash; it infers the Env shape `S` and threads it through
21
+ * &mdash; it infers the Env shape `E` and threads it through
22
22
  * `app.useContext`, `app.useEnv`, and `app.Resource`, so handler
23
23
  * `context.env` is typed accordingly. Pass `env` directly here only
24
24
  * for advanced cases where the loose record type is sufficient.
@@ -2,18 +2,28 @@ import { Adapter, Stored } from './types';
2
2
  export type { Adapter, Encoded } from './types';
3
3
  /**
4
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.
5
+ * **strictly synchronous** {@link Adapter} (localStorage, MMKV,
6
+ * chrome.storage with a sync facade, etc.) and traffics in {@link
7
+ * Stored} envelopes &mdash; storage entries serialise as {@link
8
+ * Encoded}`<T>` so the `Temporal.Instant` timestamp survives the
9
+ * string round-trip and `.exceeds({...})` can short-circuit on the
10
+ * persisted timestamp after a reload.
11
+ *
12
+ * Every method on the Cache is sync &mdash; the model-literal sync
13
+ * read has no place to wait, so the adapter contract foregoes
14
+ * `Promise` entirely. Async backends (IndexedDB, AsyncStorage,
15
+ * `chrome.storage.local`) need a sync facade hydrated at app entry;
16
+ * see `recipes/storage.md` for the pattern. React Native projects
17
+ * should reach for {@link https://github.com/mrousavy/react-native-mmkv
18
+ * `react-native-mmkv`} &mdash; it's synchronous out of the box and
19
+ * drops straight into the Adapter contract.
11
20
  *
12
21
  * 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
22
+ * instance &mdash; useful for tests, ephemeral state, or when you
23
+ * want a first-class cache object to share between Resources without
15
24
  * persistence. Pass an {@link Adapter} to back the cache with a
16
- * persistent store.
25
+ * persistent store; when supplied, the adapter is the **only** tier
26
+ * &mdash; the Cache does not maintain a separate in-memory mirror.
17
27
  *
18
28
  * @example
19
29
  * ```ts
@@ -36,9 +46,55 @@ export type { Adapter, Encoded } from './types';
36
46
  * ```
37
47
  */
38
48
  export type Cache = {
49
+ /**
50
+ * Returns the {@link Stored} envelope for `key`. The envelope is
51
+ * `empty()` when nothing is persisted; otherwise it carries the
52
+ * decoded payload and the timestamp recorded at write-time.
53
+ *
54
+ * @template T The payload type expected at `key`.
55
+ * @param key Cache slot identifier &mdash; usually the JSON-stringified
56
+ * call-site params, prefixed by the Resource's namespace.
57
+ */
39
58
  get<T>(key: string): Stored<T>;
40
- set<T>(key: string, value: Stored<T>): boolean;
59
+ /**
60
+ * Writes `value` to `key`. Skipped when the envelope has no concrete
61
+ * payload (e.g. an `empty()` slot), since there is nothing meaningful
62
+ * to persist. Serialisation, quota errors, and unserialisable payloads
63
+ * are swallowed &mdash; writes are best-effort.
64
+ *
65
+ * @template T The payload type contained in `value`.
66
+ * @param key Cache slot identifier &mdash; usually the JSON-stringified
67
+ * call-site params, prefixed by the Resource's namespace.
68
+ * @param value Stored envelope carrying the payload and its
69
+ * write-time `Temporal.Instant`.
70
+ */
71
+ set<T>(key: string, value: Stored<T>): void;
72
+ /**
73
+ * Drops a single cache slot. Best-effort &mdash; backing-store errors
74
+ * are swallowed.
75
+ *
76
+ * @param key Cache slot identifier.
77
+ */
41
78
  remove(key: string): void;
79
+ /**
80
+ * Drops every cache slot in the backing store. Best-effort &mdash;
81
+ * backing-store errors are swallowed.
82
+ */
42
83
  clear(): void;
84
+ /**
85
+ * Returns every key currently held by the backing store. Used by
86
+ * partial-match eviction (`evict(where)`) to iterate slots whose
87
+ * stored params satisfy a `where` pattern.
88
+ */
89
+ keys(): Iterable<string>;
43
90
  };
91
+ /**
92
+ * Constructs a {@link Cache} backed by `adapter`, or by an in-memory
93
+ * `Map` when none is supplied. The returned object is the same shape
94
+ * regardless &mdash; only the durability differs.
95
+ *
96
+ * @param adapter Optional synchronous backing store (localStorage, MMKV,
97
+ * or a custom sync facade). Omit for an in-memory cache scoped to
98
+ * this instance.
99
+ */
44
100
  export declare function Cache(adapter?: Adapter): Cache;