@kontsedal/olas-core 0.0.1-rc.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/LICENSE +21 -0
- package/README.md +64 -0
- package/dist/index.cjs +363 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +178 -0
- package/dist/index.d.cts.map +1 -0
- package/dist/index.d.mts +178 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +339 -0
- package/dist/index.mjs.map +1 -0
- package/dist/root-BImHnGj1.mjs +3270 -0
- package/dist/root-BImHnGj1.mjs.map +1 -0
- package/dist/root-Bazp5_Ik.cjs +3347 -0
- package/dist/root-Bazp5_Ik.cjs.map +1 -0
- package/dist/testing.cjs +81 -0
- package/dist/testing.cjs.map +1 -0
- package/dist/testing.d.cts +56 -0
- package/dist/testing.d.cts.map +1 -0
- package/dist/testing.d.mts +56 -0
- package/dist/testing.d.mts.map +1 -0
- package/dist/testing.mjs +78 -0
- package/dist/testing.mjs.map +1 -0
- package/dist/types-CAMgqCMz.d.mts +816 -0
- package/dist/types-CAMgqCMz.d.mts.map +1 -0
- package/dist/types-emq_lZd7.d.cts +816 -0
- package/dist/types-emq_lZd7.d.cts.map +1 -0
- package/package.json +47 -0
- package/src/__dev__.d.ts +8 -0
- package/src/controller/define.ts +50 -0
- package/src/controller/index.ts +12 -0
- package/src/controller/instance.ts +499 -0
- package/src/controller/root.ts +160 -0
- package/src/controller/types.ts +195 -0
- package/src/devtools.ts +0 -0
- package/src/emitter.ts +79 -0
- package/src/errors.ts +49 -0
- package/src/forms/field.ts +303 -0
- package/src/forms/form-types.ts +130 -0
- package/src/forms/form.ts +640 -0
- package/src/forms/index.ts +2 -0
- package/src/forms/types.ts +1 -0
- package/src/forms/validators.ts +70 -0
- package/src/index.ts +89 -0
- package/src/query/client.ts +934 -0
- package/src/query/define.ts +154 -0
- package/src/query/entry.ts +322 -0
- package/src/query/focus-online.ts +73 -0
- package/src/query/index.ts +3 -0
- package/src/query/infinite.ts +462 -0
- package/src/query/keys.ts +33 -0
- package/src/query/local.ts +113 -0
- package/src/query/mutation.ts +384 -0
- package/src/query/plugin.ts +135 -0
- package/src/query/types.ts +168 -0
- package/src/query/use.ts +321 -0
- package/src/scope.ts +42 -0
- package/src/selection.ts +146 -0
- package/src/signals/index.ts +3 -0
- package/src/signals/readonly.ts +22 -0
- package/src/signals/runtime.ts +115 -0
- package/src/signals/types.ts +31 -0
- package/src/testing.ts +142 -0
- package/src/timing/debounced.ts +32 -0
- package/src/timing/index.ts +2 -0
- package/src/timing/throttled.ts +46 -0
- package/src/utils.ts +13 -0
|
@@ -0,0 +1,462 @@
|
|
|
1
|
+
import { batch, computed, type Signal, signal } from '../signals'
|
|
2
|
+
import type { ReadSignal } from '../signals/types'
|
|
3
|
+
import { isAbortError } from '../utils'
|
|
4
|
+
import type { AsyncState, AsyncStatus, RetryDelay, RetryPolicy } from './types'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Configuration for `defineInfiniteQuery({ ... })`. Spec §5.7, §20.4.
|
|
8
|
+
*
|
|
9
|
+
* - `getNextPageParam(lastPage, allPages)` returns the param for the next
|
|
10
|
+
* page, or `null` when there's no more.
|
|
11
|
+
* - `getPreviousPageParam` (optional) enables bidirectional infinite lists.
|
|
12
|
+
* - `itemsOf(page)` (optional) flattens pages into items for the
|
|
13
|
+
* `subscription.flat` convenience signal.
|
|
14
|
+
*/
|
|
15
|
+
export type InfiniteFetchCtx<PageParam> = {
|
|
16
|
+
pageParam: PageParam
|
|
17
|
+
signal: AbortSignal
|
|
18
|
+
deps: import('../controller/types').AmbientDeps
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type InfiniteQuerySpec<Args extends unknown[], PageParam, TPage, TItem = TPage> = {
|
|
22
|
+
key: (...args: Args) => unknown[]
|
|
23
|
+
/**
|
|
24
|
+
* Fetcher receives an `InfiniteFetchCtx` (pageParam + signal + deps) as
|
|
25
|
+
* the first arg and positional cache args after. See `FetchCtx` for the
|
|
26
|
+
* regular-query analogue.
|
|
27
|
+
*/
|
|
28
|
+
fetcher: (ctx: InfiniteFetchCtx<PageParam>, ...args: Args) => Promise<TPage>
|
|
29
|
+
initialPageParam: PageParam
|
|
30
|
+
getNextPageParam: (lastPage: TPage, allPages: TPage[]) => PageParam | null
|
|
31
|
+
getPreviousPageParam?: (firstPage: TPage, allPages: TPage[]) => PageParam | null
|
|
32
|
+
itemsOf?: (page: TPage) => TItem[]
|
|
33
|
+
staleTime?: number
|
|
34
|
+
gcTime?: number
|
|
35
|
+
refetchInterval?: number
|
|
36
|
+
keepPreviousData?: boolean
|
|
37
|
+
retry?: RetryPolicy
|
|
38
|
+
retryDelay?: RetryDelay
|
|
39
|
+
/**
|
|
40
|
+
* Stable identifier used by `QueryClientPlugin`s (`@kontsedal/olas-cross-tab`,
|
|
41
|
+
* etc.). Infinite queries do NOT propagate cross-tab in v1 — the
|
|
42
|
+
* page-array payload is too heavy to be a safe default — but the field is
|
|
43
|
+
* accepted for forward compatibility. SPEC §13.2.
|
|
44
|
+
*/
|
|
45
|
+
queryId?: string
|
|
46
|
+
/**
|
|
47
|
+
* Opt into cross-tab sync. No effect for infinite queries in v1 (see
|
|
48
|
+
* `queryId` doc above).
|
|
49
|
+
*/
|
|
50
|
+
crossTab?: boolean
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Module-scoped handle for a paginated query. Mirrors `Query<Args, TPage[]>`
|
|
55
|
+
* with paginated `setData` semantics.
|
|
56
|
+
*/
|
|
57
|
+
export type InfiniteQuery<Args extends unknown[], TPage, _TItem> = {
|
|
58
|
+
readonly __olas: 'infiniteQuery'
|
|
59
|
+
invalidate(...args: Args): void
|
|
60
|
+
invalidateAll(): void
|
|
61
|
+
setData(...args: [...Args, updater: (prev: TPage[] | undefined) => TPage[]]): {
|
|
62
|
+
rollback: () => void
|
|
63
|
+
}
|
|
64
|
+
prefetch(...args: Args): Promise<TPage>
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* What `ctx.use(infiniteQuery, ...)` returns. Extends `AsyncState<TPage[]>`
|
|
69
|
+
* with paginated controls: `fetchNextPage` / `fetchPreviousPage`,
|
|
70
|
+
* `hasNextPage` / `hasPreviousPage`, and per-direction `isFetching` signals.
|
|
71
|
+
*
|
|
72
|
+
* `flat` is a convenience: present when the query spec provides `itemsOf` —
|
|
73
|
+
* otherwise it's an empty array.
|
|
74
|
+
*/
|
|
75
|
+
export type InfiniteQuerySubscription<TPage, TItem> = AsyncState<TPage[]> & {
|
|
76
|
+
pages: ReadSignal<TPage[]>
|
|
77
|
+
flat: ReadSignal<TItem[]>
|
|
78
|
+
hasNextPage: ReadSignal<boolean>
|
|
79
|
+
hasPreviousPage: ReadSignal<boolean>
|
|
80
|
+
isFetchingNextPage: ReadSignal<boolean>
|
|
81
|
+
isFetchingPreviousPage: ReadSignal<boolean>
|
|
82
|
+
fetchNextPage: () => Promise<void>
|
|
83
|
+
fetchPreviousPage: () => Promise<void>
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
import type { Snapshot } from './types'
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Holds an array of pages plus their pageParams. Supports fetchNextPage /
|
|
90
|
+
* fetchPreviousPage / invalidate (drops all pages). Race-protected.
|
|
91
|
+
*
|
|
92
|
+
* Internal.
|
|
93
|
+
*/
|
|
94
|
+
export class InfiniteEntry<TPage, TItem, PageParam> {
|
|
95
|
+
readonly pages: Signal<TPage[]> = signal<TPage[]>([])
|
|
96
|
+
readonly pageParams: Signal<PageParam[]>
|
|
97
|
+
readonly data: ReadSignal<TPage[] | undefined>
|
|
98
|
+
readonly error: Signal<unknown | undefined> = signal(undefined)
|
|
99
|
+
readonly status: Signal<AsyncStatus> = signal<AsyncStatus>('idle')
|
|
100
|
+
readonly isLoading: Signal<boolean> = signal(false)
|
|
101
|
+
readonly isFetching: Signal<boolean> = signal(false)
|
|
102
|
+
readonly isStale: Signal<boolean> = signal(true)
|
|
103
|
+
readonly lastUpdatedAt: Signal<number | undefined> = signal(undefined)
|
|
104
|
+
readonly hasPendingMutations: Signal<boolean> = signal(false)
|
|
105
|
+
|
|
106
|
+
readonly isFetchingNextPage: Signal<boolean> = signal(false)
|
|
107
|
+
readonly isFetchingPreviousPage: Signal<boolean> = signal(false)
|
|
108
|
+
|
|
109
|
+
readonly hasNextPage: ReadSignal<boolean>
|
|
110
|
+
readonly hasPreviousPage: ReadSignal<boolean>
|
|
111
|
+
readonly flat: ReadSignal<TItem[]>
|
|
112
|
+
|
|
113
|
+
private currentFetchId = 0
|
|
114
|
+
private currentAbort: AbortController | null = null
|
|
115
|
+
private staleTimer: ReturnType<typeof setTimeout> | null = null
|
|
116
|
+
private snapshots: Array<{ id: number; prev: TPage[]; live: boolean }> = []
|
|
117
|
+
private nextSnapshotId = 0
|
|
118
|
+
private disposed = false
|
|
119
|
+
|
|
120
|
+
private readonly fetcher: (pageCtx: {
|
|
121
|
+
pageParam: PageParam
|
|
122
|
+
signal: AbortSignal
|
|
123
|
+
}) => Promise<TPage>
|
|
124
|
+
private readonly initialPageParam: PageParam
|
|
125
|
+
private readonly getNextPageParam: (lastPage: TPage, allPages: TPage[]) => PageParam | null
|
|
126
|
+
private readonly getPreviousPageParam:
|
|
127
|
+
| ((firstPage: TPage, allPages: TPage[]) => PageParam | null)
|
|
128
|
+
| undefined
|
|
129
|
+
private readonly staleTime: number
|
|
130
|
+
private readonly retry: RetryPolicy
|
|
131
|
+
private readonly retryDelay: RetryDelay
|
|
132
|
+
private readonly itemsOf?: (page: TPage) => TItem[]
|
|
133
|
+
|
|
134
|
+
constructor(opts: {
|
|
135
|
+
fetcher: (pageCtx: { pageParam: PageParam; signal: AbortSignal }) => Promise<TPage>
|
|
136
|
+
initialPageParam: PageParam
|
|
137
|
+
getNextPageParam: (lastPage: TPage, allPages: TPage[]) => PageParam | null
|
|
138
|
+
getPreviousPageParam?: (firstPage: TPage, allPages: TPage[]) => PageParam | null
|
|
139
|
+
itemsOf?: (page: TPage) => TItem[]
|
|
140
|
+
staleTime?: number
|
|
141
|
+
retry?: RetryPolicy
|
|
142
|
+
retryDelay?: RetryDelay
|
|
143
|
+
}) {
|
|
144
|
+
this.fetcher = opts.fetcher
|
|
145
|
+
this.initialPageParam = opts.initialPageParam
|
|
146
|
+
this.getNextPageParam = opts.getNextPageParam
|
|
147
|
+
this.getPreviousPageParam = opts.getPreviousPageParam
|
|
148
|
+
this.itemsOf = opts.itemsOf
|
|
149
|
+
this.staleTime = opts.staleTime ?? 0
|
|
150
|
+
this.retry = opts.retry ?? 0
|
|
151
|
+
this.retryDelay = opts.retryDelay ?? 1000
|
|
152
|
+
this.pageParams = signal<PageParam[]>([])
|
|
153
|
+
this.data = computed(() => {
|
|
154
|
+
const ps = this.pages.value
|
|
155
|
+
return ps.length === 0 ? undefined : ps
|
|
156
|
+
})
|
|
157
|
+
this.flat = computed<TItem[]>(() => {
|
|
158
|
+
const ps = this.pages.value
|
|
159
|
+
if (!this.itemsOf) return ps as unknown as TItem[]
|
|
160
|
+
const out: TItem[] = []
|
|
161
|
+
for (const p of ps) {
|
|
162
|
+
for (const item of this.itemsOf(p)) out.push(item)
|
|
163
|
+
}
|
|
164
|
+
return out
|
|
165
|
+
})
|
|
166
|
+
this.hasNextPage = computed(() => {
|
|
167
|
+
const ps = this.pages.value
|
|
168
|
+
if (ps.length === 0) return false
|
|
169
|
+
return this.getNextPageParam(ps[ps.length - 1] as TPage, ps) !== null
|
|
170
|
+
})
|
|
171
|
+
this.hasPreviousPage = computed(() => {
|
|
172
|
+
const ps = this.pages.value
|
|
173
|
+
if (ps.length === 0) return false
|
|
174
|
+
const fn = this.getPreviousPageParam
|
|
175
|
+
if (!fn) return false
|
|
176
|
+
return fn(ps[0] as TPage, ps) !== null
|
|
177
|
+
})
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Initial / refetch — drops all pages and fetches starting from initialPageParam. */
|
|
181
|
+
startFetch(): Promise<TPage> {
|
|
182
|
+
if (this.disposed) return Promise.reject(new Error('Entry disposed'))
|
|
183
|
+
const myId = ++this.currentFetchId
|
|
184
|
+
this.currentAbort?.abort()
|
|
185
|
+
const abort = new AbortController()
|
|
186
|
+
this.currentAbort = abort
|
|
187
|
+
|
|
188
|
+
const previouslyHadPages = this.pages.peek().length > 0
|
|
189
|
+
batch(() => {
|
|
190
|
+
this.status.set('pending')
|
|
191
|
+
this.isFetching.set(true)
|
|
192
|
+
this.isLoading.set(!previouslyHadPages)
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
return this.runFetch(
|
|
196
|
+
myId,
|
|
197
|
+
abort.signal,
|
|
198
|
+
this.initialPageParam,
|
|
199
|
+
(page, param) => {
|
|
200
|
+
if (myId !== this.currentFetchId || this.disposed) return
|
|
201
|
+
batch(() => {
|
|
202
|
+
this.pages.set([page])
|
|
203
|
+
this.pageParams.set([param])
|
|
204
|
+
this.error.set(undefined)
|
|
205
|
+
this.status.set('success')
|
|
206
|
+
this.isLoading.set(false)
|
|
207
|
+
this.isFetching.set(false)
|
|
208
|
+
this.lastUpdatedAt.set(Date.now())
|
|
209
|
+
this.isStale.set(this.staleTime === 0)
|
|
210
|
+
})
|
|
211
|
+
if (this.staleTime > 0) this.scheduleStaleness()
|
|
212
|
+
},
|
|
213
|
+
'initial',
|
|
214
|
+
)
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
fetchNextPage(): Promise<void> {
|
|
218
|
+
if (this.disposed) return Promise.reject(new Error('Entry disposed'))
|
|
219
|
+
if (this.isFetchingNextPage.peek()) return Promise.resolve()
|
|
220
|
+
const ps = this.pages.peek()
|
|
221
|
+
if (ps.length === 0) {
|
|
222
|
+
return this.startFetch().then(() => {})
|
|
223
|
+
}
|
|
224
|
+
const nextParam = this.getNextPageParam(ps[ps.length - 1] as TPage, ps)
|
|
225
|
+
if (nextParam === null) return Promise.resolve()
|
|
226
|
+
|
|
227
|
+
const myId = ++this.currentFetchId
|
|
228
|
+
const abort = new AbortController()
|
|
229
|
+
this.currentAbort?.abort()
|
|
230
|
+
this.currentAbort = abort
|
|
231
|
+
batch(() => {
|
|
232
|
+
this.isFetchingNextPage.set(true)
|
|
233
|
+
this.isFetching.set(true)
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
return this.runFetch(
|
|
237
|
+
myId,
|
|
238
|
+
abort.signal,
|
|
239
|
+
nextParam,
|
|
240
|
+
(page, param) => {
|
|
241
|
+
if (myId !== this.currentFetchId || this.disposed) return
|
|
242
|
+
batch(() => {
|
|
243
|
+
this.pages.set([...this.pages.peek(), page])
|
|
244
|
+
this.pageParams.set([...this.pageParams.peek(), param])
|
|
245
|
+
this.isFetchingNextPage.set(false)
|
|
246
|
+
this.isFetching.set(false)
|
|
247
|
+
this.lastUpdatedAt.set(Date.now())
|
|
248
|
+
})
|
|
249
|
+
},
|
|
250
|
+
'next',
|
|
251
|
+
).then(() => {})
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
fetchPreviousPage(): Promise<void> {
|
|
255
|
+
if (this.disposed) return Promise.reject(new Error('Entry disposed'))
|
|
256
|
+
if (this.isFetchingPreviousPage.peek()) return Promise.resolve()
|
|
257
|
+
if (!this.getPreviousPageParam) return Promise.resolve()
|
|
258
|
+
const ps = this.pages.peek()
|
|
259
|
+
if (ps.length === 0) {
|
|
260
|
+
return this.startFetch().then(() => {})
|
|
261
|
+
}
|
|
262
|
+
const prevParam = this.getPreviousPageParam(ps[0] as TPage, ps)
|
|
263
|
+
if (prevParam === null) return Promise.resolve()
|
|
264
|
+
|
|
265
|
+
const myId = ++this.currentFetchId
|
|
266
|
+
const abort = new AbortController()
|
|
267
|
+
this.currentAbort?.abort()
|
|
268
|
+
this.currentAbort = abort
|
|
269
|
+
batch(() => {
|
|
270
|
+
this.isFetchingPreviousPage.set(true)
|
|
271
|
+
this.isFetching.set(true)
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
return this.runFetch(
|
|
275
|
+
myId,
|
|
276
|
+
abort.signal,
|
|
277
|
+
prevParam,
|
|
278
|
+
(page, param) => {
|
|
279
|
+
if (myId !== this.currentFetchId || this.disposed) return
|
|
280
|
+
batch(() => {
|
|
281
|
+
this.pages.set([page, ...this.pages.peek()])
|
|
282
|
+
this.pageParams.set([param, ...this.pageParams.peek()])
|
|
283
|
+
this.isFetchingPreviousPage.set(false)
|
|
284
|
+
this.isFetching.set(false)
|
|
285
|
+
this.lastUpdatedAt.set(Date.now())
|
|
286
|
+
})
|
|
287
|
+
},
|
|
288
|
+
'prev',
|
|
289
|
+
).then(() => {})
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
private async runFetch(
|
|
293
|
+
myId: number,
|
|
294
|
+
signal: AbortSignal,
|
|
295
|
+
pageParam: PageParam,
|
|
296
|
+
onSuccess: (page: TPage, param: PageParam) => void,
|
|
297
|
+
direction: 'initial' | 'next' | 'prev',
|
|
298
|
+
): Promise<TPage> {
|
|
299
|
+
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 })
|
|
306
|
+
if (myId !== this.currentFetchId || this.disposed) {
|
|
307
|
+
throw new DOMException('Superseded', 'AbortError')
|
|
308
|
+
}
|
|
309
|
+
onSuccess(page, pageParam)
|
|
310
|
+
return page
|
|
311
|
+
} catch (err) {
|
|
312
|
+
if (myId !== this.currentFetchId || this.disposed || isAbortError(err)) {
|
|
313
|
+
throw err
|
|
314
|
+
}
|
|
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
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
refetch(): Promise<TPage> {
|
|
337
|
+
return this.startFetch()
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
invalidate(): Promise<TPage> {
|
|
341
|
+
if (this.staleTimer != null) {
|
|
342
|
+
clearTimeout(this.staleTimer)
|
|
343
|
+
this.staleTimer = null
|
|
344
|
+
}
|
|
345
|
+
this.isStale.set(true)
|
|
346
|
+
return this.startFetch()
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
reset(): void {
|
|
350
|
+
if (this.disposed) return
|
|
351
|
+
batch(() => {
|
|
352
|
+
this.error.set(undefined)
|
|
353
|
+
this.status.set(this.pages.peek().length > 0 ? 'success' : 'idle')
|
|
354
|
+
})
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
setData(updater: (prev: TPage[] | undefined) => TPage[]): Snapshot {
|
|
358
|
+
if (this.disposed) {
|
|
359
|
+
return { rollback: () => {}, finalize: () => {} }
|
|
360
|
+
}
|
|
361
|
+
const prev = this.pages.peek()
|
|
362
|
+
const next = updater(prev.length === 0 ? undefined : prev)
|
|
363
|
+
const id = this.nextSnapshotId++
|
|
364
|
+
const record = { id, prev, live: true }
|
|
365
|
+
this.snapshots.push(record)
|
|
366
|
+
|
|
367
|
+
batch(() => {
|
|
368
|
+
this.pages.set(next)
|
|
369
|
+
if (this.status.peek() === 'idle' || this.status.peek() === 'pending') {
|
|
370
|
+
this.status.set('success')
|
|
371
|
+
}
|
|
372
|
+
this.lastUpdatedAt.set(Date.now())
|
|
373
|
+
this.hasPendingMutations.set(true)
|
|
374
|
+
})
|
|
375
|
+
|
|
376
|
+
return {
|
|
377
|
+
rollback: () => {
|
|
378
|
+
if (!record.live || this.disposed) return
|
|
379
|
+
record.live = false
|
|
380
|
+
batch(() => {
|
|
381
|
+
this.pages.set(record.prev)
|
|
382
|
+
this.snapshots = this.snapshots.filter((s) => s.id !== id)
|
|
383
|
+
this.hasPendingMutations.set(this.snapshots.some((s) => s.live))
|
|
384
|
+
})
|
|
385
|
+
},
|
|
386
|
+
finalize: () => {
|
|
387
|
+
if (!record.live || this.disposed) return
|
|
388
|
+
record.live = false
|
|
389
|
+
this.snapshots = this.snapshots.filter((s) => s.id !== id)
|
|
390
|
+
if (!this.snapshots.some((s) => s.live)) {
|
|
391
|
+
this.hasPendingMutations.set(false)
|
|
392
|
+
}
|
|
393
|
+
},
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
firstValue(): Promise<TPage[]> {
|
|
398
|
+
if (this.status.peek() === 'success') {
|
|
399
|
+
return Promise.resolve(this.pages.peek())
|
|
400
|
+
}
|
|
401
|
+
if (this.status.peek() === 'error') {
|
|
402
|
+
return Promise.reject(this.error.peek())
|
|
403
|
+
}
|
|
404
|
+
return new Promise<TPage[]>((resolve, reject) => {
|
|
405
|
+
const unsub = this.status.subscribe((s) => {
|
|
406
|
+
if (s === 'success') {
|
|
407
|
+
unsub()
|
|
408
|
+
resolve(this.pages.peek())
|
|
409
|
+
} else if (s === 'error') {
|
|
410
|
+
unsub()
|
|
411
|
+
reject(this.error.peek())
|
|
412
|
+
}
|
|
413
|
+
})
|
|
414
|
+
})
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
isStaleNow(): boolean {
|
|
418
|
+
const last = this.lastUpdatedAt.peek()
|
|
419
|
+
if (last === undefined) return true
|
|
420
|
+
return Date.now() - last >= this.staleTime
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
private scheduleStaleness(): void {
|
|
424
|
+
if (this.staleTimer != null) clearTimeout(this.staleTimer)
|
|
425
|
+
if (this.staleTime > 0) {
|
|
426
|
+
this.staleTimer = setTimeout(() => {
|
|
427
|
+
this.staleTimer = null
|
|
428
|
+
if (!this.disposed) this.isStale.set(true)
|
|
429
|
+
}, this.staleTime)
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
dispose(): void {
|
|
434
|
+
if (this.disposed) return
|
|
435
|
+
this.disposed = true
|
|
436
|
+
if (this.staleTimer != null) {
|
|
437
|
+
clearTimeout(this.staleTimer)
|
|
438
|
+
this.staleTimer = null
|
|
439
|
+
}
|
|
440
|
+
this.currentAbort?.abort()
|
|
441
|
+
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
|
|
450
|
+
}
|
|
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
|
+
})
|
|
462
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stable string hash of a key tuple. Two equal-by-content args produce the
|
|
3
|
+
* same string regardless of property iteration order. Handles primitives,
|
|
4
|
+
* arrays, plain objects, Date.
|
|
5
|
+
*
|
|
6
|
+
* Functions and symbols throw — keys must be serializable so distinct
|
|
7
|
+
* subscribers can share entries.
|
|
8
|
+
*/
|
|
9
|
+
export function stableHash(args: readonly unknown[]): string {
|
|
10
|
+
return JSON.stringify(args, replacer)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const replacer = (_key: string, value: unknown): unknown => {
|
|
14
|
+
if (typeof value === 'function') {
|
|
15
|
+
throw new Error('[olas] query keys cannot contain functions')
|
|
16
|
+
}
|
|
17
|
+
if (typeof value === 'symbol') {
|
|
18
|
+
throw new Error('[olas] query keys cannot contain symbols')
|
|
19
|
+
}
|
|
20
|
+
if (value === undefined) return '__undefined__'
|
|
21
|
+
if (value instanceof Date) return { __date: value.toISOString() }
|
|
22
|
+
if (value instanceof Map || value instanceof Set) {
|
|
23
|
+
throw new Error('[olas] query keys cannot contain Map/Set — use arrays/objects')
|
|
24
|
+
}
|
|
25
|
+
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
26
|
+
const sorted: Record<string, unknown> = {}
|
|
27
|
+
for (const k of Object.keys(value).sort()) {
|
|
28
|
+
sorted[k] = (value as Record<string, unknown>)[k]
|
|
29
|
+
}
|
|
30
|
+
return sorted
|
|
31
|
+
}
|
|
32
|
+
return value
|
|
33
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { effect, untracked } from '../signals'
|
|
2
|
+
import type { ReadSignal } from '../signals/types'
|
|
3
|
+
import { Entry } from './entry'
|
|
4
|
+
import type { LocalCache, Snapshot } from './types'
|
|
5
|
+
|
|
6
|
+
export type LocalCacheOptions<T> = {
|
|
7
|
+
key?: () => readonly unknown[]
|
|
8
|
+
staleTime?: number
|
|
9
|
+
keepPreviousData?: boolean
|
|
10
|
+
initialData?: T | undefined
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
class LocalCacheImpl<T> implements LocalCache<T> {
|
|
14
|
+
private readonly entry: Entry<T>
|
|
15
|
+
private keyEffectDispose: (() => void) | null = null
|
|
16
|
+
private disposed = false
|
|
17
|
+
private readonly keepPreviousData: boolean
|
|
18
|
+
private lastSucceededFor: unknown[] | null = null
|
|
19
|
+
|
|
20
|
+
constructor(fetcher: (signal: AbortSignal) => Promise<T>, options: LocalCacheOptions<T>) {
|
|
21
|
+
this.keepPreviousData = options.keepPreviousData ?? false
|
|
22
|
+
this.entry = new Entry<T>({
|
|
23
|
+
fetcher: () => fetcher,
|
|
24
|
+
staleTime: options.staleTime ?? 0,
|
|
25
|
+
initialData: options.initialData,
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
if (options.key) {
|
|
29
|
+
const keyFn = options.key
|
|
30
|
+
this.keyEffectDispose = effect(() => {
|
|
31
|
+
// Track keys.
|
|
32
|
+
const keyArgs = keyFn() as unknown[]
|
|
33
|
+
untracked(() => {
|
|
34
|
+
if (!this.keepPreviousData) {
|
|
35
|
+
// Reset data on key change so consumers see "loading" rather than
|
|
36
|
+
// the previous key's stale value.
|
|
37
|
+
if (this.lastSucceededFor != null && !arraysEqual(this.lastSucceededFor, keyArgs)) {
|
|
38
|
+
this.entry.data.set(undefined)
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
this.entry.startFetch().then(
|
|
42
|
+
() => {
|
|
43
|
+
this.lastSucceededFor = [...keyArgs]
|
|
44
|
+
},
|
|
45
|
+
() => {
|
|
46
|
+
/* error already captured on entry */
|
|
47
|
+
},
|
|
48
|
+
)
|
|
49
|
+
})
|
|
50
|
+
})
|
|
51
|
+
} else {
|
|
52
|
+
this.entry.startFetch().catch(() => {
|
|
53
|
+
/* error already captured on entry */
|
|
54
|
+
})
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
get data(): ReadSignal<T | undefined> {
|
|
59
|
+
return this.entry.data
|
|
60
|
+
}
|
|
61
|
+
get error(): ReadSignal<unknown | undefined> {
|
|
62
|
+
return this.entry.error
|
|
63
|
+
}
|
|
64
|
+
get status(): ReadSignal<'idle' | 'pending' | 'success' | 'error'> {
|
|
65
|
+
return this.entry.status
|
|
66
|
+
}
|
|
67
|
+
get isLoading(): ReadSignal<boolean> {
|
|
68
|
+
return this.entry.isLoading
|
|
69
|
+
}
|
|
70
|
+
get isFetching(): ReadSignal<boolean> {
|
|
71
|
+
return this.entry.isFetching
|
|
72
|
+
}
|
|
73
|
+
get isStale(): ReadSignal<boolean> {
|
|
74
|
+
return this.entry.isStale
|
|
75
|
+
}
|
|
76
|
+
get lastUpdatedAt(): ReadSignal<number | undefined> {
|
|
77
|
+
return this.entry.lastUpdatedAt
|
|
78
|
+
}
|
|
79
|
+
get hasPendingMutations(): ReadSignal<boolean> {
|
|
80
|
+
return this.entry.hasPendingMutations
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
refetch = (): Promise<T> => this.entry.refetch()
|
|
84
|
+
reset = (): void => this.entry.reset()
|
|
85
|
+
firstValue = (): Promise<T> => this.entry.firstValue()
|
|
86
|
+
invalidate = (): void => {
|
|
87
|
+
this.entry.invalidate().catch(() => {})
|
|
88
|
+
}
|
|
89
|
+
setData = (updater: (prev: T | undefined) => T): Snapshot => this.entry.setData(updater)
|
|
90
|
+
|
|
91
|
+
dispose(): void {
|
|
92
|
+
if (this.disposed) return
|
|
93
|
+
this.disposed = true
|
|
94
|
+
this.keyEffectDispose?.()
|
|
95
|
+
this.keyEffectDispose = null
|
|
96
|
+
this.entry.dispose()
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function createLocalCache<T>(
|
|
101
|
+
fetcher: (signal: AbortSignal) => Promise<T>,
|
|
102
|
+
options?: LocalCacheOptions<T>,
|
|
103
|
+
): LocalCache<T> {
|
|
104
|
+
return new LocalCacheImpl(fetcher, options ?? {})
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function arraysEqual(a: readonly unknown[], b: readonly unknown[]): boolean {
|
|
108
|
+
if (a.length !== b.length) return false
|
|
109
|
+
for (let i = 0; i < a.length; i++) {
|
|
110
|
+
if (!Object.is(a[i], b[i])) return false
|
|
111
|
+
}
|
|
112
|
+
return true
|
|
113
|
+
}
|