@kontsedal/olas-core 0.0.6 → 0.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.
Files changed (61) hide show
  1. package/dist/index.cjs +0 -0
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.d.cts +109 -27
  4. package/dist/index.d.cts.map +1 -1
  5. package/dist/index.d.mts +109 -27
  6. package/dist/index.d.mts.map +1 -1
  7. package/dist/index.mjs +0 -0
  8. package/dist/index.mjs.map +1 -1
  9. package/dist/{root-B6pNq75w.mjs → root-Byq-QYTp.mjs} +1465 -347
  10. package/dist/root-Byq-QYTp.mjs.map +1 -0
  11. package/dist/{root-CFgYhBd-.cjs → root-CPSL5kpR.cjs} +1470 -346
  12. package/dist/root-CPSL5kpR.cjs.map +1 -0
  13. package/dist/testing.cjs +5 -1
  14. package/dist/testing.cjs.map +1 -1
  15. package/dist/testing.d.cts +3 -2
  16. package/dist/testing.d.cts.map +1 -1
  17. package/dist/testing.d.mts +3 -2
  18. package/dist/testing.d.mts.map +1 -1
  19. package/dist/testing.mjs +5 -2
  20. package/dist/testing.mjs.map +1 -1
  21. package/dist/{types-Bmi04hDI.d.mts → types-Brp-4WaZ.d.mts} +233 -23
  22. package/dist/types-Brp-4WaZ.d.mts.map +1 -0
  23. package/dist/{types-BsZMPPOE.d.cts → types-DzDIypPo.d.cts} +233 -23
  24. package/dist/types-DzDIypPo.d.cts.map +1 -0
  25. package/package.json +4 -1
  26. package/src/controller/instance.ts +345 -94
  27. package/src/controller/root.ts +52 -30
  28. package/src/controller/types.ts +55 -4
  29. package/src/emitter.ts +8 -2
  30. package/src/errors.ts +39 -3
  31. package/src/forms/field.ts +219 -25
  32. package/src/forms/form-types.ts +16 -3
  33. package/src/forms/form.ts +360 -38
  34. package/src/forms/index.ts +3 -1
  35. package/src/forms/types.ts +27 -1
  36. package/src/forms/validators.ts +45 -11
  37. package/src/index.ts +13 -7
  38. package/src/query/client.ts +237 -58
  39. package/src/query/define.ts +0 -0
  40. package/src/query/entry.ts +259 -15
  41. package/src/query/focus-online.ts +53 -11
  42. package/src/query/infinite.ts +351 -47
  43. package/src/query/keys.ts +33 -2
  44. package/src/query/local.ts +3 -0
  45. package/src/query/mutation.ts +27 -6
  46. package/src/query/plugin.ts +43 -4
  47. package/src/query/types.ts +76 -6
  48. package/src/query/use.ts +53 -10
  49. package/src/selection.ts +49 -12
  50. package/src/signals/readonly.ts +3 -0
  51. package/src/signals/runtime.ts +27 -0
  52. package/src/signals/types.ts +10 -0
  53. package/src/testing.ts +9 -0
  54. package/src/timing/debounced.ts +106 -23
  55. package/src/timing/index.ts +1 -1
  56. package/src/timing/throttled.ts +85 -28
  57. package/src/utils.ts +15 -2
  58. package/dist/root-B6pNq75w.mjs.map +0 -1
  59. package/dist/root-CFgYhBd-.cjs.map +0 -1
  60. package/dist/types-Bmi04hDI.d.mts.map +0 -1
  61. package/dist/types-BsZMPPOE.d.cts.map +0 -1
@@ -1,8 +1,16 @@
1
1
  import { batch, computed, type Signal, signal } from '../signals'
2
2
  import type { ReadSignal } from '../signals/types'
3
3
  import { abortableSleep, isAbortError } from '../utils'
4
+ import { subscribeReconnect } from './focus-online'
4
5
  import { structuralShare } from './structural-share'
5
- import type { AsyncState, AsyncStatus, RetryDelay, RetryPolicy, Snapshot } from './types'
6
+ import type {
7
+ AsyncState,
8
+ AsyncStatus,
9
+ NetworkMode,
10
+ RetryDelay,
11
+ RetryPolicy,
12
+ Snapshot,
13
+ } from './types'
6
14
 
