@kontsedal/olas-core 0.0.5 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/dist/index.cjs +0 -0
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.d.cts +109 -27
  4. package/dist/index.d.cts.map +1 -1
  5. package/dist/index.d.mts +109 -27
  6. package/dist/index.d.mts.map +1 -1
  7. package/dist/index.mjs +0 -0
  8. package/dist/index.mjs.map +1 -1
  9. package/dist/{root-DqWolle_.mjs → root-Byq-QYTp.mjs} +1478 -347
  10. package/dist/root-Byq-QYTp.mjs.map +1 -0
  11. package/dist/{root-lBp7qziQ.cjs → root-CPSL5kpR.cjs} +1483 -346
  12. package/dist/root-CPSL5kpR.cjs.map +1 -0
  13. package/dist/testing.cjs +5 -1
  14. package/dist/testing.cjs.map +1 -1
  15. package/dist/testing.d.cts +3 -2
  16. package/dist/testing.d.cts.map +1 -1
  17. package/dist/testing.d.mts +3 -2
  18. package/dist/testing.d.mts.map +1 -1
  19. package/dist/testing.mjs +5 -2
  20. package/dist/testing.mjs.map +1 -1
  21. package/dist/{types-BH1o6nYa.d.mts → types-Brp-4WaZ.d.mts} +244 -23
  22. package/dist/types-Brp-4WaZ.d.mts.map +1 -0
  23. package/dist/{types-C4Vtkxbn.d.cts → types-DzDIypPo.d.cts} +244 -23
  24. package/dist/types-DzDIypPo.d.cts.map +1 -0
  25. package/package.json +4 -1
  26. package/src/controller/instance.ts +360 -94
  27. package/src/controller/root.ts +58 -30
  28. package/src/controller/types.ts +66 -4
  29. package/src/emitter.ts +8 -2
  30. package/src/errors.ts +39 -3
  31. package/src/forms/field.ts +219 -25
  32. package/src/forms/form-types.ts +16 -3
  33. package/src/forms/form.ts +360 -38
  34. package/src/forms/index.ts +3 -1
  35. package/src/forms/types.ts +27 -1
  36. package/src/forms/validators.ts +45 -11
  37. package/src/index.ts +13 -7
  38. package/src/query/client.ts +237 -58
  39. package/src/query/define.ts +0 -0
  40. package/src/query/entry.ts +259 -15
  41. package/src/query/focus-online.ts +53 -11
  42. package/src/query/infinite.ts +351 -47
  43. package/src/query/keys.ts +33 -2
  44. package/src/query/local.ts +3 -0
  45. package/src/query/mutation.ts +27 -6
  46. package/src/query/plugin.ts +43 -4
  47. package/src/query/types.ts +76 -6
  48. package/src/query/use.ts +53 -10
  49. package/src/selection.ts +49 -12
  50. package/src/signals/readonly.ts +3 -0
  51. package/src/signals/runtime.ts +27 -0
  52. package/src/signals/types.ts +10 -0
  53. package/src/testing.ts +9 -0
  54. package/src/timing/debounced.ts +106 -23
  55. package/src/timing/index.ts +1 -1
  56. package/src/timing/throttled.ts +85 -28
  57. package/src/utils.ts +15 -2
  58. package/dist/root-DqWolle_.mjs.map +0 -1
  59. package/dist/root-lBp7qziQ.cjs.map +0 -1
  60. package/dist/types-BH1o6nYa.d.mts.map +0 -1
  61. package/dist/types-C4Vtkxbn.d.cts.map +0 -1
@@ -1,13 +1,16 @@
1
- import { isStandardSchema, type StandardSchemaV1 } from './standard-schema'
2
- import type { Validator } from './types'
1
+ import type { StandardSchemaV1, StandardSchemaV1Issue } from './standard-schema'
2
+ import type { FormIssue, Validator } from './types'
3
3
 
