@playfast/reform 1.1.1 → 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/README.md +85 -55
  2. package/package.json +17 -17
  3. package/src/boundary/boundary.test.ts +82 -33
  4. package/src/boundary/boundary.ts +141 -32
  5. package/src/calc/asyncCalc.invalidate.test.ts +733 -32
  6. package/src/calc/asyncCalc.test.ts +397 -225
  7. package/src/calc/asyncCalc.ts +115 -100
  8. package/src/calc/asyncCalcTypes.ts +147 -0
  9. package/src/calc/calc.test.ts +13 -16
  10. package/src/calc/calc.ts +44 -18
  11. package/src/calc/calcFamily.test.ts +90 -32
  12. package/src/calc/calcFamily.ts +56 -25
  13. package/src/calc/compose.ts +1 -14
  14. package/src/channel/channel.ts +25 -6
  15. package/src/channel/{procedureRegistry.ts → procedures.ts} +41 -23
  16. package/src/compose/composition.test.ts +19 -0
  17. package/src/compose/composition.ts +168 -81
  18. package/src/compose/compositionTypes.ts +249 -0
  19. package/src/compose/props.ts +13 -6
  20. package/src/compose/provide.ts +187 -46
  21. package/src/compose/slot.ts +156 -19
  22. package/src/compose/structure.test.ts +6 -3
  23. package/src/compose/structure.ts +106 -13
  24. package/src/compose/ui.test.ts +19 -3
  25. package/src/compose/ui.ts +147 -54
  26. package/src/compose/ui.typecheck.ts +40 -4
  27. package/src/definition/definition.ts +22 -9
  28. package/src/event/event.fromSource.test.ts +172 -0
  29. package/src/event/event.test.ts +10 -0
  30. package/src/event/event.ts +102 -18
  31. package/src/feature/feature.mount.test.ts +123 -56
  32. package/src/feature/feature.test.ts +67 -63
  33. package/src/feature/feature.ts +222 -262
  34. package/src/feature/feature.typecheck.ts +224 -91
  35. package/src/feature/featureBinding.ts +130 -20
  36. package/src/feature/featureClass.ts +162 -0
  37. package/src/feature/featureConfig.ts +192 -0
  38. package/src/feature/featureFactory.ts +163 -0
  39. package/src/feature/featureModule.ts +136 -0
  40. package/src/feature/featureModule.typecheck.ts +63 -0
  41. package/src/feature/featureNativeTree.ts +152 -0
  42. package/src/feature/featureRequirement.ts +82 -0
  43. package/src/feature/featureVariants.ts +234 -0
  44. package/src/feature/mountFeature.ts +54 -0
  45. package/src/graph/closure.ts +222 -0
  46. package/src/graph/services.ts +95 -0
  47. package/src/graph/summary.ts +134 -0
  48. package/src/index.ts +171 -245
  49. package/src/internal/bucketCache.ts +39 -0
  50. package/src/internal/capture.ts +9 -4
  51. package/src/internal/env.ts +1 -3
  52. package/src/internal/errors.ts +21 -10
  53. package/src/internal/queryDriver.ts +61 -25
  54. package/src/internal/queryDriverHydrate.ts +45 -0
  55. package/src/internal/queryDriverStore.ts +8 -5
  56. package/src/internal/queryDriverTypes.ts +11 -11
  57. package/src/internal/reuse.test.ts +10 -3
  58. package/src/internal/reuse.ts +5 -2
  59. package/src/internal/scheduler.ts +4 -1
  60. package/src/internal/sources.ts +25 -11
  61. package/src/internal/store.test.ts +27 -6
  62. package/src/internal/track.ts +3 -2
  63. package/src/internal.ts +222 -0
  64. package/src/namespace/namespace.ts +46 -25
  65. package/src/procedure/procedure.ts +38 -11
  66. package/src/reducer/reducer.ts +78 -34
  67. package/src/remote/pendingQueue.ts +145 -0
  68. package/src/remote/remoteState.test.ts +536 -354
  69. package/src/remote/remoteState.ts +145 -106
  70. package/src/remote/remoteState.typecheck.ts +47 -23
  71. package/src/remote/remoteStateMake.ts +97 -0
  72. package/src/remote/remoteStateSend.ts +95 -37
  73. package/src/remote/remoteStateTypes.ts +155 -0
  74. package/src/runtime/appRuntime.activation.test.ts +104 -0
  75. package/src/runtime/appRuntime.test.ts +187 -111
  76. package/src/runtime/appRuntime.ts +36 -130
  77. package/src/runtime/capturedAppRuntime.ts +274 -0
  78. package/src/runtime/eventBudget.test.ts +39 -12
  79. package/src/runtime/eventBudget.ts +9 -8
  80. package/src/runtime/featureMount.ts +94 -0
  81. package/src/runtime/hardening.test.ts +22 -13
  82. package/src/runtime/instrumentation.test.ts +213 -194
  83. package/src/runtime/instrumentation.ts +6 -11
  84. package/src/runtime/loop.test.ts +84 -7
  85. package/src/runtime/loop.ts +69 -40
  86. package/src/runtime/runtimeHandle.ts +124 -0
  87. package/src/scene/featureScene.test.ts +4 -2
  88. package/src/scene/scene.ts +234 -64
  89. package/src/scene/seedScene.test.ts +69 -86
  90. package/src/state/state.nominal.typecheck.ts +68 -0
  91. package/src/state/state.test.ts +17 -0
  92. package/src/state/state.ts +79 -26
  93. package/src/state/stateFamily.test.ts +39 -8
  94. package/src/state/stateFamily.ts +55 -22
  95. package/src/state/stateGroup.ts +53 -17
  96. package/src/state/token.ts +40 -10
  97. package/src/synced/syncedStore.ts +46 -23
  98. package/src/testkit/flight.testkit.ts +84 -0
  99. package/src/wire/tree.test.ts +8 -4
  100. package/src/wire/triggers.test.ts +6 -2
  101. package/src/wire/triggers.ts +14 -10
  102. package/src/calc/asyncCalcDefinitions.ts +0 -48
  103. package/src/internal/ctx.ts +0 -9
  104. package/src/remote/remoteStateDefinition.ts +0 -135
  105. package/src/remote/remoteStateLayers.ts +0 -118
  106. package/src/remote/remoteStateLiveTypes.ts +0 -59
@@ -1,33 +1,57 @@
1
- import { Effect, PubSub, Runtime } from 'effect'
1
+ import { Effect, Layer, MutableRef, Option, PubSub, Runtime, Schema } from 'effect'
2
2
  import { type Manifest, definitionClass } from '../definition/definition'
3
+ import { resolveScheduler } from '../internal/scheduler'
3
4
  import { Bus, publish, type Tagged } from '../runtime/bus'
5
+ import type { Source } from '../state/token'
4
6
  import type { Trigger } from '../ui/trigger'
5
- import type { AnySchema, AnyValue } from '../internal/variance'
6
7
 
7
8
  export type EventOf<N extends string, P> = Tagged & { readonly _tag: N } & P
8
9
 
9
- export interface EventManifest<N extends string, P> extends Manifest {
10
+ export interface EventSchemaReflection {
11
+ readonly ast: Schema.Schema<unknown, unknown, unknown>['ast']
12
+ }
13
+
14
+ export interface EventManifest<N extends string> extends Manifest {
10
15
  readonly kind: 'Event'
11
16
  readonly name: N
12
- readonly schema: AnySchema<P>
17
+ readonly schema: EventSchemaReflection
13
18
  }
14
19
 
15
- export interface EventClass<out N extends string, in out P> {
20
+ export interface EventReflection<out N extends string = string, out P = unknown> {
16
21
  new (): {}
17
- readonly manifest: EventManifest<N, P>
22
+ readonly manifest: EventManifest<N>
18
23
  readonly tag: N
24
+ readonly Payload: P
25
+ }
26
+
27
+ export type EventCapture<Result> = <N extends string, P>(event: EventClass<N, P>) => Result
28
+
29
+ export interface AnyEvent extends EventReflection {
30
+ readonly buildUnknown: (payload: unknown) => Tagged
31
+ readonly capture: <Result>(visit: EventCapture<Result>) => Result
32
+ }
33
+
34
+ export interface EventClass<out N extends string, in out P> extends EventReflection<N, P> {
19
35
  readonly build: (payload: P) => EventOf<N, P>
20
36
  readonly trigger: Effect.Effect<Trigger<P>, never, Bus>
37
+ readonly buildUnknown: (payload: unknown) => Tagged
38
+ readonly capture: <Result>(visit: EventCapture<Result>) => Result
21
39
  }
22
40
 
23
- export type AnyEvent = EventClass<string, AnyValue>
24
- export type EventType<E> = E extends EventClass<infer N, infer P> ? EventOf<N, P> : never
41
+ export type EventName<E> = E extends EventReflection<infer N, infer _P> ? N : never
42
+ export type EventPayload<E> = E extends EventReflection<string, infer P> ? P : never
43
+ export type EventType<E> = E extends EventReflection<infer N, infer P> ? EventOf<N, P> : never
25
44
 
26
- export const make = <const N extends string, P>(
45
+ export const make = <const N extends string, P, Encoded>(
27
46
  name: N,
28
- schema: AnySchema<P>,
47
+ schema: Schema.Schema<P, Encoded, never>,
29
48
  ): EventClass<N, P> => {
30
- const build = (payload: P): EventOf<N, P> => ({ _tag: name, ...payload })
49
+ const build = (payload: P): EventOf<N, P> => ({ ...payload, _tag: name })
50
+ // Payloads at existential boundaries are already-decoded domain values, so
51
+ // check the Type side — decodeUnknownSync would reject transforming schemas
52
+ // (e.g. a real Option, whose _tag lives on the prototype).
53
+ const validatePayload = Schema.validateSync(schema)
54
+ const buildUnknown = (payload: unknown): Tagged => build(validatePayload(payload))
31
55
  const trigger = Effect.gen(function* () {
32
56
  const bus = yield* Bus
33
57
  const runtime = yield* Effect.runtime<never>()
@@ -35,22 +59,82 @@ export const make = <const N extends string, P>(
35
59
  Runtime.runSync(runtime)(PubSub.publish(bus, { priority: 'High', event: build(payload) }))
36
60
  }
37
61
  })
38
- return definitionClass<EventClass<N, P>>({
62
+ const event: EventClass<N, P> = definitionClass<EventClass<N, P>>({
39
63
  manifest: { kind: 'Event' as const, name, schema },
40
64
  tag: name,
41
65
  build,
66
+ buildUnknown,
42
67
  trigger,
68
+ capture: <Result>(visit: EventCapture<Result>): Result => visit(event),
43
69
  })
70
+ return event
44
71
  }
45
72
 
46
- export const construct = <N extends string, P>(event: EventClass<N, P>, payload: P): EventOf<N, P> =>
47
- event.build(payload)
73
+ export function construct<N extends string, P>(event: EventClass<N, P>, payload: P): EventOf<N, P>
74
+ export function construct<E extends AnyEvent>(event: E, payload: EventPayload<E>): EventType<E>
75
+ export function construct(event: AnyEvent, payload: unknown): Tagged {
76
+ return event.buildUnknown(payload)
77
+ }
48
78
 
49
- export const dispatch = <N extends string, P>(
50
- event: EventClass<N, P>,
51
- payload: P,
52
- ): Effect.Effect<void, never, Bus> => publish('Normal', event.build(payload))
79
+ export const dispatch = <E extends AnyEvent>(
80
+ event: E,
81
+ payload: EventPayload<E>,
82
+ ): Effect.Effect<void, never, Bus> => publish('Normal', event.buildUnknown(payload))
53
83
 
54
84
  export const trigger = <N extends string, P>(
55
85
  event: EventClass<N, P>,
56
86
  ): Effect.Effect<Trigger<P>, never, Bus> => event.trigger
87
+
88
+ export interface SourceChange<A> {
89
+ readonly previous: Option.Option<A>
90
+ readonly current: A
91
+ }
92
+
93
+ export interface SourceEventConfigExternalApi<A, P> {
94
+ readonly emitInitial?: boolean
95
+ readonly payload: (change: SourceChange<A>) => Option.Option<P>
96
+ }
97
+ export type SourceEventConfig<A, P> = SourceEventConfigExternalApi<A, P>
98
+
99
+ export const liveFromSource = <N extends string, P, SourceName extends string, A, Identifier>(
100
+ event: EventClass<N, P>,
101
+ source: Source<SourceName, A, Identifier>,
102
+ config: SourceEventConfigExternalApi<A, P>,
103
+ ): Layer.Layer<never, never, Identifier | Bus> =>
104
+ Layer.scopedDiscard(
105
+ Effect.gen(function* () {
106
+ const store = yield* source.store
107
+ const scheduler = yield* resolveScheduler
108
+ const runtime = yield* Effect.runtime<Bus>()
109
+ const initial = store.getSnapshot()
110
+ // The scheduler coalesces bursts, so track the last value this adapter observed.
111
+ const seen = MutableRef.make(initial)
112
+ // A queued notification may outlive unsubscribe within the same tick.
113
+ const active = MutableRef.make(true)
114
+ const emit = (change: SourceChange<A>): void => {
115
+ if (!MutableRef.get(active)) {
116
+ return
117
+ }
118
+ const payload = config.payload(change)
119
+ if (Option.isSome(payload)) {
120
+ Runtime.runSync(runtime)(publish('Normal', event.build(payload.value)))
121
+ }
122
+ }
123
+ const unsubscribe = store.subscribe(() => {
124
+ const current = store.getSnapshot()
125
+ const previous = MutableRef.get(seen)
126
+ MutableRef.set(seen, current)
127
+ emit({ previous: Option.some(previous), current })
128
+ })
129
+ yield* Effect.addFinalizer(() =>
130
+ Effect.sync(() => {
131
+ MutableRef.set(active, false)
132
+ unsubscribe()
133
+ }),
134
+ )
135
+ if (config.emitInitial === true) {
136
+ // Defer until reducers and procedures from the same layer build have registered.
137
+ scheduler.schedule([() => emit({ previous: Option.none(), current: initial })])
138
+ }
139
+ }),
140
+ )
@@ -1,46 +1,105 @@
1
- import { type Cause, Duration, Effect, Exit, Layer, Scope } from "effect";
2
- import { Schema as S } from "effect";
3
- import { expect, it } from "@effect/vitest";
4
- import * as Composition from "../compose/composition";
5
- import { ui } from "../compose/ui";
6
- import * as Event from "../event/event";
7
- import * as Reducer from "../reducer/reducer";
8
- import * as State from "../state/state";
9
- import { publish } from "../runtime/bus";
10
- import { Engine, Reducers } from "../runtime/loop";
11
- import * as Feature from "./feature";
12
- import { type EngineServices, featureModule, mountFeature } from "./feature";
1
+ import { type Cause, Context, Duration, Effect, Exit, Layer, Scope } from 'effect'
2
+ import { Schema as S } from 'effect'
3
+ import { expect, it } from '@effect/vitest'
4
+ import * as Composition from '../compose/composition'
5
+ import { mount } from '../compose/structure'
6
+ import { ui } from '../compose/ui'
7
+ import * as Event from '../event/event'
8
+ import * as Reducer from '../reducer/reducer'
9
+ import * as State from '../state/state'
10
+ import { publish } from '../runtime/bus'
11
+ import { Engine, Reducers } from '../runtime/loop'
12
+ import * as Feature from './feature'
13
+ import { type EngineServices, featureModule, mountFeature } from './feature'
13
14
 
14
15
  // A self-contained feature: a Count state + a reducer that folds Tick. The base
15
16
  // seeds Count; the logic provides the reducer over it and leaves the engine
16
17
  // (Reducers registry) OPEN, so `mountFeature` builds it against the live engine.
17
- class Tick extends Event.make("Tick", S.Struct({})) {}
18
- class Count extends State.make("count", S.Number) {}
19
- class CountReducer extends Reducer.make("CountReducer", {
18
+ class Tick extends Event.make('Tick', S.Struct({})) {}
19
+ class Count extends State.make('count', S.Number) {}
20
+ class CountReducer extends Reducer.make('CountReducer', {
20
21
  states: [Count],
21
22
  events: [Tick],
22
23
  }) {}
23
24
 
24
- const mod = featureModule(
25
- [],
26
- Reducer.live(CountReducer, (n) => n + 1).pipe(
27
- Layer.provideMerge(State.live(Count, 0)),
28
- ),
29
- );
25
+ class MountedCounterProbe extends Context.Tag('test/MountedCounterProbe')<
26
+ MountedCounterProbe,
27
+ { readonly value: number }
28
+ >() {}
29
+
30
+ class LazyMountedProbe extends Context.Tag('test/LazyMountedProbe')<
31
+ LazyMountedProbe,
32
+ { readonly value: string }
33
+ >() {}
34
+
35
+ class UnmountedProbe extends Context.Tag('test/UnmountedProbe')<
36
+ UnmountedProbe,
37
+ { readonly value: boolean }
38
+ >() {}
30
39
 
31
40
  // A trivial composition to hang the feature off (definitions only).
32
- class CtrUi extends ui("CtrUi")<{ props: {}; events: {} }>() {}
33
- class CtrComp extends Composition.make("CtrComp", {
34
- title: "Counter",
41
+ class CtrUi extends ui('CtrUi')<{ props: {}; events: {} }>() {}
42
+ class CtrComp extends Composition.make('CtrComp', {
43
+ title: 'Counter',
35
44
  ui: CtrUi,
36
- }) {}
45
+ })<CtrComp>() {}
37
46
 
38
- class CounterFeature extends Feature.make("counter", {
47
+ const CtrLive = Composition.live(CtrComp, function* () {
48
+ return mount({ props: {}, slots: {} })
49
+ })
50
+
51
+ const mod = featureModule(
52
+ [],
53
+ Layer.mergeAll(
54
+ CtrLive,
55
+ Reducer.live(CountReducer, (n) => n + 1).pipe(Layer.provideMerge(State.live(Count, 0))),
56
+ Layer.succeed(MountedCounterProbe, { value: 42 }),
57
+ ),
58
+ )
59
+
60
+ class CounterFeature extends Feature.make('counter', {
39
61
  composition: CtrComp,
40
62
  module: mod,
41
63
  boot: [Event.construct(Tick, {})],
42
64
  }) {}
43
65
 
66
+ const lazyMod = featureModule(
67
+ [],
68
+ Layer.merge(CtrLive, Layer.succeed(LazyMountedProbe, { value: 'lazy' })),
69
+ )
70
+
71
+ class LazyCounterFeature extends Feature.make('lazy-counter', {
72
+ composition: CtrComp,
73
+ loadingStrategy: 'lazy',
74
+ load: Effect.succeed(lazyMod),
75
+ placeholder: { loading: CtrComp, failed: CtrComp },
76
+ }) {}
77
+
78
+ const CounterNative = Feature.tree<typeof CounterFeature>(CounterFeature.token)
79
+ const LazyCounterNative = Feature.tree<typeof LazyCounterFeature>(LazyCounterFeature.token)
80
+
81
+ const mountCounterFeature = (engineContext: Context.Context<EngineServices>) =>
82
+ mountFeature(CounterNative.binding, engineContext)
83
+
84
+ const mountLazyCounterFeature = (engineContext: Context.Context<EngineServices>) =>
85
+ mountFeature(LazyCounterNative.binding, engineContext)
86
+
87
+ type ContextServices<C> = C extends Context.Context<infer Services> ? Services : never
88
+ type EagerMountedServices = ContextServices<
89
+ Effect.Effect.Success<ReturnType<typeof mountCounterFeature>>
90
+ >
91
+ type LazyMountedServices = ContextServices<
92
+ Effect.Effect.Success<ReturnType<typeof mountLazyCounterFeature>>
93
+ >
94
+ type Includes<Services, Candidate> = Candidate extends Services ? true : false
95
+
96
+ const mountedServiceTypeProof: readonly [
97
+ Includes<EagerMountedServices, MountedCounterProbe>,
98
+ Includes<EagerMountedServices, UnmountedProbe>,
99
+ Includes<LazyMountedServices, LazyMountedProbe>,
100
+ Includes<LazyMountedServices, UnmountedProbe>,
101
+ ] = [true, false, true, false]
102
+
44
103
  // Poll a read until it satisfies a predicate (the loop drains on a forked fiber).
45
104
  const waitUntil = <A, R>(
46
105
  read: Effect.Effect<A, never, R>,
@@ -49,46 +108,54 @@ const waitUntil = <A, R>(
49
108
  Effect.flatMap(read, (a) =>
50
109
  pred(a)
51
110
  ? Effect.succeed(a)
52
- : Effect.flatMap(Effect.sleep(Duration.millis(1)), () =>
53
- waitUntil(read, pred),
54
- ),
55
- ).pipe(Effect.timeout(Duration.seconds(2)));
111
+ : Effect.flatMap(Effect.sleep(Duration.millis(1)), () => waitUntil(read, pred)),
112
+ ).pipe(Effect.timeout(Duration.seconds(2)))
56
113
 
57
114
  it.scopedLive(
58
- "a mounted feature registers on the shared loop, boots, and folds dispatched events",
115
+ 'a mounted feature registers on the shared loop, boots, and folds dispatched events',
59
116
  () =>
60
117
  Effect.gen(function* () {
61
- const engineContext = yield* Effect.context<EngineServices>();
62
- const reducers = yield* Reducers;
63
- const baseline = reducers.entries.length;
64
-
65
- const scope = yield* Scope.make();
66
- const ctx = yield* mountFeature(
67
- CounterFeature.binding,
68
- engineContext,
69
- ).pipe(Effect.provideService(Scope.Scope, scope));
118
+ const engineContext = yield* Effect.context<EngineServices>()
119
+ const reducers = yield* Reducers
120
+ const baseline = reducers.entries.length
121
+
122
+ const scope = yield* Scope.make()
123
+ const ctx = yield* mountCounterFeature(engineContext).pipe(
124
+ Effect.provideService(Scope.Scope, scope),
125
+ )
126
+ expect(mountedServiceTypeProof).toEqual([true, false, true, false])
127
+ expect(Context.get(ctx, MountedCounterProbe).value).toBe(42)
128
+ expect(Context.getOption(ctx, UnmountedProbe)._tag).toBe('None')
70
129
  // Registered into the SHARED registry.
71
- expect(reducers.entries.length).toBe(baseline + 1);
130
+ expect(reducers.entries.length).toBe(baseline + 1)
72
131
 
73
132
  // boot dispatched Tick → Count folds to 1 on the shared loop.
74
- const booted = yield* waitUntil(
75
- Count.pipe(Effect.provide(ctx)),
76
- (n) => n === 1,
77
- );
78
- expect(booted).toBe(1);
133
+ const booted = yield* waitUntil(Count.pipe(Effect.provide(ctx)), (n) => n === 1)
134
+ expect(booted).toBe(1)
79
135
 
80
136
  // A further dispatch on the shared bus folds again → 2.
81
- yield* publish("High", Event.construct(Tick, {}));
82
- const folded = yield* waitUntil(
83
- Count.pipe(Effect.provide(ctx)),
84
- (n) => n === 2,
85
- );
86
- expect(folded).toBe(2);
137
+ yield* publish('High', Event.construct(Tick, {}))
138
+ const folded = yield* waitUntil(Count.pipe(Effect.provide(ctx)), (n) => n === 2)
139
+ expect(folded).toBe(2)
87
140
 
88
141
  // Unmount: closing the feature scope unregisters its reducer — the entry is
89
142
  // reclaimed, not left folding onto an orphaned store.
90
- yield* Scope.close(scope, Exit.succeed(undefined));
91
- expect(reducers.entries.length).toBe(baseline);
92
- expect(reducers.byTag.get("Tick")).toBeUndefined();
143
+ yield* Scope.close(scope, Exit.succeed(undefined))
144
+ expect(reducers.entries.length).toBe(baseline)
145
+ expect(reducers.byTag.get('Tick')).toBeUndefined()
93
146
  }).pipe(Effect.provide(Engine)),
94
- );
147
+ )
148
+
149
+ it.scopedLive('a lazy feature retains its mounted service type', () =>
150
+ Effect.gen(function* () {
151
+ const engineContext = yield* Effect.context<EngineServices>()
152
+ const scope = yield* Scope.make()
153
+ const ctx = yield* mountLazyCounterFeature(engineContext).pipe(
154
+ Effect.provideService(Scope.Scope, scope),
155
+ )
156
+
157
+ expect(Context.get(ctx, LazyMountedProbe).value).toBe('lazy')
158
+ expect(Context.getOption(ctx, UnmountedProbe)._tag).toBe('None')
159
+ yield* Scope.close(scope, Exit.succeed(undefined))
160
+ }).pipe(Effect.provide(Engine)),
161
+ )
@@ -1,81 +1,85 @@
1
- import { Effect, Layer, Option } from "effect";
2
- import { expect, it } from "@effect/vitest";
3
- import * as Composition from "../compose/composition";
4
- import { ui } from "../compose/ui";
5
- import * as Feature from "./feature";
6
- import {
7
- featureModule,
8
- isFeature,
9
- isFeatureBinding,
10
- lazyImport,
11
- } from "./feature";
1
+ import { Effect, Option } from 'effect'
2
+ import { expect, it } from '@effect/vitest'
3
+ import * as Composition from '../compose/composition'
4
+ import { mount } from '../compose/structure'
5
+ import { ui } from '../compose/ui'
6
+ import * as Feature from './feature'
7
+ import { featureModule, isFeature, isFeatureBinding, lazyImport } from './feature'
12
8
 
13
9
  // Minimal composition surface to hang features off — definitions only, no logic.
14
- class TestUi extends ui("TestUi")<{ props: {}; events: {} }>() {}
15
- class TestComp extends Composition.make("TestComp", {
16
- title: "Test",
10
+ class TestUi extends ui('TestUi')<{ props: {}; events: {} }>() {}
11
+ class TestComp extends Composition.make('TestComp', {
12
+ title: 'Test',
17
13
  ui: TestUi,
18
- }) {}
19
- class FailComp extends Composition.make("FailComp", {
20
- title: "Fail",
14
+ })<TestComp>() {}
15
+ class FailComp extends Composition.make('FailComp', {
16
+ title: 'Fail',
21
17
  ui: TestUi,
22
- }) {}
18
+ })<FailComp>() {}
19
+
20
+ const TestLive = Composition.live(TestComp, function* () {
21
+ return mount({ props: {}, slots: {} })
22
+ })
23
23
 
24
- // A trivial loaded module: empty layer, no shared requirements.
25
- const mod = featureModule([], Layer.empty);
24
+ // A valid Feature module owns its declared root Composition service.
25
+ const mod = featureModule([], TestLive)
26
26
 
27
- class LazyFeature extends Feature.make("lazyFeature", {
28
- loadingStrategy: "lazy",
27
+ class LazyFeature extends Feature.make('lazyFeature', {
28
+ loadingStrategy: 'lazy',
29
29
  composition: TestComp,
30
30
  load: lazyImport(() => Promise.resolve({ default: mod })),
31
31
  placeholder: { loading: TestComp, failed: FailComp },
32
32
  }) {}
33
33
 
34
- class EagerFeature extends Feature.make("eagerFeature", {
34
+ class SameShapeFeature extends Feature.make('lazyFeature', {
35
+ loadingStrategy: 'lazy',
36
+ composition: TestComp,
37
+ load: lazyImport(() => Promise.resolve({ default: mod })),
38
+ placeholder: { loading: TestComp, failed: FailComp },
39
+ }) {}
40
+
41
+ class EagerFeature extends Feature.make('eagerFeature', {
35
42
  composition: TestComp,
36
43
  module: mod,
37
44
  }) {}
38
45
 
39
- it("a lazy feature reflects as a Feature manifest with placeholders, no chunk loaded", () => {
40
- expect(LazyFeature.manifest.kind).toBe("Feature");
41
- expect(LazyFeature.manifest.name).toBe("lazyFeature");
42
- expect(LazyFeature.manifest.strategy).toBe("lazy");
43
- expect(LazyFeature.manifest.composition.name).toBe("TestComp");
44
- expect(Option.getOrThrow(LazyFeature.manifest.placeholder).loading.name).toBe(
45
- "TestComp",
46
- );
47
- expect(Option.getOrThrow(LazyFeature.manifest.placeholder).failed.name).toBe(
48
- "FailComp",
49
- );
50
- expect(LazyFeature.eagerModule).toBeUndefined();
51
- });
46
+ const LazyNative = Feature.tree<typeof LazyFeature>(LazyFeature.token)
47
+ const EagerNative = Feature.tree<typeof EagerFeature>(EagerFeature.token)
48
+
49
+ it('a lazy feature reflects as a Feature manifest with placeholders, no chunk loaded', () => {
50
+ expect(LazyFeature.manifest.kind).toBe('Feature')
51
+ expect(LazyFeature.manifest.name).toBe('lazyFeature')
52
+ expect(LazyFeature.manifest.strategy).toBe('lazy')
53
+ expect(LazyFeature.manifest.composition.name).toBe('TestComp')
54
+ expect(Option.getOrThrow(LazyFeature.manifest.placeholder).loading.name).toBe('TestComp')
55
+ expect(Option.getOrThrow(LazyFeature.manifest.placeholder).failed.name).toBe('FailComp')
56
+ expect(LazyNative.eagerModule).toBeUndefined()
57
+ })
52
58
 
53
- it("an eager feature reflects as default strategy with its module available", () => {
54
- expect(EagerFeature.manifest.strategy).toBe("default");
55
- expect(Option.isNone(EagerFeature.manifest.placeholder)).toBe(true);
56
- expect(EagerFeature.eagerModule).toBe(mod);
57
- });
59
+ it('an eager feature reflects as default strategy with its module available', () => {
60
+ expect(EagerFeature.manifest.strategy).toBe('default')
61
+ expect(Option.isNone(EagerFeature.manifest.placeholder)).toBe(true)
62
+ expect(EagerNative.eagerModule).toBe(mod)
63
+ })
58
64
 
59
- it("isFeature / isFeatureBinding discriminate", () => {
60
- expect(isFeature(LazyFeature)).toBe(true);
61
- expect(isFeature(TestComp)).toBe(false);
62
- expect(isFeature(null)).toBe(false);
63
- expect(isFeatureBinding(LazyFeature.binding)).toBe(true);
64
- expect(isFeatureBinding(TestComp)).toBe(false);
65
- });
65
+ it('isFeature / isFeatureBinding discriminate', () => {
66
+ expect(isFeature(LazyFeature)).toBe(true)
67
+ expect(isFeature(TestComp)).toBe(false)
68
+ expect(isFeature(null)).toBe(false)
69
+ expect(isFeatureBinding(LazyNative.binding)).toBe(true)
70
+ expect(isFeatureBinding(TestComp)).toBe(false)
71
+ expect(LazyFeature.token).not.toBe(SameShapeFeature.token)
72
+ expect(LazyFeature.token.identity).not.toBe(SameShapeFeature.token.identity)
73
+ })
66
74
 
67
- it.live(
68
- "load is an Effect (not a thenable) that yields the module and its requires",
69
- () =>
70
- Effect.gen(function* () {
71
- // No `.then` on `load` — it is an Effect, run on the Effect runtime.
72
- expect(typeof (LazyFeature.load as { then?: unknown }).then).toBe(
73
- "undefined",
74
- );
75
- const loaded = yield* LazyFeature.load;
76
- expect(loaded.requires).toEqual([]);
77
- expect(loaded).toBe(mod);
78
- // default strategy resolves immediately to its module.
79
- expect(yield* EagerFeature.load).toBe(mod);
80
- }),
81
- );
75
+ it.live('load is an Effect (not a thenable) that yields the module and its requires', () =>
76
+ Effect.gen(function* () {
77
+ // No `.then` on `load` — it is an Effect, run on the Effect runtime.
78
+ expect('then' in LazyNative.load).toBe(false)
79
+ const loaded = yield* LazyNative.load
80
+ expect(loaded.requires).toEqual([])
81
+ expect(loaded).toBe(mod)
82
+ // default strategy resolves immediately to its module.
83
+ expect(yield* EagerNative.load).toBe(mod)
84
+ }),
85
+ )