@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.
Files changed (66) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +64 -0
  3. package/dist/index.cjs +363 -0
  4. package/dist/index.cjs.map +1 -0
  5. package/dist/index.d.cts +178 -0
  6. package/dist/index.d.cts.map +1 -0
  7. package/dist/index.d.mts +178 -0
  8. package/dist/index.d.mts.map +1 -0
  9. package/dist/index.mjs +339 -0
  10. package/dist/index.mjs.map +1 -0
  11. package/dist/root-BImHnGj1.mjs +3270 -0
  12. package/dist/root-BImHnGj1.mjs.map +1 -0
  13. package/dist/root-Bazp5_Ik.cjs +3347 -0
  14. package/dist/root-Bazp5_Ik.cjs.map +1 -0
  15. package/dist/testing.cjs +81 -0
  16. package/dist/testing.cjs.map +1 -0
  17. package/dist/testing.d.cts +56 -0
  18. package/dist/testing.d.cts.map +1 -0
  19. package/dist/testing.d.mts +56 -0
  20. package/dist/testing.d.mts.map +1 -0
  21. package/dist/testing.mjs +78 -0
  22. package/dist/testing.mjs.map +1 -0
  23. package/dist/types-CAMgqCMz.d.mts +816 -0
  24. package/dist/types-CAMgqCMz.d.mts.map +1 -0
  25. package/dist/types-emq_lZd7.d.cts +816 -0
  26. package/dist/types-emq_lZd7.d.cts.map +1 -0
  27. package/package.json +47 -0
  28. package/src/__dev__.d.ts +8 -0
  29. package/src/controller/define.ts +50 -0
  30. package/src/controller/index.ts +12 -0
  31. package/src/controller/instance.ts +499 -0
  32. package/src/controller/root.ts +160 -0
  33. package/src/controller/types.ts +195 -0
  34. package/src/devtools.ts +0 -0
  35. package/src/emitter.ts +79 -0
  36. package/src/errors.ts +49 -0
  37. package/src/forms/field.ts +303 -0
  38. package/src/forms/form-types.ts +130 -0
  39. package/src/forms/form.ts +640 -0
  40. package/src/forms/index.ts +2 -0
  41. package/src/forms/types.ts +1 -0
  42. package/src/forms/validators.ts +70 -0
  43. package/src/index.ts +89 -0
  44. package/src/query/client.ts +934 -0
  45. package/src/query/define.ts +154 -0
  46. package/src/query/entry.ts +322 -0
  47. package/src/query/focus-online.ts +73 -0
  48. package/src/query/index.ts +3 -0
  49. package/src/query/infinite.ts +462 -0
  50. package/src/query/keys.ts +33 -0
  51. package/src/query/local.ts +113 -0
  52. package/src/query/mutation.ts +384 -0
  53. package/src/query/plugin.ts +135 -0
  54. package/src/query/types.ts +168 -0
  55. package/src/query/use.ts +321 -0
  56. package/src/scope.ts +42 -0
  57. package/src/selection.ts +146 -0
  58. package/src/signals/index.ts +3 -0
  59. package/src/signals/readonly.ts +22 -0
  60. package/src/signals/runtime.ts +115 -0
  61. package/src/signals/types.ts +31 -0
  62. package/src/testing.ts +142 -0
  63. package/src/timing/debounced.ts +32 -0
  64. package/src/timing/index.ts +2 -0
  65. package/src/timing/throttled.ts +46 -0
  66. 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
+ }