4
4
  /**
5
5
  * Wrap any Standard-Schema-compatible schema (Zod 4, Valibot 1, ArkType 2,
6
- * …) as an Olas validator. The validator returns the first issue's message
7
- * on failure (or `'Invalid'` if no issues are produced), `null` on success.
6
+ * …) as an Olas validator. Returns **all** issues as `FormIssue[]`, each
7
+ * carrying the schema's own `path` so a whole-form schema used as a
8
+ * form-level validator routes each issue onto the matching field (T5.2),
9
+ * and a leaf schema (whose issues have empty paths) still reports on the
10
+ * field it's attached to. An empty array means "valid".
8
11
  *
9
12
  * Standard Schema validators may be sync or async; this wrapper threads
10
- * through whichever the schema returns — `Promise<string|null>` only when
13
+ * through whichever the schema returns — `Promise<FormIssue[]>` only when
11
14
  * the underlying validate call is itself async.
12
15
  *
13
16
  * `signal` is accepted to match the `Validator<T>` shape but isn't forwarded
@@ -18,15 +21,33 @@ export function validator<I, O>(schema: StandardSchemaV1<I, O>): Validator<I> {
18
21
  void signal
19
22
  const result = schema['~standard'].validate(value)
20
23
  if (result instanceof Promise) {
21
- return result.then(messageFromResult)
24
+ return result.then(issuesFromResult)
22
25
  }
23
- return messageFromResult(result)
26
+ return issuesFromResult(result)
24
27
  }
25
28
  }
26
29
 
27
- function messageFromResult(result: { issues?: ReadonlyArray<{ message: string }> }): string | null {
28
- if (result.issues === undefined || result.issues.length === 0) return null
29
- return result.issues[0]?.message ?? 'Invalid'
30
+ function issuesFromResult(result: { issues?: ReadonlyArray<StandardSchemaV1Issue> }): FormIssue[] {
31
+ if (result.issues === undefined || result.issues.length === 0) return []
32
+ return result.issues.map((issue) => ({
33
+ path: normalizeIssuePath(issue.path),
34
+ message: issue.message ?? 'Invalid',
35
+ }))
36
+ }
37
+
38
+ /**
39
+ * Standard Schema issue paths are `(PropertyKey | { key: PropertyKey })[]`.
40
+ * Flatten to the `(string | number)[]` shape `FormIssue` uses: numeric keys
41
+ * stay numeric (array indices), everything else stringifies.
42
+ */
43
+ function normalizeIssuePath(path: StandardSchemaV1Issue['path']): (string | number)[] {
44
+ if (path === undefined) return []
45
+ const out: (string | number)[] = []
46
+ for (const seg of path) {
47
+ const key = typeof seg === 'object' && seg !== null ? seg.key : seg
48
+ out.push(typeof key === 'number' ? key : String(key))
49
+ }
50
+ return out
30
51
  }
31
52
 
32
53
  export { isStandardSchema, type StandardSchemaV1 } from './standard-schema'
@@ -35,15 +56,28 @@ const isEmpty = (value: unknown): boolean => {
35
56
  if (value === undefined || value === null) return true
36
57
  if (typeof value === 'string') return value.length === 0
37
58
  if (Array.isArray(value)) return value.length === 0
59
+ // A boolean `false` is a legitimate value (a toggle set to "off"), NOT empty
60
+ // — `required` accepts it. For a consent / confirm checkbox that MUST be
61
+ // ticked, use `mustBeTrue` instead (T5.3).
38
62
  return false
39
63
  }
40
64
 
41
- /** Reject empty values (undefined, null, empty string, empty array). */
65
+ /** Reject empty values (undefined, null, empty string, empty array). Booleans always pass. */
42
66
  export const required =
43
67
  <T>(message = 'Required'): Validator<T> =>
44
68
  (value) =>
45
69
  isEmpty(value) ? message : null
46
70
 
71
+ /**
72
+ * Reject any value that isn't boolean `true` — the "you must check this box"
73
+ * rule for consent / terms-of-service / confirm checkboxes. Unlike `required`
74
+ * (which now accepts `false` as a valid boolean), this fails on `false`.
75
+ */
76
+ export const mustBeTrue =
77
+ (message = 'Required'): Validator<boolean> =>
78
+ (value) =>
79
+ value === true ? null : message
80
+
47
81
  /** Reject strings / arrays shorter than `n`. Allows null/undefined (use with `required` to forbid). */
48
82
  export const minLength =
49
83
  (n: number, message?: string): Validator<string | readonly unknown[]> =>
package/src/index.ts CHANGED
@@ -24,9 +24,16 @@ export type { DebugBus, DebugCacheEntry, DebugEvent } from './devtools'
24
24
  // Emitter
25
25
  export type { Emitter, EmitterErrorReporter } from './emitter'
26
26
  export { createEmitter } from './emitter'
27
- export type { ErrorContext } from './errors'
27
+ export type { ErrorContext, ErrorContextInput, ErrorHandler } from './errors'
28
28
  // Forms — stdlib validators + Standard Schema adapter + debouncedValidator
