@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.
- package/dist/index.cjs +0 -0
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +109 -27
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.mts +109 -27
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +0 -0
- package/dist/index.mjs.map +1 -1
- package/dist/{root-B6pNq75w.mjs → root-Byq-QYTp.mjs} +1465 -347
- package/dist/root-Byq-QYTp.mjs.map +1 -0
- package/dist/{root-CFgYhBd-.cjs → root-CPSL5kpR.cjs} +1470 -346
- package/dist/root-CPSL5kpR.cjs.map +1 -0
- package/dist/testing.cjs +5 -1
- package/dist/testing.cjs.map +1 -1
- package/dist/testing.d.cts +3 -2
- package/dist/testing.d.cts.map +1 -1
- package/dist/testing.d.mts +3 -2
- package/dist/testing.d.mts.map +1 -1
- package/dist/testing.mjs +5 -2
- package/dist/testing.mjs.map +1 -1
- package/dist/{types-Bmi04hDI.d.mts → types-Brp-4WaZ.d.mts} +233 -23
- package/dist/types-Brp-4WaZ.d.mts.map +1 -0
- package/dist/{types-BsZMPPOE.d.cts → types-DzDIypPo.d.cts} +233 -23
- package/dist/types-DzDIypPo.d.cts.map +1 -0
- package/package.json +4 -1
- package/src/controller/instance.ts +345 -94
- package/src/controller/root.ts +52 -30
- package/src/controller/types.ts +55 -4
- package/src/emitter.ts +8 -2
- package/src/errors.ts +39 -3
- package/src/forms/field.ts +219 -25
- package/src/forms/form-types.ts +16 -3
- package/src/forms/form.ts +360 -38
- package/src/forms/index.ts +3 -1
- package/src/forms/types.ts +27 -1
- package/src/forms/validators.ts +45 -11
- package/src/index.ts +13 -7
- package/src/query/client.ts +237 -58
- package/src/query/define.ts +0 -0
- package/src/query/entry.ts +259 -15
- package/src/query/focus-online.ts +53 -11
- package/src/query/infinite.ts +351 -47
- package/src/query/keys.ts +33 -2
- package/src/query/local.ts +3 -0
- package/src/query/mutation.ts +27 -6
- package/src/query/plugin.ts +43 -4
- package/src/query/types.ts +76 -6
- package/src/query/use.ts +53 -10
- package/src/selection.ts +49 -12
- package/src/signals/readonly.ts +3 -0
- package/src/signals/runtime.ts +27 -0
- package/src/signals/types.ts +10 -0
- package/src/testing.ts +9 -0
- package/src/timing/debounced.ts +106 -23
- package/src/timing/index.ts +1 -1
- package/src/timing/throttled.ts +85 -28
- package/src/utils.ts +15 -2
- package/dist/root-B6pNq75w.mjs.map +0 -1
- package/dist/root-CFgYhBd-.cjs.map +0 -1
- package/dist/types-Bmi04hDI.d.mts.map +0 -1
- 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:
|
|
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 (
|
|
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
|
-
//
|
|
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.
|
|
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 (
|
|
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.
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
|
535
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
692
|
-
|
|
693
|
-
const
|
|
694
|
-
|
|
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
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
912
|
+
// Drop removed keys.
|
|
913
|
+
for (const key of [...childMap.keys()]) {
|
|
914
|
+
if (!itemByKey.has(key)) removeKey(key)
|
|
915
|
+
}
|
|
701
916
|
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
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
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
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
|
-
|
|
767
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
847
|
-
|
|
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
|
-
|
|
1107
|
+
childNode = null
|
|
859
1108
|
}
|
|
860
|
-
//
|
|
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
|
-
|
|
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: () => {
|