@firsthandjs/data 0.5.0 → 0.6.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/README.md CHANGED
@@ -1,20 +1,22 @@
1
1
  # @firsthandjs/data
2
2
 
3
- Resources, actions and invalidation for Firsthand: what is loaded, as reactive
4
- state, and when it has to be loaded again.
3
+ Data that comes from outside the reactive graph, brought in as reactive state:
4
+ what is loaded, what state that is in, and when it has to be loaded again.
5
5
 
6
- **Documentation:** [guide](https://github.com/firsthandjs/firsthand/blob/main/docs/guide/09-data.md) · [API reference](https://github.com/firsthandjs/firsthand/blob/main/docs/reference/data.md) · [ADR-0022](https://github.com/firsthandjs/firsthand/blob/main/docs/adr/0022-resources-not-a-cache.md)
6
+ **Documentation:** [guide](https://github.com/firsthandjs/firsthand/blob/main/docs/guide/09-data.md) · [API reference](https://github.com/firsthandjs/firsthand/blob/main/docs/reference/data.md) · [ADR-0022](https://github.com/firsthandjs/firsthand/blob/main/docs/adr/0022-resources-not-a-cache.md) · [ADR-0023](https://github.com/firsthandjs/firsthand/blob/main/docs/adr/0023-one-cache-at-the-transport-edge.md)
7
7
 
8
8
  ```
9
9
  npm install @firsthandjs/data
10
10
  ```
11
11
 
12
- 2.81 kB gzip. It depends on `@firsthandjs/core` and `@firsthandjs/dom`.
12
+ 3.65 kB gzip. It depends on `@firsthandjs/core` and `@firsthandjs/dom`.
13
13
 
14
14
  ```tsx
15
- const user = useResource(async ({ signal, tags }) => {
15
+ const api = createFetchClient({ baseUrl: '/api' });
16
+
17
+ const user = useResource(({ request, tags }) => {
16
18
  tags(tag('user', { id: props.id }));
17
- return json<User>(`/api/users/${props.id}`)({ signal });
19
+ return api.get<User>(`/users/${props.id}`)(request);
18
20
  });
19
21
 
20
22
  return <h1>{user.data.value?.name ?? '…'}</h1>;
@@ -24,65 +26,74 @@ No key, no name, no variables object. `props.id` is read inside the loader, so
24
26
  changing it runs the loader again and aborts what was in flight — exactly as an
25
27
  `effect` behaves, and for the same reason.
26
28
 
27
- ## What it is not
29
+ A server is the common case, not the only one: a worker, IndexedDB, a
30
+ WebSocket, a computation too expensive to repeat are all the same shape.
28
31
 
29
- **It is not a cache.** A cache is defined by a second lookup for the same thing
30
- finding the first one's result, and that needs identity: a key, a name,
31
- something two callers agree on. Every form of that is a thing to forget or
32
- collide on. A resource belongs to its call site instead, and two call sites are
33
- two resources whatever their tags say.
32
+ ## Two layers
34
33
 
35
- Deduplication, response caching and normalisation belong one layer down, where
36
- the knowledge of what is _the same thing_ actually livesApollo's cache, urql's
37
- `cacheExchange`, the browser's HTTP cache, or three lines of your own.
34
+ **Reactivity** `useResource`, `useAction`, tags knows who is watching what,
35
+ what state it is in, and when it must run again. **Transport** a client
36
+ knows how to send one request. Between them is one object:
38
37
 
39
- | Layer | Owns | Who |
40
- | ------------- | ---------------------------------------- | ----------------------------- |
41
- | Normalisation | one entity, one truth, everywhere | Apollo, urql-graphcache |
42
- | Request cache | not asking twice | those clients, the HTTP cache |
43
- | **Resources** | **reactive state, status, invalidation** | **this package** |
38
+ ```ts
39
+ type Loader<T> = (request: { signal: AbortSignal; force: boolean }) => Promise<T>;
40
+ ```
44
41
 
45
- ## Tags are for invalidation
42
+ `signal` ends a request nobody wants. `force` says this run exists _because_
43
+ something was invalidated, so a cache may not answer it. That is the whole
44
+ contract, which is why a loader can be any function at all.
46
45
 
47
- They may be coarse, they may overlap, and two unrelated sources may share one.
48
- That is the feature — and it is safe, because they are not identity.
46
+ ## The cache is the transport's
49
47
 
50
- ```tsx
51
- const rename = useAction(async (input: Rename, { signal, invalidates }) => {
52
- const changed = await json<Changed>(`/api/users/by-name/${input.name}`, {
53
- method: 'PATCH',
54
- json: input,
55
- signal,
56
- })({ signal });
57
- // The client knew a name; only the server knows which id that was.
58
- invalidates(...changed.tags);
59
- return changed.user;
60
- });
48
+ A cache answers "have I got this already?", which needs to know when two things
49
+ are the same thing. A resource belongs to its call site, so that knowledge does
50
+ not exist in the reactivity layer — but at the transport it is right there in
51
+ the request.
52
+
53
+ ```ts
54
+ const cache = createCacheClient({ ttl: 30_000 });
55
+
56
+ // In front of a client…
57
+ const api = createFetchClient({ baseUrl: '/api', cache });
58
+
59
+ // …or in front of anything else, which is the other half of what it is for.
60
+ const report = useResource(({ request }) =>
61
+ cache.read(`report:${month.value}`, () => buildReport(month.value))(request),
62
+ );
61
63
  ```
62
64
 
63
- Declare them where you know them: before the `await` when the caller does,
64
- after it when only the server does. `tags()` **replaces**, so a run says what
65
- it is about rather than accumulating what it used to be about.
65
+ One cache, both jobs. With no `ttl` it still shares what is in flight — ten
66
+ components asking at once make one request, which is waste removed rather than
67
+ staleness introduced. `force` drops the entry, which is how an invalidation
68
+ reaches all the way down.
66
69
 
67
- ## Reaching past a cache
70
+ ## Tags are for invalidation
68
71
 
69
- The loader is told _why_ it is running. Without that, a transport cache would
70
- hand back the answer that was just invalidated:
72
+ They may be coarse, they may overlap, and two unrelated sources may share one.
73
+ Nothing is ever looked up by them, which is what makes that safe.
71
74
 
72
75
  ```tsx
73
- useResource(async ({ signal, force }) =>
74
- json<User>('/api/users/5', { cache: force ? 'reload' : 'default' })({ signal }),
75
- );
76
+ const rename = useAction((name: string, { request, invalidates }) => {
77
+ invalidates(tag('user', { id: props.id }), tag('users'));
78
+ return api.patch<User>(`/users/${props.id}`, { json: { name } })(request);
79
+ });
76
80
  ```
77
81
 
78
- `fetchPolicy: 'network-only'` for Apollo, `requestPolicy` for urql the
79
- helper packages do it for you.
82
+ `tags()` **replaces**, so a run says what it is about rather than accumulating
83
+ what it used to be about — and it may be called after the answer, for the case
84
+ where only the server knows which user `/users/me` was.
80
85
 
81
86
  ## Bringing a client
82
87
 
83
- `@firsthandjs/data-axios`, `-urql` and `-apollo` bind an instance **you** built:
84
- your interceptors, your links, your authentication. None of them depends on the
85
- client it binds, so none of them has a version to follow.
88
+ `createFetchClient` is a small REST client on the browser's own `fetch`: a base
89
+ URL, headers read per request so a token may change, a failed status thrown,
90
+ the abort signal wired through, and the cache.
91
+
92
+ For anything else, [`@firsthandjs/data-axios`](https://www.npmjs.com/package/@firsthandjs/data-axios),
93
+ [`-urql`](https://www.npmjs.com/package/@firsthandjs/data-urql) and
94
+ [`-apollo`](https://www.npmjs.com/package/@firsthandjs/data-apollo) bind an
95
+ instance **you** built: your interceptors, your links, your authentication.
96
+ None of them depends on the client it binds, so none has a version to follow.
86
97
 
87
98
  For GraphQL, tags come out of the `.gql` file as `@tag` / `@invalidates`
88
99
  directives, read at build time by `@firsthandjs/data/vite` and typed by
@@ -91,9 +102,9 @@ object with the directives already removed.
91
102
 
92
103
  ## What it deliberately does not do
93
104
 
94
- Interceptors, retries, backoff, token refresh, request de-duplication, progress
95
- events, XSRF, a Node adapter, normalisation, optimistic cache surgery,
96
- pagination helpers. Each belongs to a transport or to an application's own
97
- policy — and a loader takes any client, because it takes any function.
105
+ Interceptors, retries, backoff, token refresh, progress events, XSRF,
106
+ normalisation, optimistic cache surgery, pagination helpers. Each belongs to a
107
+ transport or to an application's own policy — and a loader takes any client,
108
+ because it takes any function.
98
109
 
99
110
  MIT licensed.
@@ -0,0 +1,86 @@
1
+ /**
2
+ * The cache — one, and the transport layer's, not the store's.
3
+ *
4
+ * ADR-0022 says resources are not a cache, and they are not: a resource
5
+ * belongs to its call site, and two call sites are two resources. What that
6
+ * argument leaves open is that the *transport* wants one, and an application
7
+ * that brings no client has nowhere to put it. This is that place.
8
+ *
9
+ * It is deliberately one cache for two jobs. `createFetchClient` keeps its
10
+ * responses here, and an algorithm of your own — an expensive computation, a
11
+ * worker round trip, a read out of IndexedDB — keeps its results here through
12
+ * exactly the same `read`. There is no second, hidden implementation inside
13
+ * the fetch client, because two caches with two sets of rules is the thing
14
+ * this project spent an ADR refusing.
15
+ *
16
+ * ```ts
17
+ * const cache = createCacheClient({ ttl: 30_000 });
18
+ *
19
+ * const primes = useResource((context) =>
20
+ * cache.read(`primes:${limit.value}`, () => sieve(limit.value))(context.request),
21
+ * );
22
+ * ```
23
+ *
24
+ * What it does, and the whole of it:
25
+ *
26
+ * - **Serves a fresh entry** without running the producer. Freshness is `ttl`,
27
+ * which is 0 by default: nothing is reused, but see the next point.
28
+ * - **Deduplicates what is in flight.** Two callers asking for one key while
29
+ * the request is out get one request and both get its answer — at any `ttl`,
30
+ * because two identical requests overlapping in time is waste rather than
31
+ * staleness.
32
+ * - **Honours `force`.** An invalidated resource reaches through: the entry is
33
+ * dropped, the producer runs, and the answer replaces what was there. This
34
+ * is the point where the store's invalidation and the transport's memory
35
+ * meet, and without it an invalidation would be answered out of the cache it
36
+ * was meant to defeat.
37
+ * - **Forgets.** `forget(key)`, `forget()` for all of it, and the oldest entry
38
+ * goes when `max` is reached.
39
+ */
40
+ import type { Loader } from './store.js';
41
+ export interface CacheOptions {
42
+ /**
43
+ * How long an answer is served again without running the producer, in ms.
44
+ *
45
+ * Default 0: every run asks again, and only what is *in flight* is shared.
46
+ * That is the safe default — an application that has not thought about
47
+ * staleness does not silently get some.
48
+ */
49
+ readonly ttl?: number;
50
+ /**
51
+ * How many entries to keep. The least recently read goes first. Default 100.
52
+ *
53
+ * A cache without a bound is a leak with a plan, and the number is here
54
+ * rather than in a comment because only the application knows what its
55
+ * entries weigh.
56
+ */
57
+ readonly max?: number;
58
+ /** For tests: what `Date.now()` should be. */
59
+ readonly now?: () => number;
60
+ }
61
+ export interface CacheClient {
62
+ /**
63
+ * Wraps a producer so its answer is kept under `key`.
64
+ *
65
+ * The producer is given a request of the cache's own: its signal is aborted
66
+ * when *every* waiter has gone, so one component leaving does not cancel a
67
+ * request another is still waiting for.
68
+ */
69
+ read<T>(key: string, produce: Loader<T>): Loader<T>;
70
+ /**
71
+ * Puts a value in directly — a push from a socket, a known first page.
72
+ *
73
+ * A value you hand over is not an answer that might already be stale, so
74
+ * with no `ttl` configured it stays until it is forgotten rather than
75
+ * expiring immediately.
76
+ */
77
+ write(key: string, value: unknown): void;
78
+ /** Reads what is there without running anything. `undefined` if stale. */
79
+ peek(key: string): unknown;
80
+ /** Forgets one key, or everything. */
81
+ forget(key?: string): void;
82
+ /** How many entries are held, in flight included. */
83
+ readonly size: number;
84
+ }
85
+ export declare function createCacheClient(options?: CacheOptions): CacheClient;
86
+ //# sourceMappingURL=cache.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cache.d.ts","sourceRoot":"","sources":["../src/cache.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAsCG;AACH,OAAO,KAAK,EAAe,MAAM,EAAE,MAAM,YAAY,CAAC;AAEtD,MAAM,WAAW,YAAY;IAC3B;;;;;;OAMG;IACH,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,CAAC;IACtB;;;;;;OAMG;IACH,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,CAAC;IACtB,8CAA8C;IAC9C,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,MAAM,CAAC;CAC7B;AAED,MAAM,WAAW,WAAW;IAC1B;;;;;;OAMG;IACH,IAAI,CAAC,CAAC,EAAE,GAAG,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,CAAC,CAAC,CAAC,GAAG,MAAM,CAAC,CAAC,CAAC,CAAC;IACpD;;;;;;OAMG;IACH,KAAK,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO,GAAG,IAAI,CAAC;IACzC,0EAA0E;IAC1E,IAAI,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;IAC3B,sCAAsC;IACtC,MAAM,CAAC,GAAG,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3B,qDAAqD;IACrD,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;CACvB;AAaD,wBAAgB,iBAAiB,CAAC,OAAO,GAAE,YAAiB,GAAG,WAAW,CAkHzE"}
package/dist/http.d.ts CHANGED
@@ -1,11 +1,5 @@
1
- /**
2
- * REST, which needs almost nothing.
3
- *
4
- * `fetch` is already the API; what a cache needs from it is an abort signal
5
- * wired up and a failed status turned into a thrown error, because a promise
6
- * that resolves with a 500 makes every caller write the same four lines.
7
- */
8
- import type { LoadContext } from './store.js';
1
+ import { type CacheClient, type CacheOptions } from './cache.js';
2
+ import type { Loader } from './store.js';
9
3
  /**
10
4
  * Thrown for any response outside 2xx.
11
5
  *
@@ -22,7 +16,7 @@ export declare class FirsthandHttpError extends Error {
22
16
  body: unknown);
23
17
  }
24
18
  /**
25
- * What `json` takes: `RequestInit`, plus the one thing the platform lacks.
19
+ * What a call takes: `RequestInit`, plus the one thing the platform lacks.
26
20
  *
27
21
  * `method`, `credentials`, `mode`, `headers` and the rest are passed straight
28
22
  * through. So is `body`: a `FormData`, a `URLSearchParams` or a `Blob` is
@@ -37,28 +31,61 @@ export interface JsonRequest extends Omit<RequestInit, 'body'> {
37
31
  readonly body?: BodyInit | null;
38
32
  /** Sent as JSON, with the content type the platform will not set for you. */
39
33
  readonly json?: unknown;
34
+ /**
35
+ * Where this call's answer is kept: a key of your own, or `false` for
36
+ * nowhere.
37
+ *
38
+ * A key is for when the URL is not the identity — a POST that reads, say.
39
+ * `false` is for the read that must never be served from memory. The
40
+ * platform's own `cache` option is untouched and still passed to `fetch`,
41
+ * because that one is the HTTP cache and a different thing entirely.
42
+ *
43
+ * A *lifetime* belongs to the cache rather than to a call, so it is set once
44
+ * in `createFetchClient({ cache: { ttl } })`, or on a variation of the client
45
+ * made with `.with({ cache: { ttl } })`.
46
+ */
47
+ readonly cacheKey?: string | false;
40
48
  }
41
- /**
42
- * `fetch` for a JSON endpoint: the abort signal wired up, a failed status
43
- * thrown, and the body parsed.
44
- *
45
- * ```ts
46
- * const user = useResource(async ({ signal, tags }) => {
47
- * tags(tag('user', { id: props.id }));
48
- * return json<User>(`/api/users/${props.id}`)({ signal });
49
- * });
50
- * ```
51
- *
52
- * A write is the same call with a method and a body:
53
- *
54
- * ```ts
55
- * json<Note>('/api/notes', { method: 'POST', json: { title } });
56
- * json<Upload>('/api/files', { method: 'POST', body: formData });
57
- * ```
58
- *
59
- * This is the platform, not a client. There is no base URL, no instance, no
60
- * interceptor and no retry here, and there will not be: those belong to a
61
- * client, and a loader takes any client because it takes any function.
62
- */
63
- export declare function json<T>(input: string, init?: JsonRequest): (context: Pick<LoadContext, 'signal'>) => Promise<T>;
49
+ export interface FetchClientOptions {
50
+ /** Prefixed to every relative path. A path beginning with `http` is left alone. */
51
+ readonly baseUrl?: string;
52
+ /**
53
+ * Headers for every request. A function is called **per request and
54
+ * untracked**, which is what lets a token change without making every
55
+ * resource depend on it.
56
+ */
57
+ readonly headers?: HeadersInit | (() => HeadersInit);
58
+ /**
59
+ * Where answers are kept. `false` for none (the default), options for a
60
+ * cache of this client's own, or a `CacheClient` shared with the rest of the
61
+ * application.
62
+ */
63
+ readonly cache?: false | CacheOptions | CacheClient;
64
+ /** Applied to every request: `credentials`, `mode`, `referrerPolicy`, … */
65
+ readonly init?: RequestInit;
66
+ /**
67
+ * The seam. Wrap `fetch` to log, to retry, or to end a session on a 401
68
+ * and keep that in one place rather than in a plugin system.
69
+ */
70
+ readonly fetch?: typeof globalThis.fetch;
71
+ }
72
+ export interface FetchClient {
73
+ /** Any method: the general form the others are named shortcuts for. */
74
+ request<T>(url: string, init?: JsonRequest): Loader<T>;
75
+ get<T>(url: string, init?: JsonRequest): Loader<T>;
76
+ post<T>(url: string, init?: JsonRequest): Loader<T>;
77
+ put<T>(url: string, init?: JsonRequest): Loader<T>;
78
+ patch<T>(url: string, init?: JsonRequest): Loader<T>;
79
+ /** `delete` is a keyword in enough places to be worth avoiding. */
80
+ remove<T>(url: string, init?: JsonRequest): Loader<T>;
81
+ /**
82
+ * A copy with some options replaced: another base URL, other headers, no
83
+ * cache. The cache is shared unless this call replaces it, so a specialised
84
+ * client is a variation rather than a second application.
85
+ */
86
+ with(options: FetchClientOptions): FetchClient;
87
+ /** The cache this client keeps its answers in, if it has one. */
88
+ readonly cache: CacheClient | undefined;
89
+ }
90
+ export declare function createFetchClient(options?: FetchClientOptions): FetchClient;
64
91
  //# sourceMappingURL=http.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"http.d.ts","sourceRoot":"","sources":["../src/http.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AACH,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AAE9C;;;;;GAKG;AACH,qBAAa,kBAAmB,SAAQ,KAAK;IAEzC,QAAQ,CAAC,MAAM,EAAE,MAAM;IACvB,QAAQ,CAAC,GAAG,EAAE,MAAM;IACpB,yCAAyC;IACzC,QAAQ,CAAC,IAAI,EAAE,OAAO;gBAHb,MAAM,EAAE,MAAM,EACd,GAAG,EAAE,MAAM;IACpB,yCAAyC;IAChC,IAAI,EAAE,OAAO;CAKzB;AAED;;;;;;;;;;;GAWG;AACH,MAAM,WAAW,WAAY,SAAQ,IAAI,CAAC,WAAW,EAAE,MAAM,CAAC;IAC5D,QAAQ,CAAC,IAAI,CAAC,EAAE,QAAQ,GAAG,IAAI,CAAC;IAChC,6EAA6E;IAC7E,QAAQ,CAAC,IAAI,CAAC,EAAE,OAAO,CAAC;CACzB;AAED;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,wBAAgB,IAAI,CAAC,CAAC,EACpB,KAAK,EAAE,MAAM,EACb,IAAI,GAAE,WAAgB,GACrB,CAAC,OAAO,EAAE,IAAI,CAAC,WAAW,EAAE,QAAQ,CAAC,KAAK,OAAO,CAAC,CAAC,CAAC,CAwBtD"}
1
+ {"version":3,"file":"http.d.ts","sourceRoot":"","sources":["../src/http.ts"],"names":[],"mappings":"AA8BA,OAAO,EAAqB,KAAK,WAAW,EAAE,KAAK,YAAY,EAAE,MAAM,YAAY,CAAC;AACpF,OAAO,KAAK,EAAe,MAAM,EAAE,MAAM,YAAY,CAAC;AAEtD;;;;;GAKG;AACH,qBAAa,kBAAmB,SAAQ,KAAK;IAEzC,QAAQ,CAAC,MAAM,EAAE,MAAM;IACvB,QAAQ,CAAC,GAAG,EAAE,MAAM;IACpB,yCAAyC;IACzC,QAAQ,CAAC,IAAI,EAAE,OAAO;gBAHb,MAAM,EAAE,MAAM,EACd,GAAG,EAAE,MAAM;IACpB,yCAAyC;IAChC,IAAI,EAAE,OAAO;CAKzB;AAED;;;;;;;;;;;GAWG;AACH,MAAM,WAAW,WAAY,SAAQ,IAAI,CAAC,WAAW,EAAE,MAAM,CAAC;IAC5D,QAAQ,CAAC,IAAI,CAAC,EAAE,QAAQ,GAAG,IAAI,CAAC;IAChC,6EAA6E;IAC7E,QAAQ,CAAC,IAAI,CAAC,EAAE,OAAO,CAAC;IACxB;;;;;;;;;;;;OAYG;IACH,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,GAAG,KAAK,CAAC;CACpC;AAED,MAAM,WAAW,kBAAkB;IACjC,mFAAmF;IACnF,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAC;IAC1B;;;;OAIG;IACH,QAAQ,CAAC,OAAO,CAAC,EAAE,WAAW,GAAG,CAAC,MAAM,WAAW,CAAC,CAAC;IACrD;;;;OAIG;IACH,QAAQ,CAAC,KAAK,CAAC,EAAE,KAAK,GAAG,YAAY,GAAG,WAAW,CAAC;IACpD,2EAA2E;IAC3E,QAAQ,CAAC,IAAI,CAAC,EAAE,WAAW,CAAC;IAC5B;;;OAGG;IACH,QAAQ,CAAC,KAAK,CAAC,EAAE,OAAO,UAAU,CAAC,KAAK,CAAC;CAC1C;AAED,MAAM,WAAW,WAAW;IAC1B,uEAAuE;IACvE,OAAO,CAAC,CAAC,EAAE,GAAG,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,WAAW,GAAG,MAAM,CAAC,CAAC,CAAC,CAAC;IACvD,GAAG,CAAC,CAAC,EAAE,GAAG,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,WAAW,GAAG,MAAM,CAAC,CAAC,CAAC,CAAC;IACnD,IAAI,CAAC,CAAC,EAAE,GAAG,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,WAAW,GAAG,MAAM,CAAC,CAAC,CAAC,CAAC;IACpD,GAAG,CAAC,CAAC,EAAE,GAAG,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,WAAW,GAAG,MAAM,CAAC,CAAC,CAAC,CAAC;IACnD,KAAK,CAAC,CAAC,EAAE,GAAG,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,WAAW,GAAG,MAAM,CAAC,CAAC,CAAC,CAAC;IACrD,mEAAmE;IACnE,MAAM,CAAC,CAAC,EAAE,GAAG,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,WAAW,GAAG,MAAM,CAAC,CAAC,CAAC,CAAC;IACtD;;;;OAIG;IACH,IAAI,CAAC,OAAO,EAAE,kBAAkB,GAAG,WAAW,CAAC;IAC/C,iEAAiE;IACjE,QAAQ,CAAC,KAAK,EAAE,WAAW,GAAG,SAAS,CAAC;CACzC;AAmDD,wBAAgB,iBAAiB,CAAC,OAAO,GAAE,kBAAuB,GAAG,WAAW,CAmD/E"}
package/dist/index.d.ts CHANGED
@@ -9,18 +9,24 @@
9
9
  export { createData, DataContext, useData, useInvalidate } from './resource.js';
10
10
  export { useResource, useAction, fromObservable, fromPromise } from './resource.js';
11
11
  export type { Action, ObservableLike, BridgeOptions, ResourceOptions } from './resource.js';
12
- export type { ActionContext, DataOptions, DataStore, LoadContext, Resource, Status, Storage, } from './store.js';
12
+ export type { ActionContext, DataOptions, DataRequest, DataStore, LoadContext, Loader, Resource, Status, Storage, } from './store.js';
13
+ /**
14
+ * The cache — one of them, and the transport's.
15
+ *
16
+ * `createFetchClient` keeps its answers here, and so does an algorithm of your
17
+ * own: the same `read`, the same lifetimes, and the same `force` reaching
18
+ * through when a resource is invalidated (ADR-0023).
19
+ */
20
+ export { createCacheClient } from './cache.js';
21
+ export type { CacheClient, CacheOptions } from './cache.js';
13
22
  export { tag, tagMatches, anyTagMatches } from './tags.js';
14
23
  export type { Tag, TagVars, Variables } from './tags.js';
15
24
  export { parseGraphQL, resolveTags, FirsthandDirectiveError } from './document.js';
16
25
  export type { DocumentArguments, GraphQLDocument, TagTemplate, TagValue } from './document.js';
17
26
  /**
18
- * `fetch`, with the three things a resource needs from it.
19
- *
20
- * The platform, not a vendor: no base URLs, no instances, no interceptors, no
21
- * retry. Those belong to a client, and a loader takes any client because it
22
- * takes any function.
27
+ * `fetch`, as a client configured once: a base URL, headers read per request
28
+ * so a token may change, a failed status thrown, and the shared cache.
23
29
  */
24
- export { json, FirsthandHttpError } from './http.js';
25
- export type { JsonRequest } from './http.js';
30
+ export { createFetchClient, FirsthandHttpError } from './http.js';
31
+ export type { FetchClient, FetchClientOptions, JsonRequest } from './http.js';
26
32
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,OAAO,EAAE,UAAU,EAAE,WAAW,EAAE,OAAO,EAAE,aAAa,EAAE,MAAM,eAAe,CAAC;AAChF,OAAO,EAAE,WAAW,EAAE,SAAS,EAAE,cAAc,EAAE,WAAW,EAAE,MAAM,eAAe,CAAC;AACpF,YAAY,EAAE,MAAM,EAAE,cAAc,EAAE,aAAa,EAAE,eAAe,EAAE,MAAM,eAAe,CAAC;AAC5F,YAAY,EACV,aAAa,EACb,WAAW,EACX,SAAS,EACT,WAAW,EACX,QAAQ,EACR,MAAM,EACN,OAAO,GACR,MAAM,YAAY,CAAC;AAEpB,OAAO,EAAE,GAAG,EAAE,UAAU,EAAE,aAAa,EAAE,MAAM,WAAW,CAAC;AAC3D,YAAY,EAAE,GAAG,EAAE,OAAO,EAAE,SAAS,EAAE,MAAM,WAAW,CAAC;AAEzD,OAAO,EAAE,YAAY,EAAE,WAAW,EAAE,uBAAuB,EAAE,MAAM,eAAe,CAAC;AACnF,YAAY,EAAE,iBAAiB,EAAE,eAAe,EAAE,WAAW,EAAE,QAAQ,EAAE,MAAM,eAAe,CAAC;AAE/F;;;;;;GAMG;AACH,OAAO,EAAE,IAAI,EAAE,kBAAkB,EAAE,MAAM,WAAW,CAAC;AACrD,YAAY,EAAE,WAAW,EAAE,MAAM,WAAW,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,OAAO,EAAE,UAAU,EAAE,WAAW,EAAE,OAAO,EAAE,aAAa,EAAE,MAAM,eAAe,CAAC;AAChF,OAAO,EAAE,WAAW,EAAE,SAAS,EAAE,cAAc,EAAE,WAAW,EAAE,MAAM,eAAe,CAAC;AACpF,YAAY,EAAE,MAAM,EAAE,cAAc,EAAE,aAAa,EAAE,eAAe,EAAE,MAAM,eAAe,CAAC;AAC5F,YAAY,EACV,aAAa,EACb,WAAW,EACX,WAAW,EACX,SAAS,EACT,WAAW,EACX,MAAM,EACN,QAAQ,EACR,MAAM,EACN,OAAO,GACR,MAAM,YAAY,CAAC;AAEpB;;;;;;GAMG;AACH,OAAO,EAAE,iBAAiB,EAAE,MAAM,YAAY,CAAC;AAC/C,YAAY,EAAE,WAAW,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AAE5D,OAAO,EAAE,GAAG,EAAE,UAAU,EAAE,aAAa,EAAE,MAAM,WAAW,CAAC;AAC3D,YAAY,EAAE,GAAG,EAAE,OAAO,EAAE,SAAS,EAAE,MAAM,WAAW,CAAC;AAEzD,OAAO,EAAE,YAAY,EAAE,WAAW,EAAE,uBAAuB,EAAE,MAAM,eAAe,CAAC;AACnF,YAAY,EAAE,iBAAiB,EAAE,eAAe,EAAE,WAAW,EAAE,QAAQ,EAAE,MAAM,eAAe,CAAC;AAE/F;;;GAGG;AACH,OAAO,EAAE,iBAAiB,EAAE,kBAAkB,EAAE,MAAM,WAAW,CAAC;AAClE,YAAY,EAAE,WAAW,EAAE,kBAAkB,EAAE,WAAW,EAAE,MAAM,WAAW,CAAC"}
package/dist/index.dev.js CHANGED
@@ -164,16 +164,14 @@ function useResource(load, options = {}) {
164
164
  if (entry.data.peek() === void 0) {
165
165
  entry.status.value = "loading";
166
166
  }
167
- const context = {
168
- signal: controller.signal,
169
- force,
170
- tags: (...next) => {
171
- entry.tags = next;
172
- if (entry.pending.length > 0 && anyTagMatches(entry.pending, next)) {
173
- entry.superseded = true;
174
- }
167
+ const declare = (...next) => {
168
+ entry.tags = next;
169
+ if (entry.pending.length > 0 && anyTagMatches(entry.pending, next)) {
170
+ entry.superseded = true;
175
171
  }
176
172
  };
173
+ const request = { signal: controller.signal, force, tags: declare };
174
+ const context = { ...request, request, tags: declare };
177
175
  return load(context).then(async (value) => {
178
176
  if (controller.signal.aborted || entry.disposed) {
179
177
  return void 0;
@@ -246,12 +244,19 @@ function useAction(run) {
246
244
  entry.loading.value = true;
247
245
  entry.status.value = "loading";
248
246
  try {
247
+ const invalidates = (...tags) => {
248
+ invalidating = tags;
249
+ };
249
250
  const result = await untrack(
250
251
  () => run(input, {
251
252
  signal: current.signal,
252
- invalidates: (...tags) => {
253
- invalidating = tags;
254
- }
253
+ invalidates,
254
+ // An action changes something, so nothing it sends may be answered
255
+ // out of a cache: `force` is not a choice here. `tags` is the
256
+ // store's invalidation, so a client that knows what a mutation
257
+ // changed — a document with `@invalidates` — reports it without
258
+ // the call site repeating it.
259
+ request: { signal: current.signal, force: true, tags: invalidates }
255
260
  })
256
261
  );
257
262
  if (current.signal.aborted || entry.disposed) {
@@ -310,6 +315,118 @@ async function void_(promise) {
310
315
  return void 0;
311
316
  }
312
317
 
318
+ // packages/data/src/cache.ts
319
+ function createCacheClient(options = {}) {
320
+ const ttl = options.ttl ?? 0;
321
+ const max = options.max ?? 100;
322
+ const now = options.now ?? (() => Date.now());
323
+ const entries = /* @__PURE__ */ new Map();
324
+ const drop = (key) => {
325
+ const entry = entries.get(key);
326
+ entry?.controller?.abort();
327
+ entries.delete(key);
328
+ };
329
+ const keep = (key, entry) => {
330
+ entries.delete(key);
331
+ entries.set(key, entry);
332
+ if (entries.size > max) {
333
+ for (const oldest of entries.keys()) {
334
+ drop(oldest);
335
+ break;
336
+ }
337
+ }
338
+ };
339
+ return {
340
+ get size() {
341
+ return entries.size;
342
+ },
343
+ peek: (key) => {
344
+ const entry = entries.get(key);
345
+ if (entry === void 0 || entry.expires <= now()) {
346
+ return void 0;
347
+ }
348
+ return entry.value;
349
+ },
350
+ write: (key, value) => {
351
+ keep(key, {
352
+ value,
353
+ expires: ttl === 0 ? Infinity : now() + ttl,
354
+ inflight: null,
355
+ controller: null,
356
+ waiting: 0
357
+ });
358
+ },
359
+ forget: (key) => {
360
+ if (key === void 0) {
361
+ for (const held of [...entries.keys()]) {
362
+ drop(held);
363
+ }
364
+ return;
365
+ }
366
+ drop(key);
367
+ },
368
+ read: (key, produce) => async (request) => {
369
+ const held = entries.get(key);
370
+ if (request.force) {
371
+ drop(key);
372
+ } else if (held !== void 0) {
373
+ if (held.inflight !== null) {
374
+ return await share(held, request);
375
+ }
376
+ if (held.expires > now()) {
377
+ keep(key, held);
378
+ return held.value;
379
+ }
380
+ entries.delete(key);
381
+ }
382
+ const controller = new AbortController();
383
+ const entry = {
384
+ value: void 0,
385
+ expires: 0,
386
+ inflight: null,
387
+ controller,
388
+ waiting: 0
389
+ };
390
+ const run = produce({ signal: controller.signal, force: request.force }).then((value) => {
391
+ if (entries.get(key) === entry) {
392
+ if (ttl === 0) {
393
+ entries.delete(key);
394
+ } else {
395
+ entry.value = value;
396
+ entry.expires = now() + ttl;
397
+ entry.inflight = null;
398
+ entry.controller = null;
399
+ }
400
+ }
401
+ return value;
402
+ }).catch((error) => {
403
+ if (entries.get(key) === entry) {
404
+ entries.delete(key);
405
+ }
406
+ throw error;
407
+ });
408
+ entry.inflight = run;
409
+ keep(key, entry);
410
+ return await share(entry, request);
411
+ }
412
+ };
413
+ }
414
+ async function share(entry, request) {
415
+ entry.waiting += 1;
416
+ const stop = () => {
417
+ if (entry.waiting <= 1 && entry.inflight !== null) {
418
+ entry.controller?.abort();
419
+ }
420
+ };
421
+ request.signal.addEventListener("abort", stop, { once: true });
422
+ try {
423
+ return await entry.inflight;
424
+ } finally {
425
+ request.signal.removeEventListener("abort", stop);
426
+ entry.waiting -= 1;
427
+ }
428
+ }
429
+
313
430
  // packages/data/src/document.ts
314
431
  var DIRECTIVE = /^@(tag|invalidates)\b/;
315
432
  var OPERATION = /\b(query|mutation|subscription)\b[^\S\n]*([A-Za-z_]\w*)?/;
@@ -509,6 +626,7 @@ function resolveTags(templates, variables) {
509
626
  }
510
627
 
511
628
  // packages/data/src/http.ts
629
+ import { untrack as untrack2 } from "@firsthandjs/core";
512
630
  var FirsthandHttpError = class extends Error {
513
631
  constructor(status, url, body) {
514
632
  super(`HTTP ${String(status)} for ${url}`);
@@ -521,36 +639,79 @@ var FirsthandHttpError = class extends Error {
521
639
  url;
522
640
  body;
523
641
  };
524
- function json(input, init = {}) {
525
- return async ({ signal: signal2 }) => {
526
- const { json: payload, ...rest } = init;
527
- const options = { ...rest, signal: signal2 };
528
- if (payload !== void 0) {
529
- options.body = JSON.stringify(payload);
530
- const headers = new Headers(init.headers);
531
- if (!headers.has("content-type")) {
532
- headers.set("content-type", "application/json");
533
- }
534
- options.headers = headers;
535
- }
536
- const response = await fetch(input, options);
537
- const text = await response.text();
538
- const parsed = text === "" ? void 0 : JSON.parse(text);
539
- if (!response.ok) {
540
- throw new FirsthandHttpError(response.status, input, parsed);
642
+ function resolve(options, url, init) {
643
+ const target = /^[a-z]+:\/\//i.test(url) ? url : `${options.baseUrl ?? ""}${url}`;
644
+ const { json: payload, cacheKey: _key, ...rest } = init;
645
+ const merged = { ...options.init, ...rest };
646
+ const headers = new Headers(
647
+ // Untracked: a header function reads a token, and a token is not something
648
+ // a resource may depend on — writing it would re-send every request that
649
+ // built a header from it, including on the way out of a sign-out.
650
+ typeof options.headers === "function" ? untrack2(options.headers) : options.headers
651
+ );
652
+ for (const [name, value] of new Headers(init.headers ?? {})) {
653
+ headers.set(name, value);
654
+ }
655
+ if (payload !== void 0) {
656
+ merged.body = JSON.stringify(payload);
657
+ if (!headers.has("content-type")) {
658
+ headers.set("content-type", "application/json");
541
659
  }
542
- return parsed;
660
+ }
661
+ merged.headers = headers;
662
+ return [target, merged];
663
+ }
664
+ async function send(options, url, init, request) {
665
+ const call = options.fetch ?? globalThis.fetch;
666
+ const response = await call(url, { ...init, signal: request.signal });
667
+ const text = await response.text();
668
+ const parsed = text === "" ? void 0 : JSON.parse(text);
669
+ if (!response.ok) {
670
+ throw new FirsthandHttpError(response.status, url, parsed);
671
+ }
672
+ return parsed;
673
+ }
674
+ function createFetchClient(options = {}) {
675
+ const cache = options.cache === void 0 || options.cache === false ? void 0 : "read" in options.cache ? options.cache : createCacheClient(options.cache);
676
+ const client = {
677
+ cache,
678
+ request: (url, init = {}) => async (request) => {
679
+ const [target, merged] = resolve(options, url, init);
680
+ const method = (merged.method ?? "GET").toUpperCase();
681
+ const key = typeof init.cacheKey === "string" ? init.cacheKey : method === "GET" || method === "HEAD" ? `${method} ${target}` : void 0;
682
+ if (cache === void 0 || init.cacheKey === false || key === void 0) {
683
+ return await send(options, target, merged, request);
684
+ }
685
+ return await cache.read(key, (shared) => send(options, target, merged, shared))(
686
+ request
687
+ );
688
+ },
689
+ get: (url, init = {}) => client.request(url, init),
690
+ post: (url, init = {}) => client.request(url, { method: "POST", ...init }),
691
+ put: (url, init = {}) => client.request(url, { method: "PUT", ...init }),
692
+ patch: (url, init = {}) => client.request(url, { method: "PATCH", ...init }),
693
+ remove: (url, init = {}) => client.request(url, { method: "DELETE", ...init }),
694
+ with: (overrides) => createFetchClient({
695
+ ...options,
696
+ ...overrides,
697
+ // The cache is the one option that is shared rather than rebuilt: a
698
+ // specialised client is a variation of this one, not a second
699
+ // application with its own memory.
700
+ cache: overrides.cache ?? cache ?? false
701
+ })
543
702
  };
703
+ return client;
544
704
  }
545
705
  export {
546
706
  DataContext,
547
707
  FirsthandDirectiveError,
548
708
  FirsthandHttpError,
549
709
  anyTagMatches,
710
+ createCacheClient,
550
711
  createData,
712
+ createFetchClient,
551
713
  fromObservable,
552
714
  fromPromise,
553
- json,
554
715
  parseGraphQL,
555
716
  resolveTags,
556
717
  tag,
package/dist/index.js CHANGED
@@ -1,2 +1,2 @@
1
- import{createContext as I,effect as H,onCleanup as S,untrack as N,useContext as q}from"@firsthandjs/core";import{batch as O,signal as x}from"@firsthandjs/core";var j=Object.freeze({});function R(e,t=j){return{name:e,vars:t}}function D(e,t){if(e.name!==t.name)return!1;for(let r of Object.keys(e.vars))if(!Object.is(e.vars[r],t.vars[r]))return!1;return!0}function p(e,t){for(let r of e)for(let n of t)if(D(r,n))return!0;return!1}function C(e={}){let t=new Set;return{storage:e.storage,hold:r=>(t.add(r),()=>t.delete(r)),invalidate:async(...r)=>{let n=[];for(let a of[...t])a.controller!==null&&a.pending.push(...r),p(r,a.tags)&&(a.name,a.tags,n.push(a.run(!0)));await Promise.all(n)},clear:()=>{for(let r of[...t])r.controller?.abort(),t.delete(r);try{e.storage?.clear?.()}catch{}},get size(){return t.size}}}function b(e,t){return{data:x(void 0),error:x(void 0),status:x("idle"),loading:x(!1),tags:[],controller:null,pending:[],superseded:!1,disposed:!1,name:t}}function f(e,t){O(()=>{e.data.value=t,e.error.value=void 0,e.status.value="success",e.loading.value=!1})}function v(e,t){O(()=>{e.error.value=t,e.status.value="error",e.loading.value=!1})}function k(e,t){return{data:e.data,error:e.error,status:e.status,loading:e.loading,reload:()=>e.run(!0),dispose:()=>{e.disposed=!0,e.controller?.abort(),t()}}}var P=I();function m(){return q(P).value}function Q(){let e=m();return(...t)=>e.invalidate(...t)}function A(e,t={}){let r=m(),n=b(r,t.persist),a=r.hold(n);t.persist,n.run=i=>{if(n.disposed)return Promise.resolve(void 0);n.controller?.abort();let l=new AbortController;n.controller=l,n.pending=[],n.superseded=!1,n.loading.value=!0,n.data.peek()===void 0&&(n.status.value="loading");let u={signal:l.signal,force:i,tags:(...d)=>{n.tags=d,n.pending.length>0&&p(n.pending,d)&&(n.superseded=!0)}};return e(u).then(async d=>{if(!(l.signal.aborted||n.disposed)){if(n.controller=null,f(n,d),t.persist!==void 0)try{r.storage?.write?.(t.persist,d)}catch{}return n.superseded?(n.superseded=!1,n.run(!0)):d}}).catch(d=>{l.signal.aborted||n.disposed||(n.controller=null,v(n,d))})};let o=t.persist,s=r.storage;return o!==void 0&&s?.read!==void 0&&(async()=>{try{let i=await s.read?.(o);i!==void 0&&n.data.peek()===void 0&&!n.disposed&&(f(n,i),n.loading.value=!0)}catch{}})(),H(()=>{n.run(!1)}),S(()=>{n.disposed=!0,n.controller?.abort(),t.persist,n.tags,a()}),k(n,a)}function E(e){let t=m(),r=b(t,void 0),n=null;return S(()=>{r.disposed=!0,n?.abort()}),{data:r.data,error:r.error,status:r.status,running:r.loading,run:async a=>{n?.abort(),n=new AbortController;let o=n,s=[];r.loading.value=!0,r.status.value="loading";try{let i=await N(()=>e(a,{signal:o.signal,invalidates:(...l)=>{s=l}}));return o.signal.aborted||r.disposed?void 0:(f(r,i),s.length>0&&await t.invalidate(...s),i)}catch(i){if(o.signal.aborted||r.disposed)return;v(r,i);return}}}}function _(e,t={}){let r=m(),n=b(r,void 0),a=r.hold(n);n.run=()=>t.reload===void 0?Promise.resolve(void 0):J(t.reload()),n.loading.value=!0,n.status.value="loading";let o=e.subscribe({next:i=>{f(n,i)},error:i=>{v(n,i)}}),s=typeof o=="function"?o:()=>{o.unsubscribe()};return S(()=>{n.disposed=!0,s(),a()}),k(n,()=>{s(),a()})}function z(e){return A(()=>e())}async function J(e){await e}var M=/^@(tag|invalidates)\b/,G=/\b(query|mutation|subscription)\b[^\S\n]*([A-Za-z_]\w*)?/,L=/^[_A-Za-z][_0-9A-Za-z]*/,c=class extends Error{constructor(t){super(t),this.name="FirsthandDirectiveError"}};function V(e,t){if(e.startsWith('"""',t)){let n=e.indexOf('"""',t+3);return n===-1?e.length:n+3}let r=t+1;for(;r<e.length&&e[r]!=='"';)r+=e[r]==="\\"?2:1;return r+1}function B(e,t){let r=0,n=t;for(;n<e.length;){let a=e[n];if(a==='"'){n=V(e,n);continue}if(a==="("||a==="["||a==="{")r++;else if((a===")"||a==="]"||a==="}")&&(r--,r===0))return n+1;n++}throw new c(`unclosed arguments in ${e.slice(t,t+40)}`)}function F(e,t){try{return JSON.parse(e)}catch{throw new c(`@${t}: ${e} is not a valid string`)}}function Z(e,t){let r={},n=0,a=()=>{for(;n<e.length&&/[\s,]/.test(e[n]);)n++};for(a();n<e.length;){let o=L.exec(e.slice(n));if(o===null)throw new c(`@${t}: expected an argument name at "${e.slice(n)}"`);if(n+=o[0].length,a(),e[n]!==":")throw new c(`@${t}: argument ${o[0]} has no value`);n++,a();let s=W(e,t,n);r[o[0]]=s.value,n=s.next,a()}return r}function W(e,t,r){let n=e[r];if(n==="$"){let l=L.exec(e.slice(r+1));if(l===null)throw new c(`@${t}: expected a variable name after $`);return{value:{variable:l[0]},next:r+1+l[0].length}}if(n==='"'){let l=V(e,r),u=e.slice(r,l);return{value:{literal:u.startsWith('"""')?u.slice(3,-3).trim():F(u,t)},next:l}}if(n==="["||n==="{")throw new c(`@${t}: a tag variable must be a scalar, not a list or object`);let a=/^[^\s,)]+/.exec(e.slice(r));if(a===null)throw new c(`@${t}: expected a value`);let o=a[0],s=r+o.length;if(o==="true"||o==="false")return{value:{literal:o==="true"},next:s};if(o==="null")return{value:{literal:null},next:s};let i=Number(o);return{value:{literal:Number.isNaN(i)?o:i},next:s}}function K(e){let t=[],r=[],n="",a=0,o=0;for(;a<e.length;){let s=e[a];if(s==='"'){a=V(e,a);continue}if(s==="#"){let g=e.indexOf(`
2
- `,a);a=g===-1?e.length:g;continue}if(s!=="@"){a++;continue}let i=M.exec(e.slice(a));if(i===null){a++;continue}let l=a+i[0].length,u="",d=l;for(;d<e.length&&/\s/.test(e[d]);)d++;if(e[d]==="("){let g=B(e,d);u=e.slice(d+1,g-1),l=g}let w=Z(u,i[1]),T=w.name;if(T===void 0||!("literal"in T)||typeof T.literal!="string")throw new c(`@${i[1]} needs a literal name, as in @${i[1]}(name: "user", id: $id)`);delete w.name,(i[1]==="tag"?t:r).push({name:T.literal,vars:w});let y=a;for(;y>o&&/\s/.test(e[y-1]);)y--;n+=e.slice(o,y),o=l,a=l}return{tags:t,invalidates:r,stripped:n+e.slice(o)}}function U(e){let{tags:t,invalidates:r,stripped:n}=K(e),a=G.exec(n.replace(/#[^\n]*/g,""));return{source:n,operation:a?.[2]??"",kind:a?.[1]??"query",tags:t,invalidates:r}}function X(e,t){return e.map(r=>{let n={};for(let[a,o]of Object.entries(r.vars)){if("literal"in o){n[a]=o.literal;continue}let s=t[o.variable];s!=null&&(n[a]=typeof s=="object"?JSON.stringify(s):s)}return R(r.name,n)})}var h=class extends Error{constructor(r,n,a){super(`HTTP ${String(r)} for ${n}`);this.status=r;this.url=n;this.body=a;this.name="FirsthandHttpError"}status;url;body};function Y(e,t={}){return async({signal:r})=>{let{json:n,...a}=t,o={...a,signal:r};if(n!==void 0){o.body=JSON.stringify(n);let u=new Headers(t.headers);u.has("content-type")||u.set("content-type","application/json"),o.headers=u}let s=await fetch(e,o),i=await s.text(),l=i===""?void 0:JSON.parse(i);if(!s.ok)throw new h(s.status,e,l);return l}}export{P as DataContext,c as FirsthandDirectiveError,h as FirsthandHttpError,p as anyTagMatches,C as createData,_ as fromObservable,z as fromPromise,Y as json,U as parseGraphQL,X as resolveTags,R as tag,D as tagMatches,E as useAction,m as useData,Q as useInvalidate,A as useResource};
1
+ import{createContext as J,effect as j,onCleanup as D,untrack as z,useContext as N}from"@firsthandjs/core";import{batch as L,signal as b}from"@firsthandjs/core";var F=Object.freeze({});function R(e,n=F){return{name:e,vars:n}}function O(e,n){if(e.name!==n.name)return!1;for(let a of Object.keys(e.vars))if(!Object.is(e.vars[a],n.vars[a]))return!1;return!0}function m(e,n){for(let a of e)for(let t of n)if(O(a,t))return!0;return!1}function S(e={}){let n=new Set;return{storage:e.storage,hold:a=>(n.add(a),()=>n.delete(a)),invalidate:async(...a)=>{let t=[];for(let r of[...n])r.controller!==null&&r.pending.push(...a),m(a,r.tags)&&(r.name,r.tags,t.push(r.run(!0)));await Promise.all(t)},clear:()=>{for(let a of[...n])a.controller?.abort(),n.delete(a);try{e.storage?.clear?.()}catch{}},get size(){return n.size}}}function x(e,n){return{data:b(void 0),error:b(void 0),status:b("idle"),loading:b(!1),tags:[],controller:null,pending:[],superseded:!1,disposed:!1,name:n}}function h(e,n){L(()=>{e.data.value=n,e.error.value=void 0,e.status.value="success",e.loading.value=!1})}function v(e,n){L(()=>{e.error.value=n,e.status.value="error",e.loading.value=!1})}function C(e,n){return{data:e.data,error:e.error,status:e.status,loading:e.loading,reload:()=>e.run(!0),dispose:()=>{e.disposed=!0,e.controller?.abort(),n()}}}var P=J();function y(){return N(P).value}function Q(){let e=y();return(...n)=>e.invalidate(...n)}function V(e,n={}){let a=y(),t=x(a,n.persist),r=a.hold(t);n.persist,t.run=o=>{if(t.disposed)return Promise.resolve(void 0);t.controller?.abort();let i=new AbortController;t.controller=i,t.pending=[],t.superseded=!1,t.loading.value=!0,t.data.peek()===void 0&&(t.status.value="loading");let d=(...u)=>{t.tags=u,t.pending.length>0&&m(t.pending,u)&&(t.superseded=!0)},c={signal:i.signal,force:o,tags:d},p={...c,request:c,tags:d};return e(p).then(async u=>{if(!(i.signal.aborted||t.disposed)){if(t.controller=null,h(t,u),n.persist!==void 0)try{a.storage?.write?.(n.persist,u)}catch{}return t.superseded?(t.superseded=!1,t.run(!0)):u}}).catch(u=>{i.signal.aborted||t.disposed||(t.controller=null,v(t,u))})};let s=n.persist,l=a.storage;return s!==void 0&&l?.read!==void 0&&(async()=>{try{let o=await l.read?.(s);o!==void 0&&t.data.peek()===void 0&&!t.disposed&&(h(t,o),t.loading.value=!0)}catch{}})(),j(()=>{t.run(!1)}),D(()=>{t.disposed=!0,t.controller?.abort(),n.persist,t.tags,r()}),C(t,r)}function _(e){let n=y(),a=x(n,void 0),t=null;return D(()=>{a.disposed=!0,t?.abort()}),{data:a.data,error:a.error,status:a.status,running:a.loading,run:async r=>{t?.abort(),t=new AbortController;let s=t,l=[];a.loading.value=!0,a.status.value="loading";try{let o=(...d)=>{l=d},i=await z(()=>e(r,{signal:s.signal,invalidates:o,request:{signal:s.signal,force:!0,tags:o}}));return s.signal.aborted||a.disposed?void 0:(h(a,i),l.length>0&&await n.invalidate(...l),i)}catch(o){if(s.signal.aborted||a.disposed)return;v(a,o);return}}}}function G(e,n={}){let a=y(),t=x(a,void 0),r=a.hold(t);t.run=()=>n.reload===void 0?Promise.resolve(void 0):K(n.reload()),t.loading.value=!0,t.status.value="loading";let s=e.subscribe({next:o=>{h(t,o)},error:o=>{v(t,o)}}),l=typeof s=="function"?s:()=>{s.unsubscribe()};return D(()=>{t.disposed=!0,l(),r()}),C(t,()=>{l(),r()})}function M(e){return V(()=>e())}async function K(e){await e}function k(e={}){let n=e.ttl??0,a=e.max??100,t=e.now??(()=>Date.now()),r=new Map,s=o=>{r.get(o)?.controller?.abort(),r.delete(o)},l=(o,i)=>{if(r.delete(o),r.set(o,i),r.size>a)for(let d of r.keys()){s(d);break}};return{get size(){return r.size},peek:o=>{let i=r.get(o);if(!(i===void 0||i.expires<=t()))return i.value},write:(o,i)=>{l(o,{value:i,expires:n===0?1/0:t()+n,inflight:null,controller:null,waiting:0})},forget:o=>{if(o===void 0){for(let i of[...r.keys()])s(i);return}s(o)},read:(o,i)=>async d=>{let c=r.get(o);if(d.force)s(o);else if(c!==void 0){if(c.inflight!==null)return await A(c,d);if(c.expires>t())return l(o,c),c.value;r.delete(o)}let p=new AbortController,u={value:void 0,expires:0,inflight:null,controller:p,waiting:0},T=i({signal:p.signal,force:d.force}).then(f=>(r.get(o)===u&&(n===0?r.delete(o):(u.value=f,u.expires=t()+n,u.inflight=null,u.controller=null)),f)).catch(f=>{throw r.get(o)===u&&r.delete(o),f});return u.inflight=T,l(o,u),await A(u,d)}}}async function A(e,n){e.waiting+=1;let a=()=>{e.waiting<=1&&e.inflight!==null&&e.controller?.abort()};n.signal.addEventListener("abort",a,{once:!0});try{return await e.inflight}finally{n.signal.removeEventListener("abort",a),e.waiting-=1}}var B=/^@(tag|invalidates)\b/,U=/\b(query|mutation|subscription)\b[^\S\n]*([A-Za-z_]\w*)?/,E=/^[_A-Za-z][_0-9A-Za-z]*/,g=class extends Error{constructor(n){super(n),this.name="FirsthandDirectiveError"}};function q(e,n){if(e.startsWith('"""',n)){let t=e.indexOf('"""',n+3);return t===-1?e.length:t+3}let a=n+1;for(;a<e.length&&e[a]!=='"';)a+=e[a]==="\\"?2:1;return a+1}function Z(e,n){let a=0,t=n;for(;t<e.length;){let r=e[t];if(r==='"'){t=q(e,t);continue}if(r==="("||r==="["||r==="{")a++;else if((r===")"||r==="]"||r==="}")&&(a--,a===0))return t+1;t++}throw new g(`unclosed arguments in ${e.slice(n,n+40)}`)}function W(e,n){try{return JSON.parse(e)}catch{throw new g(`@${n}: ${e} is not a valid string`)}}function X(e,n){let a={},t=0,r=()=>{for(;t<e.length&&/[\s,]/.test(e[t]);)t++};for(r();t<e.length;){let s=E.exec(e.slice(t));if(s===null)throw new g(`@${n}: expected an argument name at "${e.slice(t)}"`);if(t+=s[0].length,r(),e[t]!==":")throw new g(`@${n}: argument ${s[0]} has no value`);t++,r();let l=Y(e,n,t);a[s[0]]=l.value,t=l.next,r()}return a}function Y(e,n,a){let t=e[a];if(t==="$"){let i=E.exec(e.slice(a+1));if(i===null)throw new g(`@${n}: expected a variable name after $`);return{value:{variable:i[0]},next:a+1+i[0].length}}if(t==='"'){let i=q(e,a),d=e.slice(a,i);return{value:{literal:d.startsWith('"""')?d.slice(3,-3).trim():W(d,n)},next:i}}if(t==="["||t==="{")throw new g(`@${n}: a tag variable must be a scalar, not a list or object`);let r=/^[^\s,)]+/.exec(e.slice(a));if(r===null)throw new g(`@${n}: expected a value`);let s=r[0],l=a+s.length;if(s==="true"||s==="false")return{value:{literal:s==="true"},next:l};if(s==="null")return{value:{literal:null},next:l};let o=Number(s);return{value:{literal:Number.isNaN(o)?s:o},next:l}}function ee(e){let n=[],a=[],t="",r=0,s=0;for(;r<e.length;){let l=e[r];if(l==='"'){r=q(e,r);continue}if(l==="#"){let f=e.indexOf(`
2
+ `,r);r=f===-1?e.length:f;continue}if(l!=="@"){r++;continue}let o=B.exec(e.slice(r));if(o===null){r++;continue}let i=r+o[0].length,d="",c=i;for(;c<e.length&&/\s/.test(e[c]);)c++;if(e[c]==="("){let f=Z(e,c);d=e.slice(c+1,f-1),i=f}let p=X(d,o[1]),u=p.name;if(u===void 0||!("literal"in u)||typeof u.literal!="string")throw new g(`@${o[1]} needs a literal name, as in @${o[1]}(name: "user", id: $id)`);delete p.name,(o[1]==="tag"?n:a).push({name:u.literal,vars:p});let T=r;for(;T>s&&/\s/.test(e[T-1]);)T--;t+=e.slice(s,T),s=i,r=i}return{tags:n,invalidates:a,stripped:t+e.slice(s)}}function te(e){let{tags:n,invalidates:a,stripped:t}=ee(e),r=U.exec(t.replace(/#[^\n]*/g,""));return{source:t,operation:r?.[2]??"",kind:r?.[1]??"query",tags:n,invalidates:a}}function ne(e,n){return e.map(a=>{let t={};for(let[r,s]of Object.entries(a.vars)){if("literal"in s){t[r]=s.literal;continue}let l=n[s.variable];l!=null&&(t[r]=typeof l=="object"?JSON.stringify(l):l)}return R(a.name,t)})}import{untrack as re}from"@firsthandjs/core";var w=class extends Error{constructor(a,t,r){super(`HTTP ${String(a)} for ${t}`);this.status=a;this.url=t;this.body=r;this.name="FirsthandHttpError"}status;url;body};function ae(e,n,a){let t=/^[a-z]+:\/\//i.test(n)?n:`${e.baseUrl??""}${n}`,{json:r,cacheKey:s,...l}=a,o={...e.init,...l},i=new Headers(typeof e.headers=="function"?re(e.headers):e.headers);for(let[d,c]of new Headers(a.headers??{}))i.set(d,c);return r!==void 0&&(o.body=JSON.stringify(r),i.has("content-type")||i.set("content-type","application/json")),o.headers=i,[t,o]}async function I(e,n,a,t){let s=await(e.fetch??globalThis.fetch)(n,{...a,signal:t.signal}),l=await s.text(),o=l===""?void 0:JSON.parse(l);if(!s.ok)throw new w(s.status,n,o);return o}function $(e={}){let n=e.cache===void 0||e.cache===!1?void 0:"read"in e.cache?e.cache:k(e.cache),a={cache:n,request:(t,r={})=>async s=>{let[l,o]=ae(e,t,r),i=(o.method??"GET").toUpperCase(),d=typeof r.cacheKey=="string"?r.cacheKey:i==="GET"||i==="HEAD"?`${i} ${l}`:void 0;return n===void 0||r.cacheKey===!1||d===void 0?await I(e,l,o,s):await n.read(d,c=>I(e,l,o,c))(s)},get:(t,r={})=>a.request(t,r),post:(t,r={})=>a.request(t,{method:"POST",...r}),put:(t,r={})=>a.request(t,{method:"PUT",...r}),patch:(t,r={})=>a.request(t,{method:"PATCH",...r}),remove:(t,r={})=>a.request(t,{method:"DELETE",...r}),with:t=>$({...e,...t,cache:t.cache??n??!1})};return a}export{P as DataContext,g as FirsthandDirectiveError,w as FirsthandHttpError,m as anyTagMatches,k as createCacheClient,S as createData,$ as createFetchClient,G as fromObservable,M as fromPromise,te as parseGraphQL,ne as resolveTags,R as tag,O as tagMatches,_ as useAction,y as useData,Q as useInvalidate,V as useResource};
@@ -1 +1 @@
1
- {"version":3,"file":"resource.d.ts","sourceRoot":"","sources":["../src/resource.ts"],"names":[],"mappings":"AAiBA,OAAO,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AACxC,OAAO,EAKL,KAAK,aAAa,EAClB,KAAK,SAAS,EACd,KAAK,WAAW,EAChB,KAAK,QAAQ,EACb,KAAK,MAAM,EACZ,MAAM,YAAY,CAAC;AACpB,OAAO,EAAiB,KAAK,GAAG,EAAE,MAAM,WAAW,CAAC;AAGpD,eAAO,MAAM,WAAW,gDAA6B,CAAC;AAEtD,wBAAgB,OAAO,IAAI,SAAS,CAEnC;AAED,6DAA6D;AAC7D,wBAAgB,aAAa,IAAI,CAAC,GAAG,QAAQ,EAAE,GAAG,EAAE,KAAK,OAAO,CAAC,IAAI,CAAC,CAGrE;AAED,MAAM,WAAW,eAAe;IAC9B;;;;;;;OAOG;IACH,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAC;CAC3B;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,WAAW,CAAC,CAAC,EAC3B,IAAI,EAAE,CAAC,OAAO,EAAE,WAAW,KAAK,OAAO,CAAC,CAAC,CAAC,EAC1C,OAAO,GAAE,eAAoB,GAC5B,QAAQ,CAAC,CAAC,CAAC,CAqGb;AAED,MAAM,WAAW,MAAM,CAAC,CAAC,EAAE,CAAC;IAC1B,QAAQ,CAAC,IAAI,EAAE,QAAQ,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC;IACnC,QAAQ,CAAC,KAAK,EAAE,QAAQ,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC;IACrC,QAAQ,CAAC,MAAM,EAAE,QAAQ,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC;IACvC,QAAQ,CAAC,OAAO,EAAE,QAAQ,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC;IACzC,mEAAmE;IACnE,GAAG,CAAC,KAAK,EAAE,CAAC,GAAG,OAAO,CAAC,CAAC,GAAG,SAAS,CAAC,CAAC;CACvC;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,SAAS,CAAC,CAAC,EAAE,CAAC,EAC5B,GAAG,EAAE,CAAC,KAAK,EAAE,CAAC,EAAE,OAAO,EAAE,aAAa,KAAK,OAAO,CAAC,CAAC,CAAC,GACpD,MAAM,CAAC,CAAC,EAAE,CAAC,CAAC,CAmDd;AAED,MAAM,WAAW,cAAc,CAAC,CAAC;IAC/B,SAAS,CAAC,QAAQ,EAAE;QAClB,IAAI,CAAC,EAAE,CAAC,KAAK,EAAE,CAAC,KAAK,IAAI,CAAC;QAC1B,KAAK,CAAC,EAAE,CAAC,KAAK,EAAE,OAAO,KAAK,IAAI,CAAC;KAClC,GAAG;QAAE,WAAW,EAAE,MAAM,IAAI,CAAA;KAAE,GAAG,CAAC,MAAM,IAAI,CAAC,CAAC;CAChD;AAED,MAAM,WAAW,aAAa;IAC5B,0DAA0D;IAC1D,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,OAAO,CAAC,OAAO,CAAC,CAAC;CAC1C;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,cAAc,CAAC,CAAC,EAC9B,MAAM,EAAE,cAAc,CAAC,CAAC,CAAC,EACzB,OAAO,GAAE,aAAkB,GAC1B,QAAQ,CAAC,CAAC,CAAC,CAoCb;AAED,8EAA8E;AAC9E,wBAAgB,WAAW,CAAC,CAAC,EAAE,OAAO,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,GAAG,QAAQ,CAAC,CAAC,CAAC,CAErE;AAQD,YAAY,EAAE,WAAW,EAAE,aAAa,EAAE,QAAQ,EAAE,MAAM,EAAE,CAAC"}
1
+ {"version":3,"file":"resource.d.ts","sourceRoot":"","sources":["../src/resource.ts"],"names":[],"mappings":"AAiBA,OAAO,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AACxC,OAAO,EAKL,KAAK,aAAa,EAElB,KAAK,SAAS,EACd,KAAK,WAAW,EAChB,KAAK,QAAQ,EACb,KAAK,MAAM,EACZ,MAAM,YAAY,CAAC;AACpB,OAAO,EAAiB,KAAK,GAAG,EAAE,MAAM,WAAW,CAAC;AAGpD,eAAO,MAAM,WAAW,gDAA6B,CAAC;AAEtD,wBAAgB,OAAO,IAAI,SAAS,CAEnC;AAED,6DAA6D;AAC7D,wBAAgB,aAAa,IAAI,CAAC,GAAG,QAAQ,EAAE,GAAG,EAAE,KAAK,OAAO,CAAC,IAAI,CAAC,CAGrE;AAED,MAAM,WAAW,eAAe;IAC9B;;;;;;;OAOG;IACH,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAC;CAC3B;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,WAAW,CAAC,CAAC,EAC3B,IAAI,EAAE,CAAC,OAAO,EAAE,WAAW,KAAK,OAAO,CAAC,CAAC,CAAC,EAC1C,OAAO,GAAE,eAAoB,GAC5B,QAAQ,CAAC,CAAC,CAAC,CAqGb;AAED,MAAM,WAAW,MAAM,CAAC,CAAC,EAAE,CAAC;IAC1B,QAAQ,CAAC,IAAI,EAAE,QAAQ,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC;IACnC,QAAQ,CAAC,KAAK,EAAE,QAAQ,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC;IACrC,QAAQ,CAAC,MAAM,EAAE,QAAQ,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC;IACvC,QAAQ,CAAC,OAAO,EAAE,QAAQ,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC;IACzC,mEAAmE;IACnE,GAAG,CAAC,KAAK,EAAE,CAAC,GAAG,OAAO,CAAC,CAAC,GAAG,SAAS,CAAC,CAAC;CACvC;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,SAAS,CAAC,CAAC,EAAE,CAAC,EAC5B,GAAG,EAAE,CAAC,KAAK,EAAE,CAAC,EAAE,OAAO,EAAE,aAAa,KAAK,OAAO,CAAC,CAAC,CAAC,GACpD,MAAM,CAAC,CAAC,EAAE,CAAC,CAAC,CA0Dd;AAED,MAAM,WAAW,cAAc,CAAC,CAAC;IAC/B,SAAS,CAAC,QAAQ,EAAE;QAClB,IAAI,CAAC,EAAE,CAAC,KAAK,EAAE,CAAC,KAAK,IAAI,CAAC;QAC1B,KAAK,CAAC,EAAE,CAAC,KAAK,EAAE,OAAO,KAAK,IAAI,CAAC;KAClC,GAAG;QAAE,WAAW,EAAE,MAAM,IAAI,CAAA;KAAE,GAAG,CAAC,MAAM,IAAI,CAAC,CAAC;CAChD;AAED,MAAM,WAAW,aAAa;IAC5B,0DAA0D;IAC1D,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,OAAO,CAAC,OAAO,CAAC,CAAC;CAC1C;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,cAAc,CAAC,CAAC,EAC9B,MAAM,EAAE,cAAc,CAAC,CAAC,CAAC,EACzB,OAAO,GAAE,aAAkB,GAC1B,QAAQ,CAAC,CAAC,CAAC,CAoCb;AAED,8EAA8E;AAC9E,wBAAgB,WAAW,CAAC,CAAC,EAAE,OAAO,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,GAAG,QAAQ,CAAC,CAAC,CAAC,CAErE;AAQD,YAAY,EAAE,WAAW,EAAE,aAAa,EAAE,QAAQ,EAAE,MAAM,EAAE,CAAC"}
package/dist/store.d.ts CHANGED
@@ -19,10 +19,40 @@
19
19
  import { type ReadonlyCell, type Signal } from '@firsthandjs/core';
20
20
  import { type Tag } from './tags.js';
21
21
  export type Status = 'idle' | 'loading' | 'success' | 'error';
22
- /** What a loader is given. */
23
- export interface LoadContext {
22
+ /**
23
+ * What a loader hands to a transport: everything a request needs to be sent
24
+ * once, aborted, and — when it matters — sent *again*.
25
+ *
26
+ * The two travel together because they answer the same question. `signal` ends
27
+ * a request that is no longer wanted; `force` says this run exists *because*
28
+ * something was invalidated, so a transport cache that answers from its own
29
+ * memory would make that invalidation silently pointless. Every client in this
30
+ * project takes this object and honours both.
31
+ */
32
+ export interface DataRequest {
24
33
  /** Aborted when this run is superseded, or the resource goes away. */
25
34
  readonly signal: AbortSignal;
35
+ /**
36
+ * True when this run was caused by an invalidation or by `reload()`.
37
+ *
38
+ * A client must then skip whatever it has cached and replace it with the
39
+ * answer — which is what makes an invalidation reach all the way down.
40
+ */
41
+ readonly force: boolean;
42
+ /**
43
+ * Where a client reports what this request is about, when it knows.
44
+ *
45
+ * A GraphQL document carries its own `@tag` and `@invalidates` directives,
46
+ * so a client can declare them without the call site repeating them. In a
47
+ * resource this is the resource's `tags`; in an action it is the store's
48
+ * `invalidates` — the same hole, filled with whichever of the two the
49
+ * request belongs to. Optional, because a request written by hand may have
50
+ * nothing to say.
51
+ */
52
+ readonly tags?: (...tags: Tag[]) => void;
53
+ }
54
+ /** What a loader is given. */
55
+ export interface LoadContext extends DataRequest {
26
56
  /**
27
57
  * Declares what this resource is about. **Replaces**: call it before the
28
58
  * first `await` when you know, again after it when only the server does, and
@@ -30,15 +60,10 @@ export interface LoadContext {
30
60
  */
31
61
  readonly tags: (...tags: Tag[]) => void;
32
62
  /**
33
- * True when this run was caused by an invalidation or by `reload()`.
34
- *
35
- * It is the one place the layers touch. A transport cache would otherwise
36
- * hand back the answer that was just invalidated, and the invalidation would
37
- * be silently pointless — so pass it on: `cache: force ? 'reload' : 'default'`
38
- * for `fetch`, `fetchPolicy: force ? 'network-only' : 'cache-first'` for
39
- * Apollo, `requestPolicy` for urql.
63
+ * The two above as one object, to hand to a client:
64
+ * `api.get<User>('/users/7')(request)`.
40
65
  */
41
- readonly force: boolean;
66
+ readonly request: DataRequest;
42
67
  }
43
68
  /** What an action is given. */
44
69
  export interface ActionContext {
@@ -49,7 +74,14 @@ export interface ActionContext {
49
74
  * is the case where only the server knows what was touched.
50
75
  */
51
76
  readonly invalidates: (...tags: Tag[]) => void;
77
+ /**
78
+ * The request to hand to a client. `force` is always true here: an action
79
+ * changes something, so nothing it sends may be answered from a cache.
80
+ */
81
+ readonly request: DataRequest;
52
82
  }
83
+ /** What a client hands back: a request waiting for the context to send it. */
84
+ export type Loader<T> = (request: DataRequest) => Promise<T>;
53
85
  export interface Resource<T> {
54
86
  readonly data: ReadonlyCell<T | undefined>;
55
87
  readonly error: ReadonlyCell<unknown>;
@@ -1 +1 @@
1
- {"version":3,"file":"store.d.ts","sourceRoot":"","sources":["../src/store.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"store.d.ts","sourceRoot":"","sources":["../src/store.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@firsthandjs/data",
3
- "version": "0.5.0",
3
+ "version": "0.6.1",
4
4
  "description": "Resources, actions and invalidation for Firsthand: reactive async state, with the cache one layer down.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -28,8 +28,8 @@
28
28
  "LICENSE"
29
29
  ],
30
30
  "dependencies": {
31
- "@firsthandjs/core": "0.5.0",
32
- "@firsthandjs/dom": "0.5.0"
31
+ "@firsthandjs/core": "0.6.1",
32
+ "@firsthandjs/dom": "0.6.1"
33
33
  },
34
34
  "engines": {
35
35
  "node": ">=20.11.0"