@marianmeres/fetch-store 3.0.4 → 3.1.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,321 +1,131 @@
1
1
  # @marianmeres/fetch-store
2
2
 
3
- [![NPM version](https://img.shields.io/npm/v/@marianmeres/fetch-store)](https://www.npmjs.com/package/@marianmeres/fetch-store)
4
- [![JSR version](https://jsr.io/badges/@marianmeres/fetch-store)](https://jsr.io/@marianmeres/fetch-store)
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)
5
6
 
6
- A reactive [store](https://github.com/marianmeres/store) utility for managing async fetch
7
- operations with built-in state tracking for loading, errors, and success counts. Designed
8
- for Svelte-compatible reactivity but works with any framework.
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.
9
8
 
10
- ## Install
9
+ ## Installation
11
10
 
12
- ```shell
11
+ ```bash
13
12
  deno add jsr:@marianmeres/fetch-store
14
13
  ```
15
14
 
16
- ```shell
17
- npm i @marianmeres/fetch-store
15
+ ```bash
16
+ npm install @marianmeres/fetch-store
18
17
  ```
19
18
 
20
- ## Basic Example
19
+ ## Usage
21
20
 
22
21
  ```typescript
23
- // user-store.ts
24
22
  import { createFetchStore } from "@marianmeres/fetch-store";
25
23
 
26
24
  const userStore = createFetchStore(async (userId: string) => {
27
- const response = await fetch(`/api/users/${userId}`);
28
- if (!response.ok) throw new Error("Failed to fetch user");
29
- 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;
30
28
  });
31
29
 
32
- // Fetch user data
33
- await userStore.fetch("123");
30
+ userStore.subscribe(({ data, isFetching, lastFetchError }) => {
31
+ // react to state changes
32
+ });
34
33
 
35
- // Access current state
36
- const { data, isFetching, lastFetchError } = userStore.get();
34
+ await userStore.fetch("123");
37
35
  ```
38
36
 
39
- ### Svelte Integration
37
+ ### Svelte
40
38
 
41
39
  ```svelte
42
- <!-- UserProfile.svelte -->
43
- <script>
44
- import { onMount } from 'svelte';
45
- import { userStore } from './user-store.ts';
46
-
47
- export let userId;
48
- onMount(() => userStore.fetch(userId));
40
+ <script lang="ts">
41
+ import { userStore } from "./user-store.ts";
42
+ userStore.fetch(userId);
49
43
  </script>
50
44
 
51
45
  {#if $userStore.isFetching}
52
46
  <Spinner />
53
47
  {:else if $userStore.lastFetchError}
54
48
  <Error error={$userStore.lastFetchError} />
55
- {:else}
49
+ {:else if $userStore.data}
56
50
  <UserCard user={$userStore.data} />
57
51
  {/if}
58
52
  ```
59
53
 
60
- ## Features
61
-
62
- - **Reactive state management** - Subscribe to loading, error, and data states
63
- - **Silent fetching** - Update data without triggering loading indicators
64
- - **Fetch-once with caching** - Prevent redundant fetches within a time threshold
65
- - **Polling support** - Built-in recursive fetching for real-time updates
66
- - **Request deduplication** - Optionally return in-flight promises for concurrent calls
67
- - **Abort support** - Cancel in-flight requests with AbortController integration
68
- - **Stream support** - Handle SSE, WebSocket, or push-based data sources
69
-
70
- ## Abortable Requests
71
-
72
- Enable automatic request cancellation when a new fetch starts:
54
+ ### Abortable
73
55
 
74
56
  ```typescript
75
- import { createFetchStore } from "@marianmeres/fetch-store";
76
-
77
57
  const searchStore = createFetchStore(
78
- async (query: string, signal: AbortSignal) => {
79
- const response = await fetch(`/api/search?q=${query}`, { signal });
80
- if (!response.ok) throw new Error("Search failed");
81
- 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[];
82
61
  },
83
62
  null,
84
- null,
85
63
  { abortable: true }
86
64
  );
87
65
 
88
- // Rapid calls - each new fetch aborts the previous one
89
- searchStore.fetch("h"); // Aborted
90
- searchStore.fetch("he"); // Aborted
91
- searchStore.fetch("hel"); // Aborted
92
- searchStore.fetch("hello"); // This one completes
93
-
94
- // Manual abort
95
- searchStore.abort();
66
+ searchStore.fetch("h"); // aborted
67
+ searchStore.fetch("he"); // aborted
68
+ searchStore.fetch("hello"); // completes
96
69
  ```
97
70
 
98
- ## Request Deduplication
99
-
100
- Prevent duplicate requests when multiple calls happen while one is in-flight:
71
+ ### Deduplication
101
72
 
102
73
  ```typescript
103
- const userStore = createFetchStore(
104
- async (userId) => fetch(`/api/users/${userId}`).then(r => r.json()),
105
- null,
106
- null,
107
- { dedupeInflight: true }
108
- );
74
+ const store = createFetchStore(worker, null, { dedupeInflight: true });
109
75
 
110
- // These all return the same promise (only one HTTP request is made)
111
- const p1 = userStore.fetch("123");
112
- const p2 = userStore.fetch("123");
113
- const p3 = userStore.fetch("123");
114
-
115
- p1 === p2 && p2 === p3; // true
76
+ const a = store.fetch("x");
77
+ const b = store.fetch("x");
78
+ a === b; // true — one network call
116
79
  ```
117
80
 
118
- ## Streaming Data
119
-
120
- For streaming data sources, use `createFetchStreamStore`:
81
+ ### Cached / polled reads
121
82
 
122
83
  ```typescript
123
- import { createFetchStreamStore } from "@marianmeres/fetch-store";
124
-
125
- const sseStore = createFetchStreamStore((emit, url) => {
126
- const eventSource = new EventSource(url);
127
-
128
- eventSource.onmessage = (event) => {
129
- emit("data", JSON.parse(event.data));
130
- };
84
+ // fire only if no successful fetch yet, or more than 60s old
85
+ await store.fetchOnce("123", 60_000);
131
86
 
132
- eventSource.onerror = (error) => {
133
- emit("error", error);
134
- };
135
-
136
- // Return cleanup function
137
- return () => {
138
- eventSource.close();
139
- emit("end");
140
- };
141
- });
142
-
143
- // Start streaming (with optional auto-restart after 5s delay)
144
- const stop = sseStore.fetchStream(["/api/events"], 5000);
145
-
146
- // Later: stop the stream
87
+ // poll every 5s, silent (no isFetching flicker)
88
+ const stop = store.fetchRecursive("123", 5_000);
89
+ // ...later
147
90
  stop();
148
91
  ```
149
92
 
150
- ## Breaking Changes (v3.0.0)
151
-
152
- This major release includes the following breaking changes:
153
-
154
- ### Simplified Generic Types
155
-
156
- The `FetchStore<T>` and `FetchStreamStore<T>` interfaces have been simplified from a
157
- two-generic pattern to a single generic:
158
-
159
- ```typescript
160
- // Before (v2.x)
161
- interface FetchStore<T, V = T extends FetchStoreValue<infer D> ? D : T> { ... }
162
-
163
- // After (v3.x)
164
- interface FetchStore<T> extends StoreReadable<FetchStoreValue<T>> { ... }
165
- ```
166
-
167
- **Migration:** If you were using explicit generic parameters, simply use the data type
168
- directly: `FetchStore<User>` instead of `FetchStore<FetchStoreValue<User>, User>`.
169
-
170
- ### Simplified API Signature
171
-
172
- The `createFetchStore` and `createFetchStreamStore` functions now have a simpler signature.
173
- The `dataFactory` parameter has been moved into the `options` object:
174
-
175
- ```typescript
176
- // Before (v2.x)
177
- createFetchStore(fetchWorker, initial, dataFactory, options)
178
-
179
- // After (v3.x)
180
- createFetchStore(fetchWorker, initial, options)
181
- // dataFactory is now in options: { dataFactory: (data, old) => transformedData }
182
- ```
183
-
184
- **Migration:** Move your `dataFactory` from the third parameter into the options object:
93
+ ### Streaming (SSE)
185
94
 
186
95
  ```typescript
187
- // Before
188
- const store = createFetchStore(
189
- async () => fetchData(),
190
- null,
191
- (data, old) => ({ ...old, ...data }),
192
- { dedupeInflight: true }
193
- );
194
-
195
- // After
196
- const store = createFetchStore(
197
- async () => fetchData(),
198
- null,
199
- {
200
- dataFactory: (data, old) => ({ ...old, ...data }),
201
- dedupeInflight: true
202
- }
203
- );
204
- ```
205
-
206
- ### DataFactory Type Change
207
-
208
- The `DataFactory<T>` type now receives properly typed data instead of `unknown`:
96
+ import { createFetchStreamStore } from "@marianmeres/fetch-store";
209
97
 
210
- ```typescript
211
- // Before (v2.x)
212
- 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();
103
+ });
213
104
 
214
- // After (v3.x)
215
- type DataFactory<T> = (data: T, old?: T) => T;
105
+ const stop = feed.fetchStream(["/api/events"]);
106
+ // ...later: closes the connection, sets isFetching: false
107
+ stop();
216
108
  ```
217
109
 
218
- The `fetchWorker` must now return `Promise<T>` directly, making the types consistent
219
- throughout the API.
220
-
221
-
222
- ## API Reference
223
-
224
- ### `createFetchStore<T>(fetchWorker, initial?, options?)`
225
-
226
- Creates a reactive store for async fetch operations.
227
-
228
- **Parameters:**
229
-
230
- | Parameter | Type | Default | Description |
231
- |-----------|------|---------|-------------|
232
- | `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. |
233
- | `initial` | `T \| null` | `null` | Initial data value |
234
- | `options` | `FetchStoreOptions<T>` | `{}` | Configuration options |
235
-
236
- **Options:**
237
-
238
- | Option | Type | Default | Description |
239
- |--------|------|---------|-------------|
240
- | `fetchOnceDefaultThresholdMs` | `number` | `300000` | Default threshold (5 min) for `fetchOnce` before allowing re-fetch |
241
- | `dedupeInflight` | `boolean` | `false` | If true, concurrent `fetch()` calls return the same promise |
242
- | `abortable` | `boolean` | `false` | If true, creates AbortController for each fetch, aborting previous requests |
243
- | `dataFactory` | `(data: T, old?: T) => T` | - | Transform fetched data (useful for merge strategies) |
244
- | `onReset` | `() => void` | - | Callback invoked when `reset()` is called |
245
-
246
- **Returns:** `FetchStore<T>`
247
-
248
- ### FetchStore Methods
249
-
250
- | Method | Signature | Description |
251
- |--------|-----------|-------------|
252
- | `subscribe` | `(fn: (value) => void) => () => void` | Subscribe to store changes (Svelte-compatible) |
253
- | `get` | `() => FetchStoreValue<T>` | Get current store value |
254
- | `fetch` | `(...args) => Promise<T \| null>` | Execute fetch, sets `isFetching` to true |
255
- | `fetchSilent` | `(...args) => Promise<T \| null>` | Execute fetch without updating `isFetching` |
256
- | `fetchOnce` | `(args?, thresholdMs?) => Promise<T \| null>` | Fetch only if not already fetched or threshold passed |
257
- | `fetchOnceSilent` | `(args?, thresholdMs?) => Promise<T \| null>` | Silent version of `fetchOnce` |
258
- | `fetchRecursive` | `(args?, delayMs?) => () => void` | Start polling, returns cancel function |
259
- | `reset` | `() => void` | Reset store to initial state |
260
- | `resetError` | `() => void` | Clear `lastFetchError` only |
261
- | `touch` | `(data?: T) => void` | Update timestamps (tricks `fetchOnce`), optionally set data |
262
- | `abort` | `() => void` | Abort in-flight requests (requires `abortable: true`) |
263
- | `getInternalDataStore` | `() => StoreLike<T>` | Access internal data store |
264
-
265
- ### FetchStoreValue Properties
266
-
267
- The store value contains both data and metadata:
268
-
269
- | Property | Type | Description |
270
- |----------|------|-------------|
271
- | `data` | `T \| null` | The fetched data (null before first fetch or after reset) |
272
- | `isFetching` | `boolean` | Whether a fetch is in progress |
273
- | `lastFetchStart` | `Date \| null` | When the last fetch started |
274
- | `lastFetchEnd` | `Date \| null` | When the last fetch completed |
275
- | `lastFetchError` | `Error \| null` | Error from the last fetch, if any |
276
- | `lastFetchSilentError` | `Error \| null` | Error from the last silent fetch, if any |
277
- | `successCounter` | `number` | Number of successful fetches |
278
-
279
- ### `createFetchStreamStore<T>(fetchStreamWorker, initial?, options?)`
280
-
281
- Creates a reactive store for streaming data sources.
282
-
283
- **Parameters:**
284
-
285
- | Parameter | Type | Default | Description |
286
- |-----------|------|---------|-------------|
287
- | `fetchStreamWorker` | `(emit, ...args) => (() => void) \| void` | required | Worker receiving emit callback for events. Emit receives data of type T. Should return cleanup function. |
288
- | `initial` | `T \| null` | `null` | Initial data value |
289
- | `options` | `FetchStreamStoreOptions<T>` | `{}` | Configuration options |
290
-
291
- **Options:**
110
+ ## Features
292
111
 
293
- | Option | Type | Default | Description |
294
- |--------|------|---------|-------------|
295
- | `dataFactory` | `(data: T, old?: T) => T` | - | Transform received data (useful for merge strategies) |
296
- | `onReset` | `() => void` | - | Callback invoked when `reset()` is called |
112
+ - Reactive state: `data`, `isFetching`, `lastFetchError`, `successCounter`, timestamps
113
+ - `fetchSilent` — background refresh without loading indicator
114
+ - `fetchOnce` — TTL-cached fetch that joins any in-flight request
115
+ - `fetchRecursive` — polling with cancel
116
+ - `abortable` — automatic `AbortController` per call
117
+ - `dedupeInflight` — concurrent calls share one promise
118
+ - `dataFactory` — custom merge/transform strategies
119
+ - `createFetchStreamStore` — push-based sources (SSE, WebSocket, generators)
297
120
 
298
- **Emit Events:**
299
- - `emit("data", value)` - Push new data
300
- - `emit("error", error)` - Report an error
301
- - `emit("end")` - Signal stream completion
121
+ ## API
302
122
 
303
- **Returns:** `FetchStreamStore<T>`
123
+ See [API.md](API.md) for the complete API reference.
304
124
 
305
- ### FetchStreamStore Methods
125
+ ## Changelog
306
126
 
307
- | Method | Signature | Description |
308
- |--------|-----------|-------------|
309
- | `subscribe` | `(fn: (value) => void) => () => void` | Subscribe to store changes |
310
- | `get` | `() => FetchStreamStoreValue<T>` | Get current store value |
311
- | `fetchStream` | `(args?, recursiveDelayMs?) => () => void` | Start stream, returns stop function. If `recursiveDelayMs > 0`, restarts after "end" event. |
312
- | `reset` | `() => void` | Reset store to initial state |
313
- | `resetError` | `() => void` | Clear `lastFetchError` only |
314
- | `getInternalDataStore` | `() => StoreLike<T>` | Access internal data store |
127
+ See [CHANGELOG.md](CHANGELOG.md).
315
128
 
316
- ## Package Identity
129
+ ## License
317
130
 
318
- - **Name:** @marianmeres/fetch-store
319
- - **Author:** Marian Meres
320
- - **Repository:** https://github.com/marianmeres/fetch-store
321
- - **License:** MIT
131
+ [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
@@ -22,7 +23,7 @@ import type { FetchStreamEventName, FetchStreamStore, FetchStreamStoreOptions }
22
23
  * });
23
24
  *
24
25
  * const stop = sseStore.fetchStream(["/api/events"]);
25
- * // Later: stop() to close the connection
26
+ * // Later: stop() closes the connection and the run (isFetching: false, lastFetchEnd)
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
@@ -22,13 +23,13 @@ const DEFAULT_OPTIONS = {};
22
23
  * });
23
24
  *
24
25
  * const stop = sseStore.fetchStream(["/api/events"]);
25
- * // Later: stop() to close the connection
26
+ * // Later: stop() closes the connection and the run (isFetching: false, lastFetchEnd)
26
27
  * ```
27
28
  */
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,46 @@ 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 stop fn of the currently running stream so reset() and a new
44
+ // fetchStream() can stop it.
45
+ let _currentStop = null;
42
46
  const fetchStream = (fetchArgs = [], recursiveDelayMs = 0) => {
43
47
  let _timer;
44
48
  let _aborted = false;
45
49
  let _abortFn;
46
- // Must be hoisted so recursive calls can be cancelled properly
47
- let _abort = () => {
50
+ // `closeRun` is false when the caller is about to overwrite meta anyway
51
+ // (reset, or a new fetchStream superseding this one), so subscribers see no
52
+ // intermediate `isFetching: false` flicker.
53
+ const stop = (closeRun) => {
54
+ if (_aborted)
55
+ return;
56
+ _aborted = true;
57
+ if (_timer)
58
+ clearTimeout(_timer);
48
59
  if (typeof _abortFn === "function") {
49
- _abortFn();
60
+ try {
61
+ _abortFn();
62
+ }
63
+ catch {
64
+ // cleanup should not throw, but guard anyway
65
+ }
50
66
  }
51
- else {
52
- console.warn("`abort` is a noop (the fetchStreamWorker did not return a function).");
67
+ if (_currentStop === stop)
68
+ _currentStop = null;
69
+ // Every emit from here on is ignored (including an "end" from the cleanup
70
+ // above), so the store closes the run itself. Nothing to close when idle
71
+ // between recursive runs.
72
+ const meta = _metaStore.get();
73
+ if (closeRun && meta.isFetching) {
74
+ _metaStore.set({ ...meta, isFetching: false, lastFetchEnd: new Date() });
53
75
  }
54
- if (_timer)
55
- clearTimeout(_timer);
56
- _aborted = true;
57
76
  };
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;
77
+ // Public cancel fn takes no args (safe to pass directly as an event handler).
78
+ const cancel = () => stop(true);
79
+ const normalizedArgs = (Array.isArray(fetchArgs) ? fetchArgs : [fetchArgs]);
80
+ const runOnce = () => {
81
+ if (_aborted)
82
+ return;
62
83
  _metaStore.set({
63
84
  ..._metaStore.get(),
64
85
  isFetching: true,
@@ -68,17 +89,29 @@ export const createFetchStreamStore = (fetchStreamWorker, initial = null, option
68
89
  });
69
90
  try {
70
91
  _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)) {
92
+ // Ignore any events after cancellation.
93
+ if (_aborted)
94
+ return;
95
+ if (eventName === "data" &&
96
+ eventData !== undefined &&
97
+ !(eventData instanceof Error)) {
98
+ // A data event implicitly clears a prior error.
99
+ if (_metaStore.get().lastFetchError) {
100
+ _metaStore.set({
101
+ ..._metaStore.get(),
102
+ lastFetchError: null,
103
+ });
104
+ }
75
105
  _dataStore.set(_createData(eventData, _dataStore.get()));
76
106
  }
77
107
  else if (eventName === "error") {
78
108
  const error = eventData instanceof Error
79
109
  ? eventData
80
110
  : new Error(String(eventData));
81
- _metaStore.set({ ..._metaStore.get(), lastFetchError: error });
111
+ _metaStore.set({
112
+ ..._metaStore.get(),
113
+ lastFetchError: error,
114
+ });
82
115
  }
83
116
  else if (eventName === "end") {
84
117
  _metaStore.set({
@@ -86,28 +119,45 @@ export const createFetchStreamStore = (fetchStreamWorker, initial = null, option
86
119
  isFetching: false,
87
120
  lastFetchEnd: new Date(),
88
121
  });
89
- // Maybe recursive?
122
+ const delay = typeof recursiveDelayMs === "function"
123
+ ? recursiveDelayMs()
124
+ : recursiveDelayMs;
90
125
  if (delay > 0 && !_aborted) {
91
126
  if (_timer)
92
127
  clearTimeout(_timer);
93
128
  _timer = setTimeout(() => {
94
- if (!_aborted) {
95
- _abort = _fetchStream(normalizedArgs, delayMs);
96
- }
129
+ if (!_aborted)
130
+ runOnce();
97
131
  }, delay);
98
132
  }
99
133
  }
100
134
  }, ...normalizedArgs);
101
135
  }
102
136
  catch (e) {
137
+ // Worker threw synchronously — reset isFetching and store the error.
103
138
  const error = e instanceof Error ? e : new Error(String(e));
104
- _metaStore.set({ ..._metaStore.get(), lastFetchError: error });
139
+ _metaStore.set({
140
+ ..._metaStore.get(),
141
+ isFetching: false,
142
+ lastFetchEnd: new Date(),
143
+ lastFetchError: error,
144
+ });
105
145
  }
106
- return _abort;
107
146
  };
108
- return _fetchStream(fetchArgs, recursiveDelayMs);
147
+ // If a previous stream was still running, stop it before starting a new one.
148
+ if (_currentStop)
149
+ _currentStop(false);
150
+ _currentStop = stop;
151
+ runOnce();
152
+ return cancel;
109
153
  };
110
154
  const reset = () => {
155
+ // Stop any running stream (calls the worker's cleanup function).
156
+ if (_currentStop) {
157
+ const s = _currentStop;
158
+ _currentStop = null;
159
+ s(false);
160
+ }
111
161
  _dataStore.set(initial);
112
162
  _metaStore.set(_createMetaObj());
113
163
  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,34 @@ 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
- * @returns Cancel function to stop the stream
174
+ * @returns Cancel function: calls the worker's cleanup, ignores any later emits,
175
+ * and closes the run (`isFetching: false`, `lastFetchEnd`) if it was still active.
159
176
  */
160
- fetchStream: (args?: unknown[], recursiveDelayMs?: number | (() => number)) => () => void;
177
+ fetchStream: (args?: A | unknown, recursiveDelayMs?: number | (() => number)) => () => void;
161
178
  /**
162
179
  * The raw stream worker function passed to createFetchStreamStore.
163
180
  */
164
- fetchStreamWorker: (emit: FetchStreamEventEmitFn, ...args: unknown[]) => unknown;
181
+ fetchStreamWorker: (emit: FetchStreamEventEmitFn<T>, ...args: A) => (() => void) | void;
165
182
  /**
166
183
  * Resets the store to its initial state, clearing all data and metadata.
184
+ * Also stops any currently running stream (calls the worker's cleanup function).
167
185
  */
168
186
  reset: () => void;
169
187
  /**
@@ -172,7 +190,7 @@ export interface FetchStreamStore<T> extends StoreReadable<FetchStreamStoreValue
172
190
  resetError: () => void;
173
191
  /**
174
192
  * Returns the internal data store for direct manipulation.
175
- * Use with caution as it bypasses the stream store's state management.
193
+ * Use with caution: bypasses `dataFactory`.
176
194
  */
177
195
  getInternalDataStore: () => StoreLike<T | null>;
178
196
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marianmeres/fetch-store",
3
- "version": "3.0.4",
3
+ "version": "3.1.1",
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.5",
18
- "@marianmeres/store": "^2.4.3"
17
+ "@marianmeres/pubsub": "^3.1.0",
18
+ "@marianmeres/store": "^3.0.1"
19
19
  }
20
20
  }