@kontsedal/olas-core 0.0.1-rc.0 → 0.0.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.
Files changed (40) hide show
  1. package/dist/index.cjs +2 -1
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.d.cts +13 -2
  4. package/dist/index.d.cts.map +1 -1
  5. package/dist/index.d.mts +13 -2
  6. package/dist/index.d.mts.map +1 -1
  7. package/dist/index.mjs +2 -2
  8. package/dist/index.mjs.map +1 -1
  9. package/dist/{root-BImHnGj1.mjs → root-BCZDC5Fv.mjs} +442 -139
  10. package/dist/root-BCZDC5Fv.mjs.map +1 -0
  11. package/dist/{root-Bazp5_Ik.cjs → root-DXV1gVbQ.cjs} +447 -138
  12. package/dist/root-DXV1gVbQ.cjs.map +1 -0
  13. package/dist/testing.cjs +1 -1
  14. package/dist/testing.d.cts +1 -1
  15. package/dist/testing.d.mts +1 -1
  16. package/dist/testing.mjs +1 -1
  17. package/dist/{types-CAMgqCMz.d.mts → types-CffZ1QXt.d.cts} +82 -10
  18. package/dist/types-CffZ1QXt.d.cts.map +1 -0
  19. package/dist/{types-emq_lZd7.d.cts → types-DSlDowpE.d.mts} +82 -10
  20. package/dist/types-DSlDowpE.d.mts.map +1 -0
  21. package/package.json +28 -2
  22. package/src/controller/instance.ts +115 -15
  23. package/src/controller/root.ts +9 -1
  24. package/src/controller/types.ts +17 -7
  25. package/src/forms/field.ts +73 -8
  26. package/src/forms/form-types.ts +16 -0
  27. package/src/forms/form.ts +171 -21
  28. package/src/index.ts +5 -0
  29. package/src/query/client.ts +161 -6
  30. package/src/query/define.ts +14 -0
  31. package/src/query/entry.ts +64 -42
  32. package/src/query/infinite.ts +77 -55
  33. package/src/query/mutation.ts +11 -21
  34. package/src/query/plugin.ts +50 -0
  35. package/src/query/use.ts +80 -3
  36. package/src/utils.ts +24 -0
  37. package/dist/root-BImHnGj1.mjs.map +0 -1
  38. package/dist/root-Bazp5_Ik.cjs.map +0 -1
  39. package/dist/types-CAMgqCMz.d.mts.map +0 -1
  40. package/dist/types-emq_lZd7.d.cts.map +0 -1
@@ -1,7 +1,7 @@
1
1
  import { batch, computed, type Signal, signal } from '../signals'
2
2
  import type { ReadSignal } from '../signals/types'
3
- import { isAbortError } from '../utils'
4
- import type { AsyncState, AsyncStatus, RetryDelay, RetryPolicy } from './types'
3
+ import { abortableSleep, isAbortError } from '../utils'
4
+ import type { AsyncState, AsyncStatus, RetryDelay, RetryPolicy, Snapshot } from './types'
5
5
 
6
6
  /**
7
7
  * Configuration for `defineInfiniteQuery({ ... })`. Spec §5.7, §20.4.
@@ -58,9 +58,7 @@ export type InfiniteQuery<Args extends unknown[], TPage, _TItem> = {
58
58
  readonly __olas: 'infiniteQuery'
59
59
  invalidate(...args: Args): void
60
60
  invalidateAll(): void
61
- setData(...args: [...Args, updater: (prev: TPage[] | undefined) => TPage[]]): {
62
- rollback: () => void
63
- }
61
+ setData(...args: [...Args, updater: (prev: TPage[] | undefined) => TPage[]]): Snapshot
64
62
  prefetch(...args: Args): Promise<TPage>
65
63
  }
66
64
 
@@ -83,8 +81,6 @@ export type InfiniteQuerySubscription<TPage, TItem> = AsyncState<TPage[]> & {
83
81
  fetchPreviousPage: () => Promise<void>
84
82
  }
85
83
 
86
- import type { Snapshot } from './types'
87
-
88
84
  /**
89
85
  * Holds an array of pages plus their pageParams. Supports fetchNextPage /
90
86
  * fetchPreviousPage / invalidate (drops all pages). Race-protected.
@@ -116,6 +112,8 @@ export class InfiniteEntry<TPage, TItem, PageParam> {
116
112
  private snapshots: Array<{ id: number; prev: TPage[]; live: boolean }> = []
117
113
  private nextSnapshotId = 0
118
114
  private disposed = false
115
+ /** Mirrors `Entry.pendingFirstValueRejects` — see that field for context. */
116
+ private pendingFirstValueRejects: Array<(err: unknown) => void> = []
119
117
 
