@kontsedal/olas-core 0.0.5 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/dist/index.cjs +0 -0
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.d.cts +109 -27
  4. package/dist/index.d.cts.map +1 -1
  5. package/dist/index.d.mts +109 -27
  6. package/dist/index.d.mts.map +1 -1
  7. package/dist/index.mjs +0 -0
  8. package/dist/index.mjs.map +1 -1
  9. package/dist/{root-DqWolle_.mjs → root-Byq-QYTp.mjs} +1478 -347
  10. package/dist/root-Byq-QYTp.mjs.map +1 -0
  11. package/dist/{root-lBp7qziQ.cjs → root-CPSL5kpR.cjs} +1483 -346
  12. package/dist/root-CPSL5kpR.cjs.map +1 -0
  13. package/dist/testing.cjs +5 -1
  14. package/dist/testing.cjs.map +1 -1
  15. package/dist/testing.d.cts +3 -2
  16. package/dist/testing.d.cts.map +1 -1
  17. package/dist/testing.d.mts +3 -2
  18. package/dist/testing.d.mts.map +1 -1
  19. package/dist/testing.mjs +5 -2
  20. package/dist/testing.mjs.map +1 -1
  21. package/dist/{types-BH1o6nYa.d.mts → types-Brp-4WaZ.d.mts} +244 -23
  22. package/dist/types-Brp-4WaZ.d.mts.map +1 -0
  23. package/dist/{types-C4Vtkxbn.d.cts → types-DzDIypPo.d.cts} +244 -23
  24. package/dist/types-DzDIypPo.d.cts.map +1 -0
  25. package/package.json +4 -1
  26. package/src/controller/instance.ts +360 -94
  27. package/src/controller/root.ts +58 -30
  28. package/src/controller/types.ts +66 -4
  29. package/src/emitter.ts +8 -2
  30. package/src/errors.ts +39 -3
  31. package/src/forms/field.ts +219 -25
  32. package/src/forms/form-types.ts +16 -3
  33. package/src/forms/form.ts +360 -38
  34. package/src/forms/index.ts +3 -1
  35. package/src/forms/types.ts +27 -1
  36. package/src/forms/validators.ts +45 -11
  37. package/src/index.ts +13 -7
  38. package/src/query/client.ts +237 -58
  39. package/src/query/define.ts +0 -0
  40. package/src/query/entry.ts +259 -15
  41. package/src/query/focus-online.ts +53 -11
  42. package/src/query/infinite.ts +351 -47
  43. package/src/query/keys.ts +33 -2
  44. package/src/query/local.ts +3 -0
  45. package/src/query/mutation.ts +27 -6
  46. package/src/query/plugin.ts +43 -4
  47. package/src/query/types.ts +76 -6
  48. package/src/query/use.ts +53 -10
  49. package/src/selection.ts +49 -12
  50. package/src/signals/readonly.ts +3 -0
  51. package/src/signals/runtime.ts +27 -0
  52. package/src/signals/types.ts +10 -0
  53. package/src/testing.ts +9 -0
  54. package/src/timing/debounced.ts +106 -23
  55. package/src/timing/index.ts +1 -1
  56. package/src/timing/throttled.ts +85 -28
  57. package/src/utils.ts +15 -2
  58. package/dist/root-DqWolle_.mjs.map +0 -1
  59. package/dist/root-lBp7qziQ.cjs.map +0 -1
  60. package/dist/types-BH1o6nYa.d.mts.map +0 -1
  61. package/dist/types-C4Vtkxbn.d.cts.map +0 -1
@@ -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,123 @@ 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
184
+
185
+ /**
186
+ * Pre-seed scopes from outside the factory — used by `createRoot`'s
187
+ * `scopes:` option so an adapter (e.g. `@kontsedal/olas-router-tanstack`)
188
+ * can publish cross-cutting values without forcing the user to call
189
+ * `ctx.provide(...)` in their root controller. Idempotent per scope id:
190
+ * later calls override.
191
+ */
192
+ seedScopes(bindings: ReadonlyArray<readonly [{ __id: symbol }, unknown]>): void {
193
+ if (bindings.length === 0) return
194
+ if (this.scopes === null) this.scopes = new Map()
195
+ for (const [scope, value] of bindings) {
196
+ this.scopes.set(scope.__id, value)
197
+ }
198
+ }
84
199
 