7
15
  /**
8
16
  * Configuration for `defineInfiniteQuery({ ... })`. Spec §5.7, §20.4.
@@ -37,6 +45,10 @@ export type InfiniteQuerySpec<Args extends unknown[], PageParam, TPage, TItem =
37
45
  keepPreviousData?: boolean
38
46
  retry?: RetryPolicy
39
47
  retryDelay?: RetryDelay
48
+ /** See `QuerySpec.networkMode`. Defaults to `'online'`. */
49
+ networkMode?: NetworkMode
50
+ /** See `QuerySpec.structuralShare`. Applies to the head-page refresh. */
51
+ structuralShare?: boolean
40
52
  /**
41
53
  * Stable identifier used by `QueryClientPlugin`s (`@kontsedal/olas-cross-tab`,
42
54
  * etc.). Infinite queries do NOT propagate cross-tab in v1 — the
@@ -45,10 +57,12 @@ export type InfiniteQuerySpec<Args extends unknown[], PageParam, TPage, TItem =
45
57
  */
46
58
  queryId?: string
47
59
  /**
48
- * Opt into cross-tab sync. No effect for infinite queries in v1 (see
49
- * `queryId` doc above).
60
+ * Accepted for API symmetry, but infinite queries do NOT propagate
61
+ * cross-tab: peers can't apply page-array payloads (core's remote-apply
62
+ * paths early-return for infinite defs). The `'infinite'` / `'both'` values
63
+ * were removed in T6.4; infinite cross-tab is tracked in `BACKLOG.md`.
50
64
  */
51
- crossTab?: boolean
65
+ crossTab?: boolean | 'data'
52
66
  }
53
67
 
54
68
  /**
@@ -60,6 +74,11 @@ export type InfiniteQuery<Args extends unknown[], TPage, _TItem> = {
60
74
  invalidate(...args: Args): void
61
75
  invalidateAll(): void
62
76
  setData(...args: [...Args, updater: (prev: TPage[] | undefined) => TPage[]]): Snapshot
77
+ /** Cancel the in-flight fetch (initial/refetch or paging) for a key. See
78
+ * `Query.cancel` (spec §5, §6.4). */
79
+ cancel(...args: Args): void
80
+ /** Cancel in-flight fetches for every keyed entry of this infinite query. */
81
+ cancelAll(): void
63
82
  prefetch(...args: Args): Promise<TPage>
64
83
  }
65
84
 
@@ -80,6 +99,8 @@ export type InfiniteQuerySubscription<TPage, TItem> = AsyncState<TPage[]> & {
80
99
  isFetchingPreviousPage: ReadSignal<boolean>
81
100
  fetchNextPage: () => Promise<void>
82
101
  fetchPreviousPage: () => Promise<void>
102
+ /** Cancel this subscription's in-flight fetch (if any). See `Query.cancel`. */
103
+ cancel: () => void
83
104
  }
84
105
 
85
106
  /**
@@ -99,6 +120,9 @@ export class InfiniteEntry<TPage, TItem, PageParam> {
99
120
  readonly isStale: Signal<boolean> = signal(true)
100
121
  readonly lastUpdatedAt: Signal<number | undefined> = signal(undefined)
101
122
  readonly hasPendingMutations: Signal<boolean> = signal(false)
123
+ /** True while a fetch is parked waiting for reconnect (online-mode defer).
124
+ * See spec §5.5, T3.5. Mirrors `Entry.isPaused`. */
125
+ readonly isPaused: Signal<boolean> = signal(false)
102
126
 
103
127
  readonly isFetchingNextPage: Signal<boolean> = signal(false)
104
128
  readonly isFetchingPreviousPage: Signal<boolean> = signal(false)
@@ -110,7 +134,14 @@ export class InfiniteEntry<TPage, TItem, PageParam> {
110
134
  private currentFetchId = 0
111
135
  private currentAbort: AbortController | null = null
112
136
  private staleTimer: ReturnType<typeof setTimeout> | null = null
113
- private snapshots: Array<{ id: number; prev: TPage[]; live: boolean }> = []
137
+ /** Set by `markStale()` (invalidate without fetch). See `Entry.forcedStale`. */
138
+ private forcedStale = false
139
+ private snapshots: Array<{
140
+ id: number
141
+ prev: TPage[]
142
+ prevParams: PageParam[]
143
+ live: boolean
144
+ }> = []
114
145
  private nextSnapshotId = 0
115
146
  private disposed = false
116
147
  /** Mirrors `Entry.pendingFirstValueRejects` — see that field for context. */
@@ -127,7 +158,15 @@ export class InfiniteEntry<TPage, TItem, PageParam> {
127
158
  | undefined
128
159
  private readonly staleTime: number
129
160
  private readonly retry: RetryPolicy
130
- private readonly retryDelay: RetryDelay
161
+ private readonly retryDelay: RetryDelay | undefined
162
+ private readonly networkMode: NetworkMode
163
+ private readonly structuralShareEnabled: boolean
164
+ private reconnectUnsub: (() => void) | null = null
165
+ private deferredResolvers: Array<{
166
+ direction: 'initial' | 'next' | 'prev'
167
+ resolve: () => void
168
+ reject: (err: unknown) => void
169
+ }> = []
131
170
  private readonly itemsOf?: (page: TPage) => TItem[]
132
171
  /**
133
172
  * Mirrors `Entry.onSuccessData`. Fires from `applyFetchSuccess`-equivalent
@@ -146,6 +185,8 @@ export class InfiniteEntry<TPage, TItem, PageParam> {
146
185
  staleTime?: number
147
186
  retry?: RetryPolicy
148
187
  retryDelay?: RetryDelay
188
+ networkMode?: NetworkMode
189
+ structuralShare?: boolean
149
190
  onSuccessData?: (pages: TPage[]) => void
150
191
  }) {
151
192
  this.fetcher = opts.fetcher
@@ -155,7 +196,9 @@ export class InfiniteEntry<TPage, TItem, PageParam> {
155
196
  this.itemsOf = opts.itemsOf
156
197
  this.staleTime = opts.staleTime ?? 0
157
198
  this.retry = opts.retry ?? 0
158
- this.retryDelay = opts.retryDelay ?? 1000
199
+ this.retryDelay = opts.retryDelay
200
+ this.networkMode = opts.networkMode ?? 'online'
201
+ this.structuralShareEnabled = opts.structuralShare ?? true
159
202
  this.onSuccessData = opts.onSuccessData
160
203
  this.pageParams = signal<PageParam[]>([])
161
204
  this.data = computed(() => {
@@ -185,53 +228,136 @@ export class InfiniteEntry<TPage, TItem, PageParam> {
185
228
  })
186
229
  }
187
230
 
188
- /** Initial / refetch — drops all pages and fetches starting from initialPageParam. */
231
+ /**
232
+ * Initial load / refetch. On the initial load (no pages yet) this fetches
233
+ * the first page. On a refetch of an already-loaded entry (interval,
234
+ * invalidate, focus/reconnect) it **refetches every currently-loaded page**
235
+ * sequentially, re-deriving each page's param from the freshly-fetched data
236
+ * via `getNextPageParam` — matching TanStack and avoiding the old
237
+ * "collapse to page one" truncation that dropped every loaded page but the
238
+ * first on each refetch (T3.7). Pages/params update atomically at the end,
239
+ * so subscribers never observe a mid-refetch truncation flash.
240
+ */
189
241
  startFetch(): Promise<TPage> {
190
242
  if (this.disposed) return Promise.reject(new Error('Entry disposed'))
243
+ if (this.networkMode === 'online' && this.isOffline()) {
244
+ return this.scheduleDeferredFetch('initial') as Promise<TPage>
245
+ }
191
246
  const myId = ++this.currentFetchId
192
247
  this.currentAbort?.abort()
193
248
  const abort = new AbortController()
194
249
  this.currentAbort = abort
195
250
 
196
- const previouslyHadPages = this.pages.peek().length > 0
251
+ const previousPages = this.pages.peek()
197
252
  batch(() => {
198
253
  this.status.set('pending')
199
254
  this.isFetching.set(true)
200
- this.isLoading.set(!previouslyHadPages)
255
+ this.isLoading.set(previousPages.length === 0)
256
+ this.isPaused.set(false)
201
257
  })
202
258
 
203
- return this.runFetch(
204
- myId,
205
- abort.signal,
206
- this.initialPageParam,
207
- (page, param) => {
208
- if (myId !== this.currentFetchId || this.disposed) return
209
- // Structurally share with the previous first-page on refresh, so
210
- // unchanged pages keep their refs. We only share the head page —
211
- // initial fetch wipes the rest of the array by definition.
212
- const prevPages = this.pages.peek()
213
- const sharedPage =
214
- prevPages.length > 0 ? structuralShare(prevPages[0] as TPage, page) : page
215
- batch(() => {
216
- this.pages.set([sharedPage])
217
- this.pageParams.set([param])
218
- this.error.set(undefined)
219
- this.status.set('success')
220
- this.isLoading.set(false)
221
- this.isFetching.set(false)
222
- this.lastUpdatedAt.set(Date.now())
223
- this.isStale.set(this.staleTime === 0)
224
- })
225
- if (this.staleTime > 0) this.scheduleStaleness()
226
- this.onSuccessData?.(this.pages.peek())
227
- },
228
- 'initial',
229
- )
259
+ return this.runRefetchAll(myId, abort.signal, Math.max(1, previousPages.length), previousPages)
260
+ }
261
+
262
+ /**
263
+ * Fetch `targetCount` pages from `initialPageParam`, chaining each next param
264
+ * via `getNextPageParam` from the freshly-fetched pages. Applies the query's
265
+ * retry policy per page. Stops early if the dataset shrank (a page yields
266
+ * `getNextPageParam === null`). Writes pages/params in ONE batch at the end;
267
+ * a per-page failure keeps the existing pages and surfaces the error; a
268
+ * supersede/dispose throws `AbortError` without writing.
269
+ */
270
+ private async runRefetchAll(
271
+ myId: number,
272
+ signal: AbortSignal,
273
+ targetCount: number,
274
+ previousPages: TPage[],
275
+ ): Promise<TPage> {
276
+ const newPages: TPage[] = []
277
+ const newParams: PageParam[] = []
278
+ let pageParam: PageParam = this.initialPageParam
279
+ try {
280
+ for (let i = 0; i < targetCount; i++) {
281
+ let attempt = 0
282
+ while (true) {
283
+ if (myId !== this.currentFetchId || this.disposed) {
284
+ throw new DOMException('Superseded', 'AbortError')
285
+ }
286
+ try {
287
+ const page = await this.fetcher({ pageParam, signal })
288
+ if (myId !== this.currentFetchId || this.disposed) {
289
+ throw new DOMException('Superseded', 'AbortError')
290
+ }
291
+ newPages.push(page)
292
+ newParams.push(pageParam)
293
+ break
294
+ } catch (err) {
295
+ if (myId !== this.currentFetchId || this.disposed || isAbortError(err)) throw err
296
+ const shouldRetry =
297
+ typeof this.retry === 'number' ? attempt < this.retry : this.retry(attempt, err)
298
+ if (!shouldRetry) {
299
+ batch(() => {
300
+ this.error.set(err)
301
+ this.status.set('error')
302
+ this.isLoading.set(false)
303
+ this.isFetching.set(false)
304
+ })
305
+ throw err
306
+ }
307
+ const delay = this.computeRetryDelay(attempt)
308
+ await abortableSleep(delay, signal)
309
+ attempt += 1
310
+ }
311
+ }
312
+ const next = this.getNextPageParam(newPages[newPages.length - 1] as TPage, newPages)
313
+ if (next === null) break
314
+ pageParam = next
315
+ }
316
+ if (myId !== this.currentFetchId || this.disposed) {
317
+ throw new DOMException('Superseded', 'AbortError')
318
+ }
319
+ // Structurally share the head page so an unchanged first page keeps its
320
+ // ref (downstream `computed`s / React snapshots don't thrash).
321
+ const finalPages =
322
+ previousPages.length > 0 && this.structuralShareEnabled && newPages.length > 0
323
+ ? [structuralShare(previousPages[0] as TPage, newPages[0] as TPage), ...newPages.slice(1)]
324
+ : newPages
325
+ batch(() => {
326
+ this.pages.set(finalPages)
327
+ this.pageParams.set(newParams)
328
+ this.error.set(undefined)
329
+ this.status.set('success')
330
+ this.isLoading.set(false)
331
+ this.isFetching.set(false)
332
+ this.lastUpdatedAt.set(Date.now())
333
+ this.isStale.set(this.staleTime === 0)
334
+ })
335
+ this.forcedStale = false // fresh pages clear a prior markStale() (T3.9)
336
+ if (this.staleTime > 0) this.scheduleStaleness()
337
+ this.onSuccessData?.(this.pages.peek())
338
+ return finalPages[0] as TPage
339
+ } finally {
340
+ // Status repair (T3.3): a superseded refetch must never leave the entry
341
+ // wedged at 'pending' when data is present and nothing is fetching. The
342
+ // superseder owns the final status; this only fires for the terminal
343
+ // fetch of a supersede chain. Never clobbers a real 'error'.
344
+ if (
345
+ !this.disposed &&
346
+ this.status.peek() === 'pending' &&
347
+ !this.isFetching.peek() &&
348
+ this.pages.peek().length > 0
349
+ ) {
350
+ this.status.set('success')
351
+ }
352
+ }
230
353
  }
231
354
 
232
355
  fetchNextPage(): Promise<void> {
233
356
  if (this.disposed) return Promise.reject(new Error('Entry disposed'))
234
357
  if (this.isFetchingNextPage.peek()) return Promise.resolve()
358
+ if (this.networkMode === 'online' && this.isOffline()) {
359
+ return this.scheduleDeferredFetch('next') as Promise<void>
360
+ }
235
361
  const ps = this.pages.peek()
236
362
  if (ps.length === 0) {
237
363
  return this.startFetch().then(() => {})
@@ -257,6 +383,11 @@ export class InfiniteEntry<TPage, TItem, PageParam> {
257
383
  batch(() => {
258
384
  this.pages.set([...this.pages.peek(), page])
259
385
  this.pageParams.set([...this.pageParams.peek(), param])
386
+ this.error.set(undefined)
387
+ // A successful page op owns the terminal status: restore 'success'
388
+ // so paging that superseded a mid-flight full refetch (which left
389
+ // status at 'pending') can't wedge the entry / Suspense (T3.3).
390
+ this.status.set('success')
260
391
  this.isFetchingNextPage.set(false)
261
392
  this.isFetching.set(false)
262
393
  this.lastUpdatedAt.set(Date.now())
@@ -271,6 +402,9 @@ export class InfiniteEntry<TPage, TItem, PageParam> {
271
402
  if (this.disposed) return Promise.reject(new Error('Entry disposed'))
272
403
  if (this.isFetchingPreviousPage.peek()) return Promise.resolve()
273
404
  if (!this.getPreviousPageParam) return Promise.resolve()
405
+ if (this.networkMode === 'online' && this.isOffline()) {
406
+ return this.scheduleDeferredFetch('prev') as Promise<void>
407
+ }
274
408
  const ps = this.pages.peek()
275
409
  if (ps.length === 0) {
276
410
  return this.startFetch().then(() => {})
@@ -296,6 +430,10 @@ export class InfiniteEntry<TPage, TItem, PageParam> {
296
430
  batch(() => {
297
431
  this.pages.set([page, ...this.pages.peek()])
298
432
  this.pageParams.set([param, ...this.pageParams.peek()])
433
+ this.error.set(undefined)
434
+ // A successful page op owns the terminal status — see fetchNextPage
435
+ // (T3.3).
436
+ this.status.set('success')
299
437
  this.isFetchingPreviousPage.set(false)
300
438
  this.isFetching.set(false)
301
439
  this.lastUpdatedAt.set(Date.now())
@@ -345,8 +483,7 @@ export class InfiniteEntry<TPage, TItem, PageParam> {
345
483
  })
346
484
  throw err
347
485
  }
348
- const delay =
349
- typeof this.retryDelay === 'function' ? this.retryDelay(attempt) : this.retryDelay
486
+ const delay = this.computeRetryDelay(attempt)
350
487
  await abortableSleep(delay, signal)
351
488
  attempt += 1
352
489
  }
@@ -361,6 +498,20 @@ export class InfiniteEntry<TPage, TItem, PageParam> {
361
498
  batch(() => {
362
499
  if (direction === 'next') this.isFetchingNextPage.set(false)
363
500
  if (direction === 'prev') this.isFetchingPreviousPage.set(false)
501
+ // Status repair (T3.3): a superseded fetch must never leave the
502
+ // entry wedged at 'pending' when data is present and nothing is
503
+ // fetching. The superseding fetch normally owns the final status
504
+ // (its onSuccess sets 'success'); this is the safety net for the
505
+ // terminal fetch of a supersede chain. Only repair 'pending' — never
506
+ // clobber a real 'error' — and only when no fetch is still running.
507
+ if (
508
+ !this.disposed &&
509
+ this.status.peek() === 'pending' &&
510
+ !this.isFetching.peek() &&
511
+ this.pages.peek().length > 0
512
+ ) {
513
+ this.status.set('success')
514
+ }
364
515
  })
365
516
  }
366
517
  }
@@ -370,12 +521,19 @@ export class InfiniteEntry<TPage, TItem, PageParam> {
370
521
  return this.startFetch()
371
522
  }
372
523
 
373
- invalidate(): Promise<TPage> {
524
+ /** Force stale without fetching — see `Entry.markStale` (spec §5.7, T3.9). */
525
+ markStale(): void {
526
+ if (this.disposed) return
374
527
  if (this.staleTimer != null) {
375
528
  clearTimeout(this.staleTimer)
376
529
  this.staleTimer = null
377
530
  }
531
+ this.forcedStale = true
378
532
  this.isStale.set(true)
533
+ }
534
+
535
+ invalidate(): Promise<TPage> {
536
+ this.markStale()
379
537
  return this.startFetch()
380
538
  }
381
539
 
@@ -387,32 +545,99 @@ export class InfiniteEntry<TPage, TItem, PageParam> {
387
545
  })
388
546
  }
389
547
 
390
- setData(updater: (prev: TPage[] | undefined) => TPage[]): Snapshot {
548
+ /** Cancel an in-flight fetch (initial/refetch or paging) without touching
549
+ * pages. Supersedes + aborts the current request, then restores a settled
550
+ * status. No-op when idle. Mirrors `Entry.cancel` (spec §5, §6.4, T3.4). */
551
+ cancel(): void {
552
+ if (this.disposed || !this.isFetching.peek()) return
553
+ this.currentFetchId += 1
554
+ this.currentAbort?.abort()
555
+ this.currentAbort = null
556
+ batch(() => {
557
+ this.isFetching.set(false)
558
+ this.isLoading.set(false)
559
+ this.isFetchingNextPage.set(false)
560
+ this.isFetchingPreviousPage.set(false)
561
+ this.status.set(this.pages.peek().length > 0 ? 'success' : 'idle')
562
+ })
563
+ }
564
+
565
+ setData(updater: (prev: TPage[] | undefined) => TPage[], opts?: { track?: boolean }): Snapshot {
391
566
  if (this.disposed) {
392
567
  return { rollback: () => {}, finalize: () => {} }
393
568
  }
394
569
  const prev = this.pages.peek()
570
+ const prevParams = this.pageParams.peek()
395
571
  const next = updater(prev.length === 0 ? undefined : prev)
396
- const id = this.nextSnapshotId++
397
- const record = { id, prev, live: true }
398
- this.snapshots.push(record)
572
+ // `track: false` is a canonical cache write (plugin/remote/entities
573
+ // backprop) no optimistic snapshot, no `hasPendingMutations` flip. See
574
+ // `Entry.setData` for the full rationale. Default `true` keeps the
575
+ // optimistic-update path (`query.setData` in `onMutate`).
576
+ const track = opts?.track ?? true
577
+ // Snapshot BOTH pages and pageParams so rollback restores a consistent
578
+ // pair. Without `prevParams`, an optimistic insert would shift `pages`
579
+ // permanently out of sync with `pageParams` on rollback — and any
580
+ // subsequent `fetchNextPage`/`getNextPageParam` would operate on the
581
+ // wrong head.
582
+ const record = track ? { id: this.nextSnapshotId++, prev, prevParams, live: true } : null
583
+ if (record) this.snapshots.push(record)
584
+
585
+ // If the updater changed the page count, trim or pad pageParams so the
586
+ // two arrays stay length-aligned. Padding uses the last known param,
587
+ // which is the safest neutral choice — the caller of `setData` should
588
+ // re-key via a real fetch (or use a future param-aware overload) if
589
+ // the new page needs a fresh param.
590
+ let nextParams: PageParam[] = prevParams
591
+ if (next.length !== prevParams.length) {
592
+ if (next.length < prevParams.length) {
593
+ nextParams = prevParams.slice(0, next.length)
594
+ } else {
595
+ const pad = prevParams[prevParams.length - 1]
596
+ nextParams = prevParams.slice()
597
+ for (let i = prevParams.length; i < next.length; i++) {
598
+ nextParams.push(pad as PageParam)
599
+ }
600
+ }
601
+ }
399
602
 
400
603
  batch(() => {
401
604
  this.pages.set(next)
605
+ if (nextParams !== prevParams) this.pageParams.set(nextParams)
402
606
  if (this.status.peek() === 'idle' || this.status.peek() === 'pending') {
403
607
  this.status.set('success')
404
608
  }
405
609
  this.lastUpdatedAt.set(Date.now())
406
- this.hasPendingMutations.set(true)
610
+ if (record) this.hasPendingMutations.set(true)
407
611
  })
408
612
 
613
+ if (!record) {
614
+ return { rollback: () => {}, finalize: () => {} }
615
+ }
616
+ const id = record.id
409
617
  return {
410
618
  rollback: () => {
411
619
  if (!record.live || this.disposed) return
412
620
  record.live = false
413
621
  batch(() => {
414
- this.pages.set(record.prev)
415
- this.snapshots = this.snapshots.filter((s) => s.id !== id)
622
+ const i = this.snapshots.indexOf(record)
623
+ if (i !== -1) {
624
+ if (i === this.snapshots.length - 1) {
625
+ // Top of the stack: restore this layer's captured baseline pair
626
+ // (pages + params stay length-aligned — see the snapshot note).
627
+ this.pages.set(record.prev)
628
+ this.pageParams.set(record.prevParams)
629
+ } else {
630
+ // Not the top: leave current pages/params alone and thread this
631
+ // layer's baseline down onto the next layer, so a later
632
+ // top-rollback lands on the correct pre-everything pages instead
633
+ // of resurrecting this layer's delta (chain-splice — T3.1, spec
634
+ // §6.4). Mirrors `Entry.setData`.
635
+ const below = this.snapshots[i + 1] as (typeof this.snapshots)[number]
636
+ below.prev = record.prev
637
+ below.prevParams = record.prevParams
638
+ }
639
+ this.snapshots.splice(i, 1)
640
+ }
416
641
  this.hasPendingMutations.set(this.snapshots.some((s) => s.live))
417
642
  })
418
643
  },
@@ -457,11 +682,72 @@ export class InfiniteEntry<TPage, TItem, PageParam> {
457
682
  }
458
683
 
459
684
  isStaleNow(): boolean {
685
+ if (this.forcedStale) return true
460
686
  const last = this.lastUpdatedAt.peek()
461
687
  if (last === undefined) return true
462
688
  return Date.now() - last >= this.staleTime
463
689
  }
464
690
 
691
+ private computeRetryDelay(attempt: number): number {
692
+ const d = this.retryDelay
693
+ // Exponential backoff default when retries are on but no retryDelay given
694
+ // (mirrors Entry.computeDelay — T3.9).
695
+ if (d === undefined) return Math.min(1000 * 2 ** attempt, 30_000)
696
+ return typeof d === 'function' ? d(attempt) : d
697
+ }
698
+
699
+ private isOffline(): boolean {
700
+ return typeof navigator !== 'undefined' && navigator.onLine === false
701
+ }
702
+
703
+ private scheduleDeferredFetch(direction: 'initial' | 'next' | 'prev'): Promise<unknown> {
704
+ // Parked waiting for reconnect (T3.5). Cleared when a fetch actually
705
+ // starts (`startFetch` batch) or on drain.
706
+ this.isPaused.set(true)
707
+ if (this.reconnectUnsub === null) {
708
+ this.reconnectUnsub = subscribeReconnect(() => this.drainDeferred())
709
+ }
710
+ return new Promise<void>((resolve, reject) => {
711
+ this.deferredResolvers.push({ direction, resolve, reject })
712
+ })
713
+ }
714
+
715
+ private drainDeferred(): void {
716
+ if (this.deferredResolvers.length === 0) return
717
+ if (this.disposed) return
718
+ this.isPaused.set(false)
719
+ const pending = this.deferredResolvers
720
+ this.deferredResolvers = []
721
+ if (this.reconnectUnsub !== null) {
722
+ this.reconnectUnsub()
723
+ this.reconnectUnsub = null
724
+ }
725
+ // Collapse multiple deferrals of the same direction into one real fetch.
726
+ // Order matters: initial first (it may produce data the others need),
727
+ // then prev / next.
728
+ const seen = new Set<'initial' | 'next' | 'prev'>()
729
+ const order: Array<'initial' | 'next' | 'prev'> = ['initial', 'prev', 'next']
730
+ for (const d of pending) {
731
+ seen.add(d.direction)
732
+ }
733
+ const run = async () => {
734
+ for (const dir of order) {
735
+ if (!seen.has(dir)) continue
736
+ if (dir === 'initial') await this.startFetch()
737
+ else if (dir === 'next') await this.fetchNextPage()
738
+ else await this.fetchPreviousPage()
739
+ }
740
+ }
741
+ run().then(
742
+ () => {
743
+ for (const p of pending) p.resolve()
744
+ },
745
+ (err) => {
746
+ for (const p of pending) p.reject(err)
747
+ },
748
+ )
749
+ }
750
+
465
751
  private scheduleStaleness(): void {
466
752
  if (this.staleTimer != null) clearTimeout(this.staleTimer)
467
753
  if (this.staleTime > 0) {
@@ -481,6 +767,24 @@ export class InfiniteEntry<TPage, TItem, PageParam> {
481
767
  }
482
768
  this.currentAbort?.abort()
483
769
  this.currentAbort = null
770
+ // A disposed entry is idle — reset the in-flight flags so `waitForIdle`
771
+ // can't hang on a fetch racing dispose (T3.9). Mirrors `Entry.dispose`.
772
+ batch(() => {
773
+ this.isFetching.set(false)
774
+ this.isLoading.set(false)
775
+ this.isFetchingNextPage.set(false)
776
+ this.isFetchingPreviousPage.set(false)
777
+ })
778
+ if (this.reconnectUnsub !== null) {
779
+ this.reconnectUnsub()
780
+ this.reconnectUnsub = null
781
+ }
782
+ if (this.deferredResolvers.length > 0) {
783
+ const disposed = new DOMException('Entry disposed', 'AbortError')
784
+ const pending = this.deferredResolvers
785
+ this.deferredResolvers = []
786
+ for (const p of pending) p.reject(disposed)
787
+ }
484
788
  if (this.pendingFirstValueRejects.length > 0) {
485
789
  const disposed = new DOMException('Entry disposed', 'AbortError')
486
790
  const rejects = this.pendingFirstValueRejects
package/src/query/keys.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Stable string hash of a key tuple. Two equal-by-content args produce the
3
3
  * same string regardless of property iteration order. Handles primitives,
4
- * arrays, plain objects, Date.
4
+ * arrays, plain objects, Date, BigInt, NaN, ±Infinity.
5
5
  *
6
6
  * Functions and symbols throw — keys must be serializable so distinct
7
7
  * subscribers can share entries.
@@ -10,19 +10,50 @@ export function stableHash(args: readonly unknown[]): string {
10
10
  return JSON.stringify(args, replacer)
11
11
  }
12
12
 
13
- const replacer = (_key: string, value: unknown): unknown => {
13
+ // A regular `function` (not an arrow) so `this` is bound to the holder object
14
+ // by `JSON.stringify`. Critical: `JSON.stringify` applies `toJSON()` to a value
15
+ // BEFORE calling the replacer, so the `value` argument for a `Date` is already
16
+ // its ISO string and a class instance with a `toJSON` is already its serialized
17
+ // form — inspecting `value` would miss both (the old arrow did, making the Date
18
+ // tag and the class-instance throw dead code: `stableHash([date])` collided
19
+ // with `stableHash([date.toISOString()])`). Reading the RAW property off the
20
+ // holder (`this[key]`) recovers the true pre-`toJSON` value. Spec §5.4; T3.8.
21
+ const replacer = function (this: unknown, key: string, _value: unknown): unknown {
22
+ const value = (this as Record<string, unknown>)[key]
14
23
  if (typeof value === 'function') {
15
24
  throw new Error('[olas] query keys cannot contain functions')
16
25
  }
17
26
  if (typeof value === 'symbol') {
18
27
  throw new Error('[olas] query keys cannot contain symbols')
19
28
  }
29
+ if (typeof value === 'bigint') {
30
+ // JSON has no bigint; serialize as a tagged string so `1n` and `'1'`
31
+ // don't collide. The tag survives round-trips for debugging.
32
+ return { __bigint: value.toString() }
33
+ }
34
+ if (typeof value === 'number') {
35
+ // `JSON.stringify(NaN)` → `'null'` and likewise for Infinity, which
36
+ // would collide with a literal `null` key. Tag them explicitly.
37
+ if (Number.isNaN(value)) return '__nan__'
38
+ if (value === Number.POSITIVE_INFINITY) return '__+inf__'
39
+ if (value === Number.NEGATIVE_INFINITY) return '__-inf__'
40
+ return value
41
+ }
20
42
  if (value === undefined) return '__undefined__'
21
43
  if (value instanceof Date) return { __date: value.toISOString() }
22
44
  if (value instanceof Map || value instanceof Set) {
23
45
  throw new Error('[olas] query keys cannot contain Map/Set — use arrays/objects')
24
46
  }
25
47
  if (value && typeof value === 'object' && !Array.isArray(value)) {
48
+ // Reject class instances (proto !== Object.prototype): two `new MyKey(1)`
49
+ // calls would serialize identically to `{}` and collide. Plain objects
50
+ // pass through with sorted keys.
51
+ const proto = Object.getPrototypeOf(value)
52
+ if (proto !== null && proto !== Object.prototype) {
53
+ throw new Error(
54
+ `[olas] query keys cannot contain class instances (got ${proto.constructor?.name ?? 'unknown'}) — pass plain object/array data`,
55
+ )
56
+ }
26
57
  const sorted: Record<string, unknown> = {}
27
58
  for (const k of Object.keys(value).sort()) {
28
59
  sorted[k] = (value as Record<string, unknown>)[k]
@@ -79,6 +79,9 @@ class LocalCacheImpl<T> implements LocalCache<T> {
79
79
  get hasPendingMutations(): ReadSignal<boolean> {
80
80
  return this.entry.hasPendingMutations
81
81
  }
82
+ get isPaused(): ReadSignal<boolean> {
83
+ return this.entry.isPaused
84
+ }
82
85
 
83
86
  refetch = (): Promise<T> => this.entry.refetch()
84
87
  reset = (): void => this.entry.reset()