120
118
  private readonly fetcher: (pageCtx: {
121
119
  pageParam: PageParam
@@ -130,6 +128,13 @@ export class InfiniteEntry<TPage, TItem, PageParam> {
130
128
  private readonly retry: RetryPolicy
131
129
  private readonly retryDelay: RetryDelay
132
130
  private readonly itemsOf?: (page: TPage) => TItem[]
131
+ /**
132
+ * Mirrors `Entry.onSuccessData`. Fires from `applyFetchSuccess`-equivalent
133
+ * branches AFTER `pages.set(...)` settles. Used by `InfiniteClientEntry`
134
+ * to emit `SetDataEvent { kind: 'infinite', source: 'fetch' }` for
135
+ * `QueryClientPlugin`s (e.g. entity normalization).
136
+ */
137
+ private readonly onSuccessData?: (pages: TPage[]) => void
133
138
 
134
139
  constructor(opts: {
135
140
  fetcher: (pageCtx: { pageParam: PageParam; signal: AbortSignal }) => Promise<TPage>
@@ -140,6 +145,7 @@ export class InfiniteEntry<TPage, TItem, PageParam> {
140
145
  staleTime?: number
141
146
  retry?: RetryPolicy
142
147
  retryDelay?: RetryDelay
148
+ onSuccessData?: (pages: TPage[]) => void
143
149
  }) {
144
150
  this.fetcher = opts.fetcher
145
151
  this.initialPageParam = opts.initialPageParam
@@ -149,6 +155,7 @@ export class InfiniteEntry<TPage, TItem, PageParam> {
149
155
  this.staleTime = opts.staleTime ?? 0
150
156
  this.retry = opts.retry ?? 0
151
157
  this.retryDelay = opts.retryDelay ?? 1000
158
+ this.onSuccessData = opts.onSuccessData
152
159
  this.pageParams = signal<PageParam[]>([])
153
160
  this.data = computed(() => {
154
161
  const ps = this.pages.value
@@ -209,6 +216,7 @@ export class InfiniteEntry<TPage, TItem, PageParam> {
209
216
  this.isStale.set(this.staleTime === 0)
210
217
  })
211
218
  if (this.staleTime > 0) this.scheduleStaleness()
219
+ this.onSuccessData?.(this.pages.peek())
212
220
  },
213
221
  'initial',
214
222
  )
@@ -246,6 +254,7 @@ export class InfiniteEntry<TPage, TItem, PageParam> {
246
254
  this.isFetching.set(false)
247
255
  this.lastUpdatedAt.set(Date.now())
248
256
  })
257
+ this.onSuccessData?.(this.pages.peek())
249
258
  },
250
259
  'next',
251
260
  ).then(() => {})
@@ -284,6 +293,7 @@ export class InfiniteEntry<TPage, TItem, PageParam> {
284
293
  this.isFetching.set(false)
285
294
  this.lastUpdatedAt.set(Date.now())
286
295
  })
296
+ this.onSuccessData?.(this.pages.peek())
287
297
  },
288
298
  'prev',
289
299
  ).then(() => {})