29
- export type { StandardSchemaV1, Validator } from './forms'
29
+ export type {
30
+ FieldTransform,
31
+ FormIssue,
32
+ StandardSchemaV1,
33
+ ValidateOn,
34
+ Validator,
35
+ ValidatorResult,
36
+ } from './forms'
30
37
  export {
31
38
  email,
32
39
  isStandardSchema,
@@ -34,6 +41,7 @@ export {
34
41
  maxLength,
35
42
  min,
36
43
  minLength,
44
+ mustBeTrue,
37
45
  pattern,
38
46
  required,
39
47
  validator,
@@ -84,11 +92,7 @@ export type {
84
92
  RegisteredQuery,
85
93
  SetDataEvent,
86
94
  } from './query/plugin'
87
- export {
88
- _unregisterMutationById,
89
- lookupRegisteredMutation,
90
- lookupRegisteredQuery,
91
- } from './query/plugin'
95
+ export { lookupRegisteredMutation, lookupRegisteredQuery } from './query/plugin'
92
96
  // Query primitives
93
97
  export type {
94
98
  AsyncState,
@@ -96,6 +100,7 @@ export type {
96
100
  DehydratedEntry,
97
101
  DehydratedState,
98
102
  LocalCache,
103
+ NetworkMode,
99
104
  Query,
100
105
  QuerySpec,
101
106
  QuerySubscription,
@@ -113,6 +118,7 @@ export { selection } from './selection'
113
118
  export type { Computed, ReadSignal, Signal } from './signals'
114
119
  export { batch, computed, effect, signal, untracked } from './signals'
115
120
  // Timing
121
+ export type { TimingSignal } from './timing'
116
122
  export { debounced, throttled } from './timing'
117
123
 
118
124
  // Utilities
@@ -20,14 +20,25 @@ const DEFAULT_GC_TIME = 5 * 60_000
20
20
 
21
21
  type AnyQuery = Query<any, any> & {
22
22
  readonly __spec: QuerySpec<any, any>
23
+ readonly __id: string
23
24
  __clients: Set<QueryClient>
24
25
  }
25
26
 
26
27
  type AnyInfiniteQuery = InfiniteQuery<any, any, any> & {
27
28
  readonly __spec: InfiniteQuerySpec<any, any, any, any>
29
+ readonly __id: string
28
30
  __clients: Set<QueryClient>
29
31
  }
30
32
 
33
+ /**
34
+ * Composite hydration-buffer key: query identity + key hash. Namespacing by
35
+ * identity stops a dehydrated entry for query A being adopted by query B that
36
+ * merely hashes to the same key (spec §15, T1.2). `JSON.stringify` of the pair
37
+ * is used rather than string concatenation so no separator char is ambiguous —
38
+ * both `id` (an arbitrary user `queryId`) and `hash` are unbounded strings.
39
+ */
40
+ const hydrationKey = (id: string, hash: string): string => JSON.stringify([id, hash])
41
+
31
42
  export class ClientEntry<T> {
32
43
  readonly entry: Entry<T>
33
44
  /** The result of `spec.key(...args)` — used for hashing/identity. */
@@ -80,6 +91,8 @@ export class ClientEntry<T> {
80
91
  staleTime: spec.staleTime,
81
92
  retry: spec.retry as RetryPolicy | undefined,
82
93
  retryDelay: spec.retryDelay as RetryDelay | undefined,
94
+ networkMode: spec.networkMode,
95
+ structuralShare: spec.structuralShare,
83
96
  initialData: hydrated?.data,
84
97
  initialUpdatedAt: hydrated?.lastUpdatedAt,
85
98
  events:
@@ -142,6 +155,20 @@ export class ClientEntry<T> {
142
155
  if (this.refetchInterval == null) return
143
156
  if (this.intervalTimer != null) return
144
157
  this.intervalTimer = setInterval(() => {
158
+ // Skip when the tab is hidden — refetching while in background wastes
159
+ // battery and network. `refetchOnWindowFocus` (when enabled) will
160
+ // catch the entry up on visibility return; pure-interval users without
161
+ // focus-refetch get the catch-up via `acquire()` re-arming the timer
162
+ // when they next mount.
163
+ if (typeof document !== 'undefined' && document.visibilityState === 'hidden') {
164
+ return
165
+ }
166
+ // Join an in-flight fetch instead of aborting it. `startFetch()` aborts
167
+ // the current request, so a fetch slower than the interval would
168
+ // livelock — abort→restart every tick, never completing, hammering one
169
+ // aborted request per interval (T3.2). Skip the tick; the running fetch
170
+ // will finish and the next tick re-arms once it's idle.
171
+ if (this.entry.isFetching.peek()) return
145
172
  this.entry.startFetch().catch(() => {
146
173
  /* error already captured on entry */
147
174
  })
@@ -195,6 +222,9 @@ export class ClientEntry<T> {
195
222
  /** Refetch on focus / reconnect, but only if the data is actually stale. */
196
223
  private triggerEventRefetch(): void {
197
224
  if (!this.entry.isStaleNow()) return
225
+ // Join an in-flight fetch instead of aborting + restarting it — a focus /
226
+ // reconnect landing mid-fetch shouldn't cancel it (T3.9, cf. T3.2 interval).
227
+ if (this.entry.isFetching.peek()) return
198
228
  this.entry.startFetch().catch(() => {
199
229
  /* error already captured on entry */
200
230
  })
@@ -255,6 +285,8 @@ export class InfiniteClientEntry<TPage, TItem, PageParam> {
255
285
  staleTime: spec.staleTime,
256
286
  retry: spec.retry as RetryPolicy | undefined,
257
287
  retryDelay: spec.retryDelay as RetryDelay | undefined,
288
+ networkMode: spec.networkMode,
289
+ structuralShare: spec.structuralShare,
258
290
  // Fire SetDataEvent { kind: 'infinite', source: 'fetch' } whenever a
259
291
  // fetch settles successfully. Plugins (e.g. entity normalization) use
260
292
  // this to walk the pages and update their normalized stores. Mirrors
@@ -274,6 +306,10 @@ export class InfiniteClientEntry<TPage, TItem, PageParam> {
274
306
  }
275
307
  }
276
308
 
309
+ hasSubscribers(): boolean {
310
+ return this.subscriberCount > 0
311
+ }
312
+
277
313
  release(): void {
278
314
  this.subscriberCount -= 1
279
315
  if (this.subscriberCount <= 0) {
@@ -296,6 +332,13 @@ export class InfiniteClientEntry<TPage, TItem, PageParam> {
296
332
  private startIntervalTimer(): void {
297
333
  if (this.refetchInterval == null || this.intervalTimer != null) return
298
334
  this.intervalTimer = setInterval(() => {
335
+ // Same visibility-gate as the regular `ClientEntry.startIntervalTimer`.
336
+ if (typeof document !== 'undefined' && document.visibilityState === 'hidden') {
337
+ return
338
+ }
339
+ // Join an in-flight fetch instead of aborting it (T3.2) — see the
340
+ // regular `ClientEntry.startIntervalTimer` for the livelock rationale.
341
+ if (this.entry.isFetching.peek()) return
299
342
  this.entry.startFetch().catch(() => {
300
343
  /* error captured on entry */
301
344
  })
@@ -399,7 +442,7 @@ export class QueryClient {
399
442
  if (opts?.hydrate) this.hydrate(opts.hydrate)
400
443
  const api = this.makePluginApi()
401
444
  for (const plugin of this.plugins) {
402
- this.callPlugin(() => plugin.init?.(api))
445
+ this.callPlugin(plugin, () => plugin.init?.(api))
403
446
  }
404
447
  }
405
448
 
@@ -427,13 +470,14 @@ export class QueryClient {
427
470
  }
428
471
 
429
472
  /** Invoke a plugin callback; route exceptions through `onError`. */
430
- private callPlugin(fn: () => void): void {
473
+ private callPlugin(plugin: QueryClientPlugin, fn: () => void): void {
431
474
  try {
432
475
  fn()
433
476
  } catch (err) {
434
477
  dispatchError(this.onError, err, {
435
478
  kind: 'plugin',
436
479
  controllerPath: [],
480
+ pluginName: plugin.name,
437
481
  })
438
482
  }
439
483
  }
@@ -476,7 +520,7 @@ export class QueryClient {
476
520
  for (const plugin of this.plugins) {
477
521
  if (plugin.onSetData) {
478
522
  const cb = plugin.onSetData
479
- this.callPlugin(() => cb.call(plugin, event))
523
+ this.callPlugin(plugin, () => cb.call(plugin, event))
480
524
  }
481
525
  }
482
526
  }
@@ -498,7 +542,7 @@ export class QueryClient {
498
542
  for (const plugin of this.plugins) {
499
543
  if (plugin.onInvalidate) {
500
544
  const cb = plugin.onInvalidate
501
- this.callPlugin(() => cb.call(plugin, event))
545
+ this.callPlugin(plugin, () => cb.call(plugin, event))
502
546
  }
503
547
  }
504
548
  }
@@ -515,7 +559,7 @@ export class QueryClient {
515
559
  for (const plugin of this.plugins) {
516
560
  if (plugin.onGc) {
517
561
  const cb = plugin.onGc
518
- this.callPlugin(() => cb.call(plugin, event))
562
+ this.callPlugin(plugin, () => cb.call(plugin, event))
519
563
  }
520
564
  }
521
565
  }
@@ -535,7 +579,7 @@ export class QueryClient {
535
579
  for (const plugin of this.plugins) {
536
580
  if (plugin.onMutationEnqueue) {
537
581
  const cb = plugin.onMutationEnqueue
538
- this.callPlugin(() => cb.call(plugin, event))
582
+ this.callPlugin(plugin, () => cb.call(plugin, event))
539
583
  }
540
584
  }
541
585
  }
@@ -551,7 +595,7 @@ export class QueryClient {
551
595
  for (const plugin of this.plugins) {
552
596
  if (plugin.onMutationSettle) {
553
597
  const cb = plugin.onMutationSettle
554
- this.callPlugin(() => cb.call(plugin, event))
598
+ this.callPlugin(plugin, () => cb.call(plugin, event))
555
599
  }
556
600
  }
557
601
  }
@@ -601,13 +645,60 @@ export class QueryClient {
601
645
  if (!entry) return
602
646
  this.applyingRemote = true
603
647
  try {
604
- entry.entry.setData(() => data as never)
648
+ entry.entry.setData(() => data as never, { track: false })
605
649
  this.emitSetData(internal, entry.keyArgs, data, 'data', 'remote')
606
650
  } finally {
607
651
  this.applyingRemote = false
608
652
  }
609
653
  }
610
654
 
655
+ /**
656
+ * Apply a single dehydrated entry to the cache. Idempotent across
657
+ * pre-bind / post-bind:
658
+ *
659
+ * - If the matching `ClientEntry` already exists (a subscriber has
660
+ * bound this key), `Entry.applyHydration(data, lastUpdatedAt)` writes
661
+ * through with the server's timestamp + supersedes any inflight
662
+ * fetch. Plugins see a `SetDataEvent` with `source: 'remote'`.
663
+ * - If no `ClientEntry` exists yet (the subscribing component hasn't
664
+ * mounted), the entry is buffered in `hydratedData` so the next
665
+ * `bindEntry` for that hash picks it up — same path the constructor
666
+ * `hydrate(state)` uses.
667
+ *
668
+ * Designed for streaming SSR: each `<Suspense>` boundary that resolves
669
+ * on the server pushes its dehydrated entry through this method on the
670
+ * client as the bootstrap script executes.
671
+ */
672
+ applyDehydratedEntry(
673
+ queryId: string,
674
+ keyArgs: readonly unknown[],
675
+ data: unknown,
676
+ lastUpdatedAt: number,
677
+ ): void {
678
+ const query = lookupRegisteredQuery(queryId)
679
+ const hash = stableHash(keyArgs)
680
+ if (query && query.__olas === 'query') {
681
+ const internal = query as unknown as AnyQuery
682
+ const map = this.maps.get(internal)
683
+ const entry = map?.get(hash)
684
+ if (entry !== undefined) {
685
+ this.applyingRemote = true
686
+ try {
687
+ entry.entry.applyHydration(data, lastUpdatedAt)
688
+ this.emitSetData(internal, entry.keyArgs, data, 'data', 'remote')
689
+ } finally {
690
+ this.applyingRemote = false
691
+ }
692
+ return
693
+ }
694
+ }
695
+ // No local entry yet — buffer in the same map the constructor uses.
696
+ // The next bindEntry for this query + key will adopt the buffered payload
697
+ // and clear the slot. Namespaced by queryId so a colliding-key query can't
698
+ // steal it (spec §15, T1.2).
699
+ this.hydratedData.set(hydrationKey(queryId, hash), { data, lastUpdatedAt })
700
+ }
701
+
611
702
  /**
612
703
  * Local-originated `setData` keyed by `queryId + keyArgs`. Plugin-facing
613
704
  * (exposed via `QueryClientPluginApi.setEntryData`); used by the
@@ -633,7 +724,7 @@ export class QueryClient {
633
724
  if (!map) return
634
725
  const entry = map.get(hash)
635
726
  if (!entry) return
636
- entry.entry.setData(updater as (prev: unknown) => never)
727
+ entry.entry.setData(updater as (prev: unknown) => never, { track: false })
637
728
  this.emitSetData(internal, entry.keyArgs, entry.entry.data.peek(), 'data', 'set')
638
729
  return
639
730
  }
@@ -646,7 +737,7 @@ export class QueryClient {
646
737
  if (!map) return
647
738
  const entry = map.get(hash)
648
739
  if (!entry) return
649
- entry.entry.setData(updater as (prev: unknown[] | undefined) => unknown[])
740
+ entry.entry.setData(updater as (prev: unknown[] | undefined) => unknown[], { track: false })
650
741
  this.emitSetData(internal, entry.keyArgs, entry.entry.pages.peek(), 'infinite', 'set')
651
742
  }
652
743
 
@@ -696,7 +787,7 @@ export class QueryClient {
696
787
  }
697
788
  for (const entry of state.entries) {
698
789
  const hash = stableHash(entry.key)
699
- this.hydratedData.set(hash, {
790
+ this.hydratedData.set(hydrationKey(entry.id, hash), {
700
791
  data: entry.data,
701
792
  lastUpdatedAt: entry.lastUpdatedAt,
702
793
  })
@@ -745,10 +836,11 @@ export class QueryClient {
745
836
 
746
837
  dehydrate(): DehydratedState {
747
838
  const entries: DehydratedState['entries'] = []
748
- for (const map of this.maps.values()) {
839
+ for (const [query, map] of this.maps) {
749
840
  for (const ce of map.values()) {
750
841
  if (ce.entry.status.peek() === 'success') {
751
842
  entries.push({
843
+ id: query.__id,
752
844
  key: ce.keyArgs,
753
845
  data: ce.entry.data.peek(),
754
846
  lastUpdatedAt: ce.entry.lastUpdatedAt.peek() ?? Date.now(),
@@ -792,16 +884,27 @@ export class QueryClient {
792
884
  await Promise.all(tasks)
793
885
  }
794
886
  // The 100-iteration safety bound exists so a pathological setup that
795
- // keeps starting new fetches doesn't lock the dehydrate path. Warn so
796
- // it's surfaced silent success would let an SSR dehydrate ship an
797
- // incomplete payload looking like a clean one.
798
- if (__DEV__) {
799
- // eslint-disable-next-line no-console
800
- console.warn(
801
- '[olas] waitForIdle(): exited via the 100-iteration safety bound — ' +
802
- 'the cache or mutations keep restarting. The dehydrate payload may be incomplete.',
803
- )
887
+ // keeps starting new fetches doesn't lock the dehydrate path. This is
888
+ // a correctness failure for SSR: silently returning would let
889
+ // `dehydrate()` ship an incomplete payload that looks clean. Throw so
890
+ // the SSR boundary surfaces it instead of pretending success.
891
+ const unsettled: Array<{ key: readonly unknown[]; kind: 'data' | 'infinite' }> = []
892
+ for (const map of this.maps.values()) {
893
+ for (const ce of map.values()) {
894
+ if (ce.entry.isFetching.peek()) unsettled.push({ key: ce.keyArgs, kind: 'data' })
895
+ }
804
896
  }
897
+ for (const map of this.infiniteMaps.values()) {
898
+ for (const ce of map.values()) {
899
+ if (ce.entry.isFetching.peek()) unsettled.push({ key: ce.keyArgs, kind: 'infinite' })
900
+ }
901
+ }
902
+ const err = new Error(
903
+ '[olas] waitForIdle: exceeded 100-iteration safety bound — cache or mutations keep restarting',
904
+ ) as Error & { unsettled: typeof unsettled; mutationsInflight: number }
905
+ err.unsettled = unsettled
906
+ err.mutationsInflight = this.mutationsInflight$.peek()
907
+ throw err
805
908
  }
806
909
 
807
910
  bindEntry<Args extends unknown[], T>(query: Query<Args, T>, args: Args): ClientEntry<T> {
@@ -817,8 +920,9 @@ export class QueryClient {
817
920
  const hash = stableHash(keyArgs)
818
921
  let entry = map.get(hash) as ClientEntry<T> | undefined
819
922
  if (!entry) {
820
- const hydrated = this.hydratedData.get(hash) as { data: T; lastUpdatedAt: number } | undefined
821
- if (hydrated) this.hydratedData.delete(hash)
923
+ const hkey = hydrationKey(internal.__id, hash)
924
+ const hydrated = this.hydratedData.get(hkey) as { data: T; lastUpdatedAt: number } | undefined
925
+ if (hydrated) this.hydratedData.delete(hkey)
822
926
  // Build the fetcher-success emitter here so `emitSetData` can stay
823
927
  // `private` — `ClientEntry` doesn't reach back into the client to call
824
928
  // it; the closure captures (query, keyArgs, this) in this scope and
@@ -853,6 +957,29 @@ export class QueryClient {
853
957
  if (hydrated !== undefined) {
854
958
  this.emitSetData(internal, keyArgs, hydrated.data, 'data', 'fetch')
855
959
  }
960
+ } else if (__DEV__) {
961
+ // The fetcher closure is captured on first `bindEntry`. If a later
962
+ // subscriber binds the SAME `keyArgs` (same hash) but DIFFERENT
963
+ // `callArgs`, the next refetch will still use the first caller's
964
+ // arguments — a silent staleness footgun when a `key()` function
965
+ // collapses non-key args (e.g. headers, abort tokens). Warn so the
966
+ // mismatch is surfaced; the consumer should either include the diff
967
+ // in the key or accept the documented "first wins" behavior.
968
+ const prev = entry.callArgs
969
+ const len = Math.max(prev.length, args.length)
970
+ let mismatch = prev.length !== args.length
971
+ for (let i = 0; i < len && !mismatch; i++) {
972
+ if (!Object.is(prev[i], args[i])) mismatch = true
973
+ }
974
+ if (mismatch) {
975
+ // eslint-disable-next-line no-console
976
+ console.warn(
977
+ `[olas] bindEntry: hash collision with diverging callArgs for query` +
978
+ ` ${internal.__spec.queryId ?? '<anonymous>'} key=${JSON.stringify(keyArgs)}.` +
979
+ ` First bind's args are used by the fetcher; later args ignored.` +
980
+ ` Either include the difference in spec.key(...) or pass identical args.`,
981
+ )
982
+ }
856
983
  }
857
984
  return entry
858
985
  }
@@ -873,6 +1000,32 @@ export class QueryClient {
873
1000
  this.emitGc(entry.query, entry.keyArgs, 'data')
874
1001
  }
875
1002
 
1003
+ /**
1004
+ * Invalidate one entry: if it has subscribers, mark stale AND refetch; if
1005
+ * not, mark stale only — the next subscriber refetches. Spec §5.7 says
1006
+ * invalidate refetches only IF subscribed; the old always-fetch behavior woke
1007
+ * orphaned entries that no one was watching (T3.9). Works for both
1008
+ * `ClientEntry` and `InfiniteClientEntry`.
1009
+ */
1010
+ private invalidateEntry(entry: {
1011
+ hasSubscribers(): boolean
1012
+ keyArgs: readonly unknown[]
1013
+ entry: { invalidate(): Promise<unknown>; markStale(): void }
1014
+ }): void {
1015
+ if (entry.hasSubscribers()) {
1016
+ entry.entry.invalidate().catch((err) => {
1017
+ if (isAbortError(err)) return
1018
+ dispatchError(this.onError, err, {
1019
+ kind: 'cache',
1020
+ controllerPath: [],
1021
+ queryKey: entry.keyArgs,
1022
+ })
1023
+ })
1024
+ } else {
1025
+ entry.entry.markStale()
1026
+ }
1027
+ }
1028
+
876
1029
  invalidate<Args extends unknown[]>(query: Query<Args, any>, args: Args): void {
877
1030
  const internal = query as AnyQuery
878
1031
  const map = this.maps.get(internal)
@@ -884,14 +1037,7 @@ export class QueryClient {
884
1037
  if (__DEV__) {
885
1038
  this.devtools?.emit({ type: 'cache:invalidated', queryKey: keyArgs })
886
1039
  }
887
- entry.entry.invalidate().catch((err) => {
888
- if (isAbortError(err)) return
889
- dispatchError(this.onError, err, {
890
- kind: 'cache',
891
- controllerPath: [],
892
- queryKey: keyArgs,
893
- })
894
- })
1040
+ this.invalidateEntry(entry)
895
1041
  this.emitInvalidate(internal, keyArgs, 'data')
896
1042
  }
897
1043
 
@@ -904,18 +1050,25 @@ export class QueryClient {
904
1050
  if (__DEV__) {
905
1051
  this.devtools?.emit({ type: 'cache:invalidated', queryKey: entry.keyArgs })
906
1052
  }
907
- entry.entry.invalidate().catch((err) => {
908
- if (isAbortError(err)) return
909
- dispatchError(this.onError, err, {
910
- kind: 'cache',
911
- controllerPath: [],
912
- queryKey: entry.keyArgs,
913
- })
914
- })
1053
+ this.invalidateEntry(entry)
915
1054
  this.emitInvalidate(internal, entry.keyArgs, 'data')
916
1055
  }
917
1056
  }
918
1057
 
1058
+ cancel<Args extends unknown[]>(query: Query<Args, any>, args: Args): void {
1059
+ const internal = query as AnyQuery
1060
+ const map = this.maps.get(internal)
1061
+ if (!map) return
1062
+ const hash = stableHash(internal.__spec.key(...args))
1063
+ map.get(hash)?.entry.cancel()
1064
+ }
1065
+
1066
+ cancelAll(query: Query<any, any>): void {
1067
+ const map = this.maps.get(query as AnyQuery)
1068
+ if (!map) return
1069
+ for (const entry of map.values()) entry.entry.cancel()
1070
+ }
1071
+
919
1072
  setData<Args extends unknown[], T>(
920
1073
  query: Query<Args, T>,
921
1074
  args: Args,
@@ -927,7 +1080,21 @@ export class QueryClient {
927
1080
  // not the updater function (which would be uncloneable across
928
1081
  // BroadcastChannel).
929
1082
  this.emitSetData(entry.query, entry.keyArgs, entry.entry.data.peek(), 'data', 'set')
930
- return snapshot
1083
+ // Re-broadcast on rollback so cross-tab / entity plugins drop the failed
1084
+ // optimistic value instead of keeping it (T3.6). Guard on an actual data
1085
+ // change: a non-top chain-splice rollback (§6.4) leaves current data
1086
+ // untouched, so there is nothing new to broadcast.
1087
+ return {
1088
+ rollback: () => {
1089
+ const before = entry.entry.data.peek()
1090
+ snapshot.rollback()
1091
+ const after = entry.entry.data.peek()
1092
+ if (!Object.is(before, after)) {
1093
+ this.emitSetData(entry.query, entry.keyArgs, after, 'data', 'set')
1094
+ }
1095
+ },
1096
+ finalize: () => snapshot.finalize(),
1097
+ }
931
1098
  }
932
1099
 
933
1100
  bindInfiniteEntry<Args extends unknown[], TPage, TItem>(
@@ -993,14 +1160,7 @@ export class QueryClient {
993
1160
  const hash = stableHash(keyArgs)
994
1161
  const entry = map.get(hash)
995
1162
  if (!entry) return
996
- entry.entry.invalidate().catch((err) => {
997
- if (isAbortError(err)) return
998
- dispatchError(this.onError, err, {
999
- kind: 'cache',
1000
- controllerPath: [],
1001
- queryKey: entry.keyArgs,
1002
- })
1003
- })
1163
+ this.invalidateEntry(entry)
1004
1164
  this.emitInvalidate(internal, keyArgs, 'infinite')
1005
1165
  }
1006
1166
 
@@ -1009,18 +1169,25 @@ export class QueryClient {
1009
1169
  const map = this.infiniteMaps.get(internal)
1010
1170
  if (!map) return
1011
1171
  for (const entry of map.values()) {
1012
- entry.entry.invalidate().catch((err) => {
1013
- if (isAbortError(err)) return
1014
- dispatchError(this.onError, err, {
1015
- kind: 'cache',
1016
- controllerPath: [],
1017
- queryKey: entry.keyArgs,
1018
- })
1019
- })
1172
+ this.invalidateEntry(entry)
1020
1173
  this.emitInvalidate(internal, entry.keyArgs, 'infinite')
1021
1174
  }
1022
1175
  }
1023
1176
 
1177
+ cancelInfinite<Args extends unknown[]>(query: InfiniteQuery<Args, any, any>, args: Args): void {
1178
+ const internal = query as AnyInfiniteQuery
1179
+ const map = this.infiniteMaps.get(internal)
1180
+ if (!map) return
1181
+ const hash = stableHash(internal.__spec.key(...args))
1182
+ map.get(hash)?.entry.cancel()
1183
+ }
1184
+
1185
+ cancelAllInfinite(query: InfiniteQuery<any, any, any>): void {
1186
+ const map = this.infiniteMaps.get(query as AnyInfiniteQuery)
1187
+ if (!map) return
1188
+ for (const entry of map.values()) entry.entry.cancel()
1189
+ }
1190
+
1024
1191
  setInfiniteData<Args extends unknown[], TPage>(
1025
1192
  query: InfiniteQuery<Args, TPage, any>,
1026
1193
  args: Args,
@@ -1029,7 +1196,19 @@ export class QueryClient {
1029
1196
  const entry = this.bindInfiniteEntry(query, args)
1030
1197
  const snapshot = entry.entry.setData(updater)
1031
1198
  this.emitSetData(entry.query, entry.keyArgs, entry.entry.pages.peek(), 'infinite', 'set')
1032
- return snapshot
1199
+ // Re-broadcast on rollback so peers drop the failed optimistic pages
1200
+ // (T3.6); guard on an actual change (non-top chain-splice is a no-op).
1201
+ return {
1202
+ rollback: () => {
1203
+ const before = entry.entry.pages.peek()
1204
+ snapshot.rollback()
1205
+ const after = entry.entry.pages.peek()
1206
+ if (!Object.is(before, after)) {
1207
+ this.emitSetData(entry.query, entry.keyArgs, after, 'infinite', 'set')
1208
+ }
1209
+ },
1210
+ finalize: () => snapshot.finalize(),
1211
+ }
1033
1212
  }
1034
1213
 
1035
1214
  prefetchInfinite<Args extends unknown[], TPage>(
@@ -1103,7 +1282,7 @@ export class QueryClient {
1103
1282
  for (const plugin of this.plugins) {
1104
1283
  if (plugin.dispose) {
1105
1284
  const cb = plugin.dispose
1106
- this.callPlugin(() => cb.call(plugin))
1285
+ this.callPlugin(plugin, () => cb.call(plugin))
1107
1286
  }
1108
1287
  }
1109
1288
  }
Binary file