@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.
- 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-DqWolle_.mjs → root-Byq-QYTp.mjs} +1478 -347
- package/dist/root-Byq-QYTp.mjs.map +1 -0
- package/dist/{root-lBp7qziQ.cjs → root-CPSL5kpR.cjs} +1483 -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-BH1o6nYa.d.mts → types-Brp-4WaZ.d.mts} +244 -23
- package/dist/types-Brp-4WaZ.d.mts.map +1 -0
- package/dist/{types-C4Vtkxbn.d.cts → types-DzDIypPo.d.cts} +244 -23
- package/dist/types-DzDIypPo.d.cts.map +1 -0
- package/package.json +4 -1
- package/src/controller/instance.ts +360 -94
- package/src/controller/root.ts +58 -30
- package/src/controller/types.ts +66 -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-DqWolle_.mjs.map +0 -1
- package/dist/root-lBp7qziQ.cjs.map +0 -1
- package/dist/types-BH1o6nYa.d.mts.map +0 -1
- 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:
|
|
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 (
|
|
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
|
-
//
|
|
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.
|
|
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 (
|
|
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.
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
|
520
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
677
|
-
|
|
678
|
-
const
|
|
679
|
-
|
|
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
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
912
|
+
// Drop removed keys.
|
|
913
|
+
for (const key of [...childMap.keys()]) {
|
|
914
|
+
if (!itemByKey.has(key)) removeKey(key)
|
|
915
|
+
}
|
|
686
916
|
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
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
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
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
|
-
|
|
752
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
832
|
-
|
|
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
|
-
|
|
1107
|
+
childNode = null
|
|
844
1108
|
}
|
|
845
|
-
//
|
|
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
|
-
|
|
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: () => {
|