@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,934 @@
1
+ import type { DevtoolsEmitter } from '../devtools'
2
+ import { dispatchError, type ErrorHandler } from '../errors'
3
+ import { type Signal, signal } from '../signals'
4
+ import { Entry } from './entry'
5
+ import { subscribeReconnect, subscribeWindowFocus } from './focus-online'
6
+ import { InfiniteEntry, type InfiniteQuery, type InfiniteQuerySpec } from './infinite'
7
+ import { stableHash } from './keys'
8
+ import {
9
+ type GcEvent,
10
+ type InvalidateEvent,
11
+ lookupRegisteredQuery,
12
+ type QueryClientPlugin,
13
+ type QueryClientPluginApi,
14
+ type SetDataEvent,
15
+ } from './plugin'
16
+ import type { DehydratedState, Query, QuerySpec, RetryDelay, RetryPolicy, Snapshot } from './types'
17
+
18
+ const DEFAULT_GC_TIME = 5 * 60_000
19
+
20
+ type AnyQuery = Query<any, any> & {
21
+ readonly __spec: QuerySpec<any, any>
22
+ __clients: Set<QueryClient>
23
+ }
24
+
25
+ type AnyInfiniteQuery = InfiniteQuery<any, any, any> & {
26
+ readonly __spec: InfiniteQuerySpec<any, any, any, any>
27
+ __clients: Set<QueryClient>
28
+ }
29
+
30
+ export class ClientEntry<T> {
31
+ readonly entry: Entry<T>
32
+ /** The result of `spec.key(...args)` — used for hashing/identity. */
33
+ readonly keyArgs: readonly unknown[]
34
+ /** The original args the consumer passed — what the fetcher receives. */
35
+ readonly callArgs: readonly unknown[]
36
+ readonly client: QueryClient
37
+ readonly query: AnyQuery
38
+ private subscriberCount = 0
39
+ private gcTimer: ReturnType<typeof setTimeout> | null = null
40
+ private intervalTimer: ReturnType<typeof setInterval> | null = null
41
+ private unsubFocus: (() => void) | null = null
42
+ private unsubOnline: (() => void) | null = null
43
+ private gcTime: number
44
+ private refetchInterval: number | undefined
45
+ private refetchOnWindowFocus: boolean
46
+ private refetchOnReconnect: boolean
47
+
48
+ constructor(
49
+ client: QueryClient,
50
+ query: AnyQuery,
51
+ callArgs: readonly unknown[],
52
+ keyArgs: readonly unknown[],
53
+ spec: QuerySpec<any, T>,
54
+ hydrated?: { data: T; lastUpdatedAt: number },
55
+ ) {
56
+ this.client = client
57
+ this.query = query
58
+ this.callArgs = callArgs
59
+ this.keyArgs = keyArgs
60
+ this.gcTime = spec.gcTime ?? DEFAULT_GC_TIME
61
+ this.refetchInterval = spec.refetchInterval
62
+ this.refetchOnWindowFocus = spec.refetchOnWindowFocus ?? client.refetchOnWindowFocus
63
+ this.refetchOnReconnect = spec.refetchOnReconnect ?? client.refetchOnReconnect
64
+ const fetcherFn = spec.fetcher
65
+ const deps = client.deps as import('../controller/types').AmbientDeps
66
+ const devtools = client.devtools
67
+ const queryKey = this.keyArgs
68
+ this.entry = new Entry<T>({
69
+ fetcher: () => (signal) => fetcherFn({ signal, deps }, ...(callArgs as never[])),
70
+ staleTime: spec.staleTime,
71
+ retry: spec.retry as RetryPolicy | undefined,
72
+ retryDelay: spec.retryDelay as RetryDelay | undefined,
73
+ initialData: hydrated?.data,
74
+ initialUpdatedAt: hydrated?.lastUpdatedAt,
75
+ events:
76
+ __DEV__ && devtools !== undefined
77
+ ? {
78
+ onFetchStart: () => devtools.emit({ type: 'cache:fetch-start', queryKey }),
79
+ onFetchSuccess: (durationMs) =>
80
+ devtools.emit({ type: 'cache:fetch-success', queryKey, durationMs }),
81
+ onFetchError: (durationMs, error) =>
82
+ devtools.emit({
83
+ type: 'cache:fetch-error',
84
+ queryKey,
85
+ durationMs,
86
+ error,
87
+ }),
88
+ }
89
+ : undefined,
90
+ })
91
+ }
92
+
93
+ acquire(): void {
94
+ this.subscriberCount += 1
95
+ if (this.gcTimer != null) {
96
+ clearTimeout(this.gcTimer)
97
+ this.gcTimer = null
98
+ }
99
+ if (this.subscriberCount === 1) {
100
+ if (this.refetchInterval != null) this.startIntervalTimer()
101
+ if (this.refetchOnWindowFocus) {
102
+ this.unsubFocus = subscribeWindowFocus(() => this.triggerEventRefetch())
103
+ }
104
+ if (this.refetchOnReconnect) {
105
+ this.unsubOnline = subscribeReconnect(() => this.triggerEventRefetch())
106
+ }
107
+ }
108
+ }
109
+
110
+ release(): void {
111
+ this.subscriberCount -= 1
112
+ if (this.subscriberCount <= 0) {
113
+ this.stopIntervalTimer()
114
+ this.stopEventSubscriptions()
115
+ if (this.gcTime === 0) {
116
+ this.client.dropEntry(this)
117
+ } else {
118
+ this.gcTimer = setTimeout(() => {
119
+ this.gcTimer = null
120
+ this.client.dropEntry(this)
121
+ }, this.gcTime)
122
+ }
123
+ }
124
+ }
125
+
126
+ hasSubscribers(): boolean {
127
+ return this.subscriberCount > 0
128
+ }
129
+
130
+ startIntervalTimer(): void {
131
+ if (this.refetchInterval == null) return
132
+ if (this.intervalTimer != null) return
133
+ this.intervalTimer = setInterval(() => {
134
+ this.entry.startFetch().catch(() => {
135
+ /* error already captured on entry */
136
+ })
137
+ }, this.refetchInterval)
138
+ }
139
+
140
+ stopIntervalTimer(): void {
141
+ if (this.intervalTimer != null) {
142
+ clearInterval(this.intervalTimer)
143
+ this.intervalTimer = null
144
+ }
145
+ }
146
+
147
+ stopEventSubscriptions(): void {
148
+ if (this.unsubFocus != null) {
149
+ this.unsubFocus()
150
+ this.unsubFocus = null
151
+ }
152
+ if (this.unsubOnline != null) {
153
+ this.unsubOnline()
154
+ this.unsubOnline = null
155
+ }
156
+ }
157
+
158
+ /**
159
+ * Schedule a gc timer for an entry that was just created via a non-subscribing
160
+ * path (`prefetch`, `setData`, `invalidate`). Without this, those entries
161
+ * never trigger `release()` and would live until root dispose. Called by
162
+ * `QueryClient.bindEntry` right after creating a fresh entry; `acquire()`
163
+ * (e.g., from a subscriber that arrives shortly after a prefetch) clears it.
164
+ * No-op if the entry already has subscribers or a gc timer pending.
165
+ */
166
+ scheduleGcIfOrphan(): void {
167
+ if (this.subscriberCount > 0 || this.gcTimer != null) return
168
+ if (this.gcTime === 0) {
169
+ // Defer one microtask so the current caller (e.g. a `setData` that
170
+ // writes then expects to read back in the same tick) sees the entry.
171
+ queueMicrotask(() => {
172
+ if (this.subscriberCount === 0 && this.gcTimer == null) {
173
+ this.client.dropEntry(this)
174
+ }
175
+ })
176
+ return
177
+ }
178
+ this.gcTimer = setTimeout(() => {
179
+ this.gcTimer = null
180
+ this.client.dropEntry(this)
181
+ }, this.gcTime)
182
+ }
183
+
184
+ /** Refetch on focus / reconnect, but only if the data is actually stale. */
185
+ private triggerEventRefetch(): void {
186
+ if (!this.entry.isStaleNow()) return
187
+ this.entry.startFetch().catch(() => {
188
+ /* error already captured on entry */
189
+ })
190
+ }
191
+
192
+ dispose(): void {
193
+ if (this.gcTimer != null) {
194
+ clearTimeout(this.gcTimer)
195
+ this.gcTimer = null
196
+ }
197
+ this.stopIntervalTimer()
198
+ this.stopEventSubscriptions()
199
+ this.entry.dispose()
200
+ }
201
+ }
202
+
203
+ export class InfiniteClientEntry<TPage, TItem, PageParam> {
204
+ readonly entry: InfiniteEntry<TPage, TItem, PageParam>
205
+ readonly keyArgs: readonly unknown[]
206
+ readonly callArgs: readonly unknown[]
207
+ readonly client: QueryClient
208
+ readonly query: AnyInfiniteQuery
209
+ private subscriberCount = 0
210
+ private gcTimer: ReturnType<typeof setTimeout> | null = null
211
+ private intervalTimer: ReturnType<typeof setInterval> | null = null
212
+ private gcTime: number
213
+ private refetchInterval: number | undefined
214
+
215
+ constructor(
216
+ client: QueryClient,
217
+ query: AnyInfiniteQuery,
218
+ callArgs: readonly unknown[],
219
+ keyArgs: readonly unknown[],
220
+ spec: InfiniteQuerySpec<any, PageParam, TPage, TItem>,
221
+ ) {
222
+ this.client = client
223
+ this.query = query
224
+ this.callArgs = callArgs
225
+ this.keyArgs = keyArgs
226
+ this.gcTime = spec.gcTime ?? DEFAULT_GC_TIME
227
+ this.refetchInterval = spec.refetchInterval
228
+ const fetcherFn = spec.fetcher
229
+ const deps = client.deps as import('../controller/types').AmbientDeps
230
+ this.entry = new InfiniteEntry<TPage, TItem, PageParam>({
231
+ fetcher: ({ pageParam, signal }) =>
232
+ fetcherFn({ pageParam, signal, deps }, ...(callArgs as never[])),
233
+ initialPageParam: spec.initialPageParam,
234
+ getNextPageParam: spec.getNextPageParam,
235
+ getPreviousPageParam: spec.getPreviousPageParam,
236
+ itemsOf: spec.itemsOf,
237
+ staleTime: spec.staleTime,
238
+ retry: spec.retry as RetryPolicy | undefined,
239
+ retryDelay: spec.retryDelay as RetryDelay | undefined,
240
+ })
241
+ }
242
+
243
+ acquire(): void {
244
+ this.subscriberCount += 1
245
+ if (this.gcTimer != null) {
246
+ clearTimeout(this.gcTimer)
247
+ this.gcTimer = null
248
+ }
249
+ if (this.subscriberCount === 1 && this.refetchInterval != null) {
250
+ this.startIntervalTimer()
251
+ }
252
+ }
253
+
254
+ release(): void {
255
+ this.subscriberCount -= 1
256
+ if (this.subscriberCount <= 0) {
257
+ this.stopIntervalTimer()
258
+ if (this.gcTime === 0) {
259
+ this.client.dropInfiniteEntry(
260
+ this as unknown as InfiniteClientEntry<unknown, unknown, unknown>,
261
+ )
262
+ } else {
263
+ this.gcTimer = setTimeout(() => {
264
+ this.gcTimer = null
265
+ this.client.dropInfiniteEntry(
266
+ this as unknown as InfiniteClientEntry<unknown, unknown, unknown>,
267
+ )
268
+ }, this.gcTime)
269
+ }
270
+ }
271
+ }
272
+
273
+ private startIntervalTimer(): void {
274
+ if (this.refetchInterval == null || this.intervalTimer != null) return
275
+ this.intervalTimer = setInterval(() => {
276
+ this.entry.startFetch().catch(() => {
277
+ /* error captured on entry */
278
+ })
279
+ }, this.refetchInterval)
280
+ }
281
+
282
+ private stopIntervalTimer(): void {
283
+ if (this.intervalTimer != null) {
284
+ clearInterval(this.intervalTimer)
285
+ this.intervalTimer = null
286
+ }
287
+ }
288
+
289
+ /** See `ClientEntry.scheduleGcIfOrphan`. */
290
+ scheduleGcIfOrphan(): void {
291
+ if (this.subscriberCount > 0 || this.gcTimer != null) return
292
+ if (this.gcTime === 0) {
293
+ queueMicrotask(() => {
294
+ if (this.subscriberCount === 0 && this.gcTimer == null) {
295
+ this.client.dropInfiniteEntry(
296
+ this as unknown as InfiniteClientEntry<unknown, unknown, unknown>,
297
+ )
298
+ }
299
+ })
300
+ return
301
+ }
302
+ this.gcTimer = setTimeout(() => {
303
+ this.gcTimer = null
304
+ this.client.dropInfiniteEntry(
305
+ this as unknown as InfiniteClientEntry<unknown, unknown, unknown>,
306
+ )
307
+ }, this.gcTime)
308
+ }
309
+
310
+ dispose(): void {
311
+ if (this.gcTimer != null) {
312
+ clearTimeout(this.gcTimer)
313
+ this.gcTimer = null
314
+ }
315
+ this.stopIntervalTimer()
316
+ this.entry.dispose()
317
+ }
318
+ }
319
+
320
+ /**
321
+ * Per-root entry registry. Owns the keyed `Map<hash, ClientEntry>` per query,
322
+ * GC timers, refetch-interval timers. Subscribers are routed in/out via
323
+ * `acquire` / `release`.
324
+ */
325
+ export class QueryClient {
326
+ private readonly maps = new Map<AnyQuery, Map<string, ClientEntry<unknown>>>()
327
+ private readonly infiniteMaps = new Map<
328
+ AnyInfiniteQuery,
329
+ Map<string, InfiniteClientEntry<unknown, unknown, unknown>>
330
+ >()
331
+ private readonly touchedQueries = new Set<AnyQuery>()
332
+ private readonly touchedInfiniteQueries = new Set<AnyInfiniteQuery>()
333
+ private readonly hydratedData = new Map<string, { data: unknown; lastUpdatedAt: number }>()
334
+ /** Mutations inflight across the whole root — used by `waitForIdle`. */
335
+ readonly mutationsInflight$: Signal<number> = signal(0)
336
+ private onError: ErrorHandler | undefined
337
+ private disposed = false
338
+ /** Devtools bus, if any — passed by `createRoot`. Used to emit cache events. */
339
+ readonly devtools: DevtoolsEmitter | undefined
340
+
341
+ /** Root-level deps; passed to every `QuerySpec.fetcher` via `FetchCtx`. */
342
+ readonly deps: Record<string, unknown>
343
+
344
+ /** Root-wide defaults for refetch triggers; per-query spec overrides win. Spec §5.9. */
345
+ readonly refetchOnWindowFocus: boolean
346
+ readonly refetchOnReconnect: boolean
347
+
348
+ /**
349
+ * Installed plugins. Fired on every `setData` / `invalidate` / `gc` so
350
+ * cross-tab / persistence-like layers can observe and react. SPEC §13.2.
351
+ */
352
+ private readonly plugins: QueryClientPlugin[]
353
+ /**
354
+ * Flipped to `true` while a remote-originated write (via
355
+ * `applyRemoteSetData` / `applyRemoteInvalidate`) is being applied. The
356
+ * resulting plugin events carry `isRemote: true` so plugins know to skip
357
+ * rebroadcast.
358
+ */
359
+ private applyingRemote = false
360
+
361
+ constructor(opts?: {
362
+ onError?: ErrorHandler
363
+ hydrate?: DehydratedState
364
+ devtools?: DevtoolsEmitter
365
+ deps?: Record<string, unknown>
366
+ refetchOnWindowFocus?: boolean
367
+ refetchOnReconnect?: boolean
368
+ plugins?: QueryClientPlugin[]
369
+ }) {
370
+ this.onError = opts?.onError
371
+ this.devtools = opts?.devtools
372
+ this.deps = opts?.deps ?? {}
373
+ this.refetchOnWindowFocus = opts?.refetchOnWindowFocus ?? false
374
+ this.refetchOnReconnect = opts?.refetchOnReconnect ?? false
375
+ this.plugins = opts?.plugins ?? []
376
+ if (opts?.hydrate) this.hydrate(opts.hydrate)
377
+ const api = this.makePluginApi()
378
+ for (const plugin of this.plugins) {
379
+ this.callPlugin(() => plugin.init?.(api))
380
+ }
381
+ }
382
+
383
+ /**
384
+ * Build the `QueryClientPluginApi` view that plugins receive at `init`
385
+ * time. Closes over `this`; safe to hand out — plugins call back through
386
+ * these methods to push remote-originated writes into the local cache.
387
+ */
388
+ private makePluginApi(): QueryClientPluginApi {
389
+ const self = this
390
+ return {
391
+ applyRemoteSetData(queryId, keyArgs, data) {
392
+ self.applyRemoteSetData(queryId, keyArgs, data)
393
+ },
394
+ applyRemoteInvalidate(queryId, keyArgs) {
395
+ self.applyRemoteInvalidate(queryId, keyArgs)
396
+ },
397
+ subscribedKeys(queryId) {
398
+ return self.subscribedKeysFor(queryId)
399
+ },
400
+ }
401
+ }
402
+
403
+ /** Invoke a plugin callback; route exceptions through `onError`. */
404
+ private callPlugin(fn: () => void): void {
405
+ try {
406
+ fn()
407
+ } catch (err) {
408
+ dispatchError(this.onError, err, {
409
+ kind: 'plugin',
410
+ controllerPath: [],
411
+ })
412
+ }
413
+ }
414
+
415
+ private emitSetData(
416
+ query: AnyQuery | AnyInfiniteQuery,
417
+ keyArgs: readonly unknown[],
418
+ data: unknown,
419
+ kind: 'data' | 'infinite',
420
+ ): void {
421
+ if (this.plugins.length === 0) return
422
+ const queryId = query.__spec.queryId
423
+ if (queryId == null) return
424
+ const event: SetDataEvent = {
425
+ queryId,
426
+ keyArgs,
427
+ data,
428
+ kind,
429
+ isRemote: this.applyingRemote,
430
+ }
431
+ for (const plugin of this.plugins) {
432
+ if (plugin.onSetData) {
433
+ const cb = plugin.onSetData
434
+ this.callPlugin(() => cb.call(plugin, event))
435
+ }
436
+ }
437
+ }
438
+
439
+ private emitInvalidate(
440
+ query: AnyQuery | AnyInfiniteQuery,
441
+ keyArgs: readonly unknown[],
442
+ kind: 'data' | 'infinite',
443
+ ): void {
444
+ if (this.plugins.length === 0) return
445
+ const queryId = query.__spec.queryId
446
+ if (queryId == null) return
447
+ const event: InvalidateEvent = {
448
+ queryId,
449
+ keyArgs,
450
+ kind,
451
+ isRemote: this.applyingRemote,
452
+ }
453
+ for (const plugin of this.plugins) {
454
+ if (plugin.onInvalidate) {
455
+ const cb = plugin.onInvalidate
456
+ this.callPlugin(() => cb.call(plugin, event))
457
+ }
458
+ }
459
+ }
460
+
461
+ private emitGc(
462
+ query: AnyQuery | AnyInfiniteQuery,
463
+ keyArgs: readonly unknown[],
464
+ kind: 'data' | 'infinite',
465
+ ): void {
466
+ if (this.plugins.length === 0) return
467
+ const queryId = query.__spec.queryId
468
+ if (queryId == null) return
469
+ const event: GcEvent = { queryId, keyArgs, kind }
470
+ for (const plugin of this.plugins) {
471
+ if (plugin.onGc) {
472
+ const cb = plugin.onGc
473
+ this.callPlugin(() => cb.call(plugin, event))
474
+ }
475
+ }
476
+ }
477
+
478
+ /** Resolve `queryId → live entry-map keys`. Empty array when unknown. */
479
+ private subscribedKeysFor(queryId: string): readonly (readonly unknown[])[] {
480
+ // Defer the registry lookup to avoid an eager circular import — `define.ts`
481
+ // imports `QueryClient` as a type, and we import the registry helper here
482
+ // for runtime use only.
483
+ const query = lookupRegisteredQuery(queryId)
484
+ if (!query) return []
485
+ const out: (readonly unknown[])[] = []
486
+ if (query.__olas === 'query') {
487
+ const map = this.maps.get(query as unknown as AnyQuery)
488
+ if (map) for (const ce of map.values()) out.push(ce.keyArgs)
489
+ } else {
490
+ const map = this.infiniteMaps.get(query as unknown as AnyInfiniteQuery)
491
+ if (map) for (const ce of map.values()) out.push(ce.keyArgs)
492
+ }
493
+ return out
494
+ }
495
+
496
+ /**
497
+ * Apply a remote-originated `setData` for the query identified by
498
+ * `queryId`, scoped to the entry already keyed by `keyArgs` in this
499
+ * client. Goes through the underlying `Entry.setData` so subscribers see
500
+ * the write; plugin `onSetData` fires with `isRemote: true`.
501
+ *
502
+ * Drops silently when:
503
+ * - No query with that id is registered (the receiving tab hasn't
504
+ * imported the module that defined it).
505
+ * - The registered query is an infinite query (cross-tab infinite sync
506
+ * is deferred — see `plugin.ts` `SetDataEvent.kind`).
507
+ * - No local entry exists for that key (the receiving tab isn't
508
+ * subscribed; nothing useful to write to without callArgs for a
509
+ * future refetch).
510
+ */
511
+ applyRemoteSetData(queryId: string, keyArgs: readonly unknown[], data: unknown): void {
512
+ const query = lookupRegisteredQuery(queryId)
513
+ if (!query) return
514
+ if (query.__olas !== 'query') return // infinite — deferred for v1
515
+ const internal = query as unknown as AnyQuery
516
+ const map = this.maps.get(internal)
517
+ if (!map) return
518
+ const hash = stableHash(keyArgs)
519
+ const entry = map.get(hash)
520
+ if (!entry) return
521
+ this.applyingRemote = true
522
+ try {
523
+ entry.entry.setData(() => data as never)
524
+ this.emitSetData(internal, entry.keyArgs, data, 'data')
525
+ } finally {
526
+ this.applyingRemote = false
527
+ }
528
+ }
529
+
530
+ applyRemoteInvalidate(queryId: string, keyArgs: readonly unknown[]): void {
531
+ const query = lookupRegisteredQuery(queryId)
532
+ if (!query) return
533
+ if (query.__olas !== 'query') return // infinite — deferred for v1
534
+ const internal = query as unknown as AnyQuery
535
+ const map = this.maps.get(internal)
536
+ if (!map) return
537
+ const hash = stableHash(keyArgs)
538
+ const entry = map.get(hash)
539
+ if (!entry) return
540
+ this.applyingRemote = true
541
+ try {
542
+ // Emit AFTER kicking off invalidate so plugins reading entry state see
543
+ // post-invalidation values, mirroring setData's emit-after-write order.
544
+ entry.entry.invalidate().catch((err) => {
545
+ dispatchError(this.onError, err, {
546
+ kind: 'cache',
547
+ controllerPath: [],
548
+ queryKey: entry.keyArgs,
549
+ })
550
+ })
551
+ this.emitInvalidate(internal, entry.keyArgs, 'data')
552
+ } finally {
553
+ this.applyingRemote = false
554
+ }
555
+ }
556
+
557
+ hydrate(state: DehydratedState): void {
558
+ if (state.version !== 1) return
559
+ for (const entry of state.entries) {
560
+ const hash = stableHash(entry.key)
561
+ this.hydratedData.set(hash, {
562
+ data: entry.data,
563
+ lastUpdatedAt: entry.lastUpdatedAt,
564
+ })
565
+ }
566
+ }
567
+
568
+ /**
569
+ * Snapshot every live cache entry (regular + infinite) as a flat list of
570
+ * `DebugCacheEntry`. Exposed via `root.__debug.queryEntries()` for the
571
+ * devtools cache inspector — shows current data and state, not past
572
+ * fetch events. Spec §20.9.
573
+ */
574
+ queryEntriesSnapshot(): import('../devtools').DebugCacheEntry[] {
575
+ const out: import('../devtools').DebugCacheEntry[] = []
576
+ for (const map of this.maps.values()) {
577
+ for (const ce of map.values()) {
578
+ out.push({
579
+ key: ce.keyArgs as readonly unknown[],
580
+ status: ce.entry.status.peek(),
581
+ data: ce.entry.data.peek(),
582
+ error: ce.entry.error.peek(),
583
+ lastUpdatedAt: ce.entry.lastUpdatedAt.peek(),
584
+ isStale: ce.entry.isStale.peek(),
585
+ isFetching: ce.entry.isFetching.peek(),
586
+ hasPendingMutations: ce.entry.hasPendingMutations.peek(),
587
+ })
588
+ }
589
+ }
590
+ for (const map of this.infiniteMaps.values()) {
591
+ for (const ce of map.values()) {
592
+ out.push({
593
+ key: ce.keyArgs as readonly unknown[],
594
+ status: ce.entry.status.peek(),
595
+ // Infinite entries carry an array of pages; expose them verbatim.
596
+ data: ce.entry.pages.peek(),
597
+ error: ce.entry.error.peek(),
598
+ lastUpdatedAt: ce.entry.lastUpdatedAt.peek(),
599
+ isStale: ce.entry.isStale.peek(),
600
+ isFetching: ce.entry.isFetching.peek(),
601
+ hasPendingMutations: ce.entry.hasPendingMutations.peek(),
602
+ })
603
+ }
604
+ }
605
+ return out
606
+ }
607
+
608
+ dehydrate(): DehydratedState {
609
+ const entries: DehydratedState['entries'] = []
610
+ for (const map of this.maps.values()) {
611
+ for (const ce of map.values()) {
612
+ if (ce.entry.status.peek() === 'success') {
613
+ entries.push({
614
+ key: ce.keyArgs,
615
+ data: ce.entry.data.peek(),
616
+ lastUpdatedAt: ce.entry.lastUpdatedAt.peek() ?? Date.now(),
617
+ })
618
+ }
619
+ }
620
+ }
621
+ return { version: 1, entries }
622
+ }
623
+
624
+ async waitForIdle(): Promise<void> {
625
+ for (let safety = 0; safety < 100; safety++) {
626
+ const tasks: Promise<void>[] = []
627
+ for (const map of this.maps.values()) {
628
+ for (const ce of map.values()) {
629
+ if (ce.entry.isFetching.peek()) {
630
+ tasks.push(waitUntilFalse(ce.entry.isFetching))
631
+ }
632
+ }
633
+ }
634
+ for (const map of this.infiniteMaps.values()) {
635
+ for (const ce of map.values()) {
636
+ if (ce.entry.isFetching.peek()) {
637
+ tasks.push(waitUntilFalse(ce.entry.isFetching))
638
+ }
639
+ }
640
+ }
641
+ if (this.mutationsInflight$.peek() > 0) {
642
+ tasks.push(
643
+ new Promise<void>((resolve) => {
644
+ const unsub = this.mutationsInflight$.subscribe((v) => {
645
+ if (v === 0) {
646
+ unsub()
647
+ resolve()
648
+ }
649
+ })
650
+ }),
651
+ )
652
+ }
653
+ if (tasks.length === 0) return
654
+ await Promise.all(tasks)
655
+ }
656
+ }
657
+
658
+ bindEntry<Args extends unknown[], T>(query: Query<Args, T>, args: Args): ClientEntry<T> {
659
+ const internal = query as AnyQuery
660
+ let map = this.maps.get(internal)
661
+ if (!map) {
662
+ map = new Map()
663
+ this.maps.set(internal, map)
664
+ this.touchedQueries.add(internal)
665
+ internal.__clients.add(this)
666
+ }
667
+ const keyArgs = internal.__spec.key(...args)
668
+ const hash = stableHash(keyArgs)
669
+ let entry = map.get(hash) as ClientEntry<T> | undefined
670
+ if (!entry) {
671
+ const hydrated = this.hydratedData.get(hash) as { data: T; lastUpdatedAt: number } | undefined
672
+ if (hydrated) this.hydratedData.delete(hash)
673
+ entry = new ClientEntry<T>(this, internal, args, keyArgs, internal.__spec, hydrated)
674
+ map.set(hash, entry as ClientEntry<unknown>)
675
+ // The entry is created without an immediate subscriber (callers like
676
+ // `prefetch`/`setData`/`invalidate` reach `bindEntry` first; subscribing
677
+ // callers then call `acquire()` right after, which clears the gc timer).
678
+ entry.scheduleGcIfOrphan()
679
+ }
680
+ return entry
681
+ }
682
+
683
+ dropEntry(entry: ClientEntry<unknown>): void {
684
+ const map = this.maps.get(entry.query)
685
+ if (!map) return
686
+ const hash = stableHash(entry.keyArgs)
687
+ if (map.get(hash) !== entry) return
688
+ map.delete(hash)
689
+ entry.dispose()
690
+ if (map.size === 0) {
691
+ this.maps.delete(entry.query)
692
+ }
693
+ if (__DEV__) {
694
+ this.devtools?.emit({ type: 'cache:gc', queryKey: entry.keyArgs })
695
+ }
696
+ this.emitGc(entry.query, entry.keyArgs, 'data')
697
+ }
698
+
699
+ invalidate<Args extends unknown[]>(query: Query<Args, any>, args: Args): void {
700
+ const internal = query as AnyQuery
701
+ const map = this.maps.get(internal)
702
+ if (!map) return
703
+ const keyArgs = internal.__spec.key(...args)
704
+ const hash = stableHash(keyArgs)
705
+ const entry = map.get(hash)
706
+ if (!entry) return
707
+ if (__DEV__) {
708
+ this.devtools?.emit({ type: 'cache:invalidated', queryKey: keyArgs })
709
+ }
710
+ entry.entry.invalidate().catch((err) => {
711
+ dispatchError(this.onError, err, {
712
+ kind: 'cache',
713
+ controllerPath: [],
714
+ queryKey: keyArgs,
715
+ })
716
+ })
717
+ this.emitInvalidate(internal, keyArgs, 'data')
718
+ }
719
+
720
+ invalidateAll(query: Query<any, any>): void {
721
+ const internal = query as AnyQuery
722
+ const map = this.maps.get(internal)
723
+ if (!map) return
724
+ for (const [hash, entry] of map) {
725
+ void hash
726
+ if (__DEV__) {
727
+ this.devtools?.emit({ type: 'cache:invalidated', queryKey: entry.keyArgs })
728
+ }
729
+ entry.entry.invalidate().catch((err) => {
730
+ dispatchError(this.onError, err, {
731
+ kind: 'cache',
732
+ controllerPath: [],
733
+ queryKey: entry.keyArgs,
734
+ })
735
+ })
736
+ this.emitInvalidate(internal, entry.keyArgs, 'data')
737
+ }
738
+ }
739
+
740
+ setData<Args extends unknown[], T>(
741
+ query: Query<Args, T>,
742
+ args: Args,
743
+ updater: (prev: T | undefined) => T,
744
+ ): Snapshot {
745
+ const entry = this.bindEntry(query, args)
746
+ const snapshot = entry.entry.setData(updater)
747
+ // Read the post-update value to broadcast — plugins want the new state,
748
+ // not the updater function (which would be uncloneable across
749
+ // BroadcastChannel).
750
+ this.emitSetData(entry.query, entry.keyArgs, entry.entry.data.peek(), 'data')
751
+ return snapshot
752
+ }
753
+
754
+ bindInfiniteEntry<Args extends unknown[], TPage, TItem>(
755
+ query: InfiniteQuery<Args, TPage, TItem>,
756
+ args: Args,
757
+ ): InfiniteClientEntry<TPage, TItem, unknown> {
758
+ const internal = query as AnyInfiniteQuery
759
+ let map = this.infiniteMaps.get(internal)
760
+ if (!map) {
761
+ map = new Map()
762
+ this.infiniteMaps.set(internal, map)
763
+ this.touchedInfiniteQueries.add(internal)
764
+ internal.__clients.add(this)
765
+ }
766
+ const keyArgs = internal.__spec.key(...args)
767
+ const hash = stableHash(keyArgs)
768
+ let entry = map.get(hash) as InfiniteClientEntry<TPage, TItem, unknown> | undefined
769
+ if (!entry) {
770
+ entry = new InfiniteClientEntry<TPage, TItem, unknown>(
771
+ this,
772
+ internal,
773
+ args,
774
+ keyArgs,
775
+ internal.__spec,
776
+ )
777
+ map.set(hash, entry as InfiniteClientEntry<unknown, unknown, unknown>)
778
+ entry.scheduleGcIfOrphan()
779
+ }
780
+ return entry
781
+ }
782
+
783
+ dropInfiniteEntry(entry: InfiniteClientEntry<unknown, unknown, unknown>): void {
784
+ const map = this.infiniteMaps.get(entry.query)
785
+ if (!map) return
786
+ const hash = stableHash(entry.keyArgs)
787
+ if (map.get(hash) !== entry) return
788
+ map.delete(hash)
789
+ entry.dispose()
790
+ if (map.size === 0) {
791
+ this.infiniteMaps.delete(entry.query)
792
+ }
793
+ this.emitGc(entry.query, entry.keyArgs, 'infinite')
794
+ }
795
+
796
+ invalidateInfinite<Args extends unknown[]>(
797
+ query: InfiniteQuery<Args, any, any>,
798
+ args: Args,
799
+ ): void {
800
+ const internal = query as AnyInfiniteQuery
801
+ const map = this.infiniteMaps.get(internal)
802
+ if (!map) return
803
+ const keyArgs = internal.__spec.key(...args)
804
+ const hash = stableHash(keyArgs)
805
+ const entry = map.get(hash)
806
+ if (!entry) return
807
+ entry.entry.invalidate().catch((err) => {
808
+ dispatchError(this.onError, err, {
809
+ kind: 'cache',
810
+ controllerPath: [],
811
+ queryKey: entry.keyArgs,
812
+ })
813
+ })
814
+ this.emitInvalidate(internal, keyArgs, 'infinite')
815
+ }
816
+
817
+ invalidateAllInfinite(query: InfiniteQuery<any, any, any>): void {
818
+ const internal = query as AnyInfiniteQuery
819
+ const map = this.infiniteMaps.get(internal)
820
+ if (!map) return
821
+ for (const entry of map.values()) {
822
+ entry.entry.invalidate().catch((err) => {
823
+ dispatchError(this.onError, err, {
824
+ kind: 'cache',
825
+ controllerPath: [],
826
+ queryKey: entry.keyArgs,
827
+ })
828
+ })
829
+ this.emitInvalidate(internal, entry.keyArgs, 'infinite')
830
+ }
831
+ }
832
+
833
+ setInfiniteData<Args extends unknown[], TPage>(
834
+ query: InfiniteQuery<Args, TPage, any>,
835
+ args: Args,
836
+ updater: (prev: TPage[] | undefined) => TPage[],
837
+ ): Snapshot {
838
+ const entry = this.bindInfiniteEntry(query, args)
839
+ const snapshot = entry.entry.setData(updater)
840
+ this.emitSetData(entry.query, entry.keyArgs, entry.entry.pages.peek(), 'infinite')
841
+ return snapshot
842
+ }
843
+
844
+ prefetchInfinite<Args extends unknown[], TPage>(
845
+ query: InfiniteQuery<Args, TPage, any>,
846
+ args: Args,
847
+ ): Promise<TPage> {
848
+ const entry = this.bindInfiniteEntry(query, args)
849
+ // Acquire/release wraps the fetch so the entry isn't gc'd mid-flight by
850
+ // the orphan-gc timer scheduled in `bindInfiniteEntry`.
851
+ entry.acquire()
852
+ const promise = (async () => {
853
+ const status = entry.entry.status.peek()
854
+ if (status === 'success' && !entry.entry.isStaleNow()) {
855
+ return entry.entry.pages.peek()[0] as TPage
856
+ }
857
+ return entry.entry.startFetch()
858
+ })()
859
+ return promise.finally(() => entry.release())
860
+ }
861
+
862
+ prefetch<Args extends unknown[], T>(query: Query<Args, T>, args: Args): Promise<T> {
863
+ const entry = this.bindEntry(query, args)
864
+ entry.acquire()
865
+ const promise = (async () => {
866
+ const status = entry.entry.status.peek()
867
+ if (status === 'success' && !entry.entry.isStaleNow()) {
868
+ return entry.entry.data.peek() as T
869
+ }
870
+ if (entry.entry.isFetching.peek()) {
871
+ return entry.entry.firstValue()
872
+ }
873
+ return entry.entry.startFetch()
874
+ })()
875
+ return promise.finally(() => entry.release())
876
+ }
877
+
878
+ inflightCount(): number {
879
+ let count = 0
880
+ for (const [, map] of this.maps) {
881
+ for (const [, entry] of map) {
882
+ if (entry.entry.isFetching.peek()) count++
883
+ }
884
+ }
885
+ return count
886
+ }
887
+
888
+ dispose(): void {
889
+ if (this.disposed) return
890
+ this.disposed = true
891
+ for (const map of this.maps.values()) {
892
+ for (const entry of map.values()) {
893
+ entry.dispose()
894
+ }
895
+ }
896
+ this.maps.clear()
897
+ for (const map of this.infiniteMaps.values()) {
898
+ for (const entry of map.values()) {
899
+ entry.dispose()
900
+ }
901
+ }
902
+ this.infiniteMaps.clear()
903
+ for (const q of this.touchedQueries) {
904
+ q.__clients.delete(this)
905
+ }
906
+ this.touchedQueries.clear()
907
+ for (const q of this.touchedInfiniteQueries) {
908
+ q.__clients.delete(this)
909
+ }
910
+ this.touchedInfiniteQueries.clear()
911
+ this.hydratedData.clear()
912
+ for (const plugin of this.plugins) {
913
+ if (plugin.dispose) {
914
+ const cb = plugin.dispose
915
+ this.callPlugin(() => cb.call(plugin))
916
+ }
917
+ }
918
+ }
919
+ }
920
+
921
+ function waitUntilFalse(sig: {
922
+ peek(): boolean
923
+ subscribe(h: (v: boolean) => void): () => void
924
+ }): Promise<void> {
925
+ if (!sig.peek()) return Promise.resolve()
926
+ return new Promise<void>((resolve) => {
927
+ const unsub = sig.subscribe((v) => {
928
+ if (!v) {
929
+ unsub()
930
+ resolve()
931
+ }
932
+ })
933
+ })
934
+ }