@kontsedal/olas-core 0.0.6 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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-B6pNq75w.mjs → root-Byq-QYTp.mjs} +1465 -347
  10. package/dist/root-Byq-QYTp.mjs.map +1 -0
  11. package/dist/{root-CFgYhBd-.cjs → root-CPSL5kpR.cjs} +1470 -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-Bmi04hDI.d.mts → types-Brp-4WaZ.d.mts} +233 -23
  22. package/dist/types-Brp-4WaZ.d.mts.map +1 -0
  23. package/dist/{types-BsZMPPOE.d.cts → types-DzDIypPo.d.cts} +233 -23
  24. package/dist/types-DzDIypPo.d.cts.map +1 -0
  25. package/package.json +4 -1
  26. package/src/controller/instance.ts +345 -94
  27. package/src/controller/root.ts +52 -30
  28. package/src/controller/types.ts +55 -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-B6pNq75w.mjs.map +0 -1
  59. package/dist/root-CFgYhBd-.cjs.map +0 -1
  60. package/dist/types-Bmi04hDI.d.mts.map +0 -1
  61. package/dist/types-BsZMPPOE.d.cts.map +0 -1
@@ -103,6 +103,11 @@ var DevtoolsEmitter = class {
103
103
  const defaultHandler = (err, context) => {
104
104
  console.error("[olas]", context, err);
105
105
  };
106
+ let eventCounter = 0;
107
+ function nextEventId() {
108
+ eventCounter = eventCounter + 1 >>> 0;
109
+ return `e_${(Math.random() * 16777216 | 0).toString(16).padStart(6, "0")}${eventCounter.toString(16).padStart(6, "0")}`;
110
+ }
106
111
  /**
107
112
  * Dispatch an error to a user-provided handler, falling back to console.error.
108
113
  * The handler itself is wrapped — if it throws, the throw is swallowed and
@@ -112,16 +117,46 @@ const defaultHandler = (err, context) => {
112
117
  */
113
118
  function dispatchError(handler, err, context) {
114
119
  const fn = handler ?? defaultHandler;
120
+ const full = {
121
+ ...context,
122
+ eventId: nextEventId(),
123
+ timestamp: Date.now()
124
+ };
115
125
  try {
116
- fn(err, context);
126
+ fn(err, full);
117
127
  } catch (handlerErr) {
118
128
  try {
119
129
  console.error("[olas] onError handler threw:", handlerErr);
120
- console.error("[olas] original error:", err, context);
130
+ console.error("[olas] original error:", err, full);
121
131
  } catch {}
122
132
  }
123
133
  }
124
134
  //#endregion
135
+ //#region src/signals/readonly.ts
136
+ /**
137
+ * Project a Signal (or any object with a reactive `value` + `peek` + `subscribe`)
138
+ * as a `ReadSignal`. The returned object does not expose `set` / `update` /
139
+ * settable `value`, so it can be returned from APIs without callers mutating it.
140
+ *
141
+ * Internal helper — not exported from the package's public surface.
142
+ */
143
+ function readOnly(source) {
144
+ return Object.freeze({
145
+ get value() {
146
+ return source.value;
147
+ },
148
+ peek() {
149
+ return source.peek();
150
+ },
151
+ subscribe(handler) {
152
+ return source.subscribe(handler);
153
+ },
154
+ subscribeChanges(handler) {
155
+ return source.subscribeChanges(handler);
156
+ }
157
+ });
158
+ }
159
+ //#endregion
125
160
  //#region src/signals/runtime.ts
126
161
  var SignalImpl = class {
127
162
  inner;
@@ -140,6 +175,9 @@ var SignalImpl = class {
140
175
  subscribe(handler) {
141
176
  return this.inner.subscribe(handler);
142
177
  }
178
+ subscribeChanges(handler) {
179
+ return subscribeChangesImpl(this.inner, handler);
180
+ }
143
181
  set(value) {
144
182
  this.inner.value = value;
145
183
  }
@@ -161,8 +199,26 @@ var ComputedImpl = class {
161
199
  subscribe(handler) {
162
200
  return this.inner.subscribe(handler);
163
201
  }
202
+ subscribeChanges(handler) {
203
+ return subscribeChangesImpl(this.inner, handler);
204
+ }
164
205
  };
165
206
  /**
207
+ * Shared skip-initial wrapper around `signal.subscribe`. Preact fires the
208
+ * subscribe callback synchronously with the current value (the "initial
209
+ * fire"); the wrapper swallows it and forwards every subsequent change.
210
+ */
211
+ function subscribeChangesImpl(inner, handler) {
212
+ let initial = true;
213
+ return inner.subscribe((value) => {
214
+ if (initial) {
215
+ initial = false;
216
+ return;
217
+ }
218
+ handler(value);
219
+ });
220
+ }
221
+ /**
166
222
  * Create a writable `Signal<T>`. Reads track the current auto-tracking scope
167
223
  * (effect / computed); writes notify all subscribers (deduped via `Object.is`).
168
224
  *
@@ -232,7 +288,7 @@ function isAbortError(err) {
232
288
  function abortableSleep(ms, signal) {
233
289
  return new Promise((resolve, reject) => {
234
290
  if (signal.aborted) {
235
- reject(new DOMException("Aborted", "AbortError"));
291
+ reject(abortReason(signal));
236
292
  return;
237
293
  }
238
294
  const timer = setTimeout(() => {
@@ -242,11 +298,93 @@ function abortableSleep(ms, signal) {
242
298
  const onAbort = () => {
243
299
  clearTimeout(timer);
244
300
  signal.removeEventListener("abort", onAbort);
245
- reject(new DOMException("Aborted", "AbortError"));
301
+ reject(abortReason(signal));
246
302
  };
247
303
  signal.addEventListener("abort", onAbort, { once: true });
248
304
  });
249
305
  }
306
+ /**
307
+ * Read the caller-supplied abort reason if present, falling back to the
308
+ * canonical `DOMException('Aborted', 'AbortError')`. `signal.reason` lets
309
+ * users propagate context (e.g. "supersededByLatestWins") through abort
310
+ * chains — discarding it would force every caller to wrap with custom
311
+ * cancellation tokens.
312
+ */
313
+ function abortReason(signal) {
314
+ const reason = signal.reason;
315
+ if (reason !== void 0) return reason;
316
+ return new DOMException("Aborted", "AbortError");
317
+ }
318
+ //#endregion
319
+ //#region src/query/focus-online.ts
320
+ const focusSubs = /* @__PURE__ */ new Set();
321
+ const onlineSubs = /* @__PURE__ */ new Set();
322
+ function fireFocus() {
323
+ for (const fn of focusSubs) try {
324
+ fn();
325
+ } catch {}
326
+ }
327
+ function fireOnline() {
328
+ for (const fn of onlineSubs) try {
329
+ fn();
330
+ } catch {}
331
+ }
332
+ let focusScheduled = false;
333
+ function scheduleFocus() {
334
+ if (focusScheduled) return;
335
+ focusScheduled = true;
336
+ queueMicrotask(() => {
337
+ focusScheduled = false;
338
+ fireFocus();
339
+ });
340
+ }
341
+ function onVisibilityChange() {
342
+ if (document.visibilityState === "visible") scheduleFocus();
343
+ }
344
+ let focusInstalled = false;
345
+ let onlineInstalled = false;
346
+ function installFocus() {
347
+ if (focusInstalled) return;
348
+ if (typeof window === "undefined") return;
349
+ window.addEventListener("focus", scheduleFocus);
350
+ if (typeof document !== "undefined") document.addEventListener("visibilitychange", onVisibilityChange);
351
+ focusInstalled = true;
352
+ }
353
+ function uninstallFocus() {
354
+ if (!focusInstalled) return;
355
+ if (typeof window === "undefined") return;
356
+ window.removeEventListener("focus", scheduleFocus);
357
+ if (typeof document !== "undefined") document.removeEventListener("visibilitychange", onVisibilityChange);
358
+ focusInstalled = false;
359
+ }
360
+ function installOnline() {
361
+ if (onlineInstalled) return;
362
+ if (typeof window === "undefined") return;
363
+ window.addEventListener("online", fireOnline);
364
+ onlineInstalled = true;
365
+ }
366
+ function uninstallOnline() {
367
+ if (!onlineInstalled) return;
368
+ if (typeof window === "undefined") return;
369
+ window.removeEventListener("online", fireOnline);
370
+ onlineInstalled = false;
371
+ }
372
+ function subscribeWindowFocus(fn) {
373
+ installFocus();
374
+ focusSubs.add(fn);
375
+ return () => {
376
+ focusSubs.delete(fn);
377
+ if (focusSubs.size === 0) uninstallFocus();
378
+ };
379
+ }
380
+ function subscribeReconnect(fn) {
381
+ installOnline();
382
+ onlineSubs.add(fn);
383
+ return () => {
384
+ onlineSubs.delete(fn);
385
+ if (onlineSubs.size === 0) uninstallOnline();
386
+ };
387
+ }
250
388
  //#endregion
251
389
  //#region src/query/structural-share.ts
252
390
  /**
@@ -341,16 +479,33 @@ var Entry = class {
341
479
  lastUpdatedAt;
342
480
  hasPendingMutations = signal(false);
343
481
  isStale = signal(true);
482
+ /** True while a fetch is parked waiting for reconnect (online-mode defer or
483
+ * offlineFirst network-error park). See spec §5.5, T3.5. */
484
+ isPaused = signal(false);
344
485
  fetcherProvider;
345
486
  staleTime;
346
487
  retry;
347
488
  retryDelay;
489
+ networkMode;
490
+ structuralShareEnabled;
348
491
  currentFetchId = 0;
349
492
  currentAbort = null;
350
493
  staleTimer = null;
494
+ /** Set by `markStale()` (invalidate without fetch); forces `isStaleNow()`
495
+ * true until the next successful fetch clears it. Spec §5.7, T3.9. */
496
+ forcedStale = false;
351
497
  snapshots = [];
352
498
  nextSnapshotId = 0;
353
499
  disposed = false;
500
+ /** Subscribers to reconnect — installed lazily when a deferred fetch lands. */
501
+ reconnectUnsub = null;
502
+ /**
503
+ * Set of deferred-fetch resolvers waiting for reconnect (online mode).
504
+ * Stored at `unknown` to keep `Entry<T>` covariant in `T` — same trick as
505
+ * `onSuccessData`. Each resolver is fed the same value from
506
+ * `applySuccess`, which is `T`, then cast at the fan-out call site.
507
+ */
508
+ deferredResolvers = [];
354
509
  events;
355
510
  onSuccessData;
356
511
  fetchStartTime = 0;
@@ -364,7 +519,9 @@ var Entry = class {
364
519
  this.fetcherProvider = options.fetcher;
365
520
  this.staleTime = options.staleTime ?? 0;
366
521
  this.retry = options.retry ?? 0;
367
- this.retryDelay = options.retryDelay ?? 1e3;
522
+ this.retryDelay = options.retryDelay;
523
+ this.networkMode = options.networkMode ?? "online";
524
+ this.structuralShareEnabled = options.structuralShare ?? true;
368
525
  this.events = options.events ?? {};
369
526
  this.onSuccessData = options.onSuccessData;
370
527
  this.data = signal(options.initialData);
@@ -388,6 +545,7 @@ var Entry = class {
388
545
  }
389
546
  startFetch() {
390
547
  if (this.disposed) return Promise.reject(/* @__PURE__ */ new Error("Entry disposed"));
548
+ if (this.networkMode === "online" && this.isOffline()) return this.scheduleDeferredFetch();
391
549
  const myId = ++this.currentFetchId;
392
550
  this.currentAbort?.abort();
393
551
  const abort = new AbortController();
@@ -397,6 +555,7 @@ var Entry = class {
397
555
  this.status.set("pending");
398
556
  this.isFetching.set(true);
399
557
  this.isLoading.set(!previouslyHadData);
558
+ this.isPaused.set(false);
400
559
  });
401
560
  this.fetchStartTime = Date.now();
402
561
  try {
@@ -404,6 +563,34 @@ var Entry = class {
404
563
  } catch {}
405
564
  return this.runWithRetry(myId, abort);
406
565
  }
566
+ isOffline() {
567
+ return typeof navigator !== "undefined" && navigator.onLine === false;
568
+ }
569
+ scheduleDeferredFetch() {
570
+ this.isPaused.set(true);
571
+ if (this.reconnectUnsub === null) this.reconnectUnsub = subscribeReconnect(() => this.drainDeferred());
572
+ return new Promise((resolve, reject) => {
573
+ this.deferredResolvers.push({
574
+ resolve,
575
+ reject
576
+ });
577
+ });
578
+ }
579
+ drainDeferred() {
580
+ if (this.deferredResolvers.length === 0) return;
581
+ if (this.disposed) return;
582
+ const pending = this.deferredResolvers;
583
+ this.deferredResolvers = [];
584
+ if (this.reconnectUnsub !== null) {
585
+ this.reconnectUnsub();
586
+ this.reconnectUnsub = null;
587
+ }
588
+ this.startFetch().then((value) => {
589
+ for (const p of pending) p.resolve(value);
590
+ }, (err) => {
591
+ for (const p of pending) p.reject(err);
592
+ });
593
+ }
407
594
  async runWithRetry(myId, abort) {
408
595
  let attempt = 0;
409
596
  while (true) {
@@ -414,6 +601,14 @@ var Entry = class {
414
601
  return this.applySuccess(result);
415
602
  } catch (err) {
416
603
  if (myId !== this.currentFetchId || this.disposed || isAbortError(err)) throw err;
604
+ if (this.networkMode === "offlineFirst" && this.isOffline() && err instanceof TypeError) {
605
+ batch(() => {
606
+ this.isFetching.set(false);
607
+ this.isLoading.set(false);
608
+ this.status.set(this.data.peek() !== void 0 ? "success" : "idle");
609
+ });
610
+ return this.scheduleDeferredFetch();
611
+ }
417
612
  if (!this.shouldRetry(attempt, err)) return this.applyFailure(err);
418
613
  await abortableSleep(this.computeDelay(attempt), abort.signal);
419
614
  attempt += 1;
@@ -428,11 +623,13 @@ var Entry = class {
428
623
  }
429
624
  computeDelay(attempt) {
430
625
  const d = this.retryDelay;
626
+ if (d === void 0) return Math.min(1e3 * 2 ** attempt, 3e4);
431
627
  return typeof d === "function" ? d(attempt) : d;
432
628
  }
433
629
  applySuccess(result) {
434
630
  const prev = this.data.peek();
435
- const shared = prev === void 0 ? result : structuralShare(prev, result);
631
+ const shared = prev === void 0 || !this.structuralShareEnabled ? result : structuralShare(prev, result);
632
+ if (this.snapshots.length > 0) for (const s of this.snapshots) s.prev = shared;
436
633
  batch(() => {
437
634
  this.data.set(shared);
438
635
  this.error.set(void 0);
@@ -442,6 +639,7 @@ var Entry = class {
442
639
  this.lastUpdatedAt.set(Date.now());
443
640
  this.isStale.set(this.staleTime === 0);
444
641
  });
642
+ this.forcedStale = false;
445
643
  if (this.staleTime > 0) this.scheduleStaleness();
446
644
  try {
447
645
  this.events.onFetchSuccess?.(Date.now() - this.fetchStartTime);
@@ -471,12 +669,66 @@ var Entry = class {
471
669
  refetch() {
472
670
  return this.startFetch();
473
671
  }
474
- invalidate() {
672
+ /**
673
+ * Apply a server-supplied data + timestamp without going through the
674
+ * fetcher path. Used by streaming SSR hydration: each `<Suspense>` boundary
675
+ * that resolves on the server pushes its entry's data to the client, and
676
+ * the client routes it through here so the entry transitions to `success`
677
+ * without burning the user's fetcher.
678
+ *
679
+ * Distinct from `setData`: no Snapshot returned (no rollback semantics —
680
+ * this is canonical data, not an optimistic patch), no
681
+ * `hasPendingMutations` flip, and `lastUpdatedAt` honors the supplied
682
+ * server timestamp instead of `Date.now()`. Also bumps `currentFetchId`
683
+ * so any in-flight fetch supersedes itself rather than overwriting the
684
+ * fresher hydrated value.
685
+ */
686
+ applyHydration(data, lastUpdatedAt) {
687
+ if (this.disposed) return;
688
+ this.currentFetchId += 1;
689
+ this.currentAbort?.abort();
690
+ this.currentAbort = null;
691
+ if (this.staleTimer !== null) {
692
+ clearTimeout(this.staleTimer);
693
+ this.staleTimer = null;
694
+ }
695
+ const alreadyStale = this.staleTime === 0 || Date.now() - lastUpdatedAt >= this.staleTime;
696
+ batch(() => {
697
+ this.data.set(data);
698
+ this.error.set(void 0);
699
+ this.status.set("success");
700
+ this.isLoading.set(false);
701
+ this.isFetching.set(false);
702
+ this.lastUpdatedAt.set(lastUpdatedAt);
703
+ this.isStale.set(alreadyStale);
704
+ });
705
+ if (!alreadyStale && this.staleTime > 0) {
706
+ const remaining = this.staleTime - (Date.now() - lastUpdatedAt);
707
+ this.staleTimer = setTimeout(() => {
708
+ this.staleTimer = null;
709
+ if (!this.disposed) this.isStale.set(true);
710
+ }, remaining);
711
+ }
712
+ this.onSuccessData?.(data);
713
+ if (this.pendingFirstValueRejects.length > 0) {}
714
+ }
715
+ /**
716
+ * Force this entry stale WITHOUT fetching. `isStaleNow()` returns true until
717
+ * the next successful fetch clears the flag, so the next subscriber refetches.
718
+ * Used by `client.invalidate` for subscriber-less entries — spec §5.7 says
719
+ * invalidate refetches only IF subscribed (T3.9).
720
+ */
721
+ markStale() {
722
+ if (this.disposed) return;
475
723
  if (this.staleTimer != null) {
476
724
  clearTimeout(this.staleTimer);
477
725
  this.staleTimer = null;
478
726
  }
727
+ this.forcedStale = true;
479
728
  this.isStale.set(true);
729
+ }
730
+ invalidate() {
731
+ this.markStale();
480
732
  return this.startFetch();
481
733
  }
482
734
  reset() {
@@ -486,35 +738,80 @@ var Entry = class {
486
738
  this.status.set(this.data.peek() !== void 0 ? "success" : "idle");
487
739
  });
488
740
  }
489
- setData(updater) {
741
+ /**
742
+ * Cancel an in-flight fetch without touching `data`. Aborts the current
743
+ * request and supersedes it (bumps `currentFetchId` so its result can never
744
+ * land), then restores a settled status: `'success'` if data exists, else
745
+ * `'idle'`. No-op when nothing is fetching. This is the primitive behind
746
+ * `query.cancel(...)` / `subscription.cancel()`: the canonical optimistic
747
+ * recipe cancels outgoing refetches before an optimistic `setData` so a
748
+ * stale response can't clobber the optimistic value (spec §5, §6.4). T3.4.
749
+ */
750
+ cancel() {
751
+ if (this.disposed || !this.isFetching.peek()) return;
752
+ this.currentFetchId += 1;
753
+ this.currentAbort?.abort();
754
+ this.currentAbort = null;
755
+ batch(() => {
756
+ this.isFetching.set(false);
757
+ this.isLoading.set(false);
758
+ this.status.set(this.data.peek() !== void 0 ? "success" : "idle");
759
+ });
760
+ }
761
+ /**
762
+ * Write data into the entry.
763
+ *
764
+ * `track` (default `true`) is the optimistic-update path: it pushes a
765
+ * snapshot record and flips `hasPendingMutations`, so the returned
766
+ * `Snapshot.rollback()` can restore the pre-write baseline and
767
+ * `finalize()` commits it. This is what `query.setData(...)` inside a
768
+ * mutation's `onMutate` uses (spec §6.4).
769
+ *
770
+ * `track: false` is a canonical cache write — cross-tab receive, entities
771
+ * backprop, realtime patches (spec §13.2). It updates the data signal but
772
+ * pushes NO snapshot and does NOT flip `hasPendingMutations`, so a
773
+ * fire-and-forget plugin write can't wedge the pending flag at `true`
774
+ * forever. Returns a no-op `Snapshot`.
775
+ */
776
+ setData(updater, opts) {
490
777
  if (this.disposed) return {
491
778
  rollback: () => {},
492
779
  finalize: () => {}
493
780
  };
494
781
  const prev = this.data.peek();
495
782
  const next = updater(prev);
496
- const id = this.nextSnapshotId++;
497
- const record = {
498
- id,
783
+ const record = opts?.track ?? true ? {
784
+ id: this.nextSnapshotId++,
499
785
  prev,
500
786
  live: true
501
- };
502
- this.snapshots.push(record);
787
+ } : null;
788
+ if (record) this.snapshots.push(record);
503
789
  batch(() => {
504
790
  this.data.set(next);
505
791
  if (this.status.peek() === "idle" || this.status.peek() === "pending") this.status.set("success");
506
792
  this.lastUpdatedAt.set(Date.now());
507
- this.hasPendingMutations.set(true);
793
+ if (record) this.hasPendingMutations.set(true);
508
794
  });
795
+ if (!record) return {
796
+ rollback: () => {},
797
+ finalize: () => {}
798
+ };
799
+ const id = record.id;
509
800
  return {
510
801
  rollback: () => {
511
802
  if (!record.live || this.disposed) return;
512
803
  record.live = false;
513
804
  batch(() => {
514
- this.data.set(record.prev);
515
- this.snapshots = this.snapshots.filter((s) => s.id !== id);
516
- const anyLive = this.snapshots.some((s) => s.live);
517
- this.hasPendingMutations.set(anyLive);
805
+ const i = this.snapshots.indexOf(record);
806
+ if (i !== -1) {
807
+ if (i === this.snapshots.length - 1) this.data.set(record.prev);
808
+ else {
809
+ const below = this.snapshots[i + 1];
810
+ below.prev = record.prev;
811
+ }
812
+ this.snapshots.splice(i, 1);
813
+ }
814
+ this.hasPendingMutations.set(this.snapshots.some((s) => s.live));
518
815
  });
519
816
  },
520
817
  finalize: () => {
@@ -552,6 +849,7 @@ var Entry = class {
552
849
  * Used by the query client to decide whether to refetch on subscribe.
553
850
  */
554
851
  isStaleNow() {
852
+ if (this.forcedStale) return true;
555
853
  const last = this.lastUpdatedAt.peek();
556
854
  if (last === void 0) return true;
557
855
  return Date.now() - last >= this.staleTime;
@@ -565,6 +863,20 @@ var Entry = class {
565
863
  }
566
864
  this.currentAbort?.abort();
567
865
  this.currentAbort = null;
866
+ batch(() => {
867
+ this.isFetching.set(false);
868
+ this.isLoading.set(false);
869
+ });
870
+ if (this.reconnectUnsub !== null) {
871
+ this.reconnectUnsub();
872
+ this.reconnectUnsub = null;
873
+ }
874
+ if (this.deferredResolvers.length > 0) {
875
+ const disposed = new DOMException("Entry disposed", "AbortError");
876
+ const pending = this.deferredResolvers;
877
+ this.deferredResolvers = [];
878
+ for (const p of pending) p.reject(disposed);
879
+ }
568
880
  if (this.pendingFirstValueRejects.length > 0) {
569
881
  const disposed = new DOMException("Entry disposed", "AbortError");
570
882
  const rejects = this.pendingFirstValueRejects;
@@ -574,51 +886,6 @@ var Entry = class {
574
886
  }
575
887
  };
576
888
  //#endregion
577
- //#region src/query/focus-online.ts
578
- const focusSubs = /* @__PURE__ */ new Set();
579
- const onlineSubs = /* @__PURE__ */ new Set();
580
- let focusInstalled = false;
581
- let onlineInstalled = false;
582
- function fireFocus() {
583
- for (const fn of focusSubs) try {
584
- fn();
585
- } catch {}
586
- }
587
- function fireOnline() {
588
- for (const fn of onlineSubs) try {
589
- fn();
590
- } catch {}
591
- }
592
- function ensureFocusInstalled() {
593
- if (focusInstalled) return;
594
- if (typeof window === "undefined") return;
595
- window.addEventListener("focus", fireFocus);
596
- if (typeof document !== "undefined") document.addEventListener("visibilitychange", () => {
597
- if (document.visibilityState === "visible") fireFocus();
598
- });
599
- focusInstalled = true;
600
- }
601
- function ensureOnlineInstalled() {
602
- if (onlineInstalled) return;
603
- if (typeof window === "undefined") return;
604
- window.addEventListener("online", fireOnline);
605
- onlineInstalled = true;
606
- }
607
- function subscribeWindowFocus(fn) {
608
- ensureFocusInstalled();
609
- focusSubs.add(fn);
610
- return () => {
611
- focusSubs.delete(fn);
612
- };
613
- }
614
- function subscribeReconnect(fn) {
615
- ensureOnlineInstalled();
616
- onlineSubs.add(fn);
617
- return () => {
618
- onlineSubs.delete(fn);
619
- };
620
- }
621
- //#endregion
622
889
  //#region src/query/infinite.ts
623
890
  /**
624
891
  * Holds an array of pages plus their pageParams. Supports fetchNextPage /
@@ -637,6 +904,9 @@ var InfiniteEntry = class {
637
904
  isStale = signal(true);
638
905
  lastUpdatedAt = signal(void 0);
639
906
  hasPendingMutations = signal(false);
907
+ /** True while a fetch is parked waiting for reconnect (online-mode defer).
908
+ * See spec §5.5, T3.5. Mirrors `Entry.isPaused`. */
909
+ isPaused = signal(false);
640
910
  isFetchingNextPage = signal(false);
641
911
  isFetchingPreviousPage = signal(false);
642
912
  hasNextPage;
@@ -645,6 +915,8 @@ var InfiniteEntry = class {
645
915
  currentFetchId = 0;
646
916
  currentAbort = null;
647
917
  staleTimer = null;
918
+ /** Set by `markStale()` (invalidate without fetch). See `Entry.forcedStale`. */
919
+ forcedStale = false;
648
920
  snapshots = [];
649
921
  nextSnapshotId = 0;
650
922
  disposed = false;
@@ -657,6 +929,10 @@ var InfiniteEntry = class {
657
929
  staleTime;
658
930
  retry;
659
931
  retryDelay;
932
+ networkMode;
933
+ structuralShareEnabled;
934
+ reconnectUnsub = null;
935
+ deferredResolvers = [];
660
936
  itemsOf;
661
937
  /**
662
938
  * Mirrors `Entry.onSuccessData`. Fires from `applyFetchSuccess`-equivalent
@@ -673,7 +949,9 @@ var InfiniteEntry = class {
673
949
  this.itemsOf = opts.itemsOf;
674
950
  this.staleTime = opts.staleTime ?? 0;
675
951
  this.retry = opts.retry ?? 0;
676
- this.retryDelay = opts.retryDelay ?? 1e3;
952
+ this.retryDelay = opts.retryDelay;
953
+ this.networkMode = opts.networkMode ?? "online";
954
+ this.structuralShareEnabled = opts.structuralShare ?? true;
677
955
  this.onSuccessData = opts.onSuccessData;
678
956
  this.pageParams = signal([]);
679
957
  this.data = computed(() => {
@@ -700,26 +978,82 @@ var InfiniteEntry = class {
700
978
  return fn(ps[0], ps) !== null;
701
979
  });
702
980
  }
703
- /** Initial / refetch — drops all pages and fetches starting from initialPageParam. */
981
+ /**
982
+ * Initial load / refetch. On the initial load (no pages yet) this fetches
983
+ * the first page. On a refetch of an already-loaded entry (interval,
984
+ * invalidate, focus/reconnect) it **refetches every currently-loaded page**
985
+ * sequentially, re-deriving each page's param from the freshly-fetched data
986
+ * via `getNextPageParam` — matching TanStack and avoiding the old
987
+ * "collapse to page one" truncation that dropped every loaded page but the
988
+ * first on each refetch (T3.7). Pages/params update atomically at the end,
989
+ * so subscribers never observe a mid-refetch truncation flash.
990
+ */
704
991
  startFetch() {
705
992
  if (this.disposed) return Promise.reject(/* @__PURE__ */ new Error("Entry disposed"));
993
+ if (this.networkMode === "online" && this.isOffline()) return this.scheduleDeferredFetch("initial");
706
994
  const myId = ++this.currentFetchId;
707
995
  this.currentAbort?.abort();
708
996
  const abort = new AbortController();
709
997
  this.currentAbort = abort;
710
- const previouslyHadPages = this.pages.peek().length > 0;
998
+ const previousPages = this.pages.peek();
711
999
  batch(() => {
712
1000
  this.status.set("pending");
713
1001
  this.isFetching.set(true);
714
- this.isLoading.set(!previouslyHadPages);
1002
+ this.isLoading.set(previousPages.length === 0);
1003
+ this.isPaused.set(false);
715
1004
  });
716
- return this.runFetch(myId, abort.signal, this.initialPageParam, (page, param) => {
717
- if (myId !== this.currentFetchId || this.disposed) return;
718
- const prevPages = this.pages.peek();
719
- const sharedPage = prevPages.length > 0 ? structuralShare(prevPages[0], page) : page;
1005
+ return this.runRefetchAll(myId, abort.signal, Math.max(1, previousPages.length), previousPages);
1006
+ }
1007
+ /**
1008
+ * Fetch `targetCount` pages from `initialPageParam`, chaining each next param
1009
+ * via `getNextPageParam` from the freshly-fetched pages. Applies the query's
1010
+ * retry policy per page. Stops early if the dataset shrank (a page yields
1011
+ * `getNextPageParam === null`). Writes pages/params in ONE batch at the end;
1012
+ * a per-page failure keeps the existing pages and surfaces the error; a
1013
+ * supersede/dispose throws `AbortError` without writing.
1014
+ */
1015
+ async runRefetchAll(myId, signal, targetCount, previousPages) {
1016
+ const newPages = [];
1017
+ const newParams = [];
1018
+ let pageParam = this.initialPageParam;
1019
+ try {
1020
+ for (let i = 0; i < targetCount; i++) {
1021
+ let attempt = 0;
1022
+ while (true) {
1023
+ if (myId !== this.currentFetchId || this.disposed) throw new DOMException("Superseded", "AbortError");
1024
+ try {
1025
+ const page = await this.fetcher({
1026
+ pageParam,
1027
+ signal
1028
+ });
1029
+ if (myId !== this.currentFetchId || this.disposed) throw new DOMException("Superseded", "AbortError");
1030
+ newPages.push(page);
1031
+ newParams.push(pageParam);
1032
+ break;
1033
+ } catch (err) {
1034
+ if (myId !== this.currentFetchId || this.disposed || isAbortError(err)) throw err;
1035
+ if (!(typeof this.retry === "number" ? attempt < this.retry : this.retry(attempt, err))) {
1036
+ batch(() => {
1037
+ this.error.set(err);
1038
+ this.status.set("error");
1039
+ this.isLoading.set(false);
1040
+ this.isFetching.set(false);
1041
+ });
1042
+ throw err;
1043
+ }
1044
+ await abortableSleep(this.computeRetryDelay(attempt), signal);
1045
+ attempt += 1;
1046
+ }
1047
+ }
1048
+ const next = this.getNextPageParam(newPages[newPages.length - 1], newPages);
1049
+ if (next === null) break;
1050
+ pageParam = next;
1051
+ }
1052
+ if (myId !== this.currentFetchId || this.disposed) throw new DOMException("Superseded", "AbortError");
1053
+ const finalPages = previousPages.length > 0 && this.structuralShareEnabled && newPages.length > 0 ? [structuralShare(previousPages[0], newPages[0]), ...newPages.slice(1)] : newPages;
720
1054
  batch(() => {
721
- this.pages.set([sharedPage]);
722
- this.pageParams.set([param]);
1055
+ this.pages.set(finalPages);
1056
+ this.pageParams.set(newParams);
723
1057
  this.error.set(void 0);
724
1058
  this.status.set("success");
725
1059
  this.isLoading.set(false);
@@ -727,13 +1061,18 @@ var InfiniteEntry = class {
727
1061
  this.lastUpdatedAt.set(Date.now());
728
1062
  this.isStale.set(this.staleTime === 0);
729
1063
  });
1064
+ this.forcedStale = false;
730
1065
  if (this.staleTime > 0) this.scheduleStaleness();
731
1066
  this.onSuccessData?.(this.pages.peek());
732
- }, "initial");
1067
+ return finalPages[0];
1068
+ } finally {
1069
+ if (!this.disposed && this.status.peek() === "pending" && !this.isFetching.peek() && this.pages.peek().length > 0) this.status.set("success");
1070
+ }
733
1071
  }
734
1072
  fetchNextPage() {
735
1073
  if (this.disposed) return Promise.reject(/* @__PURE__ */ new Error("Entry disposed"));
736
1074
  if (this.isFetchingNextPage.peek()) return Promise.resolve();
1075
+ if (this.networkMode === "online" && this.isOffline()) return this.scheduleDeferredFetch("next");
737
1076
  const ps = this.pages.peek();
738
1077
  if (ps.length === 0) return this.startFetch().then(() => {});
739
1078
  const nextParam = this.getNextPageParam(ps[ps.length - 1], ps);
@@ -751,6 +1090,8 @@ var InfiniteEntry = class {
751
1090
  batch(() => {
752
1091
  this.pages.set([...this.pages.peek(), page]);
753
1092
  this.pageParams.set([...this.pageParams.peek(), param]);
1093
+ this.error.set(void 0);
1094
+ this.status.set("success");
754
1095
  this.isFetchingNextPage.set(false);
755
1096
  this.isFetching.set(false);
756
1097
  this.lastUpdatedAt.set(Date.now());
@@ -762,6 +1103,7 @@ var InfiniteEntry = class {
762
1103
  if (this.disposed) return Promise.reject(/* @__PURE__ */ new Error("Entry disposed"));
763
1104
  if (this.isFetchingPreviousPage.peek()) return Promise.resolve();
764
1105
  if (!this.getPreviousPageParam) return Promise.resolve();
1106
+ if (this.networkMode === "online" && this.isOffline()) return this.scheduleDeferredFetch("prev");
765
1107
  const ps = this.pages.peek();
766
1108
  if (ps.length === 0) return this.startFetch().then(() => {});
767
1109
  const prevParam = this.getPreviousPageParam(ps[0], ps);
@@ -779,6 +1121,8 @@ var InfiniteEntry = class {
779
1121
  batch(() => {
780
1122
  this.pages.set([page, ...this.pages.peek()]);
781
1123
  this.pageParams.set([param, ...this.pageParams.peek()]);
1124
+ this.error.set(void 0);
1125
+ this.status.set("success");
782
1126
  this.isFetchingPreviousPage.set(false);
783
1127
  this.isFetching.set(false);
784
1128
  this.lastUpdatedAt.set(Date.now());
@@ -814,7 +1158,7 @@ var InfiniteEntry = class {
814
1158
  });
815
1159
  throw err;
816
1160
  }
817
- await abortableSleep(typeof this.retryDelay === "function" ? this.retryDelay(attempt) : this.retryDelay, signal);
1161
+ await abortableSleep(this.computeRetryDelay(attempt), signal);
818
1162
  attempt += 1;
819
1163
  }
820
1164
  }
@@ -822,18 +1166,25 @@ var InfiniteEntry = class {
822
1166
  if (!succeeded) batch(() => {
823
1167
  if (direction === "next") this.isFetchingNextPage.set(false);
824
1168
  if (direction === "prev") this.isFetchingPreviousPage.set(false);
1169
+ if (!this.disposed && this.status.peek() === "pending" && !this.isFetching.peek() && this.pages.peek().length > 0) this.status.set("success");
825
1170
  });
826
1171
  }
827
1172
  }
828
1173
  refetch() {
829
1174
  return this.startFetch();
830
1175
  }
831
- invalidate() {
1176
+ /** Force stale without fetching — see `Entry.markStale` (spec §5.7, T3.9). */
1177
+ markStale() {
1178
+ if (this.disposed) return;
832
1179
  if (this.staleTimer != null) {
833
1180
  clearTimeout(this.staleTimer);
834
1181
  this.staleTimer = null;
835
1182
  }
1183
+ this.forcedStale = true;
836
1184
  this.isStale.set(true);
1185
+ }
1186
+ invalidate() {
1187
+ this.markStale();
837
1188
  return this.startFetch();
838
1189
  }
839
1190
  reset() {
@@ -843,33 +1194,73 @@ var InfiniteEntry = class {
843
1194
  this.status.set(this.pages.peek().length > 0 ? "success" : "idle");
844
1195
  });
845
1196
  }
846
- setData(updater) {
1197
+ /** Cancel an in-flight fetch (initial/refetch or paging) without touching
1198
+ * pages. Supersedes + aborts the current request, then restores a settled
1199
+ * status. No-op when idle. Mirrors `Entry.cancel` (spec §5, §6.4, T3.4). */
1200
+ cancel() {
1201
+ if (this.disposed || !this.isFetching.peek()) return;
1202
+ this.currentFetchId += 1;
1203
+ this.currentAbort?.abort();
1204
+ this.currentAbort = null;
1205
+ batch(() => {
1206
+ this.isFetching.set(false);
1207
+ this.isLoading.set(false);
1208
+ this.isFetchingNextPage.set(false);
1209
+ this.isFetchingPreviousPage.set(false);
1210
+ this.status.set(this.pages.peek().length > 0 ? "success" : "idle");
1211
+ });
1212
+ }
1213
+ setData(updater, opts) {
847
1214
  if (this.disposed) return {
848
1215
  rollback: () => {},
849
1216
  finalize: () => {}
850
1217
  };
851
1218
  const prev = this.pages.peek();
1219
+ const prevParams = this.pageParams.peek();
852
1220
  const next = updater(prev.length === 0 ? void 0 : prev);
853
- const id = this.nextSnapshotId++;
854
- const record = {
855
- id,
1221
+ const record = opts?.track ?? true ? {
1222
+ id: this.nextSnapshotId++,
856
1223
  prev,
1224
+ prevParams,
857
1225
  live: true
858
- };
859
- this.snapshots.push(record);
1226
+ } : null;
1227
+ if (record) this.snapshots.push(record);
1228
+ let nextParams = prevParams;
1229
+ if (next.length !== prevParams.length) if (next.length < prevParams.length) nextParams = prevParams.slice(0, next.length);
1230
+ else {
1231
+ const pad = prevParams[prevParams.length - 1];
1232
+ nextParams = prevParams.slice();
1233
+ for (let i = prevParams.length; i < next.length; i++) nextParams.push(pad);
1234
+ }
860
1235
  batch(() => {
861
1236
  this.pages.set(next);
1237
+ if (nextParams !== prevParams) this.pageParams.set(nextParams);
862
1238
  if (this.status.peek() === "idle" || this.status.peek() === "pending") this.status.set("success");
863
1239
  this.lastUpdatedAt.set(Date.now());
864
- this.hasPendingMutations.set(true);
1240
+ if (record) this.hasPendingMutations.set(true);
865
1241
  });
1242
+ if (!record) return {
1243
+ rollback: () => {},
1244
+ finalize: () => {}
1245
+ };
1246
+ const id = record.id;
866
1247
  return {
867
1248
  rollback: () => {
868
1249
  if (!record.live || this.disposed) return;
869
1250
  record.live = false;
870
1251
  batch(() => {
871
- this.pages.set(record.prev);
872
- this.snapshots = this.snapshots.filter((s) => s.id !== id);
1252
+ const i = this.snapshots.indexOf(record);
1253
+ if (i !== -1) {
1254
+ if (i === this.snapshots.length - 1) {
1255
+ this.pages.set(record.prev);
1256
+ this.pageParams.set(record.prevParams);
1257
+ } else {
1258
+ const below = this.snapshots[i + 1];
1259
+ below.prev = record.prev;
1260
+ below.prevParams = record.prevParams;
1261
+ }
1262
+ this.snapshots.splice(i, 1);
1263
+ }
873
1264
  this.hasPendingMutations.set(this.snapshots.some((s) => s.live));
874
1265
  });
875
1266
  },
@@ -904,10 +1295,61 @@ var InfiniteEntry = class {
904
1295
  });
905
1296
  }
906
1297
  isStaleNow() {
1298
+ if (this.forcedStale) return true;
907
1299
  const last = this.lastUpdatedAt.peek();
908
1300
  if (last === void 0) return true;
909
1301
  return Date.now() - last >= this.staleTime;
910
1302
  }
1303
+ computeRetryDelay(attempt) {
1304
+ const d = this.retryDelay;
1305
+ if (d === void 0) return Math.min(1e3 * 2 ** attempt, 3e4);
1306
+ return typeof d === "function" ? d(attempt) : d;
1307
+ }
1308
+ isOffline() {
1309
+ return typeof navigator !== "undefined" && navigator.onLine === false;
1310
+ }
1311
+ scheduleDeferredFetch(direction) {
1312
+ this.isPaused.set(true);
1313
+ if (this.reconnectUnsub === null) this.reconnectUnsub = subscribeReconnect(() => this.drainDeferred());
1314
+ return new Promise((resolve, reject) => {
1315
+ this.deferredResolvers.push({
1316
+ direction,
1317
+ resolve,
1318
+ reject
1319
+ });
1320
+ });
1321
+ }
1322
+ drainDeferred() {
1323
+ if (this.deferredResolvers.length === 0) return;
1324
+ if (this.disposed) return;
1325
+ this.isPaused.set(false);
1326
+ const pending = this.deferredResolvers;
1327
+ this.deferredResolvers = [];
1328
+ if (this.reconnectUnsub !== null) {
1329
+ this.reconnectUnsub();
1330
+ this.reconnectUnsub = null;
1331
+ }
1332
+ const seen = /* @__PURE__ */ new Set();
1333
+ const order = [
1334
+ "initial",
1335
+ "prev",
1336
+ "next"
1337
+ ];
1338
+ for (const d of pending) seen.add(d.direction);
1339
+ const run = async () => {
1340
+ for (const dir of order) {
1341
+ if (!seen.has(dir)) continue;
1342
+ if (dir === "initial") await this.startFetch();
1343
+ else if (dir === "next") await this.fetchNextPage();
1344
+ else await this.fetchPreviousPage();
1345
+ }
1346
+ };
1347
+ run().then(() => {
1348
+ for (const p of pending) p.resolve();
1349
+ }, (err) => {
1350
+ for (const p of pending) p.reject(err);
1351
+ });
1352
+ }
911
1353
  scheduleStaleness() {
912
1354
  if (this.staleTimer != null) clearTimeout(this.staleTimer);
913
1355
  if (this.staleTime > 0) this.staleTimer = setTimeout(() => {
@@ -924,6 +1366,22 @@ var InfiniteEntry = class {
924
1366
  }
925
1367
  this.currentAbort?.abort();
926
1368
  this.currentAbort = null;
1369
+ batch(() => {
1370
+ this.isFetching.set(false);
1371
+ this.isLoading.set(false);
1372
+ this.isFetchingNextPage.set(false);
1373
+ this.isFetchingPreviousPage.set(false);
1374
+ });
1375
+ if (this.reconnectUnsub !== null) {
1376
+ this.reconnectUnsub();
1377
+ this.reconnectUnsub = null;
1378
+ }
1379
+ if (this.deferredResolvers.length > 0) {
1380
+ const disposed = new DOMException("Entry disposed", "AbortError");
1381
+ const pending = this.deferredResolvers;
1382
+ this.deferredResolvers = [];
1383
+ for (const p of pending) p.reject(disposed);
1384
+ }
927
1385
  if (this.pendingFirstValueRejects.length > 0) {
928
1386
  const disposed = new DOMException("Entry disposed", "AbortError");
929
1387
  const rejects = this.pendingFirstValueRejects;
@@ -937,7 +1395,7 @@ var InfiniteEntry = class {
937
1395
  /**
938
1396
  * Stable string hash of a key tuple. Two equal-by-content args produce the
939
1397
  * same string regardless of property iteration order. Handles primitives,
940
- * arrays, plain objects, Date.
1398
+ * arrays, plain objects, Date, BigInt, NaN, ±Infinity.
941
1399
  *
942
1400
  * Functions and symbols throw — keys must be serializable so distinct
943
1401
  * subscribers can share entries.
@@ -945,13 +1403,23 @@ var InfiniteEntry = class {
945
1403
  function stableHash(args) {
946
1404
  return JSON.stringify(args, replacer);
947
1405
  }
948
- const replacer = (_key, value) => {
1406
+ const replacer = function(key, _value) {
1407
+ const value = this[key];
949
1408
  if (typeof value === "function") throw new Error("[olas] query keys cannot contain functions");
950
1409
  if (typeof value === "symbol") throw new Error("[olas] query keys cannot contain symbols");
1410
+ if (typeof value === "bigint") return { __bigint: value.toString() };
1411
+ if (typeof value === "number") {
1412
+ if (Number.isNaN(value)) return "__nan__";
1413
+ if (value === Number.POSITIVE_INFINITY) return "__+inf__";
1414
+ if (value === Number.NEGATIVE_INFINITY) return "__-inf__";
1415
+ return value;
1416
+ }
951
1417
  if (value === void 0) return "__undefined__";
952
1418
  if (value instanceof Date) return { __date: value.toISOString() };
953
1419
  if (value instanceof Map || value instanceof Set) throw new Error("[olas] query keys cannot contain Map/Set — use arrays/objects");
954
1420
  if (value && typeof value === "object" && !Array.isArray(value)) {
1421
+ const proto = Object.getPrototypeOf(value);
1422
+ if (proto !== null && proto !== Object.prototype) throw new Error(`[olas] query keys cannot contain class instances (got ${proto.constructor?.name ?? "unknown"}) — pass plain object/array data`);
955
1423
  const sorted = {};
956
1424
  for (const k of Object.keys(value).sort()) sorted[k] = value[k];
957
1425
  return sorted;
@@ -960,12 +1428,31 @@ const replacer = (_key, value) => {
960
1428
  };
961
1429
  //#endregion
962
1430
  //#region src/query/plugin.ts
963
- const queryRegistry = /* @__PURE__ */ new Map();
1431
+ /**
1432
+ * Back a module-level registry on `globalThis` under a shared `Symbol.for`
1433
+ * key. Guards the dual-package hazard: if a consumer ends up with both the ESM
1434
+ * and CJS build of `@kontsedal/olas-core` (common with mixed bundler configs),
1435
+ * each copy would otherwise own a SEPARATE registry `Map` — a mutation
1436
+ * registered via the ESM copy would be invisible to the mutation-queue plugin
1437
+ * loaded against the CJS copy, and cross-tab query lookups would miss. Sharing
1438
+ * one Map on `globalThis` makes both copies agree. T3.9.
1439
+ */
1440
+ function globalRegistry(key) {
1441
+ const store = globalThis;
1442
+ const existing = store[key];
1443
+ if (existing) return existing;
1444
+ const created = /* @__PURE__ */ new Map();
1445
+ store[key] = created;
1446
+ return created;
1447
+ }
1448
+ const queryRegistry = globalRegistry(Symbol.for("olas.queryRegistry"));
964
1449
  /**
965
1450
  * Register a query by its `queryId`. Internal — called from `defineQuery` /
966
1451
  * `defineInfiniteQuery`. Replaces any previous registration with the same
967
1452
  * id (matches Olas's "full root rebuild" HMR story; a mid-flight remote
968
- * message routed against the old `Query` simply misses).
1453
+ * message routed against the old `Query` simply misses). Dev-warns when a
1454
+ * *different* query overwrites an existing id — a genuine collision, since
1455
+ * cross-tab / persistence route by `queryId` (expected during HMR).
969
1456
  */
970
1457
  function registerQueryById(queryId, query) {
971
1458
  queryRegistry.set(queryId, query);
@@ -978,7 +1465,7 @@ function registerQueryById(queryId, query) {
978
1465
  function lookupRegisteredQuery(queryId) {
979
1466
  return queryRegistry.get(queryId);
980
1467
  }
981
- const mutationRegistry = /* @__PURE__ */ new Map();
1468
+ const mutationRegistry = globalRegistry(Symbol.for("olas.mutationRegistry"));
982
1469
  /** Register a mutation by its `mutationId`. Internal — called from `defineMutation`. */
983
1470
  function registerMutationById(mutationId, entry) {
984
1471
  mutationRegistry.set(mutationId, entry);
@@ -1000,6 +1487,14 @@ function _unregisterMutationById(mutationId) {
1000
1487
  //#endregion
1001
1488
  //#region src/query/client.ts
1002
1489
  const DEFAULT_GC_TIME = 5 * 6e4;
1490
+ /**
1491
+ * Composite hydration-buffer key: query identity + key hash. Namespacing by
1492
+ * identity stops a dehydrated entry for query A being adopted by query B that
1493
+ * merely hashes to the same key (spec §15, T1.2). `JSON.stringify` of the pair
1494
+ * is used rather than string concatenation so no separator char is ambiguous —
1495
+ * both `id` (an arbitrary user `queryId`) and `hash` are unbounded strings.
1496
+ */
1497
+ const hydrationKey = (id, hash) => JSON.stringify([id, hash]);
1003
1498
  var ClientEntry = class {
1004
1499
  entry;
1005
1500
  /** The result of `spec.key(...args)` — used for hashing/identity. */
@@ -1038,6 +1533,8 @@ var ClientEntry = class {
1038
1533
  staleTime: spec.staleTime,
1039
1534
  retry: spec.retry,
1040
1535
  retryDelay: spec.retryDelay,
1536
+ networkMode: spec.networkMode,
1537
+ structuralShare: spec.structuralShare,
1041
1538
  initialData: hydrated?.data,
1042
1539
  initialUpdatedAt: hydrated?.lastUpdatedAt,
1043
1540
  events: void 0,
@@ -1075,6 +1572,8 @@ var ClientEntry = class {
1075
1572
  if (this.refetchInterval == null) return;
1076
1573
  if (this.intervalTimer != null) return;
1077
1574
  this.intervalTimer = setInterval(() => {
1575
+ if (typeof document !== "undefined" && document.visibilityState === "hidden") return;
1576
+ if (this.entry.isFetching.peek()) return;
1078
1577
  this.entry.startFetch().catch(() => {});
1079
1578
  }, this.refetchInterval);
1080
1579
  }
@@ -1118,6 +1617,7 @@ var ClientEntry = class {
1118
1617
  /** Refetch on focus / reconnect, but only if the data is actually stale. */
1119
1618
  triggerEventRefetch() {
1120
1619
  if (!this.entry.isStaleNow()) return;
1620
+ if (this.entry.isFetching.peek()) return;
1121
1621
  this.entry.startFetch().catch(() => {});
1122
1622
  }
1123
1623
  dispose() {
@@ -1163,6 +1663,8 @@ var InfiniteClientEntry = class {
1163
1663
  staleTime: spec.staleTime,
1164
1664
  retry: spec.retry,
1165
1665
  retryDelay: spec.retryDelay,
1666
+ networkMode: spec.networkMode,
1667
+ structuralShare: spec.structuralShare,
1166
1668
  onSuccessData: onFetchSuccess
1167
1669
  });
1168
1670
  }
@@ -1174,6 +1676,9 @@ var InfiniteClientEntry = class {
1174
1676
  }
1175
1677
  if (this.subscriberCount === 1 && this.refetchInterval != null) this.startIntervalTimer();
1176
1678
  }
1679
+ hasSubscribers() {
1680
+ return this.subscriberCount > 0;
1681
+ }
1177
1682
  release() {
1178
1683
  this.subscriberCount -= 1;
1179
1684
  if (this.subscriberCount <= 0) {
@@ -1188,6 +1693,8 @@ var InfiniteClientEntry = class {
1188
1693
  startIntervalTimer() {
1189
1694
  if (this.refetchInterval == null || this.intervalTimer != null) return;
1190
1695
  this.intervalTimer = setInterval(() => {
1696
+ if (typeof document !== "undefined" && document.visibilityState === "hidden") return;
1697
+ if (this.entry.isFetching.peek()) return;
1191
1698
  this.entry.startFetch().catch(() => {});
1192
1699
  }, this.refetchInterval);
1193
1700
  }
@@ -1263,7 +1770,7 @@ var QueryClient = class {
1263
1770
  this.plugins = opts?.plugins ?? [];
1264
1771
  if (opts?.hydrate) this.hydrate(opts.hydrate);
1265
1772
  const api = this.makePluginApi();
1266
- for (const plugin of this.plugins) this.callPlugin(() => plugin.init?.(api));
1773
+ for (const plugin of this.plugins) this.callPlugin(plugin, () => plugin.init?.(api));
1267
1774
  }
1268
1775
  /**
1269
1776
  * Build the `QueryClientPluginApi` view that plugins receive at `init`
@@ -1288,13 +1795,14 @@ var QueryClient = class {
1288
1795
  };
1289
1796
  }
1290
1797
  /** Invoke a plugin callback; route exceptions through `onError`. */
1291
- callPlugin(fn) {
1798
+ callPlugin(plugin, fn) {
1292
1799
  try {
1293
1800
  fn();
1294
1801
  } catch (err) {
1295
1802
  dispatchError(this.onError, err, {
1296
1803
  kind: "plugin",
1297
- controllerPath: []
1804
+ controllerPath: [],
1805
+ pluginName: plugin.name
1298
1806
  });
1299
1807
  }
1300
1808
  }
@@ -1329,7 +1837,7 @@ var QueryClient = class {
1329
1837
  };
1330
1838
  for (const plugin of this.plugins) if (plugin.onSetData) {
1331
1839
  const cb = plugin.onSetData;
1332
- this.callPlugin(() => cb.call(plugin, event));
1840
+ this.callPlugin(plugin, () => cb.call(plugin, event));
1333
1841
  }
1334
1842
  }
1335
1843
  emitInvalidate(query, keyArgs, kind) {
@@ -1344,7 +1852,7 @@ var QueryClient = class {
1344
1852
  };
1345
1853
  for (const plugin of this.plugins) if (plugin.onInvalidate) {
1346
1854
  const cb = plugin.onInvalidate;
1347
- this.callPlugin(() => cb.call(plugin, event));
1855
+ this.callPlugin(plugin, () => cb.call(plugin, event));
1348
1856
  }
1349
1857
  }
1350
1858
  emitGc(query, keyArgs, kind) {
@@ -1358,7 +1866,7 @@ var QueryClient = class {
1358
1866
  };
1359
1867
  for (const plugin of this.plugins) if (plugin.onGc) {
1360
1868
  const cb = plugin.onGc;
1361
- this.callPlugin(() => cb.call(plugin, event));
1869
+ this.callPlugin(plugin, () => cb.call(plugin, event));
1362
1870
  }
1363
1871
  }
1364
1872
  /**
@@ -1370,7 +1878,7 @@ var QueryClient = class {
1370
1878
  if (this.plugins.length === 0) return;
1371
1879
  for (const plugin of this.plugins) if (plugin.onMutationEnqueue) {
1372
1880
  const cb = plugin.onMutationEnqueue;
1373
- this.callPlugin(() => cb.call(plugin, event));
1881
+ this.callPlugin(plugin, () => cb.call(plugin, event));
1374
1882
  }
1375
1883
  }
1376
1884
  /** Fan out a `MutationSettleEvent` to every installed plugin. SPEC §13.3. */
@@ -1378,7 +1886,7 @@ var QueryClient = class {
1378
1886
  if (this.plugins.length === 0) return;
1379
1887
  for (const plugin of this.plugins) if (plugin.onMutationSettle) {
1380
1888
  const cb = plugin.onMutationSettle;
1381
- this.callPlugin(() => cb.call(plugin, event));
1889
+ this.callPlugin(plugin, () => cb.call(plugin, event));
1382
1890
  }
1383
1891
  }
1384
1892
  /** Resolve `queryId → live entry-map keys`. Empty array when unknown. */
@@ -1422,13 +1930,52 @@ var QueryClient = class {
1422
1930
  if (!entry) return;
1423
1931
  this.applyingRemote = true;
1424
1932
  try {
1425
- entry.entry.setData(() => data);
1933
+ entry.entry.setData(() => data, { track: false });
1426
1934
  this.emitSetData(internal, entry.keyArgs, data, "data", "remote");
1427
1935
  } finally {
1428
1936
  this.applyingRemote = false;
1429
1937
  }
1430
1938
  }
1431
1939
  /**
1940
+ * Apply a single dehydrated entry to the cache. Idempotent across
1941
+ * pre-bind / post-bind:
1942
+ *
1943
+ * - If the matching `ClientEntry` already exists (a subscriber has
1944
+ * bound this key), `Entry.applyHydration(data, lastUpdatedAt)` writes
1945
+ * through with the server's timestamp + supersedes any inflight
1946
+ * fetch. Plugins see a `SetDataEvent` with `source: 'remote'`.
1947
+ * - If no `ClientEntry` exists yet (the subscribing component hasn't
1948
+ * mounted), the entry is buffered in `hydratedData` so the next
1949
+ * `bindEntry` for that hash picks it up — same path the constructor
1950
+ * `hydrate(state)` uses.
1951
+ *
1952
+ * Designed for streaming SSR: each `<Suspense>` boundary that resolves
1953
+ * on the server pushes its dehydrated entry through this method on the
1954
+ * client as the bootstrap script executes.
1955
+ */
1956
+ applyDehydratedEntry(queryId, keyArgs, data, lastUpdatedAt) {
1957
+ const query = lookupRegisteredQuery(queryId);
1958
+ const hash = stableHash(keyArgs);
1959
+ if (query && query.__olas === "query") {
1960
+ const internal = query;
1961
+ const entry = this.maps.get(internal)?.get(hash);
1962
+ if (entry !== void 0) {
1963
+ this.applyingRemote = true;
1964
+ try {
1965
+ entry.entry.applyHydration(data, lastUpdatedAt);
1966
+ this.emitSetData(internal, entry.keyArgs, data, "data", "remote");
1967
+ } finally {
1968
+ this.applyingRemote = false;
1969
+ }
1970
+ return;
1971
+ }
1972
+ }
1973
+ this.hydratedData.set(hydrationKey(queryId, hash), {
1974
+ data,
1975
+ lastUpdatedAt
1976
+ });
1977
+ }
1978
+ /**
1432
1979
  * Local-originated `setData` keyed by `queryId + keyArgs`. Plugin-facing
1433
1980
  * (exposed via `QueryClientPluginApi.setEntryData`); used by the
1434
1981
  * `@kontsedal/olas-entities` plugin to backpropagate entity patches into
@@ -1449,7 +1996,7 @@ var QueryClient = class {
1449
1996
  if (!map) return;
1450
1997
  const entry = map.get(hash);
1451
1998
  if (!entry) return;
1452
- entry.entry.setData(updater);
1999
+ entry.entry.setData(updater, { track: false });
1453
2000
  this.emitSetData(internal, entry.keyArgs, entry.entry.data.peek(), "data", "set");
1454
2001
  return;
1455
2002
  }
@@ -1458,7 +2005,7 @@ var QueryClient = class {
1458
2005
  if (!map) return;
1459
2006
  const entry = map.get(hash);
1460
2007
  if (!entry) return;
1461
- entry.entry.setData(updater);
2008
+ entry.entry.setData(updater, { track: false });
1462
2009
  this.emitSetData(internal, entry.keyArgs, entry.entry.pages.peek(), "infinite", "set");
1463
2010
  }
1464
2011
  applyRemoteInvalidate(queryId, keyArgs) {
@@ -1490,7 +2037,7 @@ var QueryClient = class {
1490
2037
  if (state.version !== 1) return;
1491
2038
  for (const entry of state.entries) {
1492
2039
  const hash = stableHash(entry.key);
1493
- this.hydratedData.set(hash, {
2040
+ this.hydratedData.set(hydrationKey(entry.id, hash), {
1494
2041
  data: entry.data,
1495
2042
  lastUpdatedAt: entry.lastUpdatedAt
1496
2043
  });
@@ -1528,7 +2075,8 @@ var QueryClient = class {
1528
2075
  }
1529
2076
  dehydrate() {
1530
2077
  const entries = [];
1531
- for (const map of this.maps.values()) for (const ce of map.values()) if (ce.entry.status.peek() === "success") entries.push({
2078
+ for (const [query, map] of this.maps) for (const ce of map.values()) if (ce.entry.status.peek() === "success") entries.push({
2079
+ id: query.__id,
1532
2080
  key: ce.keyArgs,
1533
2081
  data: ce.entry.data.peek(),
1534
2082
  lastUpdatedAt: ce.entry.lastUpdatedAt.peek() ?? Date.now()
@@ -1554,6 +2102,19 @@ var QueryClient = class {
1554
2102
  if (tasks.length === 0) return;
1555
2103
  await Promise.all(tasks);
1556
2104
  }
2105
+ const unsettled = [];
2106
+ for (const map of this.maps.values()) for (const ce of map.values()) if (ce.entry.isFetching.peek()) unsettled.push({
2107
+ key: ce.keyArgs,
2108
+ kind: "data"
2109
+ });
2110
+ for (const map of this.infiniteMaps.values()) for (const ce of map.values()) if (ce.entry.isFetching.peek()) unsettled.push({
2111
+ key: ce.keyArgs,
2112
+ kind: "infinite"
2113
+ });
2114
+ const err = /* @__PURE__ */ new Error("[olas] waitForIdle: exceeded 100-iteration safety bound — cache or mutations keep restarting");
2115
+ err.unsettled = unsettled;
2116
+ err.mutationsInflight = this.mutationsInflight$.peek();
2117
+ throw err;
1557
2118
  }
1558
2119
  bindEntry(query, args) {
1559
2120
  const internal = query;
@@ -1568,8 +2129,9 @@ var QueryClient = class {
1568
2129
  const hash = stableHash(keyArgs);
1569
2130
  let entry = map.get(hash);
1570
2131
  if (!entry) {
1571
- const hydrated = this.hydratedData.get(hash);
1572
- if (hydrated) this.hydratedData.delete(hash);
2132
+ const hkey = hydrationKey(internal.__id, hash);
2133
+ const hydrated = this.hydratedData.get(hkey);
2134
+ if (hydrated) this.hydratedData.delete(hkey);
1573
2135
  const onFetchSuccess = internal.__spec.queryId != null ? (data) => this.emitSetData(internal, keyArgs, data, "data", "fetch") : void 0;
1574
2136
  entry = new ClientEntry(this, internal, args, keyArgs, internal.__spec, hydrated, onFetchSuccess);
1575
2137
  map.set(hash, entry);
@@ -1588,6 +2150,24 @@ var QueryClient = class {
1588
2150
  if (map.size === 0) this.maps.delete(entry.query);
1589
2151
  this.emitGc(entry.query, entry.keyArgs, "data");
1590
2152
  }
2153
+ /**
2154
+ * Invalidate one entry: if it has subscribers, mark stale AND refetch; if
2155
+ * not, mark stale only — the next subscriber refetches. Spec §5.7 says
2156
+ * invalidate refetches only IF subscribed; the old always-fetch behavior woke
2157
+ * orphaned entries that no one was watching (T3.9). Works for both
2158
+ * `ClientEntry` and `InfiniteClientEntry`.
2159
+ */
2160
+ invalidateEntry(entry) {
2161
+ if (entry.hasSubscribers()) entry.entry.invalidate().catch((err) => {
2162
+ if (isAbortError(err)) return;
2163
+ dispatchError(this.onError, err, {
2164
+ kind: "cache",
2165
+ controllerPath: [],
2166
+ queryKey: entry.keyArgs
2167
+ });
2168
+ });
2169
+ else entry.entry.markStale();
2170
+ }
1591
2171
  invalidate(query, args) {
1592
2172
  const internal = query;
1593
2173
  const map = this.maps.get(internal);
@@ -1596,14 +2176,7 @@ var QueryClient = class {
1596
2176
  const hash = stableHash(keyArgs);
1597
2177
  const entry = map.get(hash);
1598
2178
  if (!entry) return;
1599
- entry.entry.invalidate().catch((err) => {
1600
- if (isAbortError(err)) return;
1601
- dispatchError(this.onError, err, {
1602
- kind: "cache",
1603
- controllerPath: [],
1604
- queryKey: keyArgs
1605
- });
1606
- });
2179
+ this.invalidateEntry(entry);
1607
2180
  this.emitInvalidate(internal, keyArgs, "data");
1608
2181
  }
1609
2182
  invalidateAll(query) {
@@ -1611,22 +2184,35 @@ var QueryClient = class {
1611
2184
  const map = this.maps.get(internal);
1612
2185
  if (!map) return;
1613
2186
  for (const [hash, entry] of map) {
1614
- entry.entry.invalidate().catch((err) => {
1615
- if (isAbortError(err)) return;
1616
- dispatchError(this.onError, err, {
1617
- kind: "cache",
1618
- controllerPath: [],
1619
- queryKey: entry.keyArgs
1620
- });
1621
- });
2187
+ this.invalidateEntry(entry);
1622
2188
  this.emitInvalidate(internal, entry.keyArgs, "data");
1623
2189
  }
1624
2190
  }
2191
+ cancel(query, args) {
2192
+ const internal = query;
2193
+ const map = this.maps.get(internal);
2194
+ if (!map) return;
2195
+ const hash = stableHash(internal.__spec.key(...args));
2196
+ map.get(hash)?.entry.cancel();
2197
+ }
2198
+ cancelAll(query) {
2199
+ const map = this.maps.get(query);
2200
+ if (!map) return;
2201
+ for (const entry of map.values()) entry.entry.cancel();
2202
+ }
1625
2203
  setData(query, args, updater) {
1626
2204
  const entry = this.bindEntry(query, args);
1627
2205
  const snapshot = entry.entry.setData(updater);
1628
2206
  this.emitSetData(entry.query, entry.keyArgs, entry.entry.data.peek(), "data", "set");
1629
- return snapshot;
2207
+ return {
2208
+ rollback: () => {
2209
+ const before = entry.entry.data.peek();
2210
+ snapshot.rollback();
2211
+ const after = entry.entry.data.peek();
2212
+ if (!Object.is(before, after)) this.emitSetData(entry.query, entry.keyArgs, after, "data", "set");
2213
+ },
2214
+ finalize: () => snapshot.finalize()
2215
+ };
1630
2216
  }
1631
2217
  bindInfiniteEntry(query, args) {
1632
2218
  const internal = query;
@@ -1666,14 +2252,7 @@ var QueryClient = class {
1666
2252
  const hash = stableHash(keyArgs);
1667
2253
  const entry = map.get(hash);
1668
2254
  if (!entry) return;
1669
- entry.entry.invalidate().catch((err) => {
1670
- if (isAbortError(err)) return;
1671
- dispatchError(this.onError, err, {
1672
- kind: "cache",
1673
- controllerPath: [],
1674
- queryKey: entry.keyArgs
1675
- });
1676
- });
2255
+ this.invalidateEntry(entry);
1677
2256
  this.emitInvalidate(internal, keyArgs, "infinite");
1678
2257
  }
1679
2258
  invalidateAllInfinite(query) {
@@ -1681,22 +2260,35 @@ var QueryClient = class {
1681
2260
  const map = this.infiniteMaps.get(internal);
1682
2261
  if (!map) return;
1683
2262
  for (const entry of map.values()) {
1684
- entry.entry.invalidate().catch((err) => {
1685
- if (isAbortError(err)) return;
1686
- dispatchError(this.onError, err, {
1687
- kind: "cache",
1688
- controllerPath: [],
1689
- queryKey: entry.keyArgs
1690
- });
1691
- });
2263
+ this.invalidateEntry(entry);
1692
2264
  this.emitInvalidate(internal, entry.keyArgs, "infinite");
1693
2265
  }
1694
2266
  }
2267
+ cancelInfinite(query, args) {
2268
+ const internal = query;
2269
+ const map = this.infiniteMaps.get(internal);
2270
+ if (!map) return;
2271
+ const hash = stableHash(internal.__spec.key(...args));
2272
+ map.get(hash)?.entry.cancel();
2273
+ }
2274
+ cancelAllInfinite(query) {
2275
+ const map = this.infiniteMaps.get(query);
2276
+ if (!map) return;
2277
+ for (const entry of map.values()) entry.entry.cancel();
2278
+ }
1695
2279
  setInfiniteData(query, args, updater) {
1696
2280
  const entry = this.bindInfiniteEntry(query, args);
1697
2281
  const snapshot = entry.entry.setData(updater);
1698
2282
  this.emitSetData(entry.query, entry.keyArgs, entry.entry.pages.peek(), "infinite", "set");
1699
- return snapshot;
2283
+ return {
2284
+ rollback: () => {
2285
+ const before = entry.entry.pages.peek();
2286
+ snapshot.rollback();
2287
+ const after = entry.entry.pages.peek();
2288
+ if (!Object.is(before, after)) this.emitSetData(entry.query, entry.keyArgs, after, "infinite", "set");
2289
+ },
2290
+ finalize: () => snapshot.finalize()
2291
+ };
1700
2292
  }
1701
2293
  prefetchInfinite(query, args) {
1702
2294
  const entry = this.bindInfiniteEntry(query, args);
@@ -1734,7 +2326,7 @@ var QueryClient = class {
1734
2326
  this.hydratedData.clear();
1735
2327
  for (const plugin of this.plugins) if (plugin.dispose) {
1736
2328
  const cb = plugin.dispose;
1737
- this.callPlugin(() => cb.call(plugin));
2329
+ this.callPlugin(plugin, () => cb.call(plugin));
1738
2330
  }
1739
2331
  }
1740
2332
  };
@@ -1819,12 +2411,52 @@ function createEmitter(options) {
1819
2411
  }
1820
2412
  //#endregion
1821
2413
  //#region src/forms/field.ts
2414
+ /**
2415
+ * Flatten a single validator result to plain message strings for a leaf field.
2416
+ * A leaf has no descendants, so a `FormIssue[]`'s paths don't route anywhere —
2417
+ * every issue's `message` applies to this field. `null` → no messages.
2418
+ */
2419
+ function messagesFromResult(result) {
2420
+ if (result == null) return [];
2421
+ if (typeof result === "string") return [result];
2422
+ return result.map((issue) => issue.message);
2423
+ }
2424
+ /**
2425
+ * Structural equality used by `Field.set` to decide whether a write returns
2426
+ * the field to its initial value (clearing `isDirty`). Cheap path for
2427
+ * primitives + `Object.is`; deep walk for arrays and plain objects. Class
2428
+ * instances, Map, Set, Date fall back to reference identity — same trade-off
2429
+ * `structural-share.ts` makes for cache data.
2430
+ */
2431
+ function isStructurallyEqual(a, b) {
2432
+ if (Object.is(a, b)) return true;
2433
+ if (a === null || b === null || typeof a !== "object" || typeof b !== "object") return false;
2434
+ if (Array.isArray(a)) {
2435
+ if (!Array.isArray(b) || a.length !== b.length) return false;
2436
+ for (let i = 0; i < a.length; i++) if (!isStructurallyEqual(a[i], b[i])) return false;
2437
+ return true;
2438
+ }
2439
+ if (Array.isArray(b)) return false;
2440
+ const protoA = Object.getPrototypeOf(a);
2441
+ const protoB = Object.getPrototypeOf(b);
2442
+ if (protoA !== Object.prototype && protoA !== null) return false;
2443
+ if (protoB !== Object.prototype && protoB !== null) return false;
2444
+ const keysA = Object.keys(a);
2445
+ const keysB = Object.keys(b);
2446
+ if (keysA.length !== keysB.length) return false;
2447
+ for (const k of keysA) {
2448
+ if (!Object.hasOwn(b, k)) return false;
2449
+ if (!isStructurallyEqual(a[k], b[k])) return false;
2450
+ }
2451
+ return true;
2452
+ }
1822
2453
  var FieldImpl = class {
1823
2454
  value$;
1824
2455
  /**
1825
2456
  * Validator-produced errors. The public `errors` getter merges this with
1826
- * `serverErrors$` so consumers see a single flat array. Kept separate so a
1827
- * re-run of validators (after a new value) doesn't clobber server errors.
2457
+ * `serverErrors$` and `formErrors$` so consumers see a single flat array.
2458
+ * Kept separate so a re-run of validators (after a new value) doesn't clobber
2459
+ * the other two channels.
1828
2460
  */
1829
2461
  validatorErrors$;
1830
2462
  /**
@@ -1832,11 +2464,29 @@ var FieldImpl = class {
1832
2464
  * `set()`, on `reset()`, or via an explicit `setErrors([])`.
1833
2465
  */
1834
2466
  serverErrors$;
2467
+ /**
2468
+ * Errors routed here by a parent (or ancestor) form-level validator that
2469
+ * targeted this field via a `FormIssue` path — the third error channel
2470
+ * beside validator + server errors (T5.2). Owned by the routing form: cleared
2471
+ * and re-applied on every form-level validation run, so it does NOT clear on
2472
+ * the field's own `set()` (a stale cross-field error survives until the next
2473
+ * form-level run recomputes it). Cleared by `reset()` / `setAsInitial()`.
2474
+ */
2475
+ formErrors$;
1835
2476
  errors$;
1836
2477
  touched$;
1837
2478
  dirty$;
1838
2479
  validating$;
1839
2480
  isValid$;
2481
+ /**
2482
+ * Validity as of the last *settled* validation pass. While a pass is in
2483
+ * flight (`validating$`), `isValid` reads this instead of the live `errors`
2484
+ * — otherwise a `debouncedValidator` would flip `isValid` to `false` on every
2485
+ * keystroke (the async-start clears `validatorErrors$`), strobing a submit
2486
+ * button. Updated only when a pass completes (T5.3). Defaults `true` (an
2487
+ * untouched field with a pending first run reads valid, not invalid).
2488
+ */
2489
+ lastValid$;
1840
2490
  revalidateTrigger$;
1841
2491
  validators;
1842
2492
  /** The value `reset()` returns to. Mutated by `setAsInitial()` so a form
@@ -1848,25 +2498,42 @@ var FieldImpl = class {
1848
2498
  disposed = false;
1849
2499
  devtoolsOwner = null;
1850
2500
  onValidatorError = null;
2501
+ validateOn;
2502
+ /** Reactive gate — when false, the validator effect skips its run. Flipped
2503
+ * on by the relevant trigger for the field's `validateOn` mode. Once on,
2504
+ * stays on for the lifetime of the field (matches RHF's `mode + reValidateMode`
2505
+ * default semantics: after the first activation, subsequent changes
2506
+ * re-validate). `reset()` flips it back to false. */
2507
+ validateUnlocked$;
1851
2508
  constructor(initial, validators = [], options) {
1852
2509
  this.initial = initial;
1853
2510
  this.validators = validators;
1854
2511
  this.onValidatorError = options?.onValidatorError ?? null;
2512
+ this.validateOn = options?.validateOn ?? "change";
1855
2513
  this.value$ = signal(initial);
1856
2514
  this.validatorErrors$ = signal([]);
1857
2515
  this.serverErrors$ = signal([]);
2516
+ this.formErrors$ = signal([]);
1858
2517
  this.touched$ = signal(false);
1859
2518
  this.dirty$ = signal(false);
1860
2519
  this.validating$ = signal(false);
2520
+ this.lastValid$ = signal(true);
1861
2521
  this.revalidateTrigger$ = signal(0);
2522
+ this.validateUnlocked$ = signal(this.validateOn === "change");
1862
2523
  this.errors$ = computed(() => {
1863
2524
  const v = this.validatorErrors$.value;
1864
2525
  const s = this.serverErrors$.value;
1865
- if (s.length === 0) return v;
1866
- if (v.length === 0) return s;
1867
- return [...v, ...s];
2526
+ const f = this.formErrors$.value;
2527
+ if (s.length === 0 && f.length === 0) return v;
2528
+ if (v.length === 0 && f.length === 0) return s;
2529
+ if (v.length === 0 && s.length === 0) return f;
2530
+ return [
2531
+ ...v,
2532
+ ...s,
2533
+ ...f
2534
+ ];
1868
2535
  });
1869
- this.isValid$ = computed(() => this.errors$.value.length === 0 && !this.validating$.value);
2536
+ this.isValid$ = computed(() => this.validating$.value ? this.lastValid$.value : this.errors$.value.length === 0);
1870
2537
  if (validators.length > 0) this.validatorDispose = effect(() => {
1871
2538
  this.runValidators();
1872
2539
  });
@@ -1887,6 +2554,9 @@ var FieldImpl = class {
1887
2554
  subscribe(handler) {
1888
2555
  return this.value$.subscribe(handler);
1889
2556
  }
2557
+ subscribeChanges(handler) {
2558
+ return this.value$.subscribeChanges(handler);
2559
+ }
1890
2560
  get errors() {
1891
2561
  return this.errors$;
1892
2562
  }
@@ -1906,7 +2576,7 @@ var FieldImpl = class {
1906
2576
  if (this.disposed) return;
1907
2577
  batch(() => {
1908
2578
  this.value$.set(value);
1909
- this.dirty$.set(true);
2579
+ this.dirty$.set(!isStructurallyEqual(value, this.initial));
1910
2580
  if (this.serverErrors$.peek().length > 0) this.serverErrors$.set([]);
1911
2581
  });
1912
2582
  }
@@ -1916,6 +2586,18 @@ var FieldImpl = class {
1916
2586
  this.serverErrors$.set(next);
1917
2587
  }
1918
2588
  /**
2589
+ * Internal — set the parent-form-validator error channel (`formErrors$`).
2590
+ * Called by the owning form's issue router when a `FormIssue` path resolves
2591
+ * to this field. Guards against churning the signal when nothing changes so
2592
+ * a whole-tree clear pass doesn't wake unrelated subscribers. See T5.2 /
2593
+ * `routeFormIssues` in `form.ts`.
2594
+ */
2595
+ setFormErrors(errors) {
2596
+ if (this.disposed) return;
2597
+ if (this.formErrors$.peek().length === 0 && errors.length === 0) return;
2598
+ this.formErrors$.set(errors.length === 0 ? [] : [...errors]);
2599
+ }
2600
+ /**
1919
2601
  * Reseat the field as if this value had been its constructor `initial`.
1920
2602
  * Sets the value, re-anchors `reset()`'s target, and does NOT mark dirty.
1921
2603
  * Used by `Form` when applying its own `initial` (in the constructor and
@@ -1928,6 +2610,8 @@ var FieldImpl = class {
1928
2610
  batch(() => {
1929
2611
  this.value$.set(value);
1930
2612
  this.dirty$.set(false);
2613
+ if (this.serverErrors$.peek().length > 0) this.serverErrors$.set([]);
2614
+ if (this.formErrors$.peek().length > 0) this.formErrors$.set([]);
1931
2615
  });
1932
2616
  }
1933
2617
  reset() {
@@ -1940,15 +2624,19 @@ var FieldImpl = class {
1940
2624
  this.touched$.set(false);
1941
2625
  this.validatorErrors$.set([]);
1942
2626
  this.serverErrors$.set([]);
2627
+ this.formErrors$.set([]);
1943
2628
  this.validating$.set(false);
2629
+ if (this.validateOn !== "change") this.validateUnlocked$.set(false);
1944
2630
  });
1945
2631
  }
1946
2632
  markTouched() {
1947
2633
  if (this.disposed) return;
1948
2634
  this.touched$.set(true);
2635
+ if (this.validateOn === "blur" && !this.validateUnlocked$.peek()) this.validateUnlocked$.set(true);
1949
2636
  }
1950
2637
  async revalidate() {
1951
2638
  if (this.disposed) return this.isValid$.peek();
2639
+ if (!this.validateUnlocked$.peek()) this.validateUnlocked$.set(true);
1952
2640
  this.revalidateTrigger$.update((n) => n + 1);
1953
2641
  await this.waitUntilSettled();
1954
2642
  return this.isValid$.peek();
@@ -1987,6 +2675,14 @@ var FieldImpl = class {
1987
2675
  if (this.disposed) return;
1988
2676
  const value = this.value$.value;
1989
2677
  this.revalidateTrigger$.value;
2678
+ if (!this.validateUnlocked$.value) {
2679
+ batch(() => {
2680
+ if (this.validatorErrors$.peek().length > 0) this.validatorErrors$.set([]);
2681
+ if (this.validating$.peek()) this.validating$.set(false);
2682
+ this.lastValid$.set(this.errors$.peek().length === 0);
2683
+ });
2684
+ return;
2685
+ }
1990
2686
  this.currentAbort?.abort();
1991
2687
  const abort = new AbortController();
1992
2688
  this.currentAbort = abort;
@@ -1996,17 +2692,21 @@ var FieldImpl = class {
1996
2692
  for (const validator of this.validators) try {
1997
2693
  const result = validator(value, abort.signal);
1998
2694
  if (result instanceof Promise) asyncPromises.push(result);
1999
- else if (result != null) syncErrors.push(result);
2695
+ else {
2696
+ const msgs = messagesFromResult(result);
2697
+ if (msgs.length > 0) syncErrors.push(...msgs);
2698
+ }
2000
2699
  } catch (err) {
2001
2700
  try {
2002
2701
  this.onValidatorError?.(err);
2003
2702
  } catch {}
2004
- syncErrors.push(err instanceof Error ? err.message : String(err));
2703
+ syncErrors.push("Validation failed");
2005
2704
  }
2006
2705
  if (syncErrors.length > 0) {
2007
2706
  batch(() => {
2008
2707
  this.validatorErrors$.set(syncErrors);
2009
2708
  this.validating$.set(false);
2709
+ this.lastValid$.set(this.errors$.peek().length === 0);
2010
2710
  });
2011
2711
  this.emitValidated(false, syncErrors);
2012
2712
  return;
@@ -2015,6 +2715,7 @@ var FieldImpl = class {
2015
2715
  batch(() => {
2016
2716
  this.validatorErrors$.set([]);
2017
2717
  this.validating$.set(false);
2718
+ this.lastValid$.set(this.errors$.peek().length === 0);
2018
2719
  });
2019
2720
  this.emitValidated(true, []);
2020
2721
  return;
@@ -2026,15 +2727,15 @@ var FieldImpl = class {
2026
2727
  Promise.allSettled(asyncPromises).then((results) => {
2027
2728
  if (myId !== this.runId || this.disposed) return;
2028
2729
  const asyncErrors = [];
2029
- for (const r of results) if (r.status === "fulfilled") {
2030
- if (r.value != null) asyncErrors.push(r.value);
2031
- } else if (!isAbortError(r.reason)) {
2730
+ for (const r of results) if (r.status === "fulfilled") asyncErrors.push(...messagesFromResult(r.value));
2731
+ else if (!isAbortError(r.reason)) {
2032
2732
  const msg = r.reason instanceof Error ? r.reason.message : String(r.reason);
2033
2733
  asyncErrors.push(msg);
2034
2734
  }
2035
2735
  batch(() => {
2036
2736
  this.validatorErrors$.set(asyncErrors);
2037
2737
  this.validating$.set(false);
2738
+ this.lastValid$.set(this.errors$.peek().length === 0);
2038
2739
  });
2039
2740
  this.emitValidated(asyncErrors.length === 0, asyncErrors);
2040
2741
  });
@@ -2065,7 +2766,9 @@ function createField(initial, validators, options) {
2065
2766
  /**
2066
2767
  * Wrap an async validator with a debounce. The debounce timer resets on every
2067
2768
  * value change. While debouncing or the request is in flight, the field's
2068
- * `isValidating` is true and `isValid` is false (treat-as-invalid-until-proven-valid).
2769
+ * `isValidating` is true and `isValid` HOLDS its last settled value (T5.3) — so
2770
+ * editing an already-valid field doesn't strobe a submit button to disabled on
2771
+ * every keystroke. A field with no prior settled validation defaults to valid.
2069
2772
  */
2070
2773
  function debouncedValidator(fn, ms) {
2071
2774
  return (value, signal) => new Promise((resolve, reject) => {
@@ -2091,6 +2794,73 @@ const FORM_BRAND = Symbol.for("olas.form");
2091
2794
  const FIELD_ARRAY_BRAND = Symbol.for("olas.fieldArray");
2092
2795
  const isForm = (x) => typeof x === "object" && x !== null && x[FORM_BRAND] === true;
2093
2796
  const isFieldArray = (x) => typeof x === "object" && x !== null && x[FIELD_ARRAY_BRAND] === true;
2797
+ /**
2798
+ * Walk a form tree from `root` following `FormIssue.path` segments. Forms walk
2799
+ * keys; field-arrays walk numeric indices. Returns the resolved node, or
2800
+ * `undefined` if the path runs off the tree (bad index, or a leaf reached with
2801
+ * an unconsumed path segment). Reads are untracked (`fields` is a static object;
2802
+ * `at()` peeks) so calling this inside a validator effect adds no dependencies.
2803
+ */
2804
+ function resolveNode(root, segments) {
2805
+ let cursor = root;
2806
+ for (const seg of segments) {
2807
+ if (cursor === void 0 || cursor === null) return void 0;
2808
+ if (isForm(cursor)) cursor = cursor.fields[String(seg)];
2809
+ else if (isFieldArray(cursor)) {
2810
+ const idx = Number(seg);
2811
+ if (!Number.isInteger(idx) || idx < 0) return void 0;
2812
+ cursor = cursor.at(idx);
2813
+ } else return;
2814
+ }
2815
+ return cursor;
2816
+ }
2817
+ /**
2818
+ * Normalize one validator result into the shared `FormIssue[]` shape. A plain
2819
+ * string is a message on the node itself (empty path); `null` contributes
2820
+ * nothing; a `FormIssue[]` is appended verbatim.
2821
+ */
2822
+ function appendIssues(out, result) {
2823
+ if (result == null) return;
2824
+ if (typeof result === "string") {
2825
+ out.push({
2826
+ path: [],
2827
+ message: result
2828
+ });
2829
+ return;
2830
+ }
2831
+ for (const issue of result) out.push(issue);
2832
+ }
2833
+ /**
2834
+ * Route a fully-collected issue set for one form-level validation run:
2835
+ * - empty-path (and unresolvable) issues → `topLevelErrors$` on the owning node
2836
+ * - path issues → the resolved descendant's `setFormErrors`
2837
+ *
2838
+ * Targets that received an error last run but not this one are cleared, so a
2839
+ * fixed cross-field rule removes its message from the field it landed on.
2840
+ * Returns the new target set for the caller to retain. MUST run inside a batch.
2841
+ */
2842
+ function routeFormIssues(root, issues, topLevelErrors$, lastTargets) {
2843
+ const topLevel = [];
2844
+ const byTarget = /* @__PURE__ */ new Map();
2845
+ for (const issue of issues) {
2846
+ if (issue.path.length === 0) {
2847
+ topLevel.push(issue.message);
2848
+ continue;
2849
+ }
2850
+ const target = resolveNode(root, issue.path);
2851
+ if (target === void 0 || typeof target.setFormErrors !== "function") {
2852
+ topLevel.push(issue.message);
2853
+ continue;
2854
+ }
2855
+ const list = byTarget.get(target);
2856
+ if (list) list.push(issue.message);
2857
+ else byTarget.set(target, [issue.message]);
2858
+ }
2859
+ topLevelErrors$.set(topLevel);
2860
+ for (const t of lastTargets) if (!byTarget.has(t)) t.setFormErrors?.([]);
2861
+ for (const [t, msgs] of byTarget) t.setFormErrors?.(msgs);
2862
+ return new Set(byTarget.keys());
2863
+ }
2094
2864
  var FormImpl = class {
2095
2865
  [FORM_BRAND] = true;
2096
2866
  fields;
@@ -2101,9 +2871,33 @@ var FormImpl = class {
2101
2871
  touched;
2102
2872
  isValidating;
2103
2873
  flatErrors;
2874
+ /**
2875
+ * Dotted paths of every leaf whose `isDirty` is `true`. Recomputes when
2876
+ * any child's dirty state flips; ordered by depth-first traversal so two
2877
+ * snapshots of a stable tree compare with `===`-friendly references on
2878
+ * unchanged subsets. Useful for partial-update PATCH payloads and
2879
+ * "highlight the changed inputs" UIs.
2880
+ */
2881
+ dirtyFields;
2104
2882
  topLevelErrors$ = signal([]);
2105
- topLevelErrors = this.topLevelErrors$;
2883
+ /**
2884
+ * Errors routed to THIS form by an ancestor form-level validator (a
2885
+ * `FormIssue` whose path resolves to this node). Merged into `topLevelErrors`
2886
+ * beside this form's own validator output, so `topLevelErrors` means "errors
2887
+ * attached to this node itself, whatever their source". Owned by the ancestor
2888
+ * router (T5.2) — see `setFormErrors`.
2889
+ */
2890
+ parentFormErrors$ = signal([]);
2891
+ topLevelErrors = computed(() => {
2892
+ const own = this.topLevelErrors$.value;
2893
+ const parent = this.parentFormErrors$.value;
2894
+ if (parent.length === 0) return own;
2895
+ if (own.length === 0) return parent;
2896
+ return [...own, ...parent];
2897
+ });
2106
2898
  topLevelValidating$ = signal(false);
2899
+ /** Targets written by the last form-level run — cleared next run if absent. */
2900
+ lastFormErrorTargets = /* @__PURE__ */ new Set();
2107
2901
  isSubmitting$ = signal(false);
2108
2902
  submitCount$ = signal(0);
2109
2903
  submitError$ = signal(void 0);
@@ -2157,12 +2951,17 @@ var FormImpl = class {
2157
2951
  return false;
2158
2952
  });
2159
2953
  this.isValid = computed(() => {
2160
- if (this.topLevelErrors$.value.length > 0) return false;
2954
+ if (this.topLevelErrors.value.length > 0) return false;
2161
2955
  if (this.isValidating.value) return false;
2162
2956
  for (const child of Object.values(this.fields)) if (!child.isValid.value) return false;
2163
2957
  return true;
2164
2958
  });
2165
2959
  this.flatErrors = computed(() => this.computeFlatErrors());
2960
+ this.dirtyFields = computed(() => {
2961
+ const out = [];
2962
+ collectDirtyFields(this.fields, "", out);
2963
+ return out;
2964
+ });
2166
2965
  if (this.validators.length > 0) this.validatorDispose = effect(() => this.runTopLevelValidators());
2167
2966
  }
2168
2967
  computeValue() {
@@ -2187,7 +2986,7 @@ var FormImpl = class {
2187
2986
  }
2188
2987
  computeFlatErrors() {
2189
2988
  const out = [];
2190
- const tle = this.topLevelErrors$.value;
2989
+ const tle = this.topLevelErrors.value;
2191
2990
  if (tle.length > 0) out.push({
2192
2991
  path: "",
2193
2992
  errors: tle
@@ -2243,11 +3042,14 @@ var FormImpl = class {
2243
3042
  for (const child of Object.values(this.fields)) if (isForm(child) || isFieldArray(child)) child.reset();
2244
3043
  else child.reset();
2245
3044
  this.topLevelErrors$.set([]);
3045
+ this.parentFormErrors$.set([]);
3046
+ this.submitCount$.set(0);
3047
+ this.submitError$.set(void 0);
3048
+ if (this.options?.initial !== void 0) {
3049
+ const ini = typeof this.options.initial === "function" ? this.options.initial() : this.options.initial;
3050
+ if (ini !== void 0) this.applyPartial(ini, true);
3051
+ }
2246
3052
  });
2247
- if (this.options?.initial !== void 0) {
2248
- const ini = typeof this.options.initial === "function" ? this.options.initial() : this.options.initial;
2249
- if (ini !== void 0) this.applyPartial(ini, true);
2250
- }
2251
3053
  }
2252
3054
  markAllTouched() {
2253
3055
  if (this.disposed) return;
@@ -2349,9 +3151,43 @@ var FormImpl = class {
2349
3151
  }
2350
3152
  });
2351
3153
  }
3154
+ /**
3155
+ * Internal — receive errors routed here by an ancestor form-level validator
3156
+ * (a `FormIssue` whose path resolved to this nested form). Guards against
3157
+ * spurious writes so a whole-tree clear pass doesn't wake subscribers. See
3158
+ * `routeFormIssues`.
3159
+ */
3160
+ setFormErrors(errors) {
3161
+ if (this.disposed) return;
3162
+ if (this.parentFormErrors$.peek().length === 0 && errors.length === 0) return;
3163
+ this.parentFormErrors$.set(errors.length === 0 ? [] : [...errors]);
3164
+ }
3165
+ /**
3166
+ * Reset a named subtree to its initial. `path` uses the same dotted /
3167
+ * bracket notation as `setErrors` / `flatErrors`. Useful when
3168
+ * `Form.set({foo: undefined})` would be ambiguous ("clear" vs "leave
3169
+ * alone" — the latter is what `set` does today).
3170
+ *
3171
+ * Walks via `resolvePath`; the resolved target must expose `reset()`
3172
+ * (Field, Form, or FieldArray all do). Unknown paths are silently
3173
+ * ignored — `flatErrors`-shaped paths from upstream code shouldn't
3174
+ * crash this. Pass an empty string to reset the whole form (same as
3175
+ * `form.reset()`).
3176
+ */
3177
+ clearSubtree(path) {
3178
+ if (this.disposed) return;
3179
+ if (path === "") {
3180
+ this.reset();
3181
+ return;
3182
+ }
3183
+ const target = this.resolvePath(path);
3184
+ if (target === void 0) return;
3185
+ if (typeof target.reset === "function") target.reset();
3186
+ }
2352
3187
  resolvePath(path) {
2353
3188
  if (path === "") return void 0;
2354
- const segments = path.split(".");
3189
+ const segments = splitPath(path);
3190
+ if (segments === null) return void 0;
2355
3191
  let cursor = this;
2356
3192
  for (const seg of segments) {
2357
3193
  if (cursor === void 0 || cursor === null) return void 0;
@@ -2388,47 +3224,99 @@ var FormImpl = class {
2388
3224
  const abort = new AbortController();
2389
3225
  this.currentValidatorAbort = abort;
2390
3226
  const myId = ++this.currentValidatorRun;
2391
- const syncErrors = [];
3227
+ const syncIssues = [];
2392
3228
  const asyncPromises = [];
2393
3229
  for (const v of this.validators) try {
2394
3230
  const r = v(value, abort.signal);
2395
3231
  if (r instanceof Promise) asyncPromises.push(r);
2396
- else if (r != null) syncErrors.push(r);
3232
+ else appendIssues(syncIssues, r);
2397
3233
  } catch (err) {
2398
3234
  try {
2399
3235
  this.onValidatorError?.(err);
2400
3236
  } catch {}
2401
- syncErrors.push(err instanceof Error ? err.message : String(err));
3237
+ syncIssues.push({
3238
+ path: [],
3239
+ message: "Validation failed"
3240
+ });
2402
3241
  }
2403
- if (syncErrors.length > 0) {
3242
+ if (syncIssues.length > 0) {
2404
3243
  batch(() => {
2405
- this.topLevelErrors$.set(syncErrors);
3244
+ this.lastFormErrorTargets = routeFormIssues(this, syncIssues, this.topLevelErrors$, this.lastFormErrorTargets);
2406
3245
  this.topLevelValidating$.set(false);
2407
3246
  });
2408
3247
  return;
2409
3248
  }
2410
3249
  if (asyncPromises.length === 0) {
2411
3250
  batch(() => {
2412
- this.topLevelErrors$.set([]);
3251
+ this.lastFormErrorTargets = routeFormIssues(this, [], this.topLevelErrors$, this.lastFormErrorTargets);
2413
3252
  this.topLevelValidating$.set(false);
2414
3253
  });
2415
3254
  return;
2416
3255
  }
2417
3256
  batch(() => {
2418
- this.topLevelErrors$.set([]);
3257
+ this.lastFormErrorTargets = routeFormIssues(this, [], this.topLevelErrors$, this.lastFormErrorTargets);
2419
3258
  this.topLevelValidating$.set(true);
2420
3259
  });
2421
3260
  Promise.allSettled(asyncPromises).then((results) => {
2422
3261
  if (myId !== this.currentValidatorRun || this.disposed) return;
2423
- const errs = [];
2424
- for (const r of results) if (r.status === "fulfilled" && r.value != null) errs.push(r.value);
3262
+ const issues = [];
3263
+ for (const r of results) if (r.status === "fulfilled") appendIssues(issues, r.value);
2425
3264
  batch(() => {
2426
- this.topLevelErrors$.set(errs);
3265
+ this.lastFormErrorTargets = routeFormIssues(this, issues, this.topLevelErrors$, this.lastFormErrorTargets);
2427
3266
  this.topLevelValidating$.set(false);
2428
3267
  });
2429
3268
  });
2430
3269
  }
2431
3270
  };
3271
+ /**
3272
+ * Split a path string into segments, accepting both dot syntax (`users.0.name`)
3273
+ * and the bracket syntax `walkErrors` emits for array items (`users[0].name`).
3274
+ * Returns `null` if the path is malformed (unclosed bracket, empty bracket,
3275
+ * non-numeric bracket index).
3276
+ */
3277
+ function splitPath(path) {
3278
+ const out = [];
3279
+ let current = "";
3280
+ for (let i = 0; i < path.length; i++) {
3281
+ const ch = path[i];
3282
+ if (ch === ".") {
3283
+ if (current !== "") {
3284
+ out.push(current);
3285
+ current = "";
3286
+ }
3287
+ continue;
3288
+ }
3289
+ if (ch === "[") {
3290
+ if (current !== "") {
3291
+ out.push(current);
3292
+ current = "";
3293
+ }
3294
+ const close = path.indexOf("]", i + 1);
3295
+ if (close === -1) return null;
3296
+ const idx = path.slice(i + 1, close);
3297
+ if (idx === "" || !/^\d+$/.test(idx)) return null;
3298
+ out.push(idx);
3299
+ i = close;
3300
+ continue;
3301
+ }
3302
+ if (ch === "]") return null;
3303
+ current += ch;
3304
+ }
3305
+ if (current !== "") out.push(current);
3306
+ return out;
3307
+ }
3308
+ function collectDirtyFields(fields, prefix, out) {
3309
+ for (const [k, child] of Object.entries(fields)) {
3310
+ const path = prefix ? `${prefix}.${k}` : k;
3311
+ if (isForm(child)) collectDirtyFields(child.fields, path, out);
3312
+ else if (isFieldArray(child)) child.items.value.forEach((item, idx) => {
3313
+ const itemPath = `${path}[${idx}]`;
3314
+ if (isForm(item)) collectDirtyFields(item.fields, itemPath, out);
3315
+ else if (item.isDirty.value) out.push(itemPath);
3316
+ });
3317
+ else if (child.isDirty.value) out.push(path);
3318
+ }
3319
+ }
2432
3320
  function walkErrors(fields, prefix, out) {
2433
3321
  for (const [k, child] of Object.entries(fields)) {
2434
3322
  const path = prefix ? `${prefix}.${k}` : k;
@@ -2482,9 +3370,28 @@ var FieldArrayImpl = class {
2482
3370
  touched;
2483
3371
  isValidating;
2484
3372
  items$;
3373
+ /**
3374
+ * Structural dirtiness — flipped by `add`/`insert`/`remove`/`move`/`clear`.
3375
+ * Item-level `isDirty` alone misses these, so a reactive `initial` + the
3376
+ * default `resetOnInitialChange: 'when-clean'` would re-seat the array on a
3377
+ * background refetch and delete rows the user just added (T5.1). Reset by
3378
+ * `reset()` and by an initial-driven re-anchor (`replaceInitialItems`).
3379
+ */
3380
+ structurallyDirty$ = signal(false);
2485
3381
  topLevelErrors$ = signal([]);
2486
- topLevelErrors = this.topLevelErrors$;
3382
+ /** Errors routed to this array by an ancestor form-level validator (T5.2) —
3383
+ * merged into `topLevelErrors` beside the array's own validator output. */
3384
+ parentFormErrors$ = signal([]);
3385
+ topLevelErrors = computed(() => {
3386
+ const own = this.topLevelErrors$.value;
3387
+ const parent = this.parentFormErrors$.value;
3388
+ if (parent.length === 0) return own;
3389
+ if (own.length === 0) return parent;
3390
+ return [...own, ...parent];
3391
+ });
2487
3392
  topLevelValidating$ = signal(false);
3393
+ /** Targets written by the last array-level run — cleared next run if absent. */
3394
+ lastFormErrorTargets = /* @__PURE__ */ new Set();
2488
3395
  itemFactory;
2489
3396
  initialItems = [];
2490
3397
  validators;
@@ -2519,6 +3426,7 @@ var FieldArrayImpl = class {
2519
3426
  return errs.length > 0 ? errs : void 0;
2520
3427
  }));
2521
3428
  this.isDirty = computed(() => {
3429
+ if (this.structurallyDirty$.value) return true;
2522
3430
  for (const item of this.items$.value) if (item.isDirty.value) return true;
2523
3431
  return false;
2524
3432
  });
@@ -2532,7 +3440,7 @@ var FieldArrayImpl = class {
2532
3440
  return false;
2533
3441
  });
2534
3442
  this.isValid = computed(() => {
2535
- if (this.topLevelErrors$.value.length > 0) return false;
3443
+ if (this.topLevelErrors.value.length > 0) return false;
2536
3444
  if (this.isValidating.value) return false;
2537
3445
  for (const item of this.items$.value) if (!item.isValid.value) return false;
2538
3446
  return true;
@@ -2542,10 +3450,20 @@ var FieldArrayImpl = class {
2542
3450
  at(index) {
2543
3451
  return this.items$.peek()[index];
2544
3452
  }
3453
+ /**
3454
+ * Internal — receive errors routed here by an ancestor form-level validator
3455
+ * (a `FormIssue` whose path resolved to this array). See `routeFormIssues`.
3456
+ */
3457
+ setFormErrors(errors) {
3458
+ if (this.disposed) return;
3459
+ if (this.parentFormErrors$.peek().length === 0 && errors.length === 0) return;
3460
+ this.parentFormErrors$.set(errors.length === 0 ? [] : [...errors]);
3461
+ }
2545
3462
  add(initial) {
2546
3463
  if (this.disposed) return;
2547
3464
  const item = this.itemFactory(initial);
2548
3465
  this.items$.set([...this.items$.peek(), item]);
3466
+ this.structurallyDirty$.set(true);
2549
3467
  }
2550
3468
  insert(index, initial) {
2551
3469
  if (this.disposed) return;
@@ -2553,6 +3471,7 @@ var FieldArrayImpl = class {
2553
3471
  const next = [...this.items$.peek()];
2554
3472
  next.splice(index, 0, item);
2555
3473
  this.items$.set(next);
3474
+ this.structurallyDirty$.set(true);
2556
3475
  }
2557
3476
  remove(index) {
2558
3477
  if (this.disposed) return;
@@ -2560,6 +3479,7 @@ var FieldArrayImpl = class {
2560
3479
  const [removed] = next.splice(index, 1);
2561
3480
  if (removed) removed.dispose?.();
2562
3481
  this.items$.set(next);
3482
+ this.structurallyDirty$.set(true);
2563
3483
  }
2564
3484
  move(from, to) {
2565
3485
  if (this.disposed) return;
@@ -2567,11 +3487,13 @@ var FieldArrayImpl = class {
2567
3487
  const [item] = next.splice(from, 1);
2568
3488
  if (item) next.splice(to, 0, item);
2569
3489
  this.items$.set(next);
3490
+ this.structurallyDirty$.set(true);
2570
3491
  }
2571
3492
  clear() {
2572
3493
  if (this.disposed) return;
2573
3494
  for (const item of this.items$.peek()) item.dispose?.();
2574
3495
  this.items$.set([]);
3496
+ this.structurallyDirty$.set(true);
2575
3497
  }
2576
3498
  /**
2577
3499
  * Internal — used by `Form.resetWithInitial` to re-anchor the array's
@@ -2581,6 +3503,7 @@ var FieldArrayImpl = class {
2581
3503
  */
2582
3504
  replaceInitialItems(items) {
2583
3505
  this.initialItems = [...items];
3506
+ this.structurallyDirty$.set(false);
2584
3507
  }
2585
3508
  reset() {
2586
3509
  if (this.disposed) return;
@@ -2588,6 +3511,8 @@ var FieldArrayImpl = class {
2588
3511
  this.clear();
2589
3512
  for (const ini of this.initialItems) this.add(ini);
2590
3513
  this.topLevelErrors$.set([]);
3514
+ this.parentFormErrors$.set([]);
3515
+ this.structurallyDirty$.set(false);
2591
3516
  });
2592
3517
  }
2593
3518
  markAllTouched() {
@@ -2625,42 +3550,45 @@ var FieldArrayImpl = class {
2625
3550
  const abort = new AbortController();
2626
3551
  this.currentValidatorAbort = abort;
2627
3552
  const myId = ++this.currentValidatorRun;
2628
- const syncErrors = [];
3553
+ const syncIssues = [];
2629
3554
  const asyncPromises = [];
2630
3555
  for (const v of this.validators) try {
2631
3556
  const r = v(value, abort.signal);
2632
3557
  if (r instanceof Promise) asyncPromises.push(r);
2633
- else if (r != null) syncErrors.push(r);
3558
+ else appendIssues(syncIssues, r);
2634
3559
  } catch (err) {
2635
3560
  try {
2636
3561
  this.onValidatorError?.(err);
2637
3562
  } catch {}
2638
- syncErrors.push(err instanceof Error ? err.message : String(err));
3563
+ syncIssues.push({
3564
+ path: [],
3565
+ message: "Validation failed"
3566
+ });
2639
3567
  }
2640
- if (syncErrors.length > 0) {
3568
+ if (syncIssues.length > 0) {
2641
3569
  batch(() => {
2642
- this.topLevelErrors$.set(syncErrors);
3570
+ this.lastFormErrorTargets = routeFormIssues(this, syncIssues, this.topLevelErrors$, this.lastFormErrorTargets);
2643
3571
  this.topLevelValidating$.set(false);
2644
3572
  });
2645
3573
  return;
2646
3574
  }
2647
3575
  if (asyncPromises.length === 0) {
2648
3576
  batch(() => {
2649
- this.topLevelErrors$.set([]);
3577
+ this.lastFormErrorTargets = routeFormIssues(this, [], this.topLevelErrors$, this.lastFormErrorTargets);
2650
3578
  this.topLevelValidating$.set(false);
2651
3579
  });
2652
3580
  return;
2653
3581
  }
2654
3582
  batch(() => {
2655
- this.topLevelErrors$.set([]);
3583
+ this.lastFormErrorTargets = routeFormIssues(this, [], this.topLevelErrors$, this.lastFormErrorTargets);
2656
3584
  this.topLevelValidating$.set(true);
2657
3585
  });
2658
3586
  Promise.allSettled(asyncPromises).then((results) => {
2659
3587
  if (myId !== this.currentValidatorRun || this.disposed) return;
2660
- const errs = [];
2661
- for (const r of results) if (r.status === "fulfilled" && r.value != null) errs.push(r.value);
3588
+ const issues = [];
3589
+ for (const r of results) if (r.status === "fulfilled") appendIssues(issues, r.value);
2662
3590
  batch(() => {
2663
- this.topLevelErrors$.set(errs);
3591
+ this.lastFormErrorTargets = routeFormIssues(this, issues, this.topLevelErrors$, this.lastFormErrorTargets);
2664
3592
  this.topLevelValidating$.set(false);
2665
3593
  });
2666
3594
  });
@@ -2797,6 +3725,9 @@ var LocalCacheImpl = class {
2797
3725
  get hasPendingMutations() {
2798
3726
  return this.entry.hasPendingMutations;
2799
3727
  }
3728
+ get isPaused() {
3729
+ return this.entry.isPaused;
3730
+ }
2800
3731
  refetch = () => this.entry.refetch();
2801
3732
  reset = () => this.entry.reset();
2802
3733
  firstValue = () => this.entry.firstValue();
@@ -2872,6 +3803,7 @@ var MutationImpl = class {
2872
3803
  data = signal(void 0);
2873
3804
  error = signal(void 0);
2874
3805
  isPending = signal(false);
3806
+ status = signal("idle");
2875
3807
  lastVariables = signal(void 0);
2876
3808
  inflight = /* @__PURE__ */ new Set();
2877
3809
  serialQueue = [];
@@ -2940,10 +3872,11 @@ var MutationImpl = class {
2940
3872
  const raw = this.spec.onMutate?.(vars) ?? void 0;
2941
3873
  snapshot = raw === void 0 ? void 0 : this.wrapSnapshot(raw);
2942
3874
  } catch (err) {
2943
- dispatchError(this.onError, err, {
2944
- kind: "mutation",
2945
- controllerPath: this.controllerPath
2946
- });
3875
+ this.error.set(err);
3876
+ this.status.set("error");
3877
+ this.safeCall(() => this.spec.onError?.(err, vars, void 0), "mutation");
3878
+ this.safeCall(() => this.spec.onSettled?.(void 0, err, vars), "mutation");
3879
+ throw err;
2947
3880
  }
2948
3881
  const handle = {
2949
3882
  abort,
@@ -2953,6 +3886,7 @@ var MutationImpl = class {
2953
3886
  this.inflightCounter?.update((n) => n + 1);
2954
3887
  batch(() => {
2955
3888
  this.isPending.set(true);
3889
+ this.status.set("pending");
2956
3890
  this.lastVariables.set(vars);
2957
3891
  });
2958
3892
  const runId = this.isPersistable ? makeRunId() : "";
@@ -2984,6 +3918,7 @@ var MutationImpl = class {
2984
3918
  batch(() => {
2985
3919
  this.data.set(result);
2986
3920
  this.error.set(void 0);
3921
+ this.status.set("success");
2987
3922
  });
2988
3923
  this.safeCall(() => this.spec.onSuccess?.(result, vars), "mutation");
2989
3924
  snapshot?.finalize();
@@ -3005,6 +3940,7 @@ var MutationImpl = class {
3005
3940
  throw err;
3006
3941
  }
3007
3942
  this.error.set(err);
3943
+ this.status.set("error");
3008
3944
  this.safeCall(() => this.spec.onError?.(err, vars, snapshot), "mutation");
3009
3945
  snapshot?.rollback();
3010
3946
  this.safeCall(() => this.spec.onSettled?.(void 0, err, vars), "mutation");
@@ -3084,6 +4020,7 @@ var MutationImpl = class {
3084
4020
  this.error.set(void 0);
3085
4021
  this.lastVariables.set(void 0);
3086
4022
  this.isPending.set(false);
4023
+ this.status.set("idle");
3087
4024
  });
3088
4025
  }
3089
4026
  dispose() {
@@ -3157,6 +4094,7 @@ var SubscriptionImpl = class {
3157
4094
  isStale;
3158
4095
  lastUpdatedAt;
3159
4096
  hasPendingMutations;
4097
+ isPaused;
3160
4098
  constructor(keepPreviousData, select) {
3161
4099
  this.keepPreviousData = keepPreviousData;
3162
4100
  this.select = select;
@@ -3181,6 +4119,7 @@ var SubscriptionImpl = class {
3181
4119
  this.isStale = computed(() => this.current$.value?.entry.isStale.value ?? true);
3182
4120
  this.lastUpdatedAt = computed(() => this.current$.value?.entry.lastUpdatedAt.value);
3183
4121
  this.hasPendingMutations = computed(() => this.current$.value?.entry.hasPendingMutations.value ?? false);
4122
+ this.isPaused = computed(() => this.current$.value?.entry.isPaused.value ?? false);
3184
4123
  }
3185
4124
  attach(entry) {
3186
4125
  const prev = this.current$.peek();
@@ -3197,11 +4136,17 @@ var SubscriptionImpl = class {
3197
4136
  refetch = () => {
3198
4137
  const cur = this.current$.peek();
3199
4138
  if (!cur) return Promise.reject(/* @__PURE__ */ new Error("[olas] no active subscription"));
3200
- return cur.entry.refetch().then((v) => this.project(v));
4139
+ return cur.entry.refetch().then((v) => this.project(v), (err) => {
4140
+ if (isAbortError(err)) return this.firstValue();
4141
+ throw err;
4142
+ });
3201
4143
  };
3202
4144
  reset = () => {
3203
4145
  this.current$.peek()?.entry.reset();
3204
4146
  };
4147
+ cancel = () => {
4148
+ this.current$.peek()?.entry.cancel();
4149
+ };
3205
4150
  firstValue = () => {
3206
4151
  const cur = this.current$.peek();
3207
4152
  if (!cur) return Promise.reject(/* @__PURE__ */ new Error("[olas] no active subscription"));
@@ -3229,8 +4174,10 @@ function createUse(client, query, keyOrOptions) {
3229
4174
  let currentEntry = null;
3230
4175
  let suspended = false;
3231
4176
  const effectDispose = effect(() => {
4177
+ const isEnabled = enabledFn ? enabledFn() : true;
4178
+ const args = isEnabled ? keyFn ? keyFn() : [] : void 0;
3232
4179
  if (suspended) return;
3233
- if (!(enabledFn ? enabledFn() : true)) {
4180
+ if (!isEnabled) {
3234
4181
  untracked(() => {
3235
4182
  if (currentEntry) {
3236
4183
  currentEntry.release();
@@ -3240,7 +4187,6 @@ function createUse(client, query, keyOrOptions) {
3240
4187
  });
3241
4188
  return;
3242
4189
  }
3243
- const args = keyFn ? keyFn() : [];
3244
4190
  untracked(() => {
3245
4191
  const entry = client.bindEntry(query, args);
3246
4192
  if (currentEntry === entry) return;
@@ -3301,6 +4247,7 @@ var InfiniteSubscriptionImpl = class {
3301
4247
  isStale;
3302
4248
  lastUpdatedAt;
3303
4249
  hasPendingMutations;
4250
+ isPaused;
3304
4251
  hasNextPage;
3305
4252
  hasPreviousPage;
3306
4253
  isFetchingNextPage;
@@ -3337,6 +4284,7 @@ var InfiniteSubscriptionImpl = class {
3337
4284
  this.isStale = computed(() => this.current$.value?.entry.isStale.value ?? true);
3338
4285
  this.lastUpdatedAt = computed(() => this.current$.value?.entry.lastUpdatedAt.value);
3339
4286
  this.hasPendingMutations = computed(() => this.current$.value?.entry.hasPendingMutations.value ?? false);
4287
+ this.isPaused = computed(() => this.current$.value?.entry.isPaused.value ?? false);
3340
4288
  this.hasNextPage = computed(() => this.current$.value?.entry.hasNextPage.value ?? false);
3341
4289
  this.hasPreviousPage = computed(() => this.current$.value?.entry.hasPreviousPage.value ?? false);
3342
4290
  this.isFetchingNextPage = computed(() => this.current$.value?.entry.isFetchingNextPage.value ?? false);
@@ -3357,11 +4305,17 @@ var InfiniteSubscriptionImpl = class {
3357
4305
  refetch = () => {
3358
4306
  const cur = this.current$.peek();
3359
4307
  if (!cur) return Promise.reject(/* @__PURE__ */ new Error("[olas] no active subscription"));
3360
- return cur.entry.refetch().then(() => cur.entry.pages.peek());
4308
+ return cur.entry.refetch().then(() => cur.entry.pages.peek(), (err) => {
4309
+ if (isAbortError(err)) return this.firstValue();
4310
+ throw err;
4311
+ });
3361
4312
  };
3362
4313
  reset = () => {
3363
4314
  this.current$.peek()?.entry.reset();
3364
4315
  };
4316
+ cancel = () => {
4317
+ this.current$.peek()?.entry.cancel();
4318
+ };
3365
4319
  firstValue = () => {
3366
4320
  const cur = this.current$.peek();
3367
4321
  if (!cur) return Promise.reject(/* @__PURE__ */ new Error("[olas] no active subscription"));
@@ -3387,8 +4341,10 @@ function createInfiniteUse(client, query, keyOrOptions) {
3387
4341
  let currentEntry = null;
3388
4342
  let suspended = false;
3389
4343
  const effectDispose = effect(() => {
4344
+ const isEnabled = enabledFn ? enabledFn() : true;
4345
+ const args = isEnabled ? keyFn ? keyFn() : [] : void 0;
3390
4346
  if (suspended) return;
3391
- if (!(enabledFn ? enabledFn() : true)) {
4347
+ if (!isEnabled) {
3392
4348
  untracked(() => {
3393
4349
  if (currentEntry) {
3394
4350
  currentEntry.release();
@@ -3398,7 +4354,6 @@ function createInfiniteUse(client, query, keyOrOptions) {
3398
4354
  });
3399
4355
  return;
3400
4356
  }
3401
- const args = keyFn ? keyFn() : [];
3402
4357
  untracked(() => {
3403
4358
  const entry = client.bindInfiniteEntry(query, args);
3404
4359
  if (currentEntry === entry) return;
@@ -3447,17 +4402,89 @@ function createInfiniteUse(client, query, keyOrOptions) {
3447
4402
  }
3448
4403
  //#endregion
3449
4404
  //#region src/controller/instance.ts
4405
+ /**
4406
+ * Append-and-unlink container for `LifecycleEntry`s. Replaces the historical
4407
+ * `LifecycleEntry[]` to make per-entry removal O(1) — important for
4408
+ * controllers that churn entries (`ctx.collection` reconcile, `lazyChild`
4409
+ * loop, `attach` short-lived children).
4410
+ *
4411
+ * Iteration order is insertion order for `forward()` and reverse-insertion
4412
+ * for `reverse()`. Iteration is safe against `push` during traversal (the
4413
+ * generator captures `next`/`prev` *before* yielding) but not against
4414
+ * unlinking the current node from inside the visitor — visitors must not
4415
+ * call `unlink` on the entry they're currently inspecting.
4416
+ */
4417
+ var LifecycleList = class {
4418
+ head = null;
4419
+ tail = null;
4420
+ _size = 0;
4421
+ get size() {
4422
+ return this._size;
4423
+ }
4424
+ push(entry) {
4425
+ const node = {
4426
+ entry,
4427
+ prev: this.tail,
4428
+ next: null,
4429
+ unlinked: false
4430
+ };
4431
+ if (this.tail !== null) this.tail.next = node;
4432
+ else this.head = node;
4433
+ this.tail = node;
4434
+ this._size += 1;
4435
+ return node;
4436
+ }
4437
+ unlink(node) {
4438
+ if (node.unlinked) return;
4439
+ node.unlinked = true;
4440
+ if (node.prev !== null) node.prev.next = node.next;
4441
+ else this.head = node.next;
4442
+ if (node.next !== null) node.next.prev = node.prev;
4443
+ else this.tail = node.prev;
4444
+ this._size -= 1;
4445
+ }
4446
+ clear() {
4447
+ this.head = null;
4448
+ this.tail = null;
4449
+ this._size = 0;
4450
+ }
4451
+ /** Yield entries in insertion order. */
4452
+ *forward() {
4453
+ let n = this.head;
4454
+ while (n !== null) {
4455
+ const next = n.next;
4456
+ yield n.entry;
4457
+ n = next;
4458
+ }
4459
+ }
4460
+ /** Yield entries in reverse-insertion order — used by dispose / suspend. */
4461
+ *reverse() {
4462
+ let n = this.tail;
4463
+ while (n !== null) {
4464
+ const prev = n.prev;
4465
+ yield n.entry;
4466
+ n = prev;
4467
+ }
4468
+ }
4469
+ };
3450
4470
  var ControllerInstance = class ControllerInstance {
3451
4471
  path;
3452
4472
  deps;
3453
4473
  state = "constructing";
3454
- entries = [];
4474
+ entries = new LifecycleList();
3455
4475
  rootShared;
3456
4476
  parent;
3457
4477
  childCounter = 0;
3458
4478
  /** Scope values provided on this instance, keyed by `Scope.__id`. */
3459
4479
  scopes = null;
3460
4480
  /**
4481
+ * Memoized result of `ctx.inject(scope)` per scope id, stamped with the
4482
+ * `scopesVersion` the lookup observed. A bump invalidates every cache
4483
+ * entry implicitly — the next `inject(scope)` finds a stale version
4484
+ * stamp and re-walks. Provide is rare; reads dominate.
4485
+ */
4486
+ injectCache = null;
4487
+ /**
3461
4488
  * Pre-seed scopes from outside the factory — used by `createRoot`'s
3462
4489
  * `scopes:` option so an adapter (e.g. `@kontsedal/olas-router-tanstack`)
3463
4490
  * can publish cross-cutting values without forcing the user to call
@@ -3492,33 +4519,31 @@ var ControllerInstance = class ControllerInstance {
3492
4519
  return api;
3493
4520
  }
3494
4521
  rollbackPartialConstruction() {
3495
- for (let i = this.entries.length - 1; i >= 0; i--) {
3496
- const entry = this.entries[i];
3497
- if (!entry) continue;
3498
- try {
3499
- this.disposeEntry(entry);
3500
- } catch {}
4522
+ for (const entry of this.entries.reverse()) try {
4523
+ this.disposeEntry(entry);
4524
+ } catch (err) {
4525
+ dispatchError(this.rootShared.onError, err, {
4526
+ kind: "effect",
4527
+ controllerPath: this.path
4528
+ });
3501
4529
  }
3502
- this.entries.length = 0;
4530
+ this.entries.clear();
3503
4531
  this.state = "disposed";
3504
4532
  }
3505
4533
  dispose() {
3506
4534
  if (this.state === "disposed") return;
3507
4535
  this.state = "disposed";
3508
- for (let i = this.entries.length - 1; i >= 0; i--) {
3509
- const entry = this.entries[i];
3510
- if (!entry) continue;
3511
- try {
3512
- this.disposeEntry(entry);
3513
- } catch (err) {
3514
- dispatchError(this.rootShared.onError, err, {
3515
- kind: "effect",
3516
- controllerPath: this.path
3517
- });
3518
- }
4536
+ for (const entry of this.entries.reverse()) try {
4537
+ this.disposeEntry(entry);
4538
+ } catch (err) {
4539
+ dispatchError(this.rootShared.onError, err, {
4540
+ kind: "effect",
4541
+ controllerPath: this.path
4542
+ });
3519
4543
  }
3520
- this.entries.length = 0;
4544
+ this.entries.clear();
3521
4545
  this.scopes = null;
4546
+ this.injectCache = null;
3522
4547
  }
3523
4548
  disposeEntry(entry) {
3524
4549
  switch (entry.kind) {
@@ -3545,49 +4570,49 @@ var ControllerInstance = class ControllerInstance {
3545
4570
  case "onResume": break;
3546
4571
  }
3547
4572
  }
4573
+ isSuspended() {
4574
+ return this.state === "suspended";
4575
+ }
3548
4576
  suspend() {
3549
4577
  if (this.state !== "active") return;
3550
4578
  this.state = "suspended";
3551
- for (let i = this.entries.length - 1; i >= 0; i--) {
3552
- const entry = this.entries[i];
3553
- if (!entry) continue;
3554
- try {
3555
- switch (entry.kind) {
3556
- case "effect":
3557
- entry.dispose?.();
3558
- entry.dispose = null;
3559
- break;
3560
- case "subscription-cache":
3561
- entry.suspend();
3562
- break;
3563
- case "child":
3564
- entry.instance.suspend();
3565
- break;
3566
- case "onSuspend":
3567
- entry.fn();
3568
- break;
3569
- default: break;
3570
- }
3571
- } catch (err) {
3572
- dispatchError(this.rootShared.onError, err, {
3573
- kind: "effect",
3574
- controllerPath: this.path
3575
- });
4579
+ for (const entry of this.entries.reverse()) try {
4580
+ switch (entry.kind) {
4581
+ case "effect":
4582
+ entry.dispose?.();
4583
+ entry.dispose = null;
4584
+ break;
4585
+ case "subscription-cache":
4586
+ entry.suspend();
4587
+ break;
4588
+ case "child":
4589
+ entry.instance.suspend();
4590
+ break;
4591
+ case "onSuspend":
4592
+ entry.fn();
4593
+ break;
4594
+ default: break;
3576
4595
  }
4596
+ } catch (err) {
4597
+ dispatchError(this.rootShared.onError, err, {
4598
+ kind: "effect",
4599
+ controllerPath: this.path
4600
+ });
3577
4601
  }
3578
4602
  }
3579
4603
  resume() {
3580
4604
  if (this.state !== "suspended") return;
3581
4605
  this.state = "active";
3582
- for (const entry of this.entries) try {
4606
+ for (const entry of this.entries.forward()) try {
3583
4607
  switch (entry.kind) {
3584
4608
  case "effect":
3585
- entry.dispose = effect(entry.factory);
4609
+ if (entry.dispose === null) entry.dispose = effect(entry.factory);
3586
4610
  break;
3587
4611
  case "subscription-cache":
3588
4612
  entry.resume();
3589
4613
  break;
3590
4614
  case "child":
4615
+ if (entry.explicitlySuspended) break;
3591
4616
  entry.instance.resume();
3592
4617
  break;
3593
4618
  case "onResume":
@@ -3604,12 +4629,15 @@ var ControllerInstance = class ControllerInstance {
3604
4629
  }
3605
4630
  buildCtx() {
3606
4631
  const self = this;
4632
+ const assertLive = (method) => {
4633
+ if (self.isTerminal()) throw new Error(`[olas] ctx.${method}() called after the controller was disposed`);
4634
+ };
3607
4635
  return {
3608
4636
  get deps() {
3609
4637
  return self.deps;
3610
4638
  },
3611
4639
  effect(fn) {
3612
- if (self.isTerminal()) return;
4640
+ assertLive("effect");
3613
4641
  const entry = {
3614
4642
  kind: "effect",
3615
4643
  factory: () => fn(),
@@ -3617,7 +4645,18 @@ var ControllerInstance = class ControllerInstance {
3617
4645
  };
3618
4646
  const wrapped = () => {
3619
4647
  try {
3620
- return fn();
4648
+ const cleanup = fn();
4649
+ if (typeof cleanup === "function") return () => {
4650
+ try {
4651
+ cleanup();
4652
+ } catch (err) {
4653
+ dispatchError(self.rootShared.onError, err, {
4654
+ kind: "effect",
4655
+ controllerPath: self.path
4656
+ });
4657
+ }
4658
+ };
4659
+ return cleanup;
3621
4660
  } catch (err) {
3622
4661
  dispatchError(self.rootShared.onError, err, {
3623
4662
  kind: "effect",
@@ -3631,6 +4670,7 @@ var ControllerInstance = class ControllerInstance {
3631
4670
  self.entries.push(entry);
3632
4671
  },
3633
4672
  cache(fetcher, options) {
4673
+ assertLive("cache");
3634
4674
  const cache = createLocalCache(fetcher, options);
3635
4675
  self.entries.push({
3636
4676
  kind: "cleanup",
@@ -3639,6 +4679,7 @@ var ControllerInstance = class ControllerInstance {
3639
4679
  return cache;
3640
4680
  },
3641
4681
  use(query, keyOrOptions) {
4682
+ assertLive("use");
3642
4683
  if (query.__olas === "infiniteQuery") {
3643
4684
  const handle = createInfiniteUse(self.rootShared.queryClient, query, keyOrOptions);
3644
4685
  self.entries.push({
@@ -3659,6 +4700,7 @@ var ControllerInstance = class ControllerInstance {
3659
4700
  return handle.subscription;
3660
4701
  },
3661
4702
  mutation(spec) {
4703
+ assertLive("mutation");
3662
4704
  const queryClient = self.rootShared.queryClient;
3663
4705
  const m = createMutation(spec, self.rootShared.onError, self.path, queryClient.mutationsInflight$, self.rootShared.devtools, spec.persist === true ? {
3664
4706
  emitEnqueue: (ev) => queryClient.emitMutationEnqueue(ev),
@@ -3671,6 +4713,7 @@ var ControllerInstance = class ControllerInstance {
3671
4713
  return m;
3672
4714
  },
3673
4715
  emitter() {
4716
+ assertLive("emitter");
3674
4717
  const e = createEmitter({ onError: (err) => {
3675
4718
  dispatchError(self.rootShared.onError, err, {
3676
4719
  kind: "emitter",
@@ -3683,13 +4726,19 @@ var ControllerInstance = class ControllerInstance {
3683
4726
  });
3684
4727
  return e;
3685
4728
  },
3686
- field(initial, validators) {
3687
- const f = createField(initial, validators, { onValidatorError: (err) => {
3688
- dispatchError(self.rootShared.onError, err, {
3689
- kind: "effect",
3690
- controllerPath: self.path
3691
- });
3692
- } });
4729
+ signal,
4730
+ computed,
4731
+ field(initial, validators, options) {
4732
+ assertLive("field");
4733
+ const f = createField(initial, validators, {
4734
+ onValidatorError: (err) => {
4735
+ dispatchError(self.rootShared.onError, err, {
4736
+ kind: "effect",
4737
+ controllerPath: self.path
4738
+ });
4739
+ },
4740
+ validateOn: options?.validateOn
4741
+ });
3693
4742
  self.entries.push({
3694
4743
  kind: "cleanup",
3695
4744
  dispose: () => f.dispose()
@@ -3702,6 +4751,7 @@ var ControllerInstance = class ControllerInstance {
3702
4751
  return f;
3703
4752
  },
3704
4753
  form(schema, options) {
4754
+ assertLive("form");
3705
4755
  const reporter = (err) => {
3706
4756
  dispatchError(self.rootShared.onError, err, {
3707
4757
  kind: "effect",
@@ -3722,6 +4772,7 @@ var ControllerInstance = class ControllerInstance {
3722
4772
  return f;
3723
4773
  },
3724
4774
  fieldArray(itemFactory, options) {
4775
+ assertLive("fieldArray");
3725
4776
  const reporter = (err) => {
3726
4777
  dispatchError(self.rootShared.onError, err, {
3727
4778
  kind: "effect",
@@ -3744,19 +4795,44 @@ var ControllerInstance = class ControllerInstance {
3744
4795
  provide(scope, value) {
3745
4796
  if (self.scopes === null) self.scopes = /* @__PURE__ */ new Map();
3746
4797
  self.scopes.set(scope.__id, value);
4798
+ self.rootShared.scopesVersion.value += 1;
3747
4799
  },
3748
4800
  inject(scope) {
4801
+ const version = self.rootShared.scopesVersion.value;
4802
+ const cache = self.injectCache;
4803
+ if (cache !== null) {
4804
+ const cached = cache.get(scope.__id);
4805
+ if (cached !== void 0 && cached.version === version) return cached.value;
4806
+ }
4807
+ const ensureMemo = () => {
4808
+ if (self.injectCache === null) self.injectCache = /* @__PURE__ */ new Map();
4809
+ return self.injectCache;
4810
+ };
3749
4811
  let node = self;
3750
4812
  while (node !== null) {
3751
4813
  const map = node.scopes;
3752
- if (map?.has(scope.__id)) return map.get(scope.__id);
4814
+ if (map?.has(scope.__id)) {
4815
+ const value = map.get(scope.__id);
4816
+ ensureMemo().set(scope.__id, {
4817
+ value,
4818
+ version
4819
+ });
4820
+ return value;
4821
+ }
3753
4822
  node = node.parent;
3754
4823
  }
3755
- if (scope.hasDefault) return scope.default;
4824
+ if (scope.hasDefault) {
4825
+ ensureMemo().set(scope.__id, {
4826
+ value: scope.default,
4827
+ version
4828
+ });
4829
+ return scope.default;
4830
+ }
3756
4831
  const label = scope.name ?? scope.__id.description ?? "unnamed";
3757
4832
  throw new Error(`[olas] ctx.inject(): no provider for scope '${label}' and no default. Provide it on an ancestor via ctx.provide(${label}, ...) or pass a default to defineScope.`);
3758
4833
  },
3759
4834
  on(emitter, handler) {
4835
+ assertLive("on");
3760
4836
  const wrapped = (value) => {
3761
4837
  try {
3762
4838
  handler(value);
@@ -3774,6 +4850,7 @@ var ControllerInstance = class ControllerInstance {
3774
4850
  });
3775
4851
  },
3776
4852
  child(def, props, options) {
4853
+ assertLive("child");
3777
4854
  const segment = self.makeChildSegment(getFactory(def), getName(def));
3778
4855
  const override = options?.deps;
3779
4856
  const childDeps = override !== void 0 ? {
@@ -3789,6 +4866,7 @@ var ControllerInstance = class ControllerInstance {
3789
4866
  return api;
3790
4867
  },
3791
4868
  attach(def, props, options) {
4869
+ assertLive("attach");
3792
4870
  const segment = self.makeChildSegment(getFactory(def), getName(def));
3793
4871
  const override = options?.deps;
3794
4872
  const childDeps = override !== void 0 ? {
@@ -3799,17 +4877,17 @@ var ControllerInstance = class ControllerInstance {
3799
4877
  const api = childInstance.construct(getFactory(def), props);
3800
4878
  const entry = {
3801
4879
  kind: "child",
3802
- instance: childInstance
4880
+ instance: childInstance,
4881
+ explicitlySuspended: false
3803
4882
  };
3804
- self.entries.push(entry);
4883
+ const node = self.entries.push(entry);
3805
4884
  let disposed = false;
3806
4885
  return {
3807
4886
  api,
3808
4887
  dispose: () => {
3809
4888
  if (disposed) return;
3810
4889
  disposed = true;
3811
- const idx = self.entries.indexOf(entry);
3812
- if (idx >= 0) self.entries.splice(idx, 1);
4890
+ self.entries.unlink(node);
3813
4891
  try {
3814
4892
  childInstance.dispose();
3815
4893
  } catch (err) {
@@ -3821,6 +4899,7 @@ var ControllerInstance = class ControllerInstance {
3821
4899
  },
3822
4900
  suspend: () => {
3823
4901
  if (disposed) return;
4902
+ entry.explicitlySuspended = true;
3824
4903
  try {
3825
4904
  childInstance.suspend();
3826
4905
  } catch (err) {
@@ -3832,6 +4911,8 @@ var ControllerInstance = class ControllerInstance {
3832
4911
  },
3833
4912
  resume: () => {
3834
4913
  if (disposed) return;
4914
+ entry.explicitlySuspended = false;
4915
+ if (self.isSuspended()) return;
3835
4916
  try {
3836
4917
  childInstance.resume();
3837
4918
  } catch (err) {
@@ -3844,6 +4925,7 @@ var ControllerInstance = class ControllerInstance {
3844
4925
  };
3845
4926
  },
3846
4927
  session(def, props, options) {
4928
+ assertLive("session");
3847
4929
  const segment = self.makeChildSegment(getFactory(def), getName(def));
3848
4930
  const override = options?.deps;
3849
4931
  const childDeps = override !== void 0 ? {
@@ -3856,13 +4938,12 @@ var ControllerInstance = class ControllerInstance {
3856
4938
  kind: "child",
3857
4939
  instance: childInstance
3858
4940
  };
3859
- self.entries.push(entry);
4941
+ const node = self.entries.push(entry);
3860
4942
  let disposed = false;
3861
4943
  const dispose = () => {
3862
4944
  if (disposed) return;
3863
4945
  disposed = true;
3864
- const idx = self.entries.indexOf(entry);
3865
- if (idx >= 0) self.entries.splice(idx, 1);
4946
+ self.entries.unlink(node);
3866
4947
  try {
3867
4948
  childInstance.dispose();
3868
4949
  } catch (err) {
@@ -3875,6 +4956,7 @@ var ControllerInstance = class ControllerInstance {
3875
4956
  return [api, dispose];
3876
4957
  },
3877
4958
  collection(options) {
4959
+ assertLive("collection");
3878
4960
  const childMap = /* @__PURE__ */ new Map();
3879
4961
  const items$ = signal([]);
3880
4962
  const size$ = computed(() => items$.value.length);
@@ -3915,8 +4997,7 @@ var ControllerInstance = class ControllerInstance {
3915
4997
  const info = childMap.get(key);
3916
4998
  if (info === void 0) return;
3917
4999
  childMap.delete(key);
3918
- const idx = self.entries.indexOf(info.entry);
3919
- if (idx >= 0) self.entries.splice(idx, 1);
5000
+ self.entries.unlink(info.node);
3920
5001
  try {
3921
5002
  info.instance.dispose();
3922
5003
  } catch (err) {
@@ -3928,60 +5009,63 @@ var ControllerInstance = class ControllerInstance {
3928
5009
  };
3929
5010
  const reconcile = () => {
3930
5011
  const source = options.source.value;
3931
- const itemByKey = /* @__PURE__ */ new Map();
3932
- for (const item of source) {
3933
- const key = options.keyOf(item);
3934
- if (!itemByKey.has(key)) itemByKey.set(key, item);
3935
- }
3936
- for (const key of [...childMap.keys()]) if (!itemByKey.has(key)) removeKey(key);
3937
- for (const [key, item] of itemByKey) {
3938
- const existing = childMap.get(key);
3939
- if (existing !== void 0) {
3940
- if (isFactoryForm) {
3941
- if (options.factory(item).controller !== existing.def) {
3942
- removeKey(key);
3943
- const built = buildChild(item);
3944
- if (built !== null) {
3945
- const entry = {
3946
- kind: "child",
3947
- instance: built.instance
3948
- };
3949
- self.entries.push(entry);
3950
- childMap.set(key, {
3951
- ...built,
3952
- entry
3953
- });
5012
+ untracked(() => {
5013
+ const itemByKey = /* @__PURE__ */ new Map();
5014
+ for (const item of source) {
5015
+ const key = options.keyOf(item);
5016
+ if (itemByKey.has(key)) continue;
5017
+ itemByKey.set(key, item);
5018
+ }
5019
+ for (const key of [...childMap.keys()]) if (!itemByKey.has(key)) removeKey(key);
5020
+ for (const [key, item] of itemByKey) {
5021
+ const existing = childMap.get(key);
5022
+ if (existing !== void 0) {
5023
+ if (isFactoryForm) {
5024
+ if (options.factory(item).controller !== existing.def) {
5025
+ removeKey(key);
5026
+ const built = buildChild(item);
5027
+ if (built !== null) {
5028
+ const entry = {
5029
+ kind: "child",
5030
+ instance: built.instance
5031
+ };
5032
+ const node = self.entries.push(entry);
5033
+ childMap.set(key, {
5034
+ ...built,
5035
+ node
5036
+ });
5037
+ }
3954
5038
  }
3955
5039
  }
5040
+ continue;
5041
+ }
5042
+ const built = buildChild(item);
5043
+ if (built !== null) {
5044
+ const entry = {
5045
+ kind: "child",
5046
+ instance: built.instance
5047
+ };
5048
+ const node = self.entries.push(entry);
5049
+ childMap.set(key, {
5050
+ ...built,
5051
+ node
5052
+ });
3956
5053
  }
3957
- continue;
3958
5054
  }
3959
- const built = buildChild(item);
3960
- if (built !== null) {
3961
- const entry = {
3962
- kind: "child",
3963
- instance: built.instance
3964
- };
3965
- self.entries.push(entry);
3966
- childMap.set(key, {
3967
- ...built,
3968
- entry
5055
+ const next = [];
5056
+ const seen = /* @__PURE__ */ new Set();
5057
+ for (const item of source) {
5058
+ const key = options.keyOf(item);
5059
+ if (seen.has(key)) continue;
5060
+ seen.add(key);
5061
+ const info = childMap.get(key);
5062
+ if (info !== void 0) next.push({
5063
+ key,
5064
+ api: info.api
3969
5065
  });
3970
5066
  }
3971
- }
3972
- const next = [];
3973
- const seen = /* @__PURE__ */ new Set();
3974
- for (const item of source) {
3975
- const key = options.keyOf(item);
3976
- if (seen.has(key)) continue;
3977
- seen.add(key);
3978
- const info = childMap.get(key);
3979
- if (info !== void 0) next.push({
3980
- key,
3981
- api: info.api
3982
- });
3983
- }
3984
- items$.set(next);
5067
+ items$.set(next);
5068
+ });
3985
5069
  };
3986
5070
  const wrapped = () => {
3987
5071
  try {
@@ -4001,27 +5085,45 @@ var ControllerInstance = class ControllerInstance {
4001
5085
  if (self.state !== "suspended") effectEntry.dispose = effect(wrapped);
4002
5086
  self.entries.push(effectEntry);
4003
5087
  return {
4004
- items: items$,
4005
- size: size$,
5088
+ items: readOnly(items$),
5089
+ size: readOnly(size$),
4006
5090
  get: (key) => childMap.get(key)?.api,
4007
- has: (key) => childMap.has(key)
5091
+ has: (key) => childMap.has(key),
5092
+ suspendItem: (key) => {
5093
+ const info = childMap.get(key);
5094
+ if (info === void 0) return;
5095
+ if (info.node.entry.kind === "child") info.node.entry.explicitlySuspended = true;
5096
+ info.instance.suspend();
5097
+ },
5098
+ resumeItem: (key) => {
5099
+ const info = childMap.get(key);
5100
+ if (info === void 0) return;
5101
+ if (info.node.entry.kind === "child") info.node.entry.explicitlySuspended = false;
5102
+ if (self.isSuspended()) return;
5103
+ info.instance.resume();
5104
+ },
5105
+ isItemSuspended: (key) => {
5106
+ const info = childMap.get(key);
5107
+ if (info === void 0) return false;
5108
+ return info.instance.isSuspended();
5109
+ }
4008
5110
  };
4009
5111
  },
4010
5112
  lazyChild(loader, props, options) {
5113
+ assertLive("lazyChild");
4011
5114
  const status$ = signal("idle");
4012
5115
  const api$ = signal(void 0);
4013
5116
  const error$ = signal(void 0);
4014
5117
  let childInstance = null;
4015
- let childEntry = null;
5118
+ let childNode = null;
4016
5119
  let pendingLoad = null;
4017
5120
  let disposed = false;
4018
- const flagEntry = {
5121
+ const flagNode = self.entries.push({
4019
5122
  kind: "onDispose",
4020
5123
  fn: () => {
4021
5124
  disposed = true;
4022
5125
  }
4023
- };
4024
- self.entries.push(flagEntry);
5126
+ });
4025
5127
  const handleFailure = (err) => {
4026
5128
  status$.set("error");
4027
5129
  error$.set(err);
@@ -4034,7 +5136,7 @@ var ControllerInstance = class ControllerInstance {
4034
5136
  if (disposed) return Promise.reject(/* @__PURE__ */ new Error("[olas] ctx.lazyChild: cannot load after dispose"));
4035
5137
  if (pendingLoad !== null) return pendingLoad;
4036
5138
  status$.set("loading");
4037
- pendingLoad = loader().then((def) => {
5139
+ const attempt = loader().then((def) => {
4038
5140
  if (disposed) throw new Error("[olas] ctx.lazyChild: disposed during load");
4039
5141
  const segment = self.makeChildSegment(getFactory(def), getName(def));
4040
5142
  const childDeps = options?.deps !== void 0 ? {
@@ -4045,11 +5147,10 @@ var ControllerInstance = class ControllerInstance {
4045
5147
  try {
4046
5148
  const api = instance.construct(getFactory(def), props);
4047
5149
  childInstance = instance;
4048
- childEntry = {
5150
+ childNode = self.entries.push({
4049
5151
  kind: "child",
4050
5152
  instance
4051
- };
4052
- self.entries.push(childEntry);
5153
+ });
4053
5154
  api$.set(api);
4054
5155
  status$.set("ready");
4055
5156
  return api;
@@ -4062,14 +5163,17 @@ var ControllerInstance = class ControllerInstance {
4062
5163
  handleFailure(err);
4063
5164
  throw err;
4064
5165
  });
4065
- return pendingLoad;
5166
+ pendingLoad = attempt;
5167
+ attempt.catch(() => {
5168
+ if (pendingLoad === attempt) pendingLoad = null;
5169
+ });
5170
+ return attempt;
4066
5171
  };
4067
5172
  const dispose = () => {
4068
5173
  if (disposed) return;
4069
5174
  disposed = true;
4070
- if (childEntry !== null && childInstance !== null) {
4071
- const idx = self.entries.indexOf(childEntry);
4072
- if (idx >= 0) self.entries.splice(idx, 1);
5175
+ if (childNode !== null && childInstance !== null) {
5176
+ self.entries.unlink(childNode);
4073
5177
  try {
4074
5178
  childInstance.dispose();
4075
5179
  } catch (err) {
@@ -4079,20 +5183,20 @@ var ControllerInstance = class ControllerInstance {
4079
5183
  });
4080
5184
  }
4081
5185
  childInstance = null;
4082
- childEntry = null;
5186
+ childNode = null;
4083
5187
  }
4084
- const flagIdx = self.entries.indexOf(flagEntry);
4085
- if (flagIdx >= 0) self.entries.splice(flagIdx, 1);
5188
+ self.entries.unlink(flagNode);
4086
5189
  };
4087
5190
  return {
4088
- status: status$,
4089
- api: api$,
4090
- error: error$,
5191
+ status: readOnly(status$),
5192
+ api: readOnly(api$),
5193
+ error: readOnly(error$),
4091
5194
  load,
4092
5195
  dispose
4093
5196
  };
4094
5197
  },
4095
5198
  onDispose(fn) {
5199
+ assertLive("onDispose");
4096
5200
  self.entries.push({
4097
5201
  kind: "onDispose",
4098
5202
  fn: () => {
@@ -4108,6 +5212,7 @@ var ControllerInstance = class ControllerInstance {
4108
5212
  });
4109
5213
  },
4110
5214
  onSuspend(fn) {
5215
+ assertLive("onSuspend");
4111
5216
  self.entries.push({
4112
5217
  kind: "onSuspend",
4113
5218
  fn: () => {
@@ -4123,6 +5228,7 @@ var ControllerInstance = class ControllerInstance {
4123
5228
  });
4124
5229
  },
4125
5230
  onResume(fn) {
5231
+ assertLive("onResume");
4126
5232
  self.entries.push({
4127
5233
  kind: "onResume",
4128
5234
  fn: () => {
@@ -4156,6 +5262,7 @@ const ROOT_METHODS = [
4156
5262
  "resume",
4157
5263
  "dehydrate",
4158
5264
  "waitForIdle",
5265
+ "applyDehydratedEntry",
4159
5266
  "__debug"
4160
5267
  ];
4161
5268
  /**
@@ -4178,7 +5285,8 @@ function createRootWithProps(def, props, options) {
4178
5285
  const instance = new ControllerInstance(null, {
4179
5286
  devtools,
4180
5287
  onError: options.onError,
4181
- queryClient
5288
+ queryClient,
5289
+ scopesVersion: { value: 0 }
4182
5290
  }, "root", options.deps);
4183
5291
  if (options.scopes !== void 0 && options.scopes.length > 0) instance.seedScopes(options.scopes);
4184
5292
  let api;
@@ -4188,8 +5296,15 @@ function createRootWithProps(def, props, options) {
4188
5296
  queryClient.dispose();
4189
5297
  throw err;
4190
5298
  }
4191
- if (typeof api !== "object" || api === null) return attachRootControls({ value: api }, instance, devtools, queryClient);
4192
- return attachRootControls(api, instance, devtools, queryClient);
5299
+ let target = api;
5300
+ if (typeof api !== "object" || api === null) target = { value: api };
5301
+ try {
5302
+ return attachRootControls(target, instance, devtools, queryClient);
5303
+ } catch (err) {
5304
+ instance.dispose();
5305
+ queryClient.dispose();
5306
+ throw err;
5307
+ }
4193
5308
  }
4194
5309
  function attachRootControls(api, instance, devtools, queryClient) {
4195
5310
  let suspendTimer = null;
@@ -4225,36 +5340,39 @@ function attachRootControls(api, instance, devtools, queryClient) {
4225
5340
  queryEntries: () => queryClient.queryEntriesSnapshot()
4226
5341
  };
4227
5342
  const target = api;
4228
- for (const method of ROOT_METHODS) if (Object.hasOwn(target, method)) throw new Error(`[olas] Root controller api defines '${method}' which conflicts with the root controls.`);
5343
+ for (const method of ROOT_METHODS) if (method in target) throw new Error(`[olas] Root controller api defines '${method}' which conflicts with the root controls.`);
5344
+ const lock = {
5345
+ enumerable: false,
5346
+ writable: false,
5347
+ configurable: false
5348
+ };
4229
5349
  Object.defineProperty(target, "dispose", {
4230
5350
  value: dispose,
4231
- enumerable: false,
4232
- configurable: true
5351
+ ...lock
4233
5352
  });
4234
5353
  Object.defineProperty(target, "suspend", {
4235
5354
  value: suspend,
4236
- enumerable: false,
4237
- configurable: true
5355
+ ...lock
4238
5356
  });
4239
5357
  Object.defineProperty(target, "resume", {
4240
5358
  value: resume,
4241
- enumerable: false,
4242
- configurable: true
5359
+ ...lock
4243
5360
  });
4244
5361
  Object.defineProperty(target, "__debug", {
4245
5362
  value: debug,
4246
- enumerable: false,
4247
- configurable: true
5363
+ ...lock
4248
5364
  });
4249
5365
  Object.defineProperty(target, "dehydrate", {
4250
5366
  value: () => queryClient.dehydrate(),
4251
- enumerable: false,
4252
- configurable: true
5367
+ ...lock
4253
5368
  });
4254
5369
  Object.defineProperty(target, "waitForIdle", {
4255
5370
  value: () => queryClient.waitForIdle(),
4256
- enumerable: false,
4257
- configurable: true
5371
+ ...lock
5372
+ });
5373
+ Object.defineProperty(target, "applyDehydratedEntry", {
5374
+ value: (queryId, keyArgs, data, lastUpdatedAt) => queryClient.applyDehydratedEntry(queryId, keyArgs, data, lastUpdatedAt),
5375
+ ...lock
4258
5376
  });
4259
5377
  return api;
4260
5378
  }
@@ -4344,6 +5462,12 @@ Object.defineProperty(exports, "lookupRegisteredQuery", {
4344
5462
  return lookupRegisteredQuery;
4345
5463
  }
4346
5464
  });
5465
+ Object.defineProperty(exports, "readOnly", {
5466
+ enumerable: true,
5467
+ get: function() {
5468
+ return readOnly;
5469
+ }
5470
+ });
4347
5471
  Object.defineProperty(exports, "registerQueryById", {
4348
5472
  enumerable: true,
4349
5473
  get: function() {
@@ -4369,4 +5493,4 @@ Object.defineProperty(exports, "untracked", {
4369
5493
  }
4370
5494
  });
4371
5495
 
4372
- //# sourceMappingURL=root-CFgYhBd-.cjs.map
5496
+ //# sourceMappingURL=root-CPSL5kpR.cjs.map