85
200
  constructor(
86
201
  parent: ControllerInstance | null,
@@ -120,16 +235,20 @@ export class ControllerInstance {
120
235
 
121
236
  private rollbackPartialConstruction(): void {
122
237
  // Tear down what was built before the throw, in reverse order.
123
- for (let i = this.entries.length - 1; i >= 0; i--) {
124
- const entry = this.entries[i]
125
- if (!entry) continue
238
+ for (const entry of this.entries.reverse()) {
126
239
  try {
127
240
  this.disposeEntry(entry)
128
- } catch {
129
- // 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
+ })
130
249
  }
131
250
  }
132
- this.entries.length = 0
251
+ this.entries.clear()
133
252
  this.state = 'disposed'
134
253
  }
135
254
 
@@ -137,9 +256,7 @@ export class ControllerInstance {
137
256
  if (this.state === 'disposed') return
138
257
  this.state = 'disposed'
139
258
 
140
- for (let i = this.entries.length - 1; i >= 0; i--) {
141
- const entry = this.entries[i]
142
- if (!entry) continue
259
+ for (const entry of this.entries.reverse()) {
143
260
  try {
144
261
  this.disposeEntry(entry)
145
262
  } catch (err) {
@@ -149,8 +266,9 @@ export class ControllerInstance {
149
266
  })
150
267
  }
151
268
  }
152
- this.entries.length = 0
269
+ this.entries.clear()
153
270
  this.scopes = null
271
+ this.injectCache = null
154
272
 
155
273
  if (__DEV__) {
156
274
  this.rootShared.devtools.emit({ type: 'controller:disposed', path: this.path })
@@ -185,13 +303,15 @@ export class ControllerInstance {
185
303
  }
186
304
  }
187
305
 
306
+ isSuspended(): boolean {
307
+ return this.state === 'suspended'
308
+ }
309
+
188
310
  suspend(): void {
189
311
  if (this.state !== 'active') return
190
312
  this.state = 'suspended'
191
313
 
192
- for (let i = this.entries.length - 1; i >= 0; i--) {
193
- const entry = this.entries[i]
194
- if (!entry) continue
314
+ for (const entry of this.entries.reverse()) {
195
315
  try {
196
316
  switch (entry.kind) {
197
317
  case 'effect':
@@ -229,11 +349,21 @@ export class ControllerInstance {
229
349
  if (this.state !== 'suspended') return
230
350
  this.state = 'active'
231
351
 
232
- for (const entry of this.entries) {
352
+ for (const entry of this.entries.forward()) {
233
353
  try {
234
354
  switch (entry.kind) {
235
355
  case 'effect':
236
- 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
+ }
237
367
  break
238
368
  case 'subscription-cache':
239
369
  // Re-acquire the entry, restart `refetchInterval`, and re-check
@@ -241,6 +371,11 @@ export class ControllerInstance {
241
371
  entry.resume()
242
372
  break
243
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
244
379
  entry.instance.resume()
245
380
  break
246
381
  case 'onResume':
@@ -266,13 +401,24 @@ export class ControllerInstance {
266
401
 
267
402
  private buildCtx(): Ctx {
268
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
+ }
269
415
  const ctx: Ctx = {
270
416
  get deps() {
271
417
  return self.deps
272
418
  },
273
419
 
274
420
  effect(fn) {
275
- if (self.isTerminal()) return
421
+ assertLive('effect')
276
422
  const entry: LifecycleEntry = {
277
423
  kind: 'effect',
278
424
  factory: () => fn(),
@@ -281,7 +427,23 @@ export class ControllerInstance {
281
427
  // Wrap with error reporting so an effect throw goes through onError.
282
428
  const wrapped = (): void | (() => void) => {
283
429
  try {
284
- 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
285
447
  } catch (err) {
286
448
  dispatchError(self.rootShared.onError, err, {
287
449
  kind: 'effect',
@@ -304,12 +466,14 @@ export class ControllerInstance {
304
466
  fetcher: (signal: AbortSignal) => Promise<T>,
305
467
  options?: LocalCacheOptions<T>,
306
468
  ): LocalCache<T> {
469
+ assertLive('cache')
307
470
  const cache = createLocalCache<T>(fetcher, options)
308
471
  self.entries.push({ kind: 'cleanup', dispose: () => cache.dispose() })
309
472
  return cache
310
473
  },
311
474
 
312
475
  use(query: any, keyOrOptions?: any): any {
476
+ assertLive('use')
313
477
  const brand = (query as { __olas?: string }).__olas
314
478
  if (brand === 'infiniteQuery') {
315
479
  const handle = createInfiniteUse(
@@ -340,6 +504,7 @@ export class ControllerInstance {
340
504
  },
341
505
 
342
506
  mutation<V, R>(spec: MutationSpec<V, R>): Mutation<V, R> {
507
+ assertLive('mutation')
343
508
  const queryClient = self.rootShared.queryClient
344
509
  const m = createMutation<V, R>(
345
510
  spec,
@@ -362,6 +527,7 @@ export class ControllerInstance {
362
527
  },
363
528
 
364
529
  emitter<T>(): Emitter<T> {
530
+ assertLive('emitter')
365
531
  const e = createEmitter<T>({
366
532
  // Spec §20.6: emit-time handler throws must not block sibling
367
533
  // handlers. Route to the root's onError with kind: 'emitter' and
@@ -377,7 +543,15 @@ export class ControllerInstance {
377
543
  return e
378
544
  },
379
545
 
380
- 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')
381
555
  // Pass the reporter at construct time so the FIRST validator pass
382
556
  // (which runs synchronously in the FieldImpl constructor's
383
557
  // validator-effect) is covered.
@@ -388,6 +562,7 @@ export class ControllerInstance {
388
562
  controllerPath: self.path,
389
563
  })
390
564
  },
565
+ validateOn: options?.validateOn,
391
566
  })
392
567
  self.entries.push({ kind: 'cleanup', dispose: () => f.dispose() })
393
568
  // Standalone fields (not inside a form) still publish field:validated
@@ -402,6 +577,7 @@ export class ControllerInstance {
402
577
  },
403
578
 
404
579
  form<S extends FormSchema>(schema: S, options?: FormOptions<S>): Form<S> {
580
+ assertLive('form')
405
581
  const reporter = (err: unknown): void => {
406
582
  dispatchError(self.rootShared.onError, err, {
407
583
  kind: 'effect',
@@ -432,6 +608,7 @@ export class ControllerInstance {
432
608
  itemFactory: (initial?: ItemInitial<I>) => I,
433
609
  options?: FieldArrayOptions<I>,
434
610
  ): FieldArray<I> {
611
+ assertLive('fieldArray')
435
612
  const reporter = (err: unknown): void => {
436
613
  dispatchError(self.rootShared.onError, err, {
437
614
  kind: 'effect',
@@ -457,18 +634,41 @@ export class ControllerInstance {
457
634
  provide<T>(scope: Scope<T>, value: T): void {
458
635
  if (self.scopes === null) self.scopes = new Map()
459
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
460
643
  },
461
644
 
462
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
+ }
463
658
  let node: ControllerInstance | null = self
464
659
  while (node !== null) {
465
660
  const map = node.scopes
466
661
  if (map?.has(scope.__id)) {
467
- 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
468
665
  }
469
666
  node = node.parent
470
667
  }
471
- 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
+ }
472
672
  const label = scope.name ?? scope.__id.description ?? 'unnamed'
473
673
  throw new Error(
474
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.`,
@@ -476,6 +676,7 @@ export class ControllerInstance {
476
676
  },
477
677
 
478
678
  on<T>(emitter: Emitter<T>, handler: (value: T) => void): void {
679
+ assertLive('on')
479
680
  const wrapped = (value: T) => {
480
681
  try {
481
682
  handler(value)
@@ -495,6 +696,7 @@ export class ControllerInstance {
495
696
  props: Props,
496
697
  options?: { deps?: Partial<Record<string, unknown>> },
497
698
  ): Api {
699
+ assertLive('child')
498
700
  const segment = self.makeChildSegment(getFactory(def), getName(def))
499
701
  const override = options?.deps
500
702
  const childDeps = override !== undefined ? { ...self.deps, ...override } : self.deps
@@ -511,21 +713,25 @@ export class ControllerInstance {
511
713
  props: Props,
512
714
  options?: { deps?: Partial<Record<string, unknown>> },
513
715
  ): { api: Api; dispose: () => void; suspend: () => void; resume: () => void } {
716
+ assertLive('attach')
514
717
  const segment = self.makeChildSegment(getFactory(def), getName(def))
515
718
  const override = options?.deps
516
719
  const childDeps = override !== undefined ? { ...self.deps, ...override } : self.deps
517
720
  const childInstance = new ControllerInstance(self, self.rootShared, segment, childDeps)
518
721
  const api = childInstance.construct(getFactory(def), props)
519
- const entry: LifecycleEntry = { kind: 'child', instance: childInstance }
520
- 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)
521
728
  let disposed = false
522
729
  return {
523
730
  api,
524
731
  dispose: () => {
525
732
  if (disposed) return
526
733
  disposed = true
527
- const idx = self.entries.indexOf(entry)
528
- if (idx >= 0) self.entries.splice(idx, 1)
734
+ self.entries.unlink(node)
529
735
  try {
530
736
  childInstance.dispose()
531
737
  } catch (err) {
@@ -542,6 +748,7 @@ export class ControllerInstance {
542
748
  // extra flag here.
543
749
  suspend: () => {
544
750
  if (disposed) return
751
+ entry.explicitlySuspended = true
545
752
  try {
546
753
  childInstance.suspend()
547
754
  } catch (err) {
@@ -553,6 +760,12 @@ export class ControllerInstance {
553
760
  },
554
761
  resume: () => {
555
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
556
769
  try {
557
770
  childInstance.resume()
558
771
  } catch (err) {
@@ -570,19 +783,19 @@ export class ControllerInstance {
570
783
  props: Props,
571
784
  options?: { deps?: Partial<Record<string, unknown>> },
572
785
  ): readonly [Api, () => void] {
786
+ assertLive('session')
573
787
  const segment = self.makeChildSegment(getFactory(def), getName(def))
574
788
  const override = options?.deps
575
789
  const childDeps = override !== undefined ? { ...self.deps, ...override } : self.deps
576
790
  const childInstance = new ControllerInstance(self, self.rootShared, segment, childDeps)
577
791
  const api = childInstance.construct(getFactory(def), props)
578
792
  const entry: LifecycleEntry = { kind: 'child', instance: childInstance }
579
- self.entries.push(entry)
793
+ const node = self.entries.push(entry)
580
794
  let disposed = false
581
795
  const dispose = (): void => {
582
796
  if (disposed) return
583
797
  disposed = true
584
- const idx = self.entries.indexOf(entry)
585
- if (idx >= 0) self.entries.splice(idx, 1)
798
+ self.entries.unlink(node)
586
799
  try {
587
800
  childInstance.dispose()
588
801
  } catch (err) {
@@ -600,10 +813,11 @@ export class ControllerInstance {
600
813
  | CollectionHomogeneousOptions<Item, K, Props, Api>
601
814
  | CollectionFactoryOptions<Item, K, R>,
602
815
  ): Collection<K, Api> | Collection<K, CollectionFactoryApi<R>> {
816
+ assertLive('collection')
603
817
  type ChildInfo = {
604
818
  instance: ControllerInstance
605
819
  api: Api
606
- entry: LifecycleEntry
820
+ node: LifecycleNode
607
821
  // For factory form: the controller def used to construct this child.
608
822
  // A different def on a future render means "rebuild with new type".
609
823
  def: ControllerDef<unknown, unknown>
@@ -659,8 +873,7 @@ export class ControllerInstance {
659
873
  const info = childMap.get(key)
660
874
  if (info === undefined) return
661
875
  childMap.delete(key)
662
- const idx = self.entries.indexOf(info.entry)
663
- if (idx >= 0) self.entries.splice(idx, 1)
876
+ self.entries.unlink(info.node)
664
877
  try {
665
878
  info.instance.dispose()
666
879
  } catch (err) {
@@ -672,57 +885,75 @@ export class ControllerInstance {
672
885
  }
673
886
 
674
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.
675
893
  const source = options.source.value
676
- const itemByKey = new Map<K, Item>()
677
- for (const item of source) {
678
- const key = options.keyOf(item)
679
- if (!itemByKey.has(key)) itemByKey.set(key, item)
680
- }
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
+ }
681
911
 
682
- // Drop removed keys.
683
- for (const key of [...childMap.keys()]) {
684
- if (!itemByKey.has(key)) removeKey(key)
685
- }
912
+ // Drop removed keys.
913
+ for (const key of [...childMap.keys()]) {
914
+ if (!itemByKey.has(key)) removeKey(key)
915
+ }
686
916
 
687
- // Add new keys + rebuild factory-form type changes.
688
- for (const [key, item] of itemByKey) {
689
- const existing = childMap.get(key)
690
- if (existing !== undefined) {
691
- if (isFactoryForm) {
692
- const result = (options as CollectionFactoryOptions<Item, K, R>).factory(
693
- item,
694
- ) as CollectionFactoryResult
695
- if ((result.controller as unknown) !== existing.def) {
696
- removeKey(key)
697
- const built = buildChild(item)
698
- if (built !== null) {
699
- const entry: LifecycleEntry = { kind: 'child', instance: built.instance }
700
- self.entries.push(entry)
701
- 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
+ }
702
933
  }
703
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 })
704
942
  }
705
- continue
706
- }
707
- const built = buildChild(item)
708
- if (built !== null) {
709
- const entry: LifecycleEntry = { kind: 'child', instance: built.instance }
710
- self.entries.push(entry)
711
- childMap.set(key, { ...built, entry })
712
943
  }
713
- }
714
944
 
715
- // Project to items signal in source order, deduped, skipping failures.
716
- const next: Array<{ key: K; api: Api }> = []
717
- const seen = new Set<K>()
718
- for (const item of source) {
719
- const key = options.keyOf(item)
720
- if (seen.has(key)) continue
721
- seen.add(key)
722
- const info = childMap.get(key)
723
- if (info !== undefined) next.push({ key, api: info.api })
724
- }
725
- 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
+ })
726
957
  }
727
958
 
728
959
  // Register the diff loop as an 'effect' entry so it pauses on suspend
@@ -748,10 +979,33 @@ export class ControllerInstance {
748
979
  self.entries.push(effectEntry)
749
980
 
750
981
  return {
751
- items: items$,
752
- 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$),
753
986
  get: (key: K) => childMap.get(key)?.api,
754
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
+ },
755
1009
  }
756
1010
  },
757
1011
 
@@ -760,12 +1014,13 @@ export class ControllerInstance {
760
1014
  props: Props,
761
1015
  options?: { deps?: Partial<Record<string, unknown>> },
762
1016
  ): LazyChild<Api> {
1017
+ assertLive('lazyChild')
763
1018
  const status$ = signal<'idle' | 'loading' | 'ready' | 'error'>('idle')
764
1019
  const api$ = signal<Api | undefined>(undefined)
765
1020
  const error$ = signal<unknown | undefined>(undefined)
766
1021
 
767
1022
  let childInstance: ControllerInstance | null = null
768
- let childEntry: LifecycleEntry | null = null
1023
+ let childNode: LifecycleNode | null = null
769
1024
  let pendingLoad: Promise<Api> | null = null
770
1025
  let disposed = false
771
1026
 
@@ -777,7 +1032,7 @@ export class ControllerInstance {
777
1032
  disposed = true
778
1033
  },
779
1034
  }
780
- self.entries.push(flagEntry)
1035
+ const flagNode = self.entries.push(flagEntry)
781
1036
 
782
1037
  const handleFailure = (err: unknown): void => {
783
1038
  status$.set('error')
@@ -792,9 +1047,13 @@ export class ControllerInstance {
792
1047
  if (disposed) {
793
1048
  return Promise.reject(new Error('[olas] ctx.lazyChild: cannot load after dispose'))
794
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.
795
1054
  if (pendingLoad !== null) return pendingLoad
796
1055
  status$.set('loading')
797
- pendingLoad = loader().then(
1056
+ const attempt = loader().then(
798
1057
  (def) => {
799
1058
  if (disposed) {
800
1059
  throw new Error('[olas] ctx.lazyChild: disposed during load')
@@ -806,8 +1065,7 @@ export class ControllerInstance {
806
1065
  try {
807
1066
  const api = instance.construct(getFactory(def), props)
808
1067
  childInstance = instance
809
- childEntry = { kind: 'child', instance }
810
- self.entries.push(childEntry)
1068
+ childNode = self.entries.push({ kind: 'child', instance })
811
1069
  api$.set(api)
812
1070
  status$.set('ready')
813
1071
  return api
@@ -822,15 +1080,21 @@ export class ControllerInstance {
822
1080
  throw err
823
1081
  },
824
1082
  )
825
- 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
826
1091
  }
827
1092
 
828
1093
  const dispose = (): void => {
829
1094
  if (disposed) return
830
1095
  disposed = true
831
- if (childEntry !== null && childInstance !== null) {
832
- const idx = self.entries.indexOf(childEntry)
833
- if (idx >= 0) self.entries.splice(idx, 1)
1096
+ if (childNode !== null && childInstance !== null) {
1097
+ self.entries.unlink(childNode)
834
1098
  try {
835
1099
  childInstance.dispose()
836
1100
  } catch (err) {
@@ -840,26 +1104,26 @@ export class ControllerInstance {
840
1104
  })
841
1105
  }
842
1106
  childInstance = null
843
- childEntry = null
1107
+ childNode = null
844
1108
  }
845
- // 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
846
1110
  // signal disposal to an in-flight loader, and `disposed` is now
847
1111
  // already true. Leaving it behind leaks one closure per ever-
848
1112
  // disposed lazyChild for the parent's remaining lifetime.
849
- const flagIdx = self.entries.indexOf(flagEntry)
850
- if (flagIdx >= 0) self.entries.splice(flagIdx, 1)
1113
+ self.entries.unlink(flagNode)
851
1114
  }
852
1115
 
853
1116
  return {
854
- status: status$,
855
- api: api$,
856
- error: error$,
1117
+ status: readOnly(status$),
1118
+ api: readOnly(api$),
1119
+ error: readOnly(error$),
857
1120
  load,
858
1121
  dispose,
859
1122
  }
860
1123
  },
861
1124
 
862
1125
  onDispose(fn) {
1126
+ assertLive('onDispose')
863
1127
  self.entries.push({
864
1128
  kind: 'onDispose',
865
1129
  fn: () => {
@@ -876,6 +1140,7 @@ export class ControllerInstance {
876
1140
  },
877
1141
 
878
1142
  onSuspend(fn) {
1143
+ assertLive('onSuspend')
879
1144
  self.entries.push({
880
1145
  kind: 'onSuspend',
881
1146
  fn: () => {
@@ -892,6 +1157,7 @@ export class ControllerInstance {
892
1157
  },
893
1158
 
894
1159
  onResume(fn) {
1160
+ assertLive('onResume')
895
1161
  self.entries.push({
896
1162
  kind: 'onResume',
897
1163
  fn: () => {