@@ -297,38 +307,54 @@ export class InfiniteEntry<TPage, TItem, PageParam> {
297
307
  direction: 'initial' | 'next' | 'prev',
298
308
  ): Promise<TPage> {
299
309
  let attempt = 0
300
- while (true) {
301
- if (myId !== this.currentFetchId || this.disposed) {
302
- throw new DOMException('Superseded', 'AbortError')
303
- }
304
- try {
305
- const page = await this.fetcher({ pageParam, signal })
310
+ let succeeded = false
311
+ try {
312
+ while (true) {
306
313
  if (myId !== this.currentFetchId || this.disposed) {
307
314
  throw new DOMException('Superseded', 'AbortError')
308
315
  }
309
- onSuccess(page, pageParam)
310
- return page
311
- } catch (err) {
312
- if (myId !== this.currentFetchId || this.disposed || isAbortError(err)) {
313
- throw err
316
+ try {
317
+ const page = await this.fetcher({ pageParam, signal })
318
+ if (myId !== this.currentFetchId || this.disposed) {
319
+ throw new DOMException('Superseded', 'AbortError')
320
+ }
321
+ onSuccess(page, pageParam)
322
+ succeeded = true
323
+ return page
324
+ } catch (err) {
325
+ if (myId !== this.currentFetchId || this.disposed || isAbortError(err)) {
326
+ throw err
327
+ }
328
+ const shouldRetry =
329
+ typeof this.retry === 'number' ? attempt < this.retry : this.retry(attempt, err)
330
+ if (!shouldRetry) {
331
+ batch(() => {
332
+ this.error.set(err)
333
+ this.status.set('error')
334
+ this.isLoading.set(false)
335
+ this.isFetching.set(false)
336
+ if (direction === 'next') this.isFetchingNextPage.set(false)
337
+ if (direction === 'prev') this.isFetchingPreviousPage.set(false)
338
+ })
339
+ throw err
340
+ }
341
+ const delay =
342
+ typeof this.retryDelay === 'function' ? this.retryDelay(attempt) : this.retryDelay
343
+ await abortableSleep(delay, signal)
344
+ attempt += 1
314
345
  }
315
- const shouldRetry =
316
- typeof this.retry === 'number' ? attempt < this.retry : this.retry(attempt, err)
317
- if (!shouldRetry) {
318
- batch(() => {
319
- this.error.set(err)
320
- this.status.set('error')
321
- this.isLoading.set(false)
322
- this.isFetching.set(false)
323
- if (direction === 'next') this.isFetchingNextPage.set(false)
324
- if (direction === 'prev') this.isFetchingPreviousPage.set(false)
325
- })
326
- throw err
327
- }
328
- const delay =
329
- typeof this.retryDelay === 'function' ? this.retryDelay(attempt) : this.retryDelay
330
- await abortableSleep(delay, signal)
331
- attempt += 1
346
+ }
347
+ } finally {
348
+ // Catch-all reset for the supersede/abort path. The success and explicit
349
+ // failure paths already reset these via `onSuccess` and the
350
+ // `applyFailure`-equivalent branch above; this guarantees that an
351
+ // aborted-mid-flight `fetchNextPage` (e.g., user calls `invalidate()`
352
+ // while paging) doesn't wedge the spinner.
353
+ if (!succeeded) {
354
+ batch(() => {
355
+ if (direction === 'next') this.isFetchingNextPage.set(false)
356
+ if (direction === 'prev') this.isFetchingPreviousPage.set(false)
357
+ })
332
358
  }
333
359
  }
334
360
  }
@@ -395,6 +421,9 @@ export class InfiniteEntry<TPage, TItem, PageParam> {
395
421
  }
396
422
 
397
423
  firstValue(): Promise<TPage[]> {
424
+ if (this.disposed) {
425
+ return Promise.reject(new DOMException('Entry disposed', 'AbortError'))
426
+ }
398
427
  if (this.status.peek() === 'success') {
399
428
  return Promise.resolve(this.pages.peek())
400
429
  }
@@ -402,13 +431,19 @@ export class InfiniteEntry<TPage, TItem, PageParam> {
402
431
  return Promise.reject(this.error.peek())
403
432
  }
404
433
  return new Promise<TPage[]>((resolve, reject) => {
434
+ const tracked = (err: unknown): void => {
435
+ this.pendingFirstValueRejects = this.pendingFirstValueRejects.filter((f) => f !== tracked)
436
+ reject(err)
437
+ }
438
+ this.pendingFirstValueRejects.push(tracked)
405
439
  const unsub = this.status.subscribe((s) => {
406
440
  if (s === 'success') {
407
441
  unsub()
442
+ this.pendingFirstValueRejects = this.pendingFirstValueRejects.filter((f) => f !== tracked)
408
443
  resolve(this.pages.peek())
409
444
  } else if (s === 'error') {
410
445
  unsub()
411
- reject(this.error.peek())
446
+ tracked(this.error.peek())
412
447
  }
413
448
  })
414
449
  })
@@ -439,24 +474,11 @@ export class InfiniteEntry<TPage, TItem, PageParam> {
439
474
  }
440
475
  this.currentAbort?.abort()
441
476
  this.currentAbort = null
442
- }
443
- }
444
-
445
- function abortableSleep(ms: number, signal: AbortSignal): Promise<void> {
446
- return new Promise((resolve, reject) => {
447
- if (signal.aborted) {
448
- reject(new DOMException('Aborted', 'AbortError'))
449
- return
477
+ if (this.pendingFirstValueRejects.length > 0) {
478
+ const disposed = new DOMException('Entry disposed', 'AbortError')
479
+ const rejects = this.pendingFirstValueRejects
480
+ this.pendingFirstValueRejects = []
481
+ for (const fn of rejects) fn(disposed)
450
482
  }
451
- const timer = setTimeout(() => {
452
- signal.removeEventListener('abort', onAbort)
453
- resolve()
454
- }, ms)
455
- const onAbort = () => {
456
- clearTimeout(timer)
457
- signal.removeEventListener('abort', onAbort)
458
- reject(new DOMException('Aborted', 'AbortError'))
459
- }
460
- signal.addEventListener('abort', onAbort, { once: true })
461
- })
483
+ }
462
484
  }
@@ -2,7 +2,7 @@ import type { DevtoolsEmitter } from '../devtools'
2
2
  import { dispatchError, type ErrorHandler } from '../errors'
3
3
  import { batch, type Signal, signal } from '../signals'
4
4
  import type { ReadSignal } from '../signals/types'
5
- import { isAbortError } from '../utils'
5
+ import { abortableSleep, isAbortError } from '../utils'
6
6
  import type { RetryDelay, RetryPolicy, Snapshot } from './types'
7
7
 
8
8
  /**
@@ -299,7 +299,16 @@ class MutationImpl<V, R> implements Mutation<V, R> {
299
299
  reset(): void {
300
300
  if (this.disposed) return
301
301
  for (const handle of this.inflight) handle.abort.abort()
302
- this.serialQueue.length = 0
302
+ // Reject queued serial runs so their awaiters don't hang — symmetric with
303
+ // `dispose()`. Without this, callers of `mutation.run(...)` on a serial
304
+ // mutation that get reset mid-queue wait forever.
305
+ if (this.serialQueue.length > 0) {
306
+ const aborted = new DOMException('Aborted', 'AbortError')
307
+ const queue = this.serialQueue
308
+ this.serialQueue = []
309
+ for (const queued of queue) queued.reject(aborted)
310
+ }
311
+ this.serialActive = false
303
312
  batch(() => {
304
313
  this.data.set(undefined)
305
314
  this.error.set(undefined)
@@ -363,22 +372,3 @@ function raceAbort<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
363
372
  )
364
373
  })
365
374
  }
366
-
367
- function abortableSleep(ms: number, signal: AbortSignal): Promise<void> {
368
- return new Promise((resolve, reject) => {
369
- if (signal.aborted) {
370
- reject(new DOMException('Aborted', 'AbortError'))
371
- return
372
- }
373
- const timer = setTimeout(() => {
374
- signal.removeEventListener('abort', onAbort)
375
- resolve()
376
- }, ms)
377
- const onAbort = () => {
378
- clearTimeout(timer)
379
- signal.removeEventListener('abort', onAbort)
380
- reject(new DOMException('Aborted', 'AbortError'))
381
- }
382
- signal.addEventListener('abort', onAbort, { once: true })
383
- })
384
- }
@@ -30,10 +30,48 @@ export type QueryClientPluginApi = {
30
30
  */
31
31
  applyRemoteSetData(queryId: string, keyArgs: readonly unknown[], data: unknown): void
32
32
  applyRemoteInvalidate(queryId: string, keyArgs: readonly unknown[]): void
33
+ /**
34
+ * Apply a local-originated `setData` to the entry identified by
35
+ * `(queryId, keyArgs)`. The resulting plugin events fire with
36
+ * `isRemote: false` and `source: 'set'` — cross-tab plugins WILL
37
+ * rebroadcast (the write is treated as if a controller called
38
+ * `client.setData(...)` directly).
39
+ *
40
+ * Drops silently when the queryId is unknown, the registered query is
41
+ * infinite, or no local entry exists for that key. The `updater`
42
+ * receives the previous data (typed as `unknown` because plugins are
43
+ * type-erased) and returns the next.
44
+ *
45
+ * Use case: entity-normalization plugins that want to backpropagate an
46
+ * `entity.update(...)` patch into every query holding that entity.
47
+ * Mutations / optimistic updates already go through the public
48
+ * `client.setData` and don't need this API.
49
+ */
50
+ setEntryData(
51
+ queryId: string,
52
+ keyArgs: readonly unknown[],
53
+ updater: (prev: unknown) => unknown,
54
+ ): void
33
55
  /**
34
56
  * Snapshot of currently bound entry keys for a query (by `queryId`). Empty
35
57
  * array when the query isn't registered, has no client entries, or the
36
58
  * `queryId` doesn't match any registered query.
59
+ *
60
+ * @example
61
+ * ```ts
62
+ * // Plugin sees an incoming invalidate; only echo it outward if any local
63
+ * // controller is actually subscribed to that key — otherwise the message
64
+ * // is unilateral noise.
65
+ * const plugin: QueryClientPlugin = {
66
+ * init(api) { this.api = api },
67
+ * onInvalidate(ev) {
68
+ * if (ev.isRemote) return
69
+ * const subscribed = this.api.subscribedKeys(ev.queryId)
70
+ * if (subscribed.length === 0) return // no local subscribers → don't send
71
+ * transport.send({ type: 'invalidate', queryId: ev.queryId, keyArgs: ev.keyArgs })
72
+ * },
73
+ * }
74
+ * ```
37
75
  */
38
76
  subscribedKeys(queryId: string): readonly (readonly unknown[])[]
39
77
  }
@@ -53,6 +91,18 @@ export type SetDataEvent = {
53
91
  * skip rebroadcast in that case — otherwise the message would echo back.
54
92
  */
55
93
  isRemote: boolean
94
+ /**
95
+ * Origin of the write. `'set'` covers explicit `client.setData` (mutations,
96
+ * optimistic updates, plugin-initiated patches). `'fetch'` fires when the
97
+ * query fetcher resolved successfully and wrote the result into the entry
98
+ * — emitted after the data signal is settled. `'remote'` is the
99
+ * `applyRemoteSetData` path (cross-tab / server-push); equivalent to
100
+ * `isRemote === true`.
101
+ *
102
+ * Layered plugins use this to decide whether to react: cross-tab broadcasts
103
+ * only on `'set'`, an entity-normalization plugin observes all sources.
104
+ */
105
+ source: 'set' | 'fetch' | 'remote'
56
106
  }
57
107
 
58
108
  export type InvalidateEvent = {
package/src/query/use.ts CHANGED
@@ -84,7 +84,15 @@ export function createUse<Args extends unknown[], T>(
84
84
  client: QueryClient,
85
85
  query: Query<Args, T>,
86
86
  keyOrOptions?: (() => Args) | UseOptions<Args>,
87
- ): { subscription: QuerySubscription<T>; dispose: () => void } {
87
+ ): {
88
+ subscription: QuerySubscription<T>
89
+ dispose: () => void
90
+ /** Suspend the subscription — release the entry (its refetchInterval +
91
+ * focus/online listeners pause) without disposing it. Spec §4.1. */
92
+ suspend: () => void
93
+ /** Resume after `suspend`. Re-acquires the entry and refetches if stale. */
94
+ resume: () => void
95
+ } {
88
96
  const internal = query as unknown as QueryInternal<Args, T>
89
97
  const spec = internal.__spec
90
98
  const keepPreviousData = spec.keepPreviousData ?? false
@@ -95,8 +103,10 @@ export function createUse<Args extends unknown[], T>(
95
103
 
96
104
  const sub = new SubscriptionImpl<T>(keepPreviousData)
97
105
  let currentEntry: ClientEntry<T> | null = null
106
+ let suspended = false
98
107
 
99
108
  const effectDispose = effect(() => {
109
+ if (suspended) return
100
110
  const isEnabled = enabledFn ? enabledFn() : true
101
111
  if (!isEnabled) {
102
112
  untracked(() => {
@@ -138,7 +148,43 @@ export function createUse<Args extends unknown[], T>(
138
148
  sub.detach()
139
149
  }
140
150
 
141
- return { subscription: sub, dispose }
151
+ const suspend = (): void => {
152
+ if (suspended) return
153
+ suspended = true
154
+ if (currentEntry) {
155
+ currentEntry.release()
156
+ currentEntry = null
157
+ }
158
+ // Keep subscription detached so reads return the last committed values
159
+ // via the entry's signals if still alive (the entry may be gc'd after
160
+ // its gcTime; that's fine — resume re-binds).
161
+ }
162
+
163
+ const resume = (): void => {
164
+ if (!suspended) return
165
+ suspended = false
166
+ // Re-evaluate the keyFn + enabled flag and rebind. The effect's deps
167
+ // didn't change while suspended, so toggling `suspended` here doesn't
168
+ // re-fire the effect on its own — force a sync rebind through the same
169
+ // code path.
170
+ const isEnabled = enabledFn ? enabledFn() : true
171
+ if (!isEnabled) return
172
+ const args = (keyFn ? keyFn() : ([] as unknown as Args)) as Args
173
+ const entry = client.bindEntry<Args, T>(query, args)
174
+ entry.acquire()
175
+ currentEntry = entry
176
+ sub.attach(entry)
177
+ // On resume, refetch if stale (matches the spec §4.1 "stale-on-resume"
178
+ // requirement). Non-stale data stays as-is.
179
+ const status = entry.entry.status.peek()
180
+ if (status === 'idle' || entry.entry.isStaleNow() || status === 'error') {
181
+ entry.entry.startFetch().catch(() => {
182
+ /* error captured on entry */
183
+ })
184
+ }
185
+ }
186
+
187
+ return { subscription: sub, dispose, suspend, resume }
142
188
  }
143
189
 
144
190
  type InfiniteQueryInternal<Args extends unknown[], TPage, TItem> = InfiniteQuery<
@@ -265,6 +311,8 @@ export function createInfiniteUse<Args extends unknown[], TPage, TItem>(
265
311
  ): {
266
312
  subscription: InfiniteQuerySubscription<TPage, TItem>
267
313
  dispose: () => void
314
+ suspend: () => void
315
+ resume: () => void
268
316
  } {
269
317
  const spec = (query as unknown as InfiniteQueryInternal<Args, TPage, TItem>).__spec
270
318
  const keepPreviousData = spec.keepPreviousData ?? false
@@ -274,8 +322,10 @@ export function createInfiniteUse<Args extends unknown[], TPage, TItem>(
274
322
 
275
323
  const sub = new InfiniteSubscriptionImpl<TPage, TItem>(keepPreviousData)
276
324
  let currentEntry: InfiniteClientEntry<TPage, TItem, unknown> | null = null
325
+ let suspended = false
277
326
 
278
327
  const effectDispose = effect(() => {
328
+ if (suspended) return
279
329
  const isEnabled = enabledFn ? enabledFn() : true
280
330
  if (!isEnabled) {
281
331
  untracked(() => {
@@ -317,5 +367,32 @@ export function createInfiniteUse<Args extends unknown[], TPage, TItem>(
317
367
  sub.detach()
318
368
  }
319
369
 
320
- return { subscription: sub, dispose }
370
+ const suspend = (): void => {
371
+ if (suspended) return
372
+ suspended = true
373
+ if (currentEntry) {
374
+ currentEntry.release()
375
+ currentEntry = null
376
+ }
377
+ }
378
+
379
+ const resume = (): void => {
380
+ if (!suspended) return
381
+ suspended = false
382
+ const isEnabled = enabledFn ? enabledFn() : true
383
+ if (!isEnabled) return
384
+ const args = (keyFn ? keyFn() : ([] as unknown as Args)) as Args
385
+ const entry = client.bindInfiniteEntry<Args, TPage, TItem>(query, args)
386
+ entry.acquire()
387
+ currentEntry = entry
388
+ sub.attach(entry)
389
+ const status = entry.entry.status.peek()
390
+ if (status === 'idle' || entry.entry.isStaleNow() || status === 'error') {
391
+ entry.entry.startFetch().catch(() => {
392
+ /* error captured on entry */
393
+ })
394
+ }
395
+ }
396
+
397
+ return { subscription: sub, dispose, suspend, resume }
321
398
  }
package/src/utils.ts CHANGED
@@ -11,3 +11,27 @@ export function isAbortError(err: unknown): boolean {
11
11
  }
12
12
  return false
13
13
  }
14
+
15
+ /**
16
+ * `setTimeout` wrapped in a promise that rejects with `AbortError` if the
17
+ * passed signal fires. Internal — used by the retry loops in `Entry`,
18
+ * `InfiniteEntry`, and `Mutation` so a slow backoff never blocks a supersede.
19
+ */
20
+ export function abortableSleep(ms: number, signal: AbortSignal): Promise<void> {
21
+ return new Promise((resolve, reject) => {
22
+ if (signal.aborted) {
23
+ reject(new DOMException('Aborted', 'AbortError'))
24
+ return
25
+ }
26
+ const timer = setTimeout(() => {
27
+ signal.removeEventListener('abort', onAbort)
28
+ resolve()
29
+ }, ms)
30
+ const onAbort = () => {
31
+ clearTimeout(timer)
32
+ signal.removeEventListener('abort', onAbort)
33
+ reject(new DOMException('Aborted', 'AbortError'))
34
+ }
35
+ signal.addEventListener('abort', onAbort, { once: true })
36
+ })
37
+ }