@marianmeres/fetch-store 3.0.3 → 3.1.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
@@ -1,318 +1,129 @@
1
1
  # @marianmeres/fetch-store
2
2
 
3
- A reactive [store](https://github.com/marianmeres/store) utility for managing async fetch
4
- operations with built-in state tracking for loading, errors, and success counts. Designed
5
- for Svelte-compatible reactivity but works with any framework.
3
+ [![JSR](https://jsr.io/badges/@marianmeres/fetch-store)](https://jsr.io/@marianmeres/fetch-store)
4
+ [![NPM](https://img.shields.io/npm/v/@marianmeres/fetch-store)](https://www.npmjs.com/package/@marianmeres/fetch-store)
5
+ [![License](https://img.shields.io/npm/l/@marianmeres/fetch-store)](LICENSE)
6
6
 
7
- ## Install
7
+ Reactive store for async fetch operations with loading/error/success state tracking, caching, polling, request deduplication, abort support, and streaming (SSE, WebSocket). Svelte-compatible but framework-agnostic.
8
8
 
9
- ```shell
9
+ ## Installation
10
+
11
+ ```bash
10
12
  deno add jsr:@marianmeres/fetch-store
11
13
  ```
12
14
 
13
- ```shell
14
- npm i @marianmeres/fetch-store
15
+ ```bash
16
+ npm install @marianmeres/fetch-store
15
17
  ```
16
18
 
17
- ## Basic Example
19
+ ## Usage
18
20
 
19
21
  ```typescript
20
- // user-store.ts
21
22
  import { createFetchStore } from "@marianmeres/fetch-store";
22
23
 
23
24
  const userStore = createFetchStore(async (userId: string) => {
24
- const response = await fetch(`/api/users/${userId}`);
25
- if (!response.ok) throw new Error("Failed to fetch user");
26
- return await response.json();
25
+ const res = await fetch(`/api/users/${userId}`);
26
+ if (!res.ok) throw new Error("Failed to fetch user");
27
+ return (await res.json()) as User;
27
28
  });
28
29
 
29
- // Fetch user data
30
- await userStore.fetch("123");
30
+ userStore.subscribe(({ data, isFetching, lastFetchError }) => {
31
+ // react to state changes
32
+ });
31
33
 
32
- // Access current state
33
- const { data, isFetching, lastFetchError } = userStore.get();
34
+ await userStore.fetch("123");
34
35
  ```
35
36
 
36
- ### Svelte Integration
37
+ ### Svelte
37
38
 
38
39
  ```svelte
39
- <!-- UserProfile.svelte -->
40
- <script>
41
- import { onMount } from 'svelte';
42
- import { userStore } from './user-store.ts';
43
-
44
- export let userId;
45
- onMount(() => userStore.fetch(userId));
40
+ <script lang="ts">
41
+ import { userStore } from "./user-store.ts";
42
+ userStore.fetch(userId);
46
43
  </script>
47
44
 
48
45
  {#if $userStore.isFetching}
49
46
  <Spinner />
50
47
  {:else if $userStore.lastFetchError}
51
48
  <Error error={$userStore.lastFetchError} />
52
- {:else}
49
+ {:else if $userStore.data}
53
50
  <UserCard user={$userStore.data} />
54
51
  {/if}
55
52
  ```
56
53
 
57
- ## Features
58
-
59
- - **Reactive state management** - Subscribe to loading, error, and data states
60
- - **Silent fetching** - Update data without triggering loading indicators
61
- - **Fetch-once with caching** - Prevent redundant fetches within a time threshold
62
- - **Polling support** - Built-in recursive fetching for real-time updates
63
- - **Request deduplication** - Optionally return in-flight promises for concurrent calls
64
- - **Abort support** - Cancel in-flight requests with AbortController integration
65
- - **Stream support** - Handle SSE, WebSocket, or push-based data sources
66
-
67
- ## Abortable Requests
68
-
69
- Enable automatic request cancellation when a new fetch starts:
54
+ ### Abortable
70
55
 
71
56
  ```typescript
72
- import { createFetchStore } from "@marianmeres/fetch-store";
73
-
74
57
  const searchStore = createFetchStore(
75
- async (query: string, signal: AbortSignal) => {
76
- const response = await fetch(`/api/search?q=${query}`, { signal });
77
- if (!response.ok) throw new Error("Search failed");
78
- return await response.json();
58
+ async (q: string, signal: AbortSignal) => {
59
+ const r = await fetch(`/api/search?q=${q}`, { signal });
60
+ return (await r.json()) as Result[];
79
61
  },
80
62
  null,
81
- null,
82
63
  { abortable: true }
83
64
  );
84
65
 
85
- // Rapid calls - each new fetch aborts the previous one
86
- searchStore.fetch("h"); // Aborted
87
- searchStore.fetch("he"); // Aborted
88
- searchStore.fetch("hel"); // Aborted
89
- searchStore.fetch("hello"); // This one completes
90
-
91
- // Manual abort
92
- searchStore.abort();
66
+ searchStore.fetch("h"); // aborted
67
+ searchStore.fetch("he"); // aborted
68
+ searchStore.fetch("hello"); // completes
93
69
  ```
94
70
 
95
- ## Request Deduplication
96
-
97
- Prevent duplicate requests when multiple calls happen while one is in-flight:
71
+ ### Deduplication
98
72
 
99
73
  ```typescript
100
- const userStore = createFetchStore(
101
- async (userId) => fetch(`/api/users/${userId}`).then(r => r.json()),
102
- null,
103
- null,
104
- { dedupeInflight: true }
105
- );
106
-
107
- // These all return the same promise (only one HTTP request is made)
108
- const p1 = userStore.fetch("123");
109
- const p2 = userStore.fetch("123");
110
- const p3 = userStore.fetch("123");
74
+ const store = createFetchStore(worker, null, { dedupeInflight: true });
111
75
 
112
- p1 === p2 && p2 === p3; // true
76
+ const a = store.fetch("x");
77
+ const b = store.fetch("x");
78
+ a === b; // true — one network call
113
79
  ```
114
80
 
115
- ## Streaming Data
116
-
117
- For streaming data sources, use `createFetchStreamStore`:
81
+ ### Cached / polled reads
118
82
 
119
83
  ```typescript
120
- import { createFetchStreamStore } from "@marianmeres/fetch-store";
121
-
122
- const sseStore = createFetchStreamStore((emit, url) => {
123
- const eventSource = new EventSource(url);
124
-
125
- eventSource.onmessage = (event) => {
126
- emit("data", JSON.parse(event.data));
127
- };
128
-
129
- eventSource.onerror = (error) => {
130
- emit("error", error);
131
- };
84
+ // fire only if no successful fetch yet, or more than 60s old
85
+ await store.fetchOnce("123", 60_000);
132
86
 
133
- // Return cleanup function
134
- return () => {
135
- eventSource.close();
136
- emit("end");
137
- };
138
- });
139
-
140
- // Start streaming (with optional auto-restart after 5s delay)
141
- const stop = sseStore.fetchStream(["/api/events"], 5000);
142
-
143
- // Later: stop the stream
87
+ // poll every 5s, silent (no isFetching flicker)
88
+ const stop = store.fetchRecursive("123", 5_000);
89
+ // ...later
144
90
  stop();
145
91
  ```
146
92
 
147
- ## Breaking Changes (v3.0.0)
148
-
149
- This major release includes the following breaking changes:
150
-
151
- ### Simplified Generic Types
152
-
153
- The `FetchStore<T>` and `FetchStreamStore<T>` interfaces have been simplified from a
154
- two-generic pattern to a single generic:
155
-
156
- ```typescript
157
- // Before (v2.x)
158
- interface FetchStore<T, V = T extends FetchStoreValue<infer D> ? D : T> { ... }
159
-
160
- // After (v3.x)
161
- interface FetchStore<T> extends StoreReadable<FetchStoreValue<T>> { ... }
162
- ```
163
-
164
- **Migration:** If you were using explicit generic parameters, simply use the data type
165
- directly: `FetchStore<User>` instead of `FetchStore<FetchStoreValue<User>, User>`.
166
-
167
- ### Simplified API Signature
168
-
169
- The `createFetchStore` and `createFetchStreamStore` functions now have a simpler signature.
170
- The `dataFactory` parameter has been moved into the `options` object:
171
-
172
- ```typescript
173
- // Before (v2.x)
174
- createFetchStore(fetchWorker, initial, dataFactory, options)
175
-
176
- // After (v3.x)
177
- createFetchStore(fetchWorker, initial, options)
178
- // dataFactory is now in options: { dataFactory: (data, old) => transformedData }
179
- ```
180
-
181
- **Migration:** Move your `dataFactory` from the third parameter into the options object:
93
+ ### Streaming (SSE)
182
94
 
183
95
  ```typescript
184
- // Before
185
- const store = createFetchStore(
186
- async () => fetchData(),
187
- null,
188
- (data, old) => ({ ...old, ...data }),
189
- { dedupeInflight: true }
190
- );
191
-
192
- // After
193
- const store = createFetchStore(
194
- async () => fetchData(),
195
- null,
196
- {
197
- dataFactory: (data, old) => ({ ...old, ...data }),
198
- dedupeInflight: true
199
- }
200
- );
201
- ```
202
-
203
- ### DataFactory Type Change
204
-
205
- The `DataFactory<T>` type now receives properly typed data instead of `unknown`:
96
+ import { createFetchStreamStore } from "@marianmeres/fetch-store";
206
97
 
207
- ```typescript
208
- // Before (v2.x)
209
- type DataFactory<T> = (raw: any, old?: T) => T;
98
+ const feed = createFetchStreamStore<Message>((emit, url: string) => {
99
+ const es = new EventSource(url);
100
+ es.onmessage = (e) => emit("data", JSON.parse(e.data));
101
+ es.onerror = (e) => emit("error", e);
102
+ return () => { es.close(); emit("end"); };
103
+ });
210
104
 
211
- // After (v3.x)
212
- type DataFactory<T> = (data: T, old?: T) => T;
105
+ const stop = feed.fetchStream(["/api/events"]);
213
106
  ```
214
107
 
215
- The `fetchWorker` must now return `Promise<T>` directly, making the types consistent
216
- throughout the API.
217
-
218
-
219
- ## API Reference
220
-
221
- ### `createFetchStore<T>(fetchWorker, initial?, options?)`
222
-
223
- Creates a reactive store for async fetch operations.
224
-
225
- **Parameters:**
226
-
227
- | Parameter | Type | Default | Description |
228
- |-----------|------|---------|-------------|
229
- | `fetchWorker` | `(...args) => Promise<T>` | required | Async function performing the fetch. Must return data of type T. When `abortable: true`, receives `AbortSignal` as the last argument. |
230
- | `initial` | `T \| null` | `null` | Initial data value |
231
- | `options` | `FetchStoreOptions<T>` | `{}` | Configuration options |
232
-
233
- **Options:**
234
-
235
- | Option | Type | Default | Description |
236
- |--------|------|---------|-------------|
237
- | `fetchOnceDefaultThresholdMs` | `number` | `300000` | Default threshold (5 min) for `fetchOnce` before allowing re-fetch |
238
- | `dedupeInflight` | `boolean` | `false` | If true, concurrent `fetch()` calls return the same promise |
239
- | `abortable` | `boolean` | `false` | If true, creates AbortController for each fetch, aborting previous requests |
240
- | `dataFactory` | `(data: T, old?: T) => T` | - | Transform fetched data (useful for merge strategies) |
241
- | `onReset` | `() => void` | - | Callback invoked when `reset()` is called |
242
-
243
- **Returns:** `FetchStore<T>`
244
-
245
- ### FetchStore Methods
246
-
247
- | Method | Signature | Description |
248
- |--------|-----------|-------------|
249
- | `subscribe` | `(fn: (value) => void) => () => void` | Subscribe to store changes (Svelte-compatible) |
250
- | `get` | `() => FetchStoreValue<T>` | Get current store value |
251
- | `fetch` | `(...args) => Promise<T \| null>` | Execute fetch, sets `isFetching` to true |
252
- | `fetchSilent` | `(...args) => Promise<T \| null>` | Execute fetch without updating `isFetching` |
253
- | `fetchOnce` | `(args?, thresholdMs?) => Promise<T \| null>` | Fetch only if not already fetched or threshold passed |
254
- | `fetchOnceSilent` | `(args?, thresholdMs?) => Promise<T \| null>` | Silent version of `fetchOnce` |
255
- | `fetchRecursive` | `(args?, delayMs?) => () => void` | Start polling, returns cancel function |
256
- | `reset` | `() => void` | Reset store to initial state |
257
- | `resetError` | `() => void` | Clear `lastFetchError` only |
258
- | `touch` | `(data?: T) => void` | Update timestamps (tricks `fetchOnce`), optionally set data |
259
- | `abort` | `() => void` | Abort in-flight requests (requires `abortable: true`) |
260
- | `getInternalDataStore` | `() => StoreLike<T>` | Access internal data store |
261
-
262
- ### FetchStoreValue Properties
263
-
264
- The store value contains both data and metadata:
265
-
266
- | Property | Type | Description |
267
- |----------|------|-------------|
268
- | `data` | `T \| null` | The fetched data (null before first fetch or after reset) |
269
- | `isFetching` | `boolean` | Whether a fetch is in progress |
270
- | `lastFetchStart` | `Date \| null` | When the last fetch started |
271
- | `lastFetchEnd` | `Date \| null` | When the last fetch completed |
272
- | `lastFetchError` | `Error \| null` | Error from the last fetch, if any |
273
- | `lastFetchSilentError` | `Error \| null` | Error from the last silent fetch, if any |
274
- | `successCounter` | `number` | Number of successful fetches |
275
-
276
- ### `createFetchStreamStore<T>(fetchStreamWorker, initial?, options?)`
277
-
278
- Creates a reactive store for streaming data sources.
279
-
280
- **Parameters:**
281
-
282
- | Parameter | Type | Default | Description |
283
- |-----------|------|---------|-------------|
284
- | `fetchStreamWorker` | `(emit, ...args) => (() => void) \| void` | required | Worker receiving emit callback for events. Emit receives data of type T. Should return cleanup function. |
285
- | `initial` | `T \| null` | `null` | Initial data value |
286
- | `options` | `FetchStreamStoreOptions<T>` | `{}` | Configuration options |
287
-
288
- **Options:**
108
+ ## Features
289
109
 
290
- | Option | Type | Default | Description |
291
- |--------|------|---------|-------------|
292
- | `dataFactory` | `(data: T, old?: T) => T` | - | Transform received data (useful for merge strategies) |
293
- | `onReset` | `() => void` | - | Callback invoked when `reset()` is called |
110
+ - Reactive state: `data`, `isFetching`, `lastFetchError`, `successCounter`, timestamps
111
+ - `fetchSilent` — background refresh without loading indicator
112
+ - `fetchOnce` — TTL-cached fetch that joins any in-flight request
113
+ - `fetchRecursive` — polling with cancel
114
+ - `abortable` — automatic `AbortController` per call
115
+ - `dedupeInflight` — concurrent calls share one promise
116
+ - `dataFactory` — custom merge/transform strategies
117
+ - `createFetchStreamStore` — push-based sources (SSE, WebSocket, generators)
294
118
 
295
- **Emit Events:**
296
- - `emit("data", value)` - Push new data
297
- - `emit("error", error)` - Report an error
298
- - `emit("end")` - Signal stream completion
119
+ ## API
299
120
 
300
- **Returns:** `FetchStreamStore<T>`
121
+ See [API.md](API.md) for the complete API reference.
301
122
 
302
- ### FetchStreamStore Methods
123
+ ## Changelog
303
124
 
304
- | Method | Signature | Description |
305
- |--------|-----------|-------------|
306
- | `subscribe` | `(fn: (value) => void) => () => void` | Subscribe to store changes |
307
- | `get` | `() => FetchStreamStoreValue<T>` | Get current store value |
308
- | `fetchStream` | `(args?, recursiveDelayMs?) => () => void` | Start stream, returns stop function. If `recursiveDelayMs > 0`, restarts after "end" event. |
309
- | `reset` | `() => void` | Reset store to initial state |
310
- | `resetError` | `() => void` | Clear `lastFetchError` only |
311
- | `getInternalDataStore` | `() => StoreLike<T>` | Access internal data store |
125
+ See [CHANGELOG.md](CHANGELOG.md).
312
126
 
313
- ## Package Identity
127
+ ## License
314
128
 
315
- - **Name:** @marianmeres/fetch-store
316
- - **Author:** Marian Meres
317
- - **Repository:** https://github.com/marianmeres/fetch-store
318
- - **License:** MIT
129
+ [MIT](LICENSE)
@@ -6,10 +6,11 @@ import type { FetchStore, FetchStoreOptions } from "./types.js";
6
6
  * state tracking for loading, errors, and success counts.
7
7
  *
8
8
  * @template T - The type of data the store will hold
9
+ * @template A - The argument tuple accepted by the fetch worker
9
10
  * @param fetchWorker - Async function that performs the actual fetch operation.
10
- * Must return data of type T. When `abortable: true`, receives AbortSignal as the last argument.
11
+ * When `abortable: true`, receives an `AbortSignal` as the last argument.
11
12
  * @param initial - Initial data value (default: null)
12
- * @param options - Configuration options including optional dataFactory for merge strategies
13
+ * @param options - Configuration options including optional `dataFactory`
13
14
  * @returns A FetchStore instance with reactive subscription and fetch methods
14
15
  *
15
16
  * @example
@@ -30,4 +31,4 @@ import type { FetchStore, FetchStoreOptions } from "./types.js";
30
31
  * await userStore.fetch("123");
31
32
  * ```
32
33
  */
33
- export declare const createFetchStore: <T>(fetchWorker: (...args: unknown[]) => Promise<T>, initial?: T | null, options?: Partial<FetchStoreOptions<T>>) => FetchStore<T>;
34
+ export declare const createFetchStore: <T, A extends unknown[] = unknown[]>(fetchWorker: (...args: A) => Promise<T>, initial?: T | null, options?: Partial<FetchStoreOptions<T>>) => FetchStore<T, A>;
@@ -11,10 +11,11 @@ const DEFAULT_OPTIONS = {
11
11
  * state tracking for loading, errors, and success counts.
12
12
  *
13
13
  * @template T - The type of data the store will hold
14
+ * @template A - The argument tuple accepted by the fetch worker
14
15
  * @param fetchWorker - Async function that performs the actual fetch operation.
15
- * Must return data of type T. When `abortable: true`, receives AbortSignal as the last argument.
16
+ * When `abortable: true`, receives an `AbortSignal` as the last argument.
16
17
  * @param initial - Initial data value (default: null)
17
- * @param options - Configuration options including optional dataFactory for merge strategies
18
+ * @param options - Configuration options including optional `dataFactory`
18
19
  * @returns A FetchStore instance with reactive subscription and fetch methods
19
20
  *
20
21
  * @example
@@ -39,7 +40,7 @@ export const createFetchStore = (fetchWorker, initial = null, options = {}) => {
39
40
  const mergedOptions = { ...DEFAULT_OPTIONS, ...options };
40
41
  const { fetchOnceDefaultThresholdMs, dedupeInflight, abortable } = mergedOptions;
41
42
  const dataFactory = options.dataFactory;
42
- // Use factory for data transformation strategies (merge/deepmerge/set/...)
43
+ // Factory for data transformation strategies (merge / deepmerge / set / ...)
43
44
  const _createData = (data, old) => typeof dataFactory === "function" ? dataFactory(data, old ?? undefined) : data;
44
45
  const _createMetaObj = () => ({
45
46
  isFetching: false,
@@ -52,10 +53,13 @@ export const createFetchStore = (fetchWorker, initial = null, options = {}) => {
52
53
  const _dataStore = createStore(initial);
53
54
  const _metaStore = createStore(_createMetaObj());
54
55
  const { subscribe, get } = createDerivedStore([_dataStore, _metaStore], ([data, meta]) => ({ data, ...meta }));
55
- // In-flight promise for deduplication
56
+ // Generation token — bumped on reset(). Any in-flight fetch whose
57
+ // generation no longer matches discards its writes.
58
+ let _generation = 0;
59
+ // In-flight promise tracking (always populated, independent of dedupeInflight).
60
+ // Used internally by fetchOnce/fetchOnceSilent to join an existing request.
56
61
  let _inflightPromise = null;
57
62
  let _inflightSilentPromise = null;
58
- // AbortController for cancellation
59
63
  let _abortController = null;
60
64
  let _abortControllerSilent = null;
61
65
  const abort = () => {
@@ -68,19 +72,17 @@ export const createFetchStore = (fetchWorker, initial = null, options = {}) => {
68
72
  _abortControllerSilent = null;
69
73
  }
70
74
  };
75
+ const _isAbortError = (e) => e instanceof DOMException && e.name === "AbortError";
71
76
  const fetch = (...rest) => {
72
- // If deduplication is enabled and there's an in-flight request, return it
73
- if (dedupeInflight && _inflightPromise) {
77
+ if (dedupeInflight && _inflightPromise)
74
78
  return _inflightPromise;
75
- }
76
- // If abortable, abort any previous request and create new controller
77
79
  if (abortable) {
78
- if (_abortController) {
80
+ if (_abortController)
79
81
  _abortController.abort();
80
- }
81
82
  _abortController = new AbortController();
82
83
  }
83
- const currentController = _abortController;
84
+ const myController = _abortController;
85
+ const myGeneration = _generation;
84
86
  const doFetch = async () => {
85
87
  _metaStore.set({
86
88
  ..._metaStore.get(),
@@ -90,106 +92,128 @@ export const createFetchStore = (fetchWorker, initial = null, options = {}) => {
90
92
  lastFetchError: null,
91
93
  });
92
94
  let error = null;
93
- let newSuccessCounter = _metaStore.get().successCounter;
95
+ let data;
94
96
  try {
95
- // Pass signal as the last argument if abortable
96
- const args = abortable && currentController
97
- ? [...rest, currentController.signal]
98
- : rest;
99
- _dataStore.set(_createData(await fetchWorker(...args), _dataStore.get()));
100
- newSuccessCounter++;
97
+ const args = (abortable && myController ? [...rest, myController.signal] : rest);
98
+ data = await fetchWorker(...args);
101
99
  }
102
100
  catch (e) {
103
- // Don't treat abort as an error that should be stored
104
- if (e instanceof DOMException && e.name === "AbortError") {
105
- // Request was aborted, don't update meta
106
- return _dataStore.get();
101
+ if (_isAbortError(e)) {
102
+ // Discard if store was reset during await.
103
+ if (myGeneration !== _generation)
104
+ return null;
105
+ // Only clear isFetching if we're still the current request
106
+ // (otherwise a newer fetch owns the meta).
107
+ if (!abortable || myController === _abortController || _abortController === null) {
108
+ _metaStore.set({
109
+ ..._metaStore.get(),
110
+ isFetching: false,
111
+ lastFetchEnd: new Date(),
112
+ });
113
+ }
114
+ return null;
107
115
  }
108
116
  error = e instanceof Error ? e : new Error(String(e));
109
117
  }
118
+ // Discard writes if reset happened during await.
119
+ if (myGeneration !== _generation)
120
+ return null;
121
+ // If abortable and a newer fetch has superseded us, drop the write
122
+ // entirely — the newer fetch will own data and meta.
123
+ if (abortable &&
124
+ myController !== _abortController &&
125
+ _abortController !== null) {
126
+ return null;
127
+ }
128
+ if (!error && data !== undefined) {
129
+ _dataStore.set(_createData(data, _dataStore.get()));
130
+ }
131
+ const prev = _metaStore.get();
110
132
  _metaStore.set({
111
- ..._metaStore.get(),
133
+ ...prev,
112
134
  isFetching: false,
113
135
  lastFetchEnd: new Date(),
114
136
  lastFetchError: error,
115
- successCounter: newSuccessCounter,
137
+ successCounter: error ? prev.successCounter : prev.successCounter + 1,
116
138
  });
117
- // Return fetched (or last) data (for non-subscribe consumption)
118
- return _metaStore.get().lastFetchError ? null : _dataStore.get();
139
+ return error ? null : _dataStore.get();
119
140
  };
120
- if (dedupeInflight) {
121
- _inflightPromise = doFetch().finally(() => {
141
+ const p = doFetch().finally(() => {
142
+ if (_inflightPromise === p)
122
143
  _inflightPromise = null;
123
- if (currentController === _abortController) {
124
- _abortController = null;
125
- }
126
- });
127
- return _inflightPromise;
128
- }
129
- return doFetch().finally(() => {
130
- if (currentController === _abortController) {
144
+ if (myController && myController === _abortController) {
131
145
  _abortController = null;
132
146
  }
133
147
  });
148
+ _inflightPromise = p;
149
+ return p;
134
150
  };
135
- // Similar to fetch, but does not touch meta.isFetching, allowing data updates
136
- // without triggering loading spinners
151
+ // Similar to fetch, but does NOT touch `isFetching` — avoids loading-spinner
152
+ // flicker for background refreshes. Still updates lastFetchStart,
153
+ // lastFetchEnd, successCounter and lastFetchSilentError so that
154
+ // fetchOnceSilent's threshold/cache logic works correctly.
137
155
  const fetchSilent = (...rest) => {
138
- // If deduplication is enabled and there's an in-flight request, return it
139
- if (dedupeInflight && _inflightSilentPromise) {
156
+ if (dedupeInflight && _inflightSilentPromise)
140
157
  return _inflightSilentPromise;
141
- }
142
- // If abortable, abort any previous silent request and create new controller
143
158
  if (abortable) {
144
- if (_abortControllerSilent) {
159
+ if (_abortControllerSilent)
145
160
  _abortControllerSilent.abort();
146
- }
147
161
  _abortControllerSilent = new AbortController();
148
162
  }
149
- const currentController = _abortControllerSilent;
163
+ const myController = _abortControllerSilent;
164
+ const myGeneration = _generation;
165
+ const startedAt = new Date();
150
166
  const doFetchSilent = async () => {
151
- const currentMeta = _metaStore.get();
152
- if (currentMeta.lastFetchSilentError) {
153
- _metaStore.set({ ...currentMeta, lastFetchSilentError: null });
154
- }
155
167
  let error = null;
168
+ let data;
156
169
  try {
157
- // Pass signal as the last argument if abortable
158
- const args = abortable && currentController
159
- ? [...rest, currentController.signal]
160
- : rest;
161
- _dataStore.set(_createData(await fetchWorker(...args), _dataStore.get()));
170
+ const args = (abortable && myController ? [...rest, myController.signal] : rest);
171
+ data = await fetchWorker(...args);
162
172
  }
163
173
  catch (e) {
164
- // Don't treat abort as an error that should be stored
165
- if (e instanceof DOMException && e.name === "AbortError") {
166
- return _dataStore.get();
174
+ if (_isAbortError(e)) {
175
+ // Discard if reset happened during await.
176
+ if (myGeneration !== _generation)
177
+ return null;
178
+ // Silent aborts do not touch meta at all — nothing to roll back.
179
+ return null;
167
180
  }
168
181
  error = e instanceof Error ? e : new Error(String(e));
169
182
  }
170
- if (error) {
171
- _metaStore.set({ ..._metaStore.get(), lastFetchSilentError: error });
183
+ if (myGeneration !== _generation)
184
+ return null;
185
+ if (abortable &&
186
+ myController !== _abortControllerSilent &&
187
+ _abortControllerSilent !== null) {
188
+ return null;
172
189
  }
173
- // Return fetched (or last) data (for non-subscribe consumption)
174
- return _metaStore.get().lastFetchSilentError ? null : _dataStore.get();
190
+ // Write data BEFORE meta so subscribers see the new data alongside the
191
+ // new meta in the final notification.
192
+ if (!error && data !== undefined) {
193
+ _dataStore.set(_createData(data, _dataStore.get()));
194
+ }
195
+ const prev = _metaStore.get();
196
+ _metaStore.set({
197
+ ...prev,
198
+ lastFetchStart: startedAt,
199
+ lastFetchEnd: new Date(),
200
+ lastFetchSilentError: error,
201
+ successCounter: error ? prev.successCounter : prev.successCounter + 1,
202
+ });
203
+ return error ? null : _dataStore.get();
175
204
  };
176
- if (dedupeInflight) {
177
- _inflightSilentPromise = doFetchSilent().finally(() => {
205
+ const p = doFetchSilent().finally(() => {
206
+ if (_inflightSilentPromise === p)
178
207
  _inflightSilentPromise = null;
179
- if (currentController === _abortControllerSilent) {
180
- _abortControllerSilent = null;
181
- }
182
- });
183
- return _inflightSilentPromise;
184
- }
185
- return doFetchSilent().finally(() => {
186
- if (currentController === _abortControllerSilent) {
208
+ if (myController && myController === _abortControllerSilent) {
187
209
  _abortControllerSilent = null;
188
210
  }
189
211
  });
212
+ _inflightSilentPromise = p;
213
+ return p;
190
214
  };
191
215
  // Sets internal timestamps to now, tricking fetchOnce into thinking data was just fetched.
192
- // Useful for external cache invalidation.
216
+ // Useful for external cache invalidation. Increments successCounter as a side effect.
193
217
  const touch = (data) => {
194
218
  if (data !== undefined) {
195
219
  _dataStore.set(data);
@@ -203,15 +227,20 @@ export const createFetchStore = (fetchWorker, initial = null, options = {}) => {
203
227
  successCounter: currentMeta.successCounter + 1,
204
228
  });
205
229
  };
230
+ const _normalizeArgs = (a) => (Array.isArray(a) ? a : [a]);
206
231
  const _fetchOnce = async (isSilent, fetchArgs = [], thresholdMs = fetchOnceDefaultThresholdMs) => {
207
- const { successCounter, isFetching, lastFetchStart } = _metaStore.get();
208
- const args = Array.isArray(fetchArgs) ? fetchArgs : [fetchArgs];
209
- if (!successCounter && !isFetching) {
232
+ const args = _normalizeArgs(fetchArgs);
233
+ // Always join any in-flight request (regardless of dedupeInflight).
234
+ // Prevents concurrent fetchOnce calls from each firing a fetch.
235
+ if (_inflightPromise)
236
+ return await _inflightPromise;
237
+ if (_inflightSilentPromise)
238
+ return await _inflightSilentPromise;
239
+ const { successCounter, lastFetchStart } = _metaStore.get();
240
+ if (!successCounter) {
210
241
  return isSilent ? await fetchSilent(...args) : await fetch(...args);
211
242
  }
212
- // Expired threshold?
213
243
  if (thresholdMs &&
214
- !isFetching &&
215
244
  lastFetchStart &&
216
245
  Date.now() - lastFetchStart.valueOf() > thresholdMs) {
217
246
  return isSilent ? await fetchSilent(...args) : await fetch(...args);
@@ -225,31 +254,41 @@ export const createFetchStore = (fetchWorker, initial = null, options = {}) => {
225
254
  const fetchOnceSilent = async (fetchArgs = [], thresholdMs = fetchOnceDefaultThresholdMs) => {
226
255
  return await _fetchOnce(true, fetchArgs, thresholdMs);
227
256
  };
228
- // Recursive polling (whether it's "long" or "short" polling depends on the server)
229
- const fetchRecursive = (fetchArgs = [], delayMs = 500) => {
257
+ // Recursive polling (long/short polling depends on the server)
258
+ const fetchRecursive = (fetchArgs = [], delayMs = 500, opts = {}) => {
259
+ const useSilent = opts.silent !== false;
260
+ const runner = useSilent ? fetchSilent : fetch;
230
261
  let _timer;
231
262
  let _aborted = false;
232
- const _fetchRecursive = (args = [], delay = 500) => {
233
- const normalizedArgs = Array.isArray(args) ? args : [args];
234
- const resolvedDelay = typeof delay === "function" ? delay() : delay;
235
- fetchSilent(...normalizedArgs).then(() => {
263
+ const _fetchRecursive = (args) => {
264
+ if (_aborted)
265
+ return;
266
+ runner(...args).then(() => {
267
+ if (_aborted)
268
+ return;
269
+ const resolvedDelay = typeof delayMs === "function" ? delayMs() : delayMs;
236
270
  if (_timer)
237
271
  clearTimeout(_timer);
238
- if (resolvedDelay > 0 && !_aborted) {
239
- _timer = setTimeout(() => !_aborted && _fetchRecursive(normalizedArgs, delay), resolvedDelay);
272
+ if (resolvedDelay > 0) {
273
+ _timer = setTimeout(() => {
274
+ if (!_aborted)
275
+ _fetchRecursive(args);
276
+ }, resolvedDelay);
240
277
  }
241
278
  });
242
- // Return a "cancel" (or "stop") control fn
243
- return () => {
244
- if (_timer)
245
- clearTimeout(_timer);
246
- _aborted = true;
247
- };
248
279
  };
249
- return _fetchRecursive(fetchArgs, delayMs);
280
+ const normalized = _normalizeArgs(fetchArgs);
281
+ _fetchRecursive(normalized);
282
+ return () => {
283
+ _aborted = true;
284
+ if (_timer)
285
+ clearTimeout(_timer);
286
+ };
250
287
  };
251
- // Resets the store to initial state; useful for cache invalidation
288
+ // Resets the store to initial state; useful for cache invalidation.
289
+ // Bumps generation so any late-resolving fetch cannot overwrite reset state.
252
290
  const reset = () => {
291
+ _generation++;
253
292
  abort();
254
293
  _dataStore.set(initial);
255
294
  _metaStore.set(_createMetaObj());
@@ -259,7 +298,11 @@ export const createFetchStore = (fetchWorker, initial = null, options = {}) => {
259
298
  mergedOptions.onReset();
260
299
  };
261
300
  const resetError = () => {
262
- _metaStore.update((old) => ({ ...old, lastFetchError: null }));
301
+ _metaStore.update((old) => ({
302
+ ...old,
303
+ lastFetchError: null,
304
+ lastFetchSilentError: null,
305
+ }));
263
306
  };
264
307
  return {
265
308
  subscribe,
@@ -5,9 +5,10 @@ import type { FetchStreamEventName, FetchStreamStore, FetchStreamStoreOptions }
5
5
  * or any push-based data pattern.
6
6
  *
7
7
  * @template T - The type of data the store will hold
8
+ * @template A - The argument tuple accepted by the stream worker (after `emit`)
8
9
  * @param fetchStreamWorker - Worker function that receives an emit callback for sending events.
9
- * The emit callback receives data of type T. Should return a cleanup/abort function, or void
10
- * if no cleanup is needed.
10
+ * The emit callback receives data of type T or Error. Should return a cleanup/abort function,
11
+ * or void if no cleanup is needed.
11
12
  * @param initial - Initial data value (default: null)
12
13
  * @param options - Configuration options including optional dataFactory for merge strategies
13
14
  * @returns A FetchStreamStore instance with reactive subscription and stream control
@@ -18,11 +19,11 @@ import type { FetchStreamEventName, FetchStreamStore, FetchStreamStoreOptions }
18
19
  * const eventSource = new EventSource(url);
19
20
  * eventSource.onmessage = (e) => emit("data", JSON.parse(e.data));
20
21
  * eventSource.onerror = (e) => emit("error", e);
21
- * return () => eventSource.close();
22
+ * return () => { eventSource.close(); emit("end"); };
22
23
  * });
23
24
  *
24
25
  * const stop = sseStore.fetchStream(["/api/events"]);
25
26
  * // Later: stop() to close the connection
26
27
  * ```
27
28
  */
28
- export declare const createFetchStreamStore: <T>(fetchStreamWorker: (emit: (eventName: FetchStreamEventName, eventData?: T | Error) => void, ...args: unknown[]) => (() => void) | void, initial?: T | null, options?: Partial<FetchStreamStoreOptions<T>>) => FetchStreamStore<T>;
29
+ export declare const createFetchStreamStore: <T, A extends unknown[] = unknown[]>(fetchStreamWorker: (emit: (eventName: FetchStreamEventName, eventData?: T | Error) => void, ...args: A) => (() => void) | void, initial?: T | null, options?: Partial<FetchStreamStoreOptions<T>>) => FetchStreamStore<T, A>;
@@ -5,9 +5,10 @@ const DEFAULT_OPTIONS = {};
5
5
  * or any push-based data pattern.
6
6
  *
7
7
  * @template T - The type of data the store will hold
8
+ * @template A - The argument tuple accepted by the stream worker (after `emit`)
8
9
  * @param fetchStreamWorker - Worker function that receives an emit callback for sending events.
9
- * The emit callback receives data of type T. Should return a cleanup/abort function, or void
10
- * if no cleanup is needed.
10
+ * The emit callback receives data of type T or Error. Should return a cleanup/abort function,
11
+ * or void if no cleanup is needed.
11
12
  * @param initial - Initial data value (default: null)
12
13
  * @param options - Configuration options including optional dataFactory for merge strategies
13
14
  * @returns A FetchStreamStore instance with reactive subscription and stream control
@@ -18,7 +19,7 @@ const DEFAULT_OPTIONS = {};
18
19
  * const eventSource = new EventSource(url);
19
20
  * eventSource.onmessage = (e) => emit("data", JSON.parse(e.data));
20
21
  * eventSource.onerror = (e) => emit("error", e);
21
- * return () => eventSource.close();
22
+ * return () => { eventSource.close(); emit("end"); };
22
23
  * });
23
24
  *
24
25
  * const stop = sseStore.fetchStream(["/api/events"]);
@@ -28,7 +29,7 @@ const DEFAULT_OPTIONS = {};
28
29
  export const createFetchStreamStore = (fetchStreamWorker, initial = null, options = {}) => {
29
30
  const mergedOptions = { ...DEFAULT_OPTIONS, ...options };
30
31
  const dataFactory = options.dataFactory;
31
- // Use factory for data transformation strategies (merge/deepmerge/set/...)
32
+ // Factory for data transformation strategies (merge / deepmerge / set / ...)
32
33
  const _createData = (data, old) => typeof dataFactory === "function" ? dataFactory(data, old ?? undefined) : data;
33
34
  const _createMetaObj = () => ({
34
35
  isFetching: false,
@@ -39,26 +40,33 @@ export const createFetchStreamStore = (fetchStreamWorker, initial = null, option
39
40
  const _dataStore = createStore(initial);
40
41
  const _metaStore = createStore(_createMetaObj());
41
42
  const { subscribe, get } = createDerivedStore([_dataStore, _metaStore], ([data, meta]) => ({ data, ...meta }));
43
+ // Tracks the cancel fn of the currently running stream so reset() can stop it.
44
+ let _currentCancel = null;
42
45
  const fetchStream = (fetchArgs = [], recursiveDelayMs = 0) => {
43
46
  let _timer;
44
47
  let _aborted = false;
45
48
  let _abortFn;
46
- // Must be hoisted so recursive calls can be cancelled properly
47
- let _abort = () => {
48
- if (typeof _abortFn === "function") {
49
- _abortFn();
50
- }
51
- else {
52
- console.warn("`abort` is a noop (the fetchStreamWorker did not return a function).");
53
- }
49
+ const cancel = () => {
50
+ if (_aborted)
51
+ return;
52
+ _aborted = true;
54
53
  if (_timer)
55
54
  clearTimeout(_timer);
56
- _aborted = true;
55
+ if (typeof _abortFn === "function") {
56
+ try {
57
+ _abortFn();
58
+ }
59
+ catch {
60
+ // cleanup should not throw, but guard anyway
61
+ }
62
+ }
63
+ if (_currentCancel === cancel)
64
+ _currentCancel = null;
57
65
  };
58
- // Inner worker (maybe recursive)
59
- const _fetchStream = (args = [], delayMs = 0) => {
60
- const normalizedArgs = Array.isArray(args) ? args : [args];
61
- const delay = typeof delayMs === "function" ? delayMs() : delayMs;
66
+ const normalizedArgs = (Array.isArray(fetchArgs) ? fetchArgs : [fetchArgs]);
67
+ const runOnce = () => {
68
+ if (_aborted)
69
+ return;
62
70
  _metaStore.set({
63
71
  ..._metaStore.get(),
64
72
  isFetching: true,
@@ -68,17 +76,29 @@ export const createFetchStreamStore = (fetchStreamWorker, initial = null, option
68
76
  });
69
77
  try {
70
78
  _abortFn = fetchStreamWorker((eventName, eventData) => {
71
- if (_metaStore.get().lastFetchError) {
72
- _metaStore.set({ ..._metaStore.get(), lastFetchError: null });
73
- }
74
- if (eventName === "data" && eventData !== undefined && !(eventData instanceof Error)) {
79
+ // Ignore any events after cancellation.
80
+ if (_aborted)
81
+ return;
82
+ if (eventName === "data" &&
83
+ eventData !== undefined &&
84
+ !(eventData instanceof Error)) {
85
+ // A data event implicitly clears a prior error.
86
+ if (_metaStore.get().lastFetchError) {
87
+ _metaStore.set({
88
+ ..._metaStore.get(),
89
+ lastFetchError: null,
90
+ });
91
+ }
75
92
  _dataStore.set(_createData(eventData, _dataStore.get()));
76
93
  }
77
94
  else if (eventName === "error") {
78
95
  const error = eventData instanceof Error
79
96
  ? eventData
80
97
  : new Error(String(eventData));
81
- _metaStore.set({ ..._metaStore.get(), lastFetchError: error });
98
+ _metaStore.set({
99
+ ..._metaStore.get(),
100
+ lastFetchError: error,
101
+ });
82
102
  }
83
103
  else if (eventName === "end") {
84
104
  _metaStore.set({
@@ -86,28 +106,45 @@ export const createFetchStreamStore = (fetchStreamWorker, initial = null, option
86
106
  isFetching: false,
87
107
  lastFetchEnd: new Date(),
88
108
  });
89
- // Maybe recursive?
109
+ const delay = typeof recursiveDelayMs === "function"
110
+ ? recursiveDelayMs()
111
+ : recursiveDelayMs;
90
112
  if (delay > 0 && !_aborted) {
91
113
  if (_timer)
92
114
  clearTimeout(_timer);
93
115
  _timer = setTimeout(() => {
94
- if (!_aborted) {
95
- _abort = _fetchStream(normalizedArgs, delayMs);
96
- }
116
+ if (!_aborted)
117
+ runOnce();
97
118
  }, delay);
98
119
  }
99
120
  }
100
121
  }, ...normalizedArgs);
101
122
  }
102
123
  catch (e) {
124
+ // Worker threw synchronously — reset isFetching and store the error.
103
125
  const error = e instanceof Error ? e : new Error(String(e));
104
- _metaStore.set({ ..._metaStore.get(), lastFetchError: error });
126
+ _metaStore.set({
127
+ ..._metaStore.get(),
128
+ isFetching: false,
129
+ lastFetchEnd: new Date(),
130
+ lastFetchError: error,
131
+ });
105
132
  }
106
- return _abort;
107
133
  };
108
- return _fetchStream(fetchArgs, recursiveDelayMs);
134
+ // If a previous stream was still running, cancel it before starting a new one.
135
+ if (_currentCancel)
136
+ _currentCancel();
137
+ _currentCancel = cancel;
138
+ runOnce();
139
+ return cancel;
109
140
  };
110
141
  const reset = () => {
142
+ // Stop any running stream (calls the worker's cleanup function).
143
+ if (_currentCancel) {
144
+ const c = _currentCancel;
145
+ _currentCancel = null;
146
+ c();
147
+ }
111
148
  _dataStore.set(initial);
112
149
  _metaStore.set(_createMetaObj());
113
150
  if (typeof mergedOptions.onReset === "function")
package/dist/types.d.ts CHANGED
@@ -14,20 +14,20 @@ export type DataFactory<T> = (data: T, old?: T) => T;
14
14
  * Contains common fields for tracking fetch operation state.
15
15
  */
16
16
  export interface FetchMetaBase {
17
- /** Whether a fetch operation is currently in progress */
17
+ /** Whether a `fetch` operation is currently in progress (silent fetches do not flip this) */
18
18
  isFetching: boolean;
19
19
  /** Timestamp when the last fetch operation started */
20
20
  lastFetchStart: Date | null;
21
21
  /** Timestamp when the last fetch operation completed */
22
22
  lastFetchEnd: Date | null;
23
- /** Error from the last fetch operation, if any */
23
+ /** Error from the last `fetch` operation, if any */
24
24
  lastFetchError: Error | null;
25
25
  }
26
26
  /**
27
27
  * Extended metadata for FetchStore including success tracking and silent fetch errors.
28
28
  */
29
29
  export interface FetchStoreMeta extends FetchMetaBase {
30
- /** Count of successful fetch operations (incremented on each success) */
30
+ /** Count of successful fetch operations (incremented on each success, silent or not) */
31
31
  successCounter: number;
32
32
  /** Error from the last silent fetch operation, if any */
33
33
  lastFetchSilentError: Error | null;
@@ -62,66 +62,80 @@ export interface FetchStreamStoreValue<T> extends FetchStreamStoreMeta {
62
62
  * Extends StoreReadable for Svelte-compatible subscribe functionality.
63
63
  *
64
64
  * @template T - The data type stored in the fetch store
65
+ * @template A - The argument tuple accepted by the fetch worker
65
66
  */
66
- export interface FetchStore<T> extends StoreReadable<FetchStoreValue<T>> {
67
+ export interface FetchStore<T, A extends unknown[] = unknown[]> extends StoreReadable<FetchStoreValue<T>> {
67
68
  /**
68
69
  * Executes the fetch worker and updates store state.
69
70
  * Sets `isFetching` to true during the operation.
70
71
  * @param args - Arguments passed to the fetch worker
71
72
  * @returns Promise resolving to fetched data or null on error
72
73
  */
73
- fetch: (...args: unknown[]) => Promise<T | null>;
74
+ fetch: (...args: A) => Promise<T | null>;
74
75
  /**
75
76
  * Like `fetch`, but does not set `isFetching` to true.
76
77
  * Useful for background refreshes without showing loading spinners.
78
+ * Still updates `lastFetchStart`, `lastFetchEnd`, and `successCounter`.
77
79
  * @param args - Arguments passed to the fetch worker
78
80
  * @returns Promise resolving to fetched data or null on error
79
81
  */
80
- fetchSilent: (...args: unknown[]) => Promise<T | null>;
82
+ fetchSilent: (...args: A) => Promise<T | null>;
81
83
  /**
82
84
  * Fetches data only if not already fetched or if the threshold has passed.
83
- * Useful for caching scenarios where you want to avoid redundant fetches.
84
- * @param args - Arguments passed to the fetch worker
85
- * @param thresholdMs - Time in ms before re-fetching is allowed (default from options)
85
+ * If a fetch (silent or not) is currently in-flight, joins it instead of firing a new one.
86
+ * @param args - Arguments passed to the fetch worker (pass as array or single value)
87
+ * @param thresholdMs - Time in ms before re-fetching is allowed (default from options).
88
+ * Use `0` to skip the threshold check and rely only on `successCounter`.
86
89
  * @returns Promise resolving to fetched data or null on error
87
90
  */
88
- fetchOnce: (args?: unknown[], thresholdMs?: number) => Promise<T | null>;
91
+ fetchOnce: (args?: A | unknown, thresholdMs?: number) => Promise<T | null>;
89
92
  /**
90
93
  * Like `fetchOnce`, but uses silent fetching (no `isFetching` state change).
91
94
  * @param args - Arguments passed to the fetch worker
92
95
  * @param thresholdMs - Time in ms before re-fetching is allowed (default from options)
93
96
  * @returns Promise resolving to fetched data or null on error
94
97
  */
95
- fetchOnceSilent: (args?: unknown[], thresholdMs?: number) => Promise<T | null>;
98
+ fetchOnceSilent: (args?: A | unknown, thresholdMs?: number) => Promise<T | null>;
96
99
  /**
97
- * Starts recursive polling using silent fetch.
100
+ * Starts recursive polling.
98
101
  * Each fetch waits for the previous one to complete before scheduling the next.
102
+ * By default uses `fetchSilent` so the UI does not flash loading state between polls.
99
103
  * @param args - Arguments passed to the fetch worker
100
- * @param delayMs - Delay between fetches in ms, or function returning delay
104
+ * @param delayMs - Delay between fetches in ms, or function returning delay (returning `0` stops)
105
+ * @param options - Optional overrides. `silent: false` uses `fetch` instead of `fetchSilent`.
101
106
  * @returns Cancel function to stop the polling
102
107
  */
103
- fetchRecursive: (args?: unknown[], delayMs?: number | (() => number)) => () => void;
108
+ fetchRecursive: (args?: A | unknown, delayMs?: number | (() => number), options?: {
109
+ silent?: boolean;
110
+ }) => () => void;
104
111
  /**
105
112
  * Resets the store to its initial state, clearing all data and metadata.
106
- * Also aborts any in-flight requests if abortable is enabled.
113
+ * Aborts in-flight requests when `abortable: true`.
114
+ * Also bumps an internal generation token so that any non-abortable in-flight
115
+ * request (or a worker that ignores the abort signal) cannot overwrite the
116
+ * reset state after it resolves.
107
117
  */
108
118
  reset: () => void;
109
119
  /**
110
- * Clears only the `lastFetchError` field without resetting other state.
120
+ * Clears only the `lastFetchError` and `lastFetchSilentError` fields
121
+ * without resetting other state.
111
122
  */
112
123
  resetError: () => void;
113
124
  /**
114
125
  * Returns the internal data store for direct manipulation.
115
- * Use with caution as it bypasses the fetch store's state management.
126
+ * Use with caution: bypasses `dataFactory` and metadata tracking.
116
127
  */
117
128
  getInternalDataStore: () => StoreLike<T | null>;
118
129
  /**
119
130
  * The raw fetch worker function passed to createFetchStore.
120
131
  */
121
- fetchWorker: (...args: unknown[]) => Promise<unknown>;
132
+ fetchWorker: (...args: A) => Promise<T>;
122
133
  /**
123
134
  * Updates internal timestamps to trick `fetchOnce` into thinking data was just fetched.
124
135
  * Optionally sets new data. Useful for external cache invalidation.
136
+ *
137
+ * Note: increments `successCounter` — consumers watching the counter should not
138
+ * assume every increment corresponds to a network success.
125
139
  * @param data - Optional new data to set
126
140
  */
127
141
  touch: (data?: T) => void;
@@ -140,30 +154,33 @@ export interface FetchStore<T> extends StoreReadable<FetchStoreValue<T>> {
140
154
  export type FetchStreamEventName = "data" | "error" | "end";
141
155
  /**
142
156
  * Emit function passed to the stream worker for sending events.
157
+ * @template T - The data type carried by "data" events
143
158
  * @param eventName - The type of event being emitted
144
159
  * @param eventData - The event payload (data chunk or error)
145
160
  */
146
- export type FetchStreamEventEmitFn = (eventName: FetchStreamEventName, eventData?: unknown) => void;
161
+ export type FetchStreamEventEmitFn<T = unknown> = (eventName: FetchStreamEventName, eventData?: T | Error) => void;
147
162
  /**
148
163
  * FetchStreamStore interface for handling streaming data sources.
149
164
  * Useful for SSE, WebSocket, or any streaming data pattern.
150
165
  *
151
166
  * @template T - The data type stored in the stream store
167
+ * @template A - The argument tuple accepted by the fetch-stream worker (after `emit`)
152
168
  */
153
- export interface FetchStreamStore<T> extends StoreReadable<FetchStreamStoreValue<T>> {
169
+ export interface FetchStreamStore<T, A extends unknown[] = unknown[]> extends StoreReadable<FetchStreamStoreValue<T>> {
154
170
  /**
155
171
  * Starts the stream and optionally restarts it after completion.
156
172
  * @param args - Arguments passed to the stream worker
157
173
  * @param recursiveDelayMs - Delay before restarting stream after "end" event (0 = no restart)
158
174
  * @returns Cancel function to stop the stream
159
175
  */
160
- fetchStream: (args?: unknown[], recursiveDelayMs?: number | (() => number)) => () => void;
176
+ fetchStream: (args?: A | unknown, recursiveDelayMs?: number | (() => number)) => () => void;
161
177
  /**
162
178
  * The raw stream worker function passed to createFetchStreamStore.
163
179
  */
164
- fetchStreamWorker: (emit: FetchStreamEventEmitFn, ...args: unknown[]) => unknown;
180
+ fetchStreamWorker: (emit: FetchStreamEventEmitFn<T>, ...args: A) => (() => void) | void;
165
181
  /**
166
182
  * Resets the store to its initial state, clearing all data and metadata.
183
+ * Also stops any currently running stream (calls the worker's cleanup function).
167
184
  */
168
185
  reset: () => void;
169
186
  /**
@@ -172,7 +189,7 @@ export interface FetchStreamStore<T> extends StoreReadable<FetchStreamStoreValue
172
189
  resetError: () => void;
173
190
  /**
174
191
  * Returns the internal data store for direct manipulation.
175
- * Use with caution as it bypasses the stream store's state management.
192
+ * Use with caution: bypasses `dataFactory`.
176
193
  */
177
194
  getInternalDataStore: () => StoreLike<T | null>;
178
195
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marianmeres/fetch-store",
3
- "version": "3.0.3",
3
+ "version": "3.1.0",
4
4
  "type": "module",
5
5
  "main": "dist/mod.js",
6
6
  "types": "dist/mod.d.ts",
@@ -14,7 +14,7 @@
14
14
  "url": "https://github.com/marianmeres/fetch-store/issues"
15
15
  },
16
16
  "dependencies": {
17
- "@marianmeres/pubsub": "^2.4.1",
18
- "@marianmeres/store": "^2.3.0"
17
+ "@marianmeres/pubsub": "^3.0.0",
18
+ "@marianmeres/store": "^3.0.0"
19
19
  }
20
20
  }