@kontsedal/olas-core 0.0.1-rc.1 → 0.0.2
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 +40 -10
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +32 -11
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.mts +32 -11
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +40 -11
- package/dist/index.mjs.map +1 -1
- package/dist/{root-BImHnGj1.mjs → root-De-6KWIZ.mjs} +750 -149
- package/dist/root-De-6KWIZ.mjs.map +1 -0
- package/dist/{root-Bazp5_Ik.cjs → root-XKEsSmcd.cjs} +755 -148
- package/dist/root-XKEsSmcd.cjs.map +1 -0
- package/dist/testing.cjs +1 -1
- package/dist/testing.d.cts +1 -1
- package/dist/testing.d.mts +1 -1
- package/dist/testing.mjs +1 -1
- package/dist/{types-CAMgqCMz.d.mts → types-C-zV1JZA.d.mts} +215 -13
- package/dist/types-C-zV1JZA.d.mts.map +1 -0
- package/dist/{types-emq_lZd7.d.cts → types-DKfpkm17.d.cts} +215 -13
- package/dist/types-DKfpkm17.d.cts.map +1 -0
- package/package.json +1 -1
- package/src/controller/index.ts +6 -0
- package/src/controller/instance.ts +432 -18
- package/src/controller/root.ts +9 -1
- package/src/controller/types.ts +148 -7
- package/src/emitter.ts +34 -3
- package/src/forms/field.ts +73 -8
- package/src/forms/form-types.ts +16 -0
- package/src/forms/form.ts +218 -26
- package/src/index.ts +12 -1
- package/src/query/client.ts +161 -6
- package/src/query/define.ts +14 -0
- package/src/query/entry.ts +64 -42
- package/src/query/infinite.ts +77 -55
- package/src/query/mutation.ts +11 -21
- package/src/query/plugin.ts +50 -0
- package/src/query/use.ts +80 -3
- package/src/signals/readonly.ts +3 -3
- package/src/timing/debounced.ts +24 -4
- package/src/timing/throttled.ts +22 -3
- package/src/utils.ts +32 -4
- package/dist/root-BImHnGj1.mjs.map +0 -1
- package/dist/root-Bazp5_Ik.cjs.map +0 -1
- package/dist/types-CAMgqCMz.d.mts.map +0 -1
- package/dist/types-emq_lZd7.d.cts.map +0 -1
|
@@ -2,7 +2,12 @@ import type { DevtoolsEmitter } from '../devtools'
|
|
|
2
2
|
import { createEmitter, type Emitter } from '../emitter'
|
|
3
3
|
import { dispatchError, type ErrorHandler } from '../errors'
|
|
4
4
|
import { bindFieldDevtoolsOwner, createField } from '../forms/field'
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
bindTreeToDevtools,
|
|
7
|
+
bindTreeValidatorErrorReporter,
|
|
8
|
+
createFieldArray,
|
|
9
|
+
createForm,
|
|
10
|
+
} from '../forms/form'
|
|
6
11
|
import type {
|
|
7
12
|
FieldArray,
|
|
8
13
|
FieldArrayOptions,
|
|
@@ -19,9 +24,19 @@ import { createMutation, type Mutation, type MutationSpec } from '../query/mutat
|
|
|
19
24
|
import type { LocalCache, Query } from '../query/types'
|
|
20
25
|
import { createInfiniteUse, createUse } from '../query/use'
|
|
21
26
|
import type { Scope } from '../scope'
|
|
22
|
-
import { effect as standaloneEffect } from '../signals'
|
|
27
|
+
import { computed, signal, effect as standaloneEffect } from '../signals'
|
|
23
28
|
import { getFactory, getName } from './define'
|
|
24
|
-
import type {
|
|
29
|
+
import type {
|
|
30
|
+
Collection,
|
|
31
|
+
CollectionFactoryApi,
|
|
32
|
+
CollectionFactoryOptions,
|
|
33
|
+
CollectionFactoryResult,
|
|
34
|
+
CollectionHomogeneousOptions,
|
|
35
|
+
ControllerDef,
|
|
36
|
+
Ctx,
|
|
37
|
+
Field,
|
|
38
|
+
LazyChild,
|
|
39
|
+
} from './types'
|
|
25
40
|
|
|
26
41
|
export type RootShared = {
|
|
27
42
|
readonly devtools: DevtoolsEmitter
|
|
@@ -36,6 +51,17 @@ type LifecycleEntry =
|
|
|
36
51
|
dispose: (() => void) | null
|
|
37
52
|
}
|
|
38
53
|
| { kind: 'cleanup'; dispose: () => void }
|
|
54
|
+
| {
|
|
55
|
+
/**
|
|
56
|
+
* Cache subscription via `ctx.use`. Suspend/resume call the
|
|
57
|
+
* `suspend`/`resume` hooks so the underlying entry's `refetchInterval`
|
|
58
|
+
* and event listeners pause for the duration. Spec §4.1.
|
|
59
|
+
*/
|
|
60
|
+
kind: 'subscription-cache'
|
|
61
|
+
dispose: () => void
|
|
62
|
+
suspend: () => void
|
|
63
|
+
resume: () => void
|
|
64
|
+
}
|
|
39
65
|
| { kind: 'child'; instance: ControllerInstance }
|
|
40
66
|
| { kind: 'onDispose'; fn: () => void }
|
|
41
67
|
| { kind: 'onSuspend'; fn: () => void }
|
|
@@ -109,7 +135,6 @@ export class ControllerInstance {
|
|
|
109
135
|
|
|
110
136
|
dispose(): void {
|
|
111
137
|
if (this.state === 'disposed') return
|
|
112
|
-
const wasSuspended = this.state === 'suspended'
|
|
113
138
|
this.state = 'disposed'
|
|
114
139
|
|
|
115
140
|
for (let i = this.entries.length - 1; i >= 0; i--) {
|
|
@@ -130,8 +155,6 @@ export class ControllerInstance {
|
|
|
130
155
|
if (__DEV__) {
|
|
131
156
|
this.rootShared.devtools.emit({ type: 'controller:disposed', path: this.path })
|
|
132
157
|
}
|
|
133
|
-
// Silence "unused" — `wasSuspended` may inform future logic; intentionally a no-op for now.
|
|
134
|
-
void wasSuspended
|
|
135
158
|
}
|
|
136
159
|
|
|
137
160
|
private disposeEntry(entry: LifecycleEntry): void {
|
|
@@ -143,6 +166,9 @@ export class ControllerInstance {
|
|
|
143
166
|
case 'cleanup':
|
|
144
167
|
entry.dispose()
|
|
145
168
|
break
|
|
169
|
+
case 'subscription-cache':
|
|
170
|
+
entry.dispose()
|
|
171
|
+
break
|
|
146
172
|
case 'child':
|
|
147
173
|
entry.instance.dispose()
|
|
148
174
|
break
|
|
@@ -172,6 +198,11 @@ export class ControllerInstance {
|
|
|
172
198
|
entry.dispose?.()
|
|
173
199
|
entry.dispose = null
|
|
174
200
|
break
|
|
201
|
+
case 'subscription-cache':
|
|
202
|
+
// Pause `refetchInterval` + focus/online listeners + release the
|
|
203
|
+
// entry from this subscriber. Spec §4.1.
|
|
204
|
+
entry.suspend()
|
|
205
|
+
break
|
|
175
206
|
case 'child':
|
|
176
207
|
entry.instance.suspend()
|
|
177
208
|
break
|
|
@@ -204,6 +235,11 @@ export class ControllerInstance {
|
|
|
204
235
|
case 'effect':
|
|
205
236
|
entry.dispose = standaloneEffect(entry.factory)
|
|
206
237
|
break
|
|
238
|
+
case 'subscription-cache':
|
|
239
|
+
// Re-acquire the entry, restart `refetchInterval`, and re-check
|
|
240
|
+
// staleness (a stale entry refetches on resume — spec §4.1).
|
|
241
|
+
entry.resume()
|
|
242
|
+
break
|
|
207
243
|
case 'child':
|
|
208
244
|
entry.instance.resume()
|
|
209
245
|
break
|
|
@@ -255,7 +291,12 @@ export class ControllerInstance {
|
|
|
255
291
|
}
|
|
256
292
|
}
|
|
257
293
|
entry.factory = wrapped
|
|
258
|
-
entry
|
|
294
|
+
// If we're suspended, register the entry but defer activation to
|
|
295
|
+
// `resume()` — otherwise the resume loop would overwrite a live
|
|
296
|
+
// `dispose` ref (the just-activated effect), leaking it.
|
|
297
|
+
if (self.state !== 'suspended') {
|
|
298
|
+
entry.dispose = standaloneEffect(wrapped)
|
|
299
|
+
}
|
|
259
300
|
self.entries.push(entry)
|
|
260
301
|
},
|
|
261
302
|
|
|
@@ -271,21 +312,31 @@ export class ControllerInstance {
|
|
|
271
312
|
use(query: any, keyOrOptions?: any): any {
|
|
272
313
|
const brand = (query as { __olas?: string }).__olas
|
|
273
314
|
if (brand === 'infiniteQuery') {
|
|
274
|
-
const
|
|
315
|
+
const handle = createInfiniteUse(
|
|
275
316
|
self.rootShared.queryClient,
|
|
276
317
|
query as InfiniteQuery<unknown[], unknown, unknown>,
|
|
277
318
|
keyOrOptions,
|
|
278
319
|
)
|
|
279
|
-
self.entries.push({
|
|
280
|
-
|
|
320
|
+
self.entries.push({
|
|
321
|
+
kind: 'subscription-cache',
|
|
322
|
+
dispose: handle.dispose,
|
|
323
|
+
suspend: handle.suspend,
|
|
324
|
+
resume: handle.resume,
|
|
325
|
+
})
|
|
326
|
+
return handle.subscription
|
|
281
327
|
}
|
|
282
|
-
const
|
|
328
|
+
const handle = createUse(
|
|
283
329
|
self.rootShared.queryClient,
|
|
284
330
|
query as Query<unknown[], unknown>,
|
|
285
331
|
keyOrOptions,
|
|
286
332
|
)
|
|
287
|
-
self.entries.push({
|
|
288
|
-
|
|
333
|
+
self.entries.push({
|
|
334
|
+
kind: 'subscription-cache',
|
|
335
|
+
dispose: handle.dispose,
|
|
336
|
+
suspend: handle.suspend,
|
|
337
|
+
resume: handle.resume,
|
|
338
|
+
})
|
|
339
|
+
return handle.subscription
|
|
289
340
|
},
|
|
290
341
|
|
|
291
342
|
mutation<V, R>(spec: MutationSpec<V, R>): Mutation<V, R> {
|
|
@@ -301,13 +352,33 @@ export class ControllerInstance {
|
|
|
301
352
|
},
|
|
302
353
|
|
|
303
354
|
emitter<T>(): Emitter<T> {
|
|
304
|
-
const e = createEmitter<T>(
|
|
355
|
+
const e = createEmitter<T>({
|
|
356
|
+
// Spec §20.6: emit-time handler throws must not block sibling
|
|
357
|
+
// handlers. Route to the root's onError with kind: 'emitter' and
|
|
358
|
+
// this controller's path.
|
|
359
|
+
onError: (err) => {
|
|
360
|
+
dispatchError(self.rootShared.onError, err, {
|
|
361
|
+
kind: 'emitter',
|
|
362
|
+
controllerPath: self.path,
|
|
363
|
+
})
|
|
364
|
+
},
|
|
365
|
+
})
|
|
305
366
|
self.entries.push({ kind: 'cleanup', dispose: () => e.dispose() })
|
|
306
367
|
return e
|
|
307
368
|
},
|
|
308
369
|
|
|
309
370
|
field<T>(initial: T, validators?: ReadonlyArray<Validator<T>>): Field<T> {
|
|
310
|
-
|
|
371
|
+
// Pass the reporter at construct time so the FIRST validator pass
|
|
372
|
+
// (which runs synchronously in the FieldImpl constructor's
|
|
373
|
+
// validator-effect) is covered.
|
|
374
|
+
const f = createField(initial, validators, {
|
|
375
|
+
onValidatorError: (err) => {
|
|
376
|
+
dispatchError(self.rootShared.onError, err, {
|
|
377
|
+
kind: 'effect',
|
|
378
|
+
controllerPath: self.path,
|
|
379
|
+
})
|
|
380
|
+
},
|
|
381
|
+
})
|
|
311
382
|
self.entries.push({ kind: 'cleanup', dispose: () => f.dispose() })
|
|
312
383
|
// Standalone fields (not inside a form) still publish field:validated
|
|
313
384
|
// events. Use the controller path with field name "(field)" — the
|
|
@@ -321,7 +392,13 @@ export class ControllerInstance {
|
|
|
321
392
|
},
|
|
322
393
|
|
|
323
394
|
form<S extends FormSchema>(schema: S, options?: FormOptions<S>): Form<S> {
|
|
324
|
-
const
|
|
395
|
+
const reporter = (err: unknown): void => {
|
|
396
|
+
dispatchError(self.rootShared.onError, err, {
|
|
397
|
+
kind: 'effect',
|
|
398
|
+
controllerPath: self.path,
|
|
399
|
+
})
|
|
400
|
+
}
|
|
401
|
+
const f = createForm(schema, options, { onValidatorError: reporter })
|
|
325
402
|
self.entries.push({ kind: 'cleanup', dispose: () => f.dispose() })
|
|
326
403
|
// Make every leaf field publish `field:validated` to the devtools bus
|
|
327
404
|
// with its key path inside the form. See spec §20.9.
|
|
@@ -332,6 +409,12 @@ export class ControllerInstance {
|
|
|
332
409
|
self.rootShared.devtools,
|
|
333
410
|
)
|
|
334
411
|
self.entries.push({ kind: 'cleanup', dispose: stop })
|
|
412
|
+
// Bind the reporter onto every leaf in the tree too (the form itself
|
|
413
|
+
// got it via the constructor option; nested forms/arrays inside the
|
|
414
|
+
// schema didn't, since they were constructed by the caller before
|
|
415
|
+
// ctx.form ran). Idempotent — leaves that already got the reporter
|
|
416
|
+
// via ctx.field get the same one set again.
|
|
417
|
+
bindTreeValidatorErrorReporter(f as unknown as Form<FormSchema>, reporter)
|
|
335
418
|
return f
|
|
336
419
|
},
|
|
337
420
|
|
|
@@ -339,7 +422,13 @@ export class ControllerInstance {
|
|
|
339
422
|
itemFactory: (initial?: ItemInitial<I>) => I,
|
|
340
423
|
options?: FieldArrayOptions<I>,
|
|
341
424
|
): FieldArray<I> {
|
|
342
|
-
const
|
|
425
|
+
const reporter = (err: unknown): void => {
|
|
426
|
+
dispatchError(self.rootShared.onError, err, {
|
|
427
|
+
kind: 'effect',
|
|
428
|
+
controllerPath: self.path,
|
|
429
|
+
})
|
|
430
|
+
}
|
|
431
|
+
const fa = createFieldArray<I>(itemFactory, options, { onValidatorError: reporter })
|
|
343
432
|
self.entries.push({ kind: 'cleanup', dispose: () => fa.dispose() })
|
|
344
433
|
const stop = bindTreeToDevtools(
|
|
345
434
|
fa as unknown as FieldArray<Field<unknown> | Form<FormSchema>>,
|
|
@@ -348,6 +437,10 @@ export class ControllerInstance {
|
|
|
348
437
|
self.rootShared.devtools,
|
|
349
438
|
)
|
|
350
439
|
self.entries.push({ kind: 'cleanup', dispose: stop })
|
|
440
|
+
bindTreeValidatorErrorReporter(
|
|
441
|
+
fa as unknown as FieldArray<Field<unknown> | Form<FormSchema>>,
|
|
442
|
+
reporter,
|
|
443
|
+
)
|
|
351
444
|
return fa
|
|
352
445
|
},
|
|
353
446
|
|
|
@@ -407,7 +500,7 @@ export class ControllerInstance {
|
|
|
407
500
|
def: ControllerDef<Props, Api>,
|
|
408
501
|
props: Props,
|
|
409
502
|
options?: { deps?: Partial<Record<string, unknown>> },
|
|
410
|
-
): { api: Api; dispose: () => void } {
|
|
503
|
+
): { api: Api; dispose: () => void; suspend: () => void; resume: () => void } {
|
|
411
504
|
const segment = self.makeChildSegment(getFactory(def), getName(def))
|
|
412
505
|
const override = options?.deps
|
|
413
506
|
const childDeps = override !== undefined ? { ...self.deps, ...override } : self.deps
|
|
@@ -432,6 +525,327 @@ export class ControllerInstance {
|
|
|
432
525
|
})
|
|
433
526
|
}
|
|
434
527
|
},
|
|
528
|
+
// Suspend / resume cascade through the child instance's lifecycle
|
|
529
|
+
// entries (same code path as `root.suspend()`); the child's state
|
|
530
|
+
// machine handles the no-op cases (suspending a disposed child,
|
|
531
|
+
// resuming an active child) on its own — no need to track an
|
|
532
|
+
// extra flag here.
|
|
533
|
+
suspend: () => {
|
|
534
|
+
if (disposed) return
|
|
535
|
+
try {
|
|
536
|
+
childInstance.suspend()
|
|
537
|
+
} catch (err) {
|
|
538
|
+
dispatchError(self.rootShared.onError, err, {
|
|
539
|
+
kind: 'effect',
|
|
540
|
+
controllerPath: self.path,
|
|
541
|
+
})
|
|
542
|
+
}
|
|
543
|
+
},
|
|
544
|
+
resume: () => {
|
|
545
|
+
if (disposed) return
|
|
546
|
+
try {
|
|
547
|
+
childInstance.resume()
|
|
548
|
+
} catch (err) {
|
|
549
|
+
dispatchError(self.rootShared.onError, err, {
|
|
550
|
+
kind: 'effect',
|
|
551
|
+
controllerPath: self.path,
|
|
552
|
+
})
|
|
553
|
+
}
|
|
554
|
+
},
|
|
555
|
+
}
|
|
556
|
+
},
|
|
557
|
+
|
|
558
|
+
session<Props, Api>(
|
|
559
|
+
def: ControllerDef<Props, Api>,
|
|
560
|
+
props: Props,
|
|
561
|
+
options?: { deps?: Partial<Record<string, unknown>> },
|
|
562
|
+
): readonly [Api, () => void] {
|
|
563
|
+
const segment = self.makeChildSegment(getFactory(def), getName(def))
|
|
564
|
+
const override = options?.deps
|
|
565
|
+
const childDeps = override !== undefined ? { ...self.deps, ...override } : self.deps
|
|
566
|
+
const childInstance = new ControllerInstance(self, self.rootShared, segment, childDeps)
|
|
567
|
+
const api = childInstance.construct(getFactory(def), props)
|
|
568
|
+
const entry: LifecycleEntry = { kind: 'child', instance: childInstance }
|
|
569
|
+
self.entries.push(entry)
|
|
570
|
+
let disposed = false
|
|
571
|
+
const dispose = (): void => {
|
|
572
|
+
if (disposed) return
|
|
573
|
+
disposed = true
|
|
574
|
+
const idx = self.entries.indexOf(entry)
|
|
575
|
+
if (idx >= 0) self.entries.splice(idx, 1)
|
|
576
|
+
try {
|
|
577
|
+
childInstance.dispose()
|
|
578
|
+
} catch (err) {
|
|
579
|
+
dispatchError(self.rootShared.onError, err, {
|
|
580
|
+
kind: 'effect',
|
|
581
|
+
controllerPath: self.path,
|
|
582
|
+
})
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
return [api, dispose] as const
|
|
586
|
+
},
|
|
587
|
+
|
|
588
|
+
collection<Item, K, Props, Api, R extends CollectionFactoryResult>(
|
|
589
|
+
options:
|
|
590
|
+
| CollectionHomogeneousOptions<Item, K, Props, Api>
|
|
591
|
+
| CollectionFactoryOptions<Item, K, R>,
|
|
592
|
+
): Collection<K, Api> | Collection<K, CollectionFactoryApi<R>> {
|
|
593
|
+
type ChildInfo = {
|
|
594
|
+
instance: ControllerInstance
|
|
595
|
+
api: Api
|
|
596
|
+
entry: LifecycleEntry
|
|
597
|
+
// For factory form: the controller def used to construct this child.
|
|
598
|
+
// A different def on a future render means "rebuild with new type".
|
|
599
|
+
def: ControllerDef<unknown, unknown>
|
|
600
|
+
}
|
|
601
|
+
const childMap = new Map<K, ChildInfo>()
|
|
602
|
+
const items$ = signal<ReadonlyArray<{ key: K; api: Api }>>([])
|
|
603
|
+
const size$ = computed(() => items$.value.length)
|
|
604
|
+
|
|
605
|
+
const isFactoryForm =
|
|
606
|
+
(options as CollectionFactoryOptions<Item, K, R>).factory !== undefined
|
|
607
|
+
|
|
608
|
+
const buildChild = (
|
|
609
|
+
item: Item,
|
|
610
|
+
): {
|
|
611
|
+
instance: ControllerInstance
|
|
612
|
+
api: Api
|
|
613
|
+
def: ControllerDef<unknown, unknown>
|
|
614
|
+
} | null => {
|
|
615
|
+
let def: ControllerDef<unknown, unknown>
|
|
616
|
+
let childProps: unknown
|
|
617
|
+
if (isFactoryForm) {
|
|
618
|
+
const factoryOpts = options as CollectionFactoryOptions<Item, K, R>
|
|
619
|
+
const result = factoryOpts.factory(item) as CollectionFactoryResult
|
|
620
|
+
def = result.controller as ControllerDef<unknown, unknown>
|
|
621
|
+
childProps = result.props
|
|
622
|
+
} else {
|
|
623
|
+
const homoOpts = options as CollectionHomogeneousOptions<Item, K, Props, Api>
|
|
624
|
+
def = homoOpts.controller as unknown as ControllerDef<unknown, unknown>
|
|
625
|
+
childProps = homoOpts.propsOf(item)
|
|
626
|
+
}
|
|
627
|
+
const segment = self.makeChildSegment(getFactory(def), getName(def))
|
|
628
|
+
const childDeps =
|
|
629
|
+
options.deps !== undefined ? { ...self.deps, ...options.deps } : self.deps
|
|
630
|
+
const instance = new ControllerInstance(self, self.rootShared, segment, childDeps)
|
|
631
|
+
try {
|
|
632
|
+
const api = instance.construct(
|
|
633
|
+
getFactory(def) as (ctx: Ctx, props: unknown) => Api,
|
|
634
|
+
childProps,
|
|
635
|
+
)
|
|
636
|
+
return { instance, api, def }
|
|
637
|
+
} catch (err) {
|
|
638
|
+
// SPEC §12.1.6: runtime construction errors in collection items
|
|
639
|
+
// route to onError; the bad item is skipped.
|
|
640
|
+
dispatchError(self.rootShared.onError, err, {
|
|
641
|
+
kind: 'construction',
|
|
642
|
+
controllerPath: self.path,
|
|
643
|
+
})
|
|
644
|
+
return null
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
const removeKey = (key: K): void => {
|
|
649
|
+
const info = childMap.get(key)
|
|
650
|
+
if (info === undefined) return
|
|
651
|
+
childMap.delete(key)
|
|
652
|
+
const idx = self.entries.indexOf(info.entry)
|
|
653
|
+
if (idx >= 0) self.entries.splice(idx, 1)
|
|
654
|
+
try {
|
|
655
|
+
info.instance.dispose()
|
|
656
|
+
} catch (err) {
|
|
657
|
+
dispatchError(self.rootShared.onError, err, {
|
|
658
|
+
kind: 'effect',
|
|
659
|
+
controllerPath: self.path,
|
|
660
|
+
})
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
const reconcile = (): void => {
|
|
665
|
+
const source = options.source.value
|
|
666
|
+
const itemByKey = new Map<K, Item>()
|
|
667
|
+
for (const item of source) {
|
|
668
|
+
const key = options.keyOf(item)
|
|
669
|
+
if (!itemByKey.has(key)) itemByKey.set(key, item)
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
// Drop removed keys.
|
|
673
|
+
for (const key of [...childMap.keys()]) {
|
|
674
|
+
if (!itemByKey.has(key)) removeKey(key)
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
// Add new keys + rebuild factory-form type changes.
|
|
678
|
+
for (const [key, item] of itemByKey) {
|
|
679
|
+
const existing = childMap.get(key)
|
|
680
|
+
if (existing !== undefined) {
|
|
681
|
+
if (isFactoryForm) {
|
|
682
|
+
const result = (options as CollectionFactoryOptions<Item, K, R>).factory(
|
|
683
|
+
item,
|
|
684
|
+
) as CollectionFactoryResult
|
|
685
|
+
if ((result.controller as unknown) !== existing.def) {
|
|
686
|
+
removeKey(key)
|
|
687
|
+
const built = buildChild(item)
|
|
688
|
+
if (built !== null) {
|
|
689
|
+
const entry: LifecycleEntry = { kind: 'child', instance: built.instance }
|
|
690
|
+
self.entries.push(entry)
|
|
691
|
+
childMap.set(key, { ...built, entry })
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
continue
|
|
696
|
+
}
|
|
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 })
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
// Project to items signal in source order, deduped, skipping failures.
|
|
706
|
+
const next: Array<{ key: K; api: Api }> = []
|
|
707
|
+
const seen = new Set<K>()
|
|
708
|
+
for (const item of source) {
|
|
709
|
+
const key = options.keyOf(item)
|
|
710
|
+
if (seen.has(key)) continue
|
|
711
|
+
seen.add(key)
|
|
712
|
+
const info = childMap.get(key)
|
|
713
|
+
if (info !== undefined) next.push({ key, api: info.api })
|
|
714
|
+
}
|
|
715
|
+
items$.set(next)
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
// Register the diff loop as an 'effect' entry so it pauses on suspend
|
|
719
|
+
// and re-runs on resume — mirrors how `ctx.effect` is wired.
|
|
720
|
+
const wrapped = (): void => {
|
|
721
|
+
try {
|
|
722
|
+
reconcile()
|
|
723
|
+
} catch (err) {
|
|
724
|
+
dispatchError(self.rootShared.onError, err, {
|
|
725
|
+
kind: 'effect',
|
|
726
|
+
controllerPath: self.path,
|
|
727
|
+
})
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
const effectEntry: LifecycleEntry = {
|
|
731
|
+
kind: 'effect',
|
|
732
|
+
factory: wrapped,
|
|
733
|
+
dispose: null,
|
|
734
|
+
}
|
|
735
|
+
if (self.state !== 'suspended') {
|
|
736
|
+
effectEntry.dispose = standaloneEffect(wrapped)
|
|
737
|
+
}
|
|
738
|
+
self.entries.push(effectEntry)
|
|
739
|
+
|
|
740
|
+
return {
|
|
741
|
+
items: items$,
|
|
742
|
+
size: size$,
|
|
743
|
+
get: (key: K) => childMap.get(key)?.api,
|
|
744
|
+
has: (key: K) => childMap.has(key),
|
|
745
|
+
}
|
|
746
|
+
},
|
|
747
|
+
|
|
748
|
+
lazyChild<Props, Api>(
|
|
749
|
+
loader: () => Promise<ControllerDef<Props, Api>>,
|
|
750
|
+
props: Props,
|
|
751
|
+
options?: { deps?: Partial<Record<string, unknown>> },
|
|
752
|
+
): LazyChild<Api> {
|
|
753
|
+
const status$ = signal<'idle' | 'loading' | 'ready' | 'error'>('idle')
|
|
754
|
+
const api$ = signal<Api | undefined>(undefined)
|
|
755
|
+
const error$ = signal<unknown | undefined>(undefined)
|
|
756
|
+
|
|
757
|
+
let childInstance: ControllerInstance | null = null
|
|
758
|
+
let childEntry: LifecycleEntry | null = null
|
|
759
|
+
let pendingLoad: Promise<Api> | null = null
|
|
760
|
+
let disposed = false
|
|
761
|
+
|
|
762
|
+
// Parent dispose flag; the child entry (when present) is disposed
|
|
763
|
+
// via the parent's normal cascade, so we don't double-tear-down.
|
|
764
|
+
const flagEntry: LifecycleEntry = {
|
|
765
|
+
kind: 'onDispose',
|
|
766
|
+
fn: () => {
|
|
767
|
+
disposed = true
|
|
768
|
+
},
|
|
769
|
+
}
|
|
770
|
+
self.entries.push(flagEntry)
|
|
771
|
+
|
|
772
|
+
const handleFailure = (err: unknown): void => {
|
|
773
|
+
status$.set('error')
|
|
774
|
+
error$.set(err)
|
|
775
|
+
dispatchError(self.rootShared.onError, err, {
|
|
776
|
+
kind: 'construction',
|
|
777
|
+
controllerPath: self.path,
|
|
778
|
+
})
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
const load = (): Promise<Api> => {
|
|
782
|
+
if (disposed) {
|
|
783
|
+
return Promise.reject(new Error('[olas] ctx.lazyChild: cannot load after dispose'))
|
|
784
|
+
}
|
|
785
|
+
if (pendingLoad !== null) return pendingLoad
|
|
786
|
+
status$.set('loading')
|
|
787
|
+
pendingLoad = loader().then(
|
|
788
|
+
(def) => {
|
|
789
|
+
if (disposed) {
|
|
790
|
+
throw new Error('[olas] ctx.lazyChild: disposed during load')
|
|
791
|
+
}
|
|
792
|
+
const segment = self.makeChildSegment(getFactory(def), getName(def))
|
|
793
|
+
const childDeps =
|
|
794
|
+
options?.deps !== undefined ? { ...self.deps, ...options.deps } : self.deps
|
|
795
|
+
const instance = new ControllerInstance(self, self.rootShared, segment, childDeps)
|
|
796
|
+
try {
|
|
797
|
+
const api = instance.construct(getFactory(def), props)
|
|
798
|
+
childInstance = instance
|
|
799
|
+
childEntry = { kind: 'child', instance }
|
|
800
|
+
self.entries.push(childEntry)
|
|
801
|
+
api$.set(api)
|
|
802
|
+
status$.set('ready')
|
|
803
|
+
return api
|
|
804
|
+
} catch (err) {
|
|
805
|
+
handleFailure(err)
|
|
806
|
+
throw err
|
|
807
|
+
}
|
|
808
|
+
},
|
|
809
|
+
(err) => {
|
|
810
|
+
if (disposed) throw err
|
|
811
|
+
handleFailure(err)
|
|
812
|
+
throw err
|
|
813
|
+
},
|
|
814
|
+
)
|
|
815
|
+
return pendingLoad
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
const dispose = (): void => {
|
|
819
|
+
if (disposed) return
|
|
820
|
+
disposed = true
|
|
821
|
+
if (childEntry !== null && childInstance !== null) {
|
|
822
|
+
const idx = self.entries.indexOf(childEntry)
|
|
823
|
+
if (idx >= 0) self.entries.splice(idx, 1)
|
|
824
|
+
try {
|
|
825
|
+
childInstance.dispose()
|
|
826
|
+
} catch (err) {
|
|
827
|
+
dispatchError(self.rootShared.onError, err, {
|
|
828
|
+
kind: 'effect',
|
|
829
|
+
controllerPath: self.path,
|
|
830
|
+
})
|
|
831
|
+
}
|
|
832
|
+
childInstance = null
|
|
833
|
+
childEntry = null
|
|
834
|
+
}
|
|
835
|
+
// Splice the parent-dispose flag entry too — its only job was to
|
|
836
|
+
// signal disposal to an in-flight loader, and `disposed` is now
|
|
837
|
+
// already true. Leaving it behind leaks one closure per ever-
|
|
838
|
+
// disposed lazyChild for the parent's remaining lifetime.
|
|
839
|
+
const flagIdx = self.entries.indexOf(flagEntry)
|
|
840
|
+
if (flagIdx >= 0) self.entries.splice(flagIdx, 1)
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
return {
|
|
844
|
+
status: status$,
|
|
845
|
+
api: api$,
|
|
846
|
+
error: error$,
|
|
847
|
+
load,
|
|
848
|
+
dispose,
|
|
435
849
|
}
|
|
436
850
|
},
|
|
437
851
|
|
package/src/controller/root.ts
CHANGED
|
@@ -48,7 +48,15 @@ export function createRootWithProps<Props, Api, TDeps extends Record<string, unk
|
|
|
48
48
|
)
|
|
49
49
|
|
|
50
50
|
// Bootstrap failure throws straight out of createRoot. Spec §12.1.5.
|
|
51
|
-
|
|
51
|
+
// Tear down the QueryClient and any plugins it spawned (window/storage
|
|
52
|
+
// listeners, transports) before re-throwing so the failure doesn't leak.
|
|
53
|
+
let api: Api
|
|
54
|
+
try {
|
|
55
|
+
api = instance.construct(getFactory(def), props)
|
|
56
|
+
} catch (err) {
|
|
57
|
+
queryClient.dispose()
|
|
58
|
+
throw err
|
|
59
|
+
}
|
|
52
60
|
|
|
53
61
|
if (typeof api !== 'object' || api === null) {
|
|
54
62
|
// Allow primitive APIs in principle but root controls must live somewhere.
|