@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.
- package/dist/index.cjs +0 -0
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +109 -27
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.mts +109 -27
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +0 -0
- package/dist/index.mjs.map +1 -1
- package/dist/{root-B6pNq75w.mjs → root-Byq-QYTp.mjs} +1465 -347
- package/dist/root-Byq-QYTp.mjs.map +1 -0
- package/dist/{root-CFgYhBd-.cjs → root-CPSL5kpR.cjs} +1470 -346
- package/dist/root-CPSL5kpR.cjs.map +1 -0
- package/dist/testing.cjs +5 -1
- package/dist/testing.cjs.map +1 -1
- package/dist/testing.d.cts +3 -2
- package/dist/testing.d.cts.map +1 -1
- package/dist/testing.d.mts +3 -2
- package/dist/testing.d.mts.map +1 -1
- package/dist/testing.mjs +5 -2
- package/dist/testing.mjs.map +1 -1
- package/dist/{types-Bmi04hDI.d.mts → types-Brp-4WaZ.d.mts} +233 -23
- package/dist/types-Brp-4WaZ.d.mts.map +1 -0
- package/dist/{types-BsZMPPOE.d.cts → types-DzDIypPo.d.cts} +233 -23
- package/dist/types-DzDIypPo.d.cts.map +1 -0
- package/package.json +4 -1
- package/src/controller/instance.ts +345 -94
- package/src/controller/root.ts +52 -30
- package/src/controller/types.ts +55 -4
- package/src/emitter.ts +8 -2
- package/src/errors.ts +39 -3
- package/src/forms/field.ts +219 -25
- package/src/forms/form-types.ts +16 -3
- package/src/forms/form.ts +360 -38
- package/src/forms/index.ts +3 -1
- package/src/forms/types.ts +27 -1
- package/src/forms/validators.ts +45 -11
- package/src/index.ts +13 -7
- package/src/query/client.ts +237 -58
- package/src/query/define.ts +0 -0
- package/src/query/entry.ts +259 -15
- package/src/query/focus-online.ts +53 -11
- package/src/query/infinite.ts +351 -47
- package/src/query/keys.ts +33 -2
- package/src/query/local.ts +3 -0
- package/src/query/mutation.ts +27 -6
- package/src/query/plugin.ts +43 -4
- package/src/query/types.ts +76 -6
- package/src/query/use.ts +53 -10
- package/src/selection.ts +49 -12
- package/src/signals/readonly.ts +3 -0
- package/src/signals/runtime.ts +27 -0
- package/src/signals/types.ts +10 -0
- package/src/testing.ts +9 -0
- package/src/timing/debounced.ts +106 -23
- package/src/timing/index.ts +1 -1
- package/src/timing/throttled.ts +85 -28
- package/src/utils.ts +15 -2
- package/dist/root-B6pNq75w.mjs.map +0 -1
- package/dist/root-CFgYhBd-.cjs.map +0 -1
- package/dist/types-Bmi04hDI.d.mts.map +0 -1
- package/dist/types-BsZMPPOE.d.cts.map +0 -1
package/src/query/infinite.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
*
|
|
49
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
-
/**
|
|
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
|
|
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(
|
|
255
|
+
this.isLoading.set(previousPages.length === 0)
|
|
256
|
+
this.isPaused.set(false)
|
|
201
257
|
})
|
|
202
258
|
|
|
203
|
-
return this.
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
397
|
-
|
|
398
|
-
|
|
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.
|
|
415
|
-
|
|
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
|
-
|
|
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]
|
package/src/query/local.ts
CHANGED
|
@@ -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()
|