@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
@@ -24,7 +24,8 @@ import { createMutation, type Mutation, type MutationSpec } from '../query/mutat
24
24
  import type { LocalCache, Query } from '../query/types'
25
25
  import { createInfiniteUse, createUse } from '../query/use'
26
26
  import type { Scope } from '../scope'
27
- import { computed, signal, effect as standaloneEffect } from '../signals'
27
+ import { computed, signal, effect as standaloneEffect, untracked } from '../signals'
28
+ import { readOnly } from '../signals/readonly'
28
29
  import { getFactory, getName } from './define'
29
30
  import type {
30
31
  Collection,
@@ -42,6 +43,14 @@ export type RootShared = {
42
43
  readonly devtools: DevtoolsEmitter
43
44
  readonly onError: ErrorHandler | undefined
44
45
  readonly queryClient: QueryClient
46
+ /**
47
+ * Monotonic counter bumped by every `ctx.provide(...)` call inside this
48
+ * root's tree. `ctx.inject(...)` caches its scope-walk result alongside
49
+ * the version it was computed at; a mismatch forces a re-walk. Cheap
50
+ * compared to the linear ancestor walk (most controllers never call
51
+ * provide, so cache hits dominate).
52
+ */
53
+ readonly scopesVersion: { value: number }
45
54
  }
46
55
 
47
56
  type LifecycleEntry =
@@ -62,7 +71,7 @@ type LifecycleEntry =
62
71
  suspend: () => void
63
72
  resume: () => void
64
73
  }
65
- | { kind: 'child'; instance: ControllerInstance }
74
+ | { kind: 'child'; instance: ControllerInstance; explicitlySuspended?: boolean }
66
75
  | { kind: 'onDispose'; fn: () => void }
67
76
  | { kind: 'onSuspend'; fn: () => void }
68
77
  | { kind: 'onResume'; fn: () => void }
@@ -70,17 +79,108 @@ type LifecycleEntry =
70
79
 
71
80
  type State = 'constructing' | 'active' | 'suspended' | 'disposed'
72
81
 
82
+ /**
83
+ * Doubly-linked-list node wrapping a `LifecycleEntry`. Holding a reference
84
+ * to the node lets call sites unlink in O(1) instead of the old
85
+ * `entries.indexOf(...) + splice(...)` which was quadratic for long-lived
86
+ * collections / lazyChildren / attach-style children.
87
+ *
88
+ * `unlinked: true` is a defensive flag — re-unlinking a node should be a
89
+ * no-op (the parent's dispose cascade may race with an explicit unlink
90
+ * from a `dispose()` returned to user code).
91
+ */
92
+ type LifecycleNode = {
93
+ entry: LifecycleEntry
94
+ prev: LifecycleNode | null
95
+ next: LifecycleNode | null
96
+ unlinked: boolean
97
+ }
98
+
99
+ /**
100
+ * Append-and-unlink container for `LifecycleEntry`s. Replaces the historical
101
+ * `LifecycleEntry[]` to make per-entry removal O(1) — important for
102
+ * controllers that churn entries (`ctx.collection` reconcile, `lazyChild`
103
+ * loop, `attach` short-lived children).
104
+ *
105
+ * Iteration order is insertion order for `forward()` and reverse-insertion
106
+ * for `reverse()`. Iteration is safe against `push` during traversal (the
107
+ * generator captures `next`/`prev` *before* yielding) but not against
108
+ * unlinking the current node from inside the visitor — visitors must not
109
+ * call `unlink` on the entry they're currently inspecting.
110
+ */
111
+ class LifecycleList {
112
+ private head: LifecycleNode | null = null
113
+ private tail: LifecycleNode | null = null
114
+ private _size = 0
115
+
116
+ get size(): number {
117
+ return this._size
118
+ }
119
+
120
+ push(entry: LifecycleEntry): LifecycleNode {
121
+ const node: LifecycleNode = { entry, prev: this.tail, next: null, unlinked: false }
122
+ if (this.tail !== null) this.tail.next = node
123
+ else this.head = node
124
+ this.tail = node
125
+ this._size += 1
126
+ return node
127
+ }
128
+
129
+ unlink(node: LifecycleNode): void {
130
+ if (node.unlinked) return
131
+ node.unlinked = true
132
+ if (node.prev !== null) node.prev.next = node.next
133
+ else this.head = node.next
134
+ if (node.next !== null) node.next.prev = node.prev
135
+ else this.tail = node.prev
136
+ this._size -= 1
137
+ }
138
+
139
+ clear(): void {
140
+ this.head = null
141
+ this.tail = null
142
+ this._size = 0
143
+ }
144
+
145
+ /** Yield entries in insertion order. */
146
+ *forward(): Generator<LifecycleEntry> {
147
+ let n = this.head
148
+ while (n !== null) {
149
+ const next = n.next
150
+ yield n.entry
151
+ n = next
152
+ }
153
+ }
154
+
155
+ /** Yield entries in reverse-insertion order — used by dispose / suspend. */
156
+ *reverse(): Generator<LifecycleEntry> {
157
+ let n = this.tail
158
+ while (n !== null) {
159
+ const prev = n.prev
160
+ yield n.entry
161
+ n = prev
162
+ }
163
+ }
164
+ }
165
+
73
166
  export class ControllerInstance {
74
167
  readonly path: readonly string[]
75
168
  readonly deps: Record<string, unknown>
76
169
 
77
170
  private state: State = 'constructing'
78
- private readonly entries: LifecycleEntry[] = []
171
+ private readonly entries: LifecycleList = new LifecycleList()
79
172
  private readonly rootShared: RootShared
80
173
  private readonly parent: ControllerInstance | null
81
174
  private childCounter = 0
82
175
  /** Scope values provided on this instance, keyed by `Scope.__id`. */
83
176
  private scopes: Map<symbol, unknown> | null = null
177
+ /**
178
+ * Memoized result of `ctx.inject(scope)` per scope id, stamped with the
179
+ * `scopesVersion` the lookup observed. A bump invalidates every cache
180
+ * entry implicitly — the next `inject(scope)` finds a stale version
181
+ * stamp and re-walks. Provide is rare; reads dominate.
182
+ */
183
+ private injectCache: Map<symbol, { value: unknown; version: number }> | null = null
84
184
 
85
185
  /**
86
186
  * Pre-seed scopes from outside the factory — used by `createRoot`'s
@@ -135,16 +235,20 @@ export class ControllerInstance {
135
235
 
136
236
  private rollbackPartialConstruction(): void {
137
237
  // Tear down what was built before the throw, in reverse order.
138
- for (let i = this.entries.length - 1; i >= 0; i--) {
139
- const entry = this.entries[i]
140
- if (!entry) continue
238
+ for (const entry of this.entries.reverse()) {
141
239
  try {
142
240
  this.disposeEntry(entry)
143
- } catch {
144
- // Rollback paths can't throw further.
241
+ } catch (err) {
242
+ // A teardown error during rollback must not mask the original
243
+ // construction throw or abort the rest of the rollback — route it to
244
+ // onError, same as dispose(). (T2.8)
245
+ dispatchError(this.rootShared.onError, err, {
246
+ kind: 'effect',
247
+ controllerPath: this.path,
248
+ })
145
249
  }
146
250
  }
147
- this.entries.length = 0
251
+ this.entries.clear()
148
252
  this.state = 'disposed'
149
253
  }
150
254
 
@@ -152,9 +256,7 @@ export class ControllerInstance {
152
256
  if (this.state === 'disposed') return
153
257
  this.state = 'disposed'
154
258
 
155
- for (let i = this.entries.length - 1; i >= 0; i--) {
156
- const entry = this.entries[i]
157
- if (!entry) continue
259
+ for (const entry of this.entries.reverse()) {
158
260
  try {
159
261
  this.disposeEntry(entry)
160
262
  } catch (err) {
@@ -164,8 +266,9 @@ export class ControllerInstance {
164
266
  })
165
267
  }
166
268
  }
167
- this.entries.length = 0
269
+ this.entries.clear()
168
270
  this.scopes = null
271
+ this.injectCache = null
169
272
 
170
273
  if (__DEV__) {
171
274
  this.rootShared.devtools.emit({ type: 'controller:disposed', path: this.path })
@@ -200,13 +303,15 @@ export class ControllerInstance {
200
303
  }
201
304
  }
202
305
 
306
+ isSuspended(): boolean {
307
+ return this.state === 'suspended'
308
+ }
309
+
203
310
  suspend(): void {
204
311
  if (this.state !== 'active') return
205
312
  this.state = 'suspended'
206
313
 
207
- for (let i = this.entries.length - 1; i >= 0; i--) {
208
- const entry = this.entries[i]
209
- if (!entry) continue
314
+ for (const entry of this.entries.reverse()) {
210
315
  try {
211
316
  switch (entry.kind) {
212
317
  case 'effect':
@@ -244,11 +349,21 @@ export class ControllerInstance {
244
349
  if (this.state !== 'suspended') return
245
350
  this.state = 'active'
246
351
 
247
- for (const entry of this.entries) {
352
+ for (const entry of this.entries.forward()) {
248
353
  try {
249
354
  switch (entry.kind) {
250
355
  case 'effect':
251
- entry.dispose = standaloneEffect(entry.factory)
356
+ // Skip effects that are already live. An effect registered DURING
357
+ // this resume (e.g. from an onResume handler) was activated
358
+ // immediately by `ctx.effect` while state is 'active'; the forward
359
+ // loop then reaches its freshly-pushed node. Re-activating would
360
+ // overwrite the live `dispose` ref without calling it — the effect
361
+ // would run twice per change and one copy would survive dispose().
362
+ // Only re-activate effects that `suspend()` cleared (dispose null).
363
+ // (T2.2)
364
+ if (entry.dispose === null) {
365
+ entry.dispose = standaloneEffect(entry.factory)
366
+ }
252
367
  break
253
368
  case 'subscription-cache':
254
369
  // Re-acquire the entry, restart `refetchInterval`, and re-check
@@ -256,6 +371,11 @@ export class ControllerInstance {
256
371
  entry.resume()
257
372
  break
258
373
  case 'child':
374
+ // Skip children explicitly suspended via attach.suspend() or
375
+ // collection suspendItem() — a whole-tree resume (KeepAlive) must
376
+ // not wake them. They resume only via their own attach.resume() /
377
+ // resumeItem(). (T2.6)
378
+ if (entry.explicitlySuspended) break
259
379
  entry.instance.resume()
260
380
  break
261
381
  case 'onResume':
@@ -281,13 +401,24 @@ export class ControllerInstance {
281
401
 
282
402
  private buildCtx(): Ctx {
283
403
  const self = this
404
+ // A ctx.* factory called after the controller was disposed would push into
405
+ // a cleared lifecycle list — a live child / subscription / effect that
406
+ // never gets torn down. It is always a programming error (a captured `ctx`
407
+ // used past its owner's lifetime), so throw rather than leak or no-op. Not
408
+ // routed through dispatchError — this is a bug in the caller, not a runtime
409
+ // condition. (T2.4, spec §4)
410
+ const assertLive = (method: string): void => {
411
+ if (self.isTerminal()) {
412
+ throw new Error(`[olas] ctx.${method}() called after the controller was disposed`)
413
+ }
414
+ }
284
415
  const ctx: Ctx = {
285
416
  get deps() {
286
417
  return self.deps
287
418
  },
288
419
 
289
420
  effect(fn) {
290
- if (self.isTerminal()) return
421
+ assertLive('effect')
291
422
  const entry: LifecycleEntry = {
292
423
  kind: 'effect',
293
424
  factory: () => fn(),
@@ -296,7 +427,23 @@ export class ControllerInstance {
296
427
  // Wrap with error reporting so an effect throw goes through onError.
297
428
  const wrapped = (): void | (() => void) => {
298
429
  try {
299
- return fn()
430
+ const cleanup = fn()
431
+ // The effect body may return a cleanup fn that the signals runtime
432
+ // calls on re-run / dispose — OUTSIDE this try. Wrap it so a throw
433
+ // there also routes to onError instead of escaping. (T2.8)
434
+ if (typeof cleanup === 'function') {
435
+ return () => {
436
+ try {
437
+ cleanup()
438
+ } catch (err) {
439
+ dispatchError(self.rootShared.onError, err, {
440
+ kind: 'effect',
441
+ controllerPath: self.path,
442
+ })
443
+ }
444
+ }
445
+ }
446
+ return cleanup
300
447
  } catch (err) {
301
448
  dispatchError(self.rootShared.onError, err, {
302
449
  kind: 'effect',
@@ -319,12 +466,14 @@ export class ControllerInstance {
319
466
  fetcher: (signal: AbortSignal) => Promise<T>,
320
467
  options?: LocalCacheOptions<T>,
321
468
  ): LocalCache<T> {
469
+ assertLive('cache')
322
470
  const cache = createLocalCache<T>(fetcher, options)
323
471
  self.entries.push({ kind: 'cleanup', dispose: () => cache.dispose() })
324
472
  return cache
325
473
  },
326
474
 
327
475
  use(query: any, keyOrOptions?: any): any {
476
+ assertLive('use')
328
477
  const brand = (query as { __olas?: string }).__olas
329
478
  if (brand === 'infiniteQuery') {
330
479
  const handle = createInfiniteUse(
@@ -355,6 +504,7 @@ export class ControllerInstance {
355
504
  },
356
505
 
357
506
  mutation<V, R>(spec: MutationSpec<V, R>): Mutation<V, R> {
507
+ assertLive('mutation')
358
508
  const queryClient = self.rootShared.queryClient
359
509
  const m = createMutation<V, R>(
360
510
  spec,
@@ -377,6 +527,7 @@ export class ControllerInstance {
377
527
  },
378
528
 
379
529
  emitter<T>(): Emitter<T> {
530
+ assertLive('emitter')
380
531
  const e = createEmitter<T>({
381
532
  // Spec §20.6: emit-time handler throws must not block sibling
382
533
  // handlers. Route to the root's onError with kind: 'emitter' and
@@ -392,7 +543,15 @@ export class ControllerInstance {
392
543
  return e
393
544
  },
394
545
 
395
- field<T>(initial: T, validators?: ReadonlyArray<Validator<T>>): Field<T> {
546
+ signal,
547
+ computed,
548
+
549
+ field<T>(
550
+ initial: T,
551
+ validators?: ReadonlyArray<Validator<T>>,
552
+ options?: { validateOn?: 'change' | 'blur' | 'submit' },
553
+ ): Field<T> {
554
+ assertLive('field')
396
555
  // Pass the reporter at construct time so the FIRST validator pass
397
556
  // (which runs synchronously in the FieldImpl constructor's
398
557
  // validator-effect) is covered.
@@ -403,6 +562,7 @@ export class ControllerInstance {
403
562
  controllerPath: self.path,
404
563
  })
405
564
  },
565
+ validateOn: options?.validateOn,
406
566
  })
407
567
  self.entries.push({ kind: 'cleanup', dispose: () => f.dispose() })
408
568
  // Standalone fields (not inside a form) still publish field:validated
@@ -417,6 +577,7 @@ export class ControllerInstance {
417
577
  },
418
578
 
419
579
  form<S extends FormSchema>(schema: S, options?: FormOptions<S>): Form<S> {
580
+ assertLive('form')
420
581
  const reporter = (err: unknown): void => {
421
582
  dispatchError(self.rootShared.onError, err, {
422
583
  kind: 'effect',
@@ -447,6 +608,7 @@ export class ControllerInstance {
447
608
  itemFactory: (initial?: ItemInitial<I>) => I,
448
609
  options?: FieldArrayOptions<I>,
449
610
  ): FieldArray<I> {
611
+ assertLive('fieldArray')
450
612
  const reporter = (err: unknown): void => {
451
613
  dispatchError(self.rootShared.onError, err, {
452
614
  kind: 'effect',
@@ -472,18 +634,41 @@ export class ControllerInstance {
472
634
  provide<T>(scope: Scope<T>, value: T): void {
473
635
  if (self.scopes === null) self.scopes = new Map()
474
636
  self.scopes.set(scope.__id, value)
637
+ // Invalidate every cached inject lookup tree-wide. A descendant
638
+ // that resolved this scope from a higher ancestor (or from a
639
+ // default) would now resolve to the new value, but its cache
640
+ // still holds the old value. Cheap: just bump the counter; cache
641
+ // entries check against it on next read.
642
+ self.rootShared.scopesVersion.value += 1
475
643
  },
476
644
 
477
645
  inject<T>(scope: Scope<T>): T {
646
+ const version = self.rootShared.scopesVersion.value
647
+ const cache = self.injectCache
648
+ if (cache !== null) {
649
+ const cached = cache.get(scope.__id)
650
+ if (cached !== undefined && cached.version === version) {
651
+ return cached.value as T
652
+ }
653
+ }
654
+ const ensureMemo = (): Map<symbol, { value: unknown; version: number }> => {
655
+ if (self.injectCache === null) self.injectCache = new Map()
656
+ return self.injectCache
657
+ }
478
658
  let node: ControllerInstance | null = self
479
659
  while (node !== null) {
480
660
  const map = node.scopes
481
661
  if (map?.has(scope.__id)) {
482
- return map.get(scope.__id) as T
662
+ const value = map.get(scope.__id) as T
663
+ ensureMemo().set(scope.__id, { value, version })
664
+ return value
483
665
  }
484
666
  node = node.parent
485
667
  }
486
- if (scope.hasDefault) return scope.default as T
668
+ if (scope.hasDefault) {
669
+ ensureMemo().set(scope.__id, { value: scope.default, version })
670
+ return scope.default as T
671
+ }
487
672
  const label = scope.name ?? scope.__id.description ?? 'unnamed'
488
673
  throw new Error(
489
674
  `[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.`,
@@ -491,6 +676,7 @@ export class ControllerInstance {
491
676
  },
492
677
 
493
678
  on<T>(emitter: Emitter<T>, handler: (value: T) => void): void {
679
+ assertLive('on')
494
680
  const wrapped = (value: T) => {
495
681
  try {
496
682
  handler(value)
@@ -510,6 +696,7 @@ export class ControllerInstance {
510
696
  props: Props,
511
697
  options?: { deps?: Partial<Record<string, unknown>> },
512
698
  ): Api {
699
+ assertLive('child')
513
700
  const segment = self.makeChildSegment(getFactory(def), getName(def))
514
701
  const override = options?.deps
515
702
  const childDeps = override !== undefined ? { ...self.deps, ...override } : self.deps
@@ -526,21 +713,25 @@ export class ControllerInstance {
526
713
  props: Props,
527
714
  options?: { deps?: Partial<Record<string, unknown>> },
528
715
  ): { api: Api; dispose: () => void; suspend: () => void; resume: () => void } {
716
+ assertLive('attach')
529
717
  const segment = self.makeChildSegment(getFactory(def), getName(def))
530
718
  const override = options?.deps
531
719
  const childDeps = override !== undefined ? { ...self.deps, ...override } : self.deps
532
720
  const childInstance = new ControllerInstance(self, self.rootShared, segment, childDeps)
533
721
  const api = childInstance.construct(getFactory(def), props)
534
- const entry: LifecycleEntry = { kind: 'child', instance: childInstance }
535
- self.entries.push(entry)
722
+ const entry = {
723
+ kind: 'child' as const,
724
+ instance: childInstance,
725
+ explicitlySuspended: false,
726
+ }
727
+ const node = self.entries.push(entry)
536
728
  let disposed = false
537
729
  return {
538
730
  api,
539
731
  dispose: () => {
540
732
  if (disposed) return
541
733
  disposed = true
542
- const idx = self.entries.indexOf(entry)
543
- if (idx >= 0) self.entries.splice(idx, 1)
734
+ self.entries.unlink(node)
544
735
  try {
545
736
  childInstance.dispose()
546
737
  } catch (err) {
@@ -557,6 +748,7 @@ export class ControllerInstance {
557
748
  // extra flag here.
558
749
  suspend: () => {
559
750
  if (disposed) return
751
+ entry.explicitlySuspended = true
560
752
  try {
561
753
  childInstance.suspend()
562
754
  } catch (err) {
@@ -568,6 +760,12 @@ export class ControllerInstance {
568
760
  },
569
761
  resume: () => {
570
762
  if (disposed) return
763
+ // Clear the explicit-suspend mark. Under a still-suspended parent,
764
+ // DON'T activate now — a child must not run inside a frozen tree; it
765
+ // rejoins the parent's next resume cascade (which no longer skips it
766
+ // now the mark is clear). (T2.6)
767
+ entry.explicitlySuspended = false
768
+ if (self.isSuspended()) return
571
769
  try {
572
770
  childInstance.resume()
573
771
  } catch (err) {
@@ -585,19 +783,19 @@ export class ControllerInstance {
585
783
  props: Props,
586
784
  options?: { deps?: Partial<Record<string, unknown>> },
587
785
  ): readonly [Api, () => void] {
786
+ assertLive('session')
588
787
  const segment = self.makeChildSegment(getFactory(def), getName(def))
589
788
  const override = options?.deps
590
789
  const childDeps = override !== undefined ? { ...self.deps, ...override } : self.deps
591
790
  const childInstance = new ControllerInstance(self, self.rootShared, segment, childDeps)
592
791
  const api = childInstance.construct(getFactory(def), props)
593
792
  const entry: LifecycleEntry = { kind: 'child', instance: childInstance }
594
- self.entries.push(entry)
793
+ const node = self.entries.push(entry)
595
794
  let disposed = false
596
795
  const dispose = (): void => {
597
796
  if (disposed) return
598
797
  disposed = true
599
- const idx = self.entries.indexOf(entry)
600
- if (idx >= 0) self.entries.splice(idx, 1)
798
+ self.entries.unlink(node)
601
799
  try {
602
800
  childInstance.dispose()
603
801
  } catch (err) {
@@ -615,10 +813,11 @@ export class ControllerInstance {
615
813
  | CollectionHomogeneousOptions<Item, K, Props, Api>
616
814
  | CollectionFactoryOptions<Item, K, R>,
617
815
  ): Collection<K, Api> | Collection<K, CollectionFactoryApi<R>> {
816
+ assertLive('collection')
618
817
  type ChildInfo = {
619
818
  instance: ControllerInstance
620
819
  api: Api
621
- entry: LifecycleEntry
820
+ node: LifecycleNode
622
821
  // For factory form: the controller def used to construct this child.
623
822
  // A different def on a future render means "rebuild with new type".
624
823
  def: ControllerDef<unknown, unknown>
@@ -674,8 +873,7 @@ export class ControllerInstance {
674
873
  const info = childMap.get(key)
675
874
  if (info === undefined) return
676
875
  childMap.delete(key)
677
- const idx = self.entries.indexOf(info.entry)
678
- if (idx >= 0) self.entries.splice(idx, 1)
876
+ self.entries.unlink(info.node)
679
877
  try {
680
878
  info.instance.dispose()
681
879
  } catch (err) {
@@ -687,57 +885,75 @@ export class ControllerInstance {
687
885
  }
688
886
 
689
887
  const reconcile = (): void => {
888
+ // Only `source` is a tracked dependency — everything else (keyOf, the
889
+ // item factory, child construct/dispose) runs untracked so a child
890
+ // factory reading an unrelated signal can't force the whole
891
+ // collection to re-reconcile on every write to it (T2.3). Mirrors the
892
+ // untracked bind in ctx.use.
690
893
  const source = options.source.value
691
- const itemByKey = new Map<K, Item>()
692
- for (const item of source) {
693
- const key = options.keyOf(item)
694
- if (!itemByKey.has(key)) itemByKey.set(key, item)
695
- }
894
+ untracked(() => {
895
+ const itemByKey = new Map<K, Item>()
896
+ for (const item of source) {
897
+ const key = options.keyOf(item)
898
+ if (itemByKey.has(key)) {
899
+ if (__DEV__) {
900
+ // eslint-disable-next-line no-console
901
+ console.warn(
902
+ `[olas] ctx.collection: duplicate key ${String(key)} in source — only the` +
903
+ ' first occurrence is kept. This is usually a bug in `keyOf(item)`;' +
904
+ ' return a unique identifier per item.',
905
+ )
906
+ }
907
+ continue
908
+ }
909
+ itemByKey.set(key, item)
910
+ }
696
911
 
697
- // Drop removed keys.
698
- for (const key of [...childMap.keys()]) {
699
- if (!itemByKey.has(key)) removeKey(key)
700
- }
912
+ // Drop removed keys.
913
+ for (const key of [...childMap.keys()]) {
914
+ if (!itemByKey.has(key)) removeKey(key)
915
+ }
701
916
 
702
- // Add new keys + rebuild factory-form type changes.
703
- for (const [key, item] of itemByKey) {
704
- const existing = childMap.get(key)
705
- if (existing !== undefined) {
706
- if (isFactoryForm) {
707
- const result = (options as CollectionFactoryOptions<Item, K, R>).factory(
708
- item,
709
- ) as CollectionFactoryResult
710
- if ((result.controller as unknown) !== existing.def) {
711
- removeKey(key)
712
- const built = buildChild(item)
713
- if (built !== null) {
714
- const entry: LifecycleEntry = { kind: 'child', instance: built.instance }
715
- self.entries.push(entry)
716
- childMap.set(key, { ...built, entry })
917
+ // Add new keys + rebuild factory-form type changes.
918
+ for (const [key, item] of itemByKey) {
919
+ const existing = childMap.get(key)
920
+ if (existing !== undefined) {
921
+ if (isFactoryForm) {
922
+ const result = (options as CollectionFactoryOptions<Item, K, R>).factory(
923
+ item,
924
+ ) as CollectionFactoryResult
925
+ if ((result.controller as unknown) !== existing.def) {
926
+ removeKey(key)
927
+ const built = buildChild(item)
928
+ if (built !== null) {
929
+ const entry: LifecycleEntry = { kind: 'child', instance: built.instance }
930
+ const node = self.entries.push(entry)
931
+ childMap.set(key, { ...built, node })
932
+ }
717
933
  }
718
934
  }
935
+ continue
936
+ }
937
+ const built = buildChild(item)
938
+ if (built !== null) {
939
+ const entry: LifecycleEntry = { kind: 'child', instance: built.instance }
940
+ const node = self.entries.push(entry)
941
+ childMap.set(key, { ...built, node })
719
942
  }
720
- continue
721
- }
722
- const built = buildChild(item)
723
- if (built !== null) {
724
- const entry: LifecycleEntry = { kind: 'child', instance: built.instance }
725
- self.entries.push(entry)
726
- childMap.set(key, { ...built, entry })
727
943
  }
728
- }
729
944
 
730
- // Project to items signal in source order, deduped, skipping failures.
731
- const next: Array<{ key: K; api: Api }> = []
732
- const seen = new Set<K>()
733
- for (const item of source) {
734
- const key = options.keyOf(item)
735
- if (seen.has(key)) continue
736
- seen.add(key)
737
- const info = childMap.get(key)
738
- if (info !== undefined) next.push({ key, api: info.api })
739
- }
740
- items$.set(next)
945
+ // Project to items signal in source order, deduped, skipping failures.
946
+ const next: Array<{ key: K; api: Api }> = []
947
+ const seen = new Set<K>()
948
+ for (const item of source) {
949
+ const key = options.keyOf(item)
950
+ if (seen.has(key)) continue
951
+ seen.add(key)
952
+ const info = childMap.get(key)
953
+ if (info !== undefined) next.push({ key, api: info.api })
954
+ }
955
+ items$.set(next)
956
+ })
741
957
  }
742
958
 
743
959
  // Register the diff loop as an 'effect' entry so it pauses on suspend
@@ -763,10 +979,33 @@ export class ControllerInstance {
763
979
  self.entries.push(effectEntry)
764
980
 
765
981
  return {
766
- items: items$,
767
- size: size$,
982
+ // readOnly so the writable backing signals can't be mutated through
983
+ // the ReadSignal-typed public surface. (T2.8)
984
+ items: readOnly(items$),
985
+ size: readOnly(size$),
768
986
  get: (key: K) => childMap.get(key)?.api,
769
987
  has: (key: K) => childMap.has(key),
988
+ suspendItem: (key: K) => {
989
+ const info = childMap.get(key)
990
+ if (info === undefined) return
991
+ // Mark the child entry so a whole-tree resume cascade skips it. (T2.6)
992
+ if (info.node.entry.kind === 'child') info.node.entry.explicitlySuspended = true
993
+ info.instance.suspend()
994
+ },
995
+ resumeItem: (key: K) => {
996
+ const info = childMap.get(key)
997
+ if (info === undefined) return
998
+ if (info.node.entry.kind === 'child') info.node.entry.explicitlySuspended = false
999
+ // Under a suspended parent, defer to the parent's next resume
1000
+ // cascade (which no longer skips the cleared entry). (T2.6)
1001
+ if (self.isSuspended()) return
1002
+ info.instance.resume()
1003
+ },
1004
+ isItemSuspended: (key: K) => {
1005
+ const info = childMap.get(key)
1006
+ if (info === undefined) return false
1007
+ return info.instance.isSuspended()
1008
+ },
770
1009
  }
771
1010
  },
772
1011
 
@@ -775,12 +1014,13 @@ export class ControllerInstance {
775
1014
  props: Props,
776
1015
  options?: { deps?: Partial<Record<string, unknown>> },
777
1016
  ): LazyChild<Api> {
1017
+ assertLive('lazyChild')
778
1018
  const status$ = signal<'idle' | 'loading' | 'ready' | 'error'>('idle')
779
1019
  const api$ = signal<Api | undefined>(undefined)
780
1020
  const error$ = signal<unknown | undefined>(undefined)
781
1021
 
782
1022
  let childInstance: ControllerInstance | null = null
783
- let childEntry: LifecycleEntry | null = null
1023
+ let childNode: LifecycleNode | null = null
784
1024
  let pendingLoad: Promise<Api> | null = null
785
1025
  let disposed = false
786
1026
 
@@ -792,7 +1032,7 @@ export class ControllerInstance {
792
1032
  disposed = true
793
1033
  },
794
1034
  }
795
- self.entries.push(flagEntry)
1035
+ const flagNode = self.entries.push(flagEntry)
796
1036
 
797
1037
  const handleFailure = (err: unknown): void => {
798
1038
  status$.set('error')
@@ -807,9 +1047,13 @@ export class ControllerInstance {
807
1047
  if (disposed) {
808
1048
  return Promise.reject(new Error('[olas] ctx.lazyChild: cannot load after dispose'))
809
1049
  }
1050
+ // Cached fulfilled or in-flight loads share a promise. A previously
1051
+ // *rejected* load doesn't — we clear `pendingLoad` in the catch
1052
+ // branch so the next `load()` reattempts the loader. Sticky
1053
+ // rejections trap consumers on a transient import-failure.
810
1054
  if (pendingLoad !== null) return pendingLoad
811
1055
  status$.set('loading')
812
- pendingLoad = loader().then(
1056
+ const attempt = loader().then(
813
1057
  (def) => {
814
1058
  if (disposed) {
815
1059
  throw new Error('[olas] ctx.lazyChild: disposed during load')
@@ -821,8 +1065,7 @@ export class ControllerInstance {
821
1065
  try {
822
1066
  const api = instance.construct(getFactory(def), props)
823
1067
  childInstance = instance
824
- childEntry = { kind: 'child', instance }
825
- self.entries.push(childEntry)
1068
+ childNode = self.entries.push({ kind: 'child', instance })
826
1069
  api$.set(api)
827
1070
  status$.set('ready')
828
1071
  return api
@@ -837,15 +1080,21 @@ export class ControllerInstance {
837
1080
  throw err
838
1081
  },
839
1082
  )
840
- return pendingLoad
1083
+ pendingLoad = attempt
1084
+ attempt.catch(() => {
1085
+ // Allow retry: drop the cached rejection if this is still the
1086
+ // current attempt. A successful load leaves `pendingLoad` in
1087
+ // place so repeat `load()` calls return the same fulfilled api.
1088
+ if (pendingLoad === attempt) pendingLoad = null
1089
+ })
1090
+ return attempt
841
1091
  }
842
1092
 
843
1093
  const dispose = (): void => {
844
1094
  if (disposed) return
845
1095
  disposed = true
846
- if (childEntry !== null && childInstance !== null) {
847
- const idx = self.entries.indexOf(childEntry)
848
- if (idx >= 0) self.entries.splice(idx, 1)
1096
+ if (childNode !== null && childInstance !== null) {
1097
+ self.entries.unlink(childNode)
849
1098
  try {
850
1099
  childInstance.dispose()
851
1100
  } catch (err) {
@@ -855,26 +1104,26 @@ export class ControllerInstance {
855
1104
  })
856
1105
  }
857
1106
  childInstance = null
858
- childEntry = null
1107
+ childNode = null
859
1108
  }
860
- // Splice the parent-dispose flag entry too — its only job was to
1109
+ // Unlink the parent-dispose flag entry too — its only job was to
861
1110
  // signal disposal to an in-flight loader, and `disposed` is now
862
1111
  // already true. Leaving it behind leaks one closure per ever-
863
1112
  // disposed lazyChild for the parent's remaining lifetime.
864
- const flagIdx = self.entries.indexOf(flagEntry)
865
- if (flagIdx >= 0) self.entries.splice(flagIdx, 1)
1113
+ self.entries.unlink(flagNode)
866
1114
  }
867
1115
 
868
1116
  return {
869
- status: status$,
870
- api: api$,
871
- error: error$,
1117
+ status: readOnly(status$),
1118
+ api: readOnly(api$),
1119
+ error: readOnly(error$),
872
1120
  load,
873
1121
  dispose,
874
1122
  }
875
1123
  },
876
1124
 
877
1125
  onDispose(fn) {
1126
+ assertLive('onDispose')
878
1127
  self.entries.push({
879
1128
  kind: 'onDispose',
880
1129
  fn: () => {
@@ -891,6 +1140,7 @@ export class ControllerInstance {
891
1140
  },
892
1141
 
893
1142
  onSuspend(fn) {
1143
+ assertLive('onSuspend')
894
1144
  self.entries.push({
895
1145
  kind: 'onSuspend',
896
1146
  fn: () => {
@@ -907,6 +1157,7 @@ export class ControllerInstance {
907
1157
  },
908
1158
 
909
1159
  onResume(fn) {
1160
+ assertLive('onResume')
910
1161
  self.entries.push({
911
1162
  kind: 'onResume',
912
1163
  fn: () => {