@kontsedal/olas-core 0.0.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 +39 -10
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +22 -12
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.mts +22 -12
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +39 -10
- package/dist/index.mjs.map +1 -1
- package/dist/{root-BCZDC5Fv.mjs → root-De-6KWIZ.mjs} +309 -11
- package/dist/root-De-6KWIZ.mjs.map +1 -0
- package/dist/{root-DXV1gVbQ.cjs → root-XKEsSmcd.cjs} +309 -11
- 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-CffZ1QXt.d.cts → types-C-zV1JZA.d.mts} +134 -4
- package/dist/types-C-zV1JZA.d.mts.map +1 -0
- package/dist/{types-DSlDowpE.d.mts → types-DKfpkm17.d.cts} +134 -4
- 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 +317 -3
- package/src/controller/types.ts +131 -0
- package/src/emitter.ts +34 -3
- package/src/forms/form.ts +47 -5
- package/src/index.ts +7 -1
- 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 +8 -4
- package/dist/root-BCZDC5Fv.mjs.map +0 -1
- package/dist/root-DXV1gVbQ.cjs.map +0 -1
- package/dist/types-CffZ1QXt.d.cts.map +0 -1
- package/dist/types-DSlDowpE.d.mts.map +0 -1
|
@@ -24,9 +24,19 @@ 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 { effect as standaloneEffect } from '../signals'
|
|
27
|
+
import { computed, signal, effect as standaloneEffect } from '../signals'
|
|
28
28
|
import { getFactory, getName } from './define'
|
|
29
|
-
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'
|
|
30
40
|
|
|
31
41
|
export type RootShared = {
|
|
32
42
|
readonly devtools: DevtoolsEmitter
|
|
@@ -342,7 +352,17 @@ export class ControllerInstance {
|
|
|
342
352
|
},
|
|
343
353
|
|
|
344
354
|
emitter<T>(): Emitter<T> {
|
|
345
|
-
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
|
+
})
|
|
346
366
|
self.entries.push({ kind: 'cleanup', dispose: () => e.dispose() })
|
|
347
367
|
return e
|
|
348
368
|
},
|
|
@@ -535,6 +555,300 @@ export class ControllerInstance {
|
|
|
535
555
|
}
|
|
536
556
|
},
|
|
537
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,
|
|
849
|
+
}
|
|
850
|
+
},
|
|
851
|
+
|
|
538
852
|
onDispose(fn) {
|
|
539
853
|
self.entries.push({
|
|
540
854
|
kind: 'onDispose',
|
package/src/controller/types.ts
CHANGED
|
@@ -79,6 +79,80 @@ export type CtrlProps<C> = C extends ControllerDef<infer P, unknown> ? P : never
|
|
|
79
79
|
/** Extract a controller's Api type. */
|
|
80
80
|
export type CtrlApi<C> = C extends ControllerDef<unknown, infer A> ? A : never
|
|
81
81
|
|
|
82
|
+
/**
|
|
83
|
+
* The reactive surface returned by `ctx.collection(...)`. `items` is the
|
|
84
|
+
* canonical ordered view (source-order, with any construction-failed items
|
|
85
|
+
* filtered out); `size` mirrors `items.length`; `get` / `has` are
|
|
86
|
+
* imperative key lookups. SPEC §11.1.
|
|
87
|
+
*/
|
|
88
|
+
export type Collection<K, Api> = {
|
|
89
|
+
readonly items: ReadSignal<ReadonlyArray<{ readonly key: K; readonly api: Api }>>
|
|
90
|
+
readonly size: ReadSignal<number>
|
|
91
|
+
get(key: K): Api | undefined
|
|
92
|
+
has(key: K): boolean
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Homogeneous form of `ctx.collection`: one controller def for every item,
|
|
97
|
+
* with `propsOf` projecting each item to the controller's `Props`. Construct
|
|
98
|
+
* happens once per new key — `propsOf` is **not** re-applied for unchanged
|
|
99
|
+
* keys.
|
|
100
|
+
*/
|
|
101
|
+
export type CollectionHomogeneousOptions<Item, K, Props, Api, TDeps = AmbientDeps> = {
|
|
102
|
+
readonly source: ReadSignal<readonly Item[]>
|
|
103
|
+
readonly keyOf: (item: Item) => K
|
|
104
|
+
readonly controller: ControllerDef<Props, Api>
|
|
105
|
+
readonly propsOf: (item: Item) => Props
|
|
106
|
+
readonly factory?: never
|
|
107
|
+
readonly propsFor?: never
|
|
108
|
+
readonly deps?: Partial<TDeps>
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Heterogeneous form of `ctx.collection`: a single `factory` decides per-item
|
|
113
|
+
* which controller + props to construct. When a key's factory result picks a
|
|
114
|
+
* *different* controller than last time, the existing child is disposed and
|
|
115
|
+
* the new one constructed (type-discriminant rebuild).
|
|
116
|
+
*
|
|
117
|
+
* `R` is the factory's *return type* (typically inferred as the union of the
|
|
118
|
+
* branches' `{ controller, props }` shapes). `Api` is then projected out as
|
|
119
|
+
* the union of every branch's controller Api via `CollectionFactoryApi<R>` —
|
|
120
|
+
* unlike a single `Api` generic, the union doesn't collapse to the first
|
|
121
|
+
* branch.
|
|
122
|
+
*/
|
|
123
|
+
export type CollectionFactoryOptions<Item, K, R, TDeps = AmbientDeps> = {
|
|
124
|
+
readonly source: ReadSignal<readonly Item[]>
|
|
125
|
+
readonly keyOf: (item: Item) => K
|
|
126
|
+
readonly controller?: never
|
|
127
|
+
readonly propsOf?: never
|
|
128
|
+
readonly factory: (item: Item) => R
|
|
129
|
+
readonly deps?: Partial<TDeps>
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Constraint for the factory form's return shape. */
|
|
133
|
+
// biome-ignore lint/suspicious/noExplicitAny: per-branch types vary
|
|
134
|
+
export type CollectionFactoryResult = { controller: ControllerDef<any, any>; props: any }
|
|
135
|
+
|
|
136
|
+
/** Extract the union of every branch's controller Api. Distributes over R. */
|
|
137
|
+
export type CollectionFactoryApi<R> = R extends {
|
|
138
|
+
// biome-ignore lint/suspicious/noExplicitAny: distributive infer across the union
|
|
139
|
+
controller: ControllerDef<any, infer A>
|
|
140
|
+
}
|
|
141
|
+
? A
|
|
142
|
+
: never
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Handle returned by `ctx.lazyChild(...)`. `status` walks `idle → loading →
|
|
146
|
+
* (ready | error)`; `api` becomes defined once `status === 'ready'`. SPEC §16.5.
|
|
147
|
+
*/
|
|
148
|
+
export type LazyChild<Api> = {
|
|
149
|
+
readonly status: ReadSignal<'idle' | 'loading' | 'ready' | 'error'>
|
|
150
|
+
readonly api: ReadSignal<Api | undefined>
|
|
151
|
+
readonly error: ReadSignal<unknown | undefined>
|
|
152
|
+
load(): Promise<Api>
|
|
153
|
+
dispose(): void
|
|
154
|
+
}
|
|
155
|
+
|
|
82
156
|
/**
|
|
83
157
|
* `ctx` is the lifecycle-bound surface every controller factory receives.
|
|
84
158
|
* Every primitive constructed through `ctx` is owned by the controller and
|
|
@@ -150,6 +224,63 @@ export type Ctx<TDeps = AmbientDeps> = {
|
|
|
150
224
|
options?: { deps?: Partial<TDeps> },
|
|
151
225
|
): { api: Api; dispose: () => void; suspend: () => void; resume: () => void }
|
|
152
226
|
|
|
227
|
+
/**
|
|
228
|
+
* Ephemeral child controller bound to either (a) the explicit `dispose()`
|
|
229
|
+
* call returned in the tuple, or (b) the parent's disposal — whichever
|
|
230
|
+
* comes first. Same lifecycle semantics as `ctx.attach` minus suspend /
|
|
231
|
+
* resume (sessions are short-lived, not pause-able). Returns a `[api,
|
|
232
|
+
* dispose]` tuple so the api shape is exactly the controller's return
|
|
233
|
+
* type, with no wrapper to unpack.
|
|
234
|
+
*
|
|
235
|
+
* Use cases: modal forms, inline edit sessions, wizards, command palette.
|
|
236
|
+
* SPEC §11.1.
|
|
237
|
+
*/
|
|
238
|
+
session<Props, Api>(
|
|
239
|
+
def: ControllerDef<Props, Api>,
|
|
240
|
+
props: Props,
|
|
241
|
+
options?: { deps?: Partial<TDeps> },
|
|
242
|
+
): readonly [api: Api, dispose: () => void]
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Diff-by-key set of child controllers driven by a reactive `source`.
|
|
246
|
+
* On every change to `source`, the collection:
|
|
247
|
+
* - **new keys** → construct a child via `controller` + `propsOf(item)`
|
|
248
|
+
* (or `factory(item)` for the heterogeneous form);
|
|
249
|
+
* - **removed keys** → dispose that child;
|
|
250
|
+
* - **unchanged keys** → leave it alone (`propsOf` is NOT re-applied).
|
|
251
|
+
*
|
|
252
|
+
* For per-item type-discriminated children, use the `factory` form —
|
|
253
|
+
* type changes for an existing key dispose and reconstruct.
|
|
254
|
+
*
|
|
255
|
+
* Construction errors (factory or controller throw) are routed to
|
|
256
|
+
* `onError` with `kind: 'construction'` and the item is **skipped** —
|
|
257
|
+
* the collection's surface shows one fewer entry. The diff loop does
|
|
258
|
+
* not re-throw. SPEC §11.1, §12.1.6.
|
|
259
|
+
*/
|
|
260
|
+
collection<Item, K, Props, Api>(
|
|
261
|
+
options: CollectionHomogeneousOptions<Item, K, Props, Api, TDeps>,
|
|
262
|
+
): Collection<K, Api>
|
|
263
|
+
collection<Item, K, R extends CollectionFactoryResult>(
|
|
264
|
+
options: CollectionFactoryOptions<Item, K, R, TDeps>,
|
|
265
|
+
): Collection<K, CollectionFactoryApi<R>>
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Code-split child controller. The loader is invoked on `load()`
|
|
269
|
+
* (idempotent), then the controller is constructed with the supplied
|
|
270
|
+
* `props`. `status` / `api` / `error` are reactive signals; subscribe
|
|
271
|
+
* via `use(child.api)` in your view layer.
|
|
272
|
+
*
|
|
273
|
+
* Parent disposal disposes the loaded child (if any) and flags any
|
|
274
|
+
* in-flight load so its eventual settle is dropped on the floor.
|
|
275
|
+
* Construction or import failures route through `onError` with
|
|
276
|
+
* `kind: 'construction'`. SPEC §16.5.
|
|
277
|
+
*/
|
|
278
|
+
lazyChild<Props, Api>(
|
|
279
|
+
loader: () => Promise<ControllerDef<Props, Api>>,
|
|
280
|
+
props: Props,
|
|
281
|
+
options?: { deps?: Partial<TDeps> },
|
|
282
|
+
): LazyChild<Api>
|
|
283
|
+
|
|
153
284
|
effect(fn: () => void | (() => void)): void
|
|
154
285
|
|
|
155
286
|
on<T>(emitter: Emitter<T>, handler: (value: T) => void): void
|
package/src/emitter.ts
CHANGED
|
@@ -20,17 +20,43 @@ export type Emitter<T> = {
|
|
|
20
20
|
|
|
21
21
|
type AnyHandler = (value: unknown) => void
|
|
22
22
|
|
|
23
|
+
/**
|
|
24
|
+
* Optional escape hatch for emit-time handler throws. If supplied, a thrown
|
|
25
|
+
* handler is reported here and emission continues with the remaining handlers
|
|
26
|
+
* (spec §20.6 — one throwing handler must not block the rest). If absent,
|
|
27
|
+
* the throw is logged via `console.error`.
|
|
28
|
+
*/
|
|
29
|
+
export type EmitterErrorReporter = (err: unknown) => void
|
|
30
|
+
|
|
23
31
|
class EmitterImpl<T> {
|
|
24
32
|
private handlers = new Set<AnyHandler>()
|
|
25
33
|
private disposed = false
|
|
26
34
|
|
|
35
|
+
constructor(private onError?: EmitterErrorReporter) {}
|
|
36
|
+
|
|
27
37
|
emit(value: T): void {
|
|
28
38
|
if (this.disposed) return
|
|
29
39
|
// Snapshot so a handler that unsubscribes itself (or another) doesn't
|
|
30
40
|
// mutate the set mid-iteration.
|
|
31
41
|
const snapshot = Array.from(this.handlers)
|
|
32
42
|
for (const handler of snapshot) {
|
|
33
|
-
|
|
43
|
+
try {
|
|
44
|
+
handler(value as unknown)
|
|
45
|
+
} catch (err) {
|
|
46
|
+
// Spec §20.6: isolate handler throws so siblings still fire.
|
|
47
|
+
if (this.onError) {
|
|
48
|
+
try {
|
|
49
|
+
this.onError(err)
|
|
50
|
+
} catch {
|
|
51
|
+
// Reporter itself threw — last resort.
|
|
52
|
+
// eslint-disable-next-line no-console
|
|
53
|
+
console.error('[olas] emitter handler threw and reporter threw:', err)
|
|
54
|
+
}
|
|
55
|
+
} else {
|
|
56
|
+
// eslint-disable-next-line no-console
|
|
57
|
+
console.error('[olas] emitter handler threw:', err)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
34
60
|
}
|
|
35
61
|
}
|
|
36
62
|
|
|
@@ -67,9 +93,14 @@ class EmitterImpl<T> {
|
|
|
67
93
|
* (or the emitter is disposed). Use this for emitters that live outside any
|
|
68
94
|
* single controller — typically in deps. Use `ctx.emitter()` for emitters that
|
|
69
95
|
* should auto-clean with a controller.
|
|
96
|
+
*
|
|
97
|
+
* Pass `onError` to receive emit-time handler throws (spec §20.6 — one
|
|
98
|
+
* throwing handler must not block the rest of the fan-out). `ctx.emitter()`
|
|
99
|
+
* wires this to the root's `onError` so deps-level emitters get isolation
|
|
100
|
+
* by default when constructed via `ctx`.
|
|
70
101
|
*/
|
|
71
|
-
export function createEmitter<T = void>(): Emitter<T> {
|
|
72
|
-
const impl = new EmitterImpl<T>()
|
|
102
|
+
export function createEmitter<T = void>(options?: { onError?: EmitterErrorReporter }): Emitter<T> {
|
|
103
|
+
const impl = new EmitterImpl<T>(options?.onError)
|
|
73
104
|
return {
|
|
74
105
|
emit: ((value?: T) => impl.emit(value as T)) as Emitter<T>['emit'],
|
|
75
106
|
on: (handler) => impl.on(handler),
|
package/src/forms/form.ts
CHANGED
|
@@ -202,10 +202,42 @@ class FormImpl<S extends FormSchema> implements Form<S> {
|
|
|
202
202
|
}
|
|
203
203
|
} else if (isFieldArray(child)) {
|
|
204
204
|
const arr = child
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
205
|
+
const newValues = val as unknown[]
|
|
206
|
+
if (asInitial) {
|
|
207
|
+
// Reset-style application: replace items wholesale and re-anchor
|
|
208
|
+
// them as the new initial so a later `reset()` returns here.
|
|
209
|
+
arr.clear()
|
|
210
|
+
for (const itemVal of newValues) {
|
|
211
|
+
arr.add(itemVal as ItemInitial<Field<unknown>>)
|
|
212
|
+
}
|
|
213
|
+
// Internal: re-anchor the initialItems list. `replaceInitialItems`
|
|
214
|
+
// is only exposed for this exact use case.
|
|
215
|
+
;(
|
|
216
|
+
arr as unknown as {
|
|
217
|
+
replaceInitialItems: (items: ReadonlyArray<unknown>) => void
|
|
218
|
+
}
|
|
219
|
+
).replaceInitialItems(newValues)
|
|
220
|
+
} else {
|
|
221
|
+
// User-driven patch: preserve item identity where the lengths
|
|
222
|
+
// overlap so touched / dirty / in-flight validators on existing
|
|
223
|
+
// items survive. Tail diff handles grow / shrink.
|
|
224
|
+
const current = arr.items.peek() as ReadonlyArray<Field<unknown> | Form<FormSchema>>
|
|
225
|
+
const overlap = Math.min(current.length, newValues.length)
|
|
226
|
+
for (let i = 0; i < overlap; i++) {
|
|
227
|
+
const item = current[i]
|
|
228
|
+
const v = newValues[i]
|
|
229
|
+
if (isForm(item)) {
|
|
230
|
+
item.set(v as DeepPartial<FormValue<FormSchema>>)
|
|
231
|
+
} else {
|
|
232
|
+
;(item as Field<unknown>).set(v)
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
for (let i = current.length; i < newValues.length; i++) {
|
|
236
|
+
arr.add(newValues[i] as ItemInitial<Field<unknown>>)
|
|
237
|
+
}
|
|
238
|
+
for (let i = current.length - 1; i >= newValues.length; i--) {
|
|
239
|
+
arr.remove(i)
|
|
240
|
+
}
|
|
209
241
|
}
|
|
210
242
|
} else {
|
|
211
243
|
const f = child as Field<unknown>
|
|
@@ -403,7 +435,7 @@ class FieldArrayImpl<I extends Field<any> | Form<any>> implements FieldArray<I>
|
|
|
403
435
|
private readonly topLevelValidating$: Signal<boolean> = signal(false)
|
|
404
436
|
|
|
405
437
|
private readonly itemFactory: (initial?: ItemInitial<I>) => I
|
|
406
|
-
private
|
|
438
|
+
private initialItems: Array<ItemInitial<I>> = []
|
|
407
439
|
private readonly validators: ReadonlyArray<FieldArrayValidator<I>>
|
|
408
440
|
private currentValidatorRun = 0
|
|
409
441
|
private currentValidatorAbort: AbortController | null = null
|
|
@@ -528,6 +560,16 @@ class FieldArrayImpl<I extends Field<any> | Form<any>> implements FieldArray<I>
|
|
|
528
560
|
this.items$.set([])
|
|
529
561
|
}
|
|
530
562
|
|
|
563
|
+
/**
|
|
564
|
+
* Internal — used by `Form.resetWithInitial` to re-anchor the array's
|
|
565
|
+
* initial items after a parent-driven `applyPartial(..., asInitial: true)`.
|
|
566
|
+
* Without this, a subsequent `reset()` would revert to the construction-
|
|
567
|
+
* time initials rather than the most-recently-applied ones.
|
|
568
|
+
*/
|
|
569
|
+
replaceInitialItems(items: ReadonlyArray<ItemInitial<I>>): void {
|
|
570
|
+
this.initialItems = [...items]
|
|
571
|
+
}
|
|
572
|
+
|
|
531
573
|
reset(): void {
|
|
532
574
|
if (this.disposed) return
|
|
533
575
|
batch(() => {
|
package/src/index.ts
CHANGED
|
@@ -3,11 +3,17 @@
|
|
|
3
3
|
// Controller container
|
|
4
4
|
export type {
|
|
5
5
|
AmbientDeps,
|
|
6
|
+
Collection,
|
|
7
|
+
CollectionFactoryApi,
|
|
8
|
+
CollectionFactoryOptions,
|
|
9
|
+
CollectionFactoryResult,
|
|
10
|
+
CollectionHomogeneousOptions,
|
|
6
11
|
ControllerDef,
|
|
7
12
|
CtrlApi,
|
|
8
13
|
CtrlProps,
|
|
9
14
|
Ctx,
|
|
10
15
|
Field,
|
|
16
|
+
LazyChild,
|
|
11
17
|
Root,
|
|
12
18
|
RootOptions,
|
|
13
19
|
} from './controller'
|
|
@@ -16,7 +22,7 @@ export { createRoot, defineController } from './controller'
|
|
|
16
22
|
export type { DebugBus, DebugCacheEntry, DebugEvent } from './devtools'
|
|
17
23
|
|
|
18
24
|
// Emitter
|
|
19
|
-
export type { Emitter } from './emitter'
|
|
25
|
+
export type { Emitter, EmitterErrorReporter } from './emitter'
|
|
20
26
|
export { createEmitter } from './emitter'
|
|
21
27
|
export type { ErrorContext } from './errors'
|
|
22
28
|
// Forms — stdlib validators + debouncedValidator
|
package/src/signals/readonly.ts
CHANGED
|
@@ -8,15 +8,15 @@ import type { ReadSignal } from './types'
|
|
|
8
8
|
* Internal helper — not exported from the package's public surface.
|
|
9
9
|
*/
|
|
10
10
|
export function readOnly<T>(source: ReadSignal<T>): ReadSignal<T> {
|
|
11
|
-
return {
|
|
11
|
+
return Object.freeze({
|
|
12
12
|
get value() {
|
|
13
13
|
return source.value
|
|
14
14
|
},
|
|
15
15
|
peek() {
|
|
16
16
|
return source.peek()
|
|
17
17
|
},
|
|
18
|
-
subscribe(handler) {
|
|
18
|
+
subscribe(handler: (value: T) => void) {
|
|
19
19
|
return source.subscribe(handler)
|
|
20
20
|
},
|
|
21
|
-
}
|
|
21
|
+
})
|
|
22
22
|
}
|