@playfast/reform 0.0.5 → 0.0.8

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 (47) hide show
  1. package/package.json +31 -10
  2. package/src/boundary/boundary.test.ts +301 -0
  3. package/src/calc/asyncCalc.test.ts +556 -0
  4. package/src/calc/asyncData.ts +9 -1
  5. package/src/calc/calc.test.ts +287 -0
  6. package/src/calc/calcFamily.test.ts +206 -0
  7. package/src/calc/compose.test.ts +68 -0
  8. package/src/channel/channel.ts +9 -5
  9. package/src/compose/host.ts +4 -1
  10. package/src/compose/props.ts +5 -1
  11. package/src/compose/ui.test.ts +62 -0
  12. package/src/compose/ui.ts +173 -44
  13. package/src/compose/ui.typecheck.ts +56 -0
  14. package/src/definition/definition.ts +2 -0
  15. package/src/event/event.test.ts +23 -0
  16. package/src/feature/feature.mount.test.ts +82 -0
  17. package/src/feature/feature.test.ts +60 -0
  18. package/src/feature/feature.typecheck.ts +108 -0
  19. package/src/index.ts +38 -2
  20. package/src/internal/capture.ts +4 -4
  21. package/src/internal/errors.test.ts +33 -0
  22. package/src/internal/errors.ts +92 -16
  23. package/src/internal/inspect.test.ts +28 -0
  24. package/src/internal/queryDriver.ts +1 -1
  25. package/src/internal/reuse.test.ts +116 -0
  26. package/src/internal/scheduler.ts +3 -1
  27. package/src/internal/sources.ts +2 -2
  28. package/src/internal/stateRegistry.ts +32 -0
  29. package/src/internal/store.test.ts +80 -0
  30. package/src/internal/track.ts +3 -1
  31. package/src/remote/remoteState.test.ts +695 -0
  32. package/src/remote/remoteState.typecheck.ts +195 -0
  33. package/src/runtime/bus.ts +6 -2
  34. package/src/runtime/hardening.test.ts +69 -0
  35. package/src/runtime/loop.test.ts +178 -0
  36. package/src/runtime/loop.ts +4 -2
  37. package/src/scene/seedScene.test.ts +169 -0
  38. package/src/state/state.ts +4 -1
  39. package/src/state/stateFamily.test.ts +138 -0
  40. package/src/state/stateFamily.ts +4 -1
  41. package/src/state/stateGroup.ts +31 -4
  42. package/src/state/token.ts +5 -4
  43. package/src/synced/syncedStore.ts +99 -0
  44. package/src/wire/tree.test.ts +81 -0
  45. package/src/wire/tree.ts +129 -0
  46. package/src/wire/triggers.test.ts +76 -0
  47. package/src/wire/triggers.ts +98 -0
@@ -0,0 +1,195 @@
1
+ // Type-level proofs enforced by `tsc --noEmit` over `src` (not a vitest file).
2
+ // These guard the cast-free seam: if a guarantee regresses, a `@ts-expect-error`
3
+ // goes unused or an assignment fails, and `tsc` breaks.
4
+
5
+ import { Context, Effect, Layer, Match, Schema as S } from 'effect'
6
+ import * as Boundary from '../boundary/boundary'
7
+ import type { AsyncData } from '../calc/asyncData'
8
+ import { type Channels, type Procedures } from '../channel/channel'
9
+ import * as Event from '../event/event'
10
+ import type { Store } from '../internal/store'
11
+ import * as Reducer from '../reducer/reducer'
12
+ import type { Bus } from '../runtime/bus'
13
+ import { type Reducers } from '../runtime/loop'
14
+ import * as State from '../state/state'
15
+ import * as StateGroup from '../state/stateGroup'
16
+ import { type AnySource, type Source } from '../state/token'
17
+ import * as RemoteState from './remoteState'
18
+
19
+ const AddIntentBase: Event.EventClass<'TAddIntent', { readonly id: string; readonly name: string }> =
20
+ Event.make('TAddIntent', S.Struct({ id: S.String, name: S.String }))
21
+ class AddIntent extends AddIntentBase {}
22
+ const RenameIntentBase: Event.EventClass<
23
+ 'TRenameIntent',
24
+ { readonly id: string; readonly name: string }
25
+ > = Event.make('TRenameIntent', S.Struct({ id: S.String, name: S.String }))
26
+ class RenameIntent extends RenameIntentBase {}
27
+ type Intent = Event.EventType<typeof AddIntent> | Event.EventType<typeof RenameIntent>
28
+
29
+ const PageBase: State.StateClass<'tcPage', number> = State.make('tcPage', S.Number)
30
+ class Page extends PageBase {}
31
+ const InputsBase: StateGroup.StateGroupClass<readonly [typeof Page]> = StateGroup.make(Page)
32
+ class Inputs extends InputsBase {}
33
+ const page: Source<'tcPage', number> = StateGroup.select(Inputs, 'tcPage')
34
+
35
+ const Row: S.Struct<{ id: typeof S.String; name: typeof S.String }> = S.Struct({
36
+ id: S.String,
37
+ name: S.String,
38
+ })
39
+ type Row = typeof Row.Type
40
+
41
+ const SendDepBase: Context.TagClass<
42
+ SendDep,
43
+ 'typecheck/SendDep',
44
+ { readonly go: Effect.Effect<void> }
45
+ > = Context.Tag('typecheck/SendDep')<SendDep, { readonly go: Effect.Effect<void> }>()
46
+ class SendDep extends SendDepBase {}
47
+
48
+ const ItemsBase: RemoteState.RemoteStateClass<
49
+ 'TItems',
50
+ readonly [typeof page],
51
+ readonly [typeof AddIntent, typeof RenameIntent],
52
+ ReadonlyArray<Row>,
53
+ string,
54
+ true
55
+ > = RemoteState.make('TItems', {
56
+ inputs: [page],
57
+ output: S.Array(Row),
58
+ error: S.String,
59
+ intents: [AddIntent, RenameIntent],
60
+ })
61
+ class Items extends ItemsBase {}
62
+
63
+ // --- intent-union inference: apply/send receive exactly the declared union ---
64
+
65
+ // POSITIVE: a Match.exhaustive fold over the declared intents compiles, `send`'s
66
+ // R2 flows from its body, and gated `disabled` is accepted.
67
+ export const okLive: Layer.Layer<
68
+ | Store<AsyncData<ReadonlyArray<Row>, string, true>>
69
+ | Store<ReadonlyArray<RemoteState.PendingIntent<Intent>>>,
70
+ never,
71
+ Store<number> | SendDep | Reducers | Procedures | Channels | Bus
72
+ > = RemoteState.live(Items, {
73
+ query: ({ tcPage }) =>
74
+ tcPage > 0 ? Effect.succeed<ReadonlyArray<Row>>([]) : Effect.fail('empty page'),
75
+ send: () => Effect.flatMap(SendDep, (dep) => dep.go),
76
+ apply: (rows, intent) =>
77
+ Match.value(intent).pipe(
78
+ Match.tag('TAddIntent', ({ id, name }) => [...rows, { id, name }]),
79
+ Match.tag('TRenameIntent', ({ id, name }) =>
80
+ rows.map((row) => (row.id === id ? { ...row, name } : row)),
81
+ ),
82
+ Match.exhaustive,
83
+ ),
84
+ disabled: ({ tcPage }) => tcPage < 0,
85
+ })
86
+
87
+ // NEGATIVE: a handler typed for an undeclared tag does not fit the union.
88
+ export const badIntent: Layer.Layer<
89
+ | Store<AsyncData<ReadonlyArray<Row>, string, true>>
90
+ | Store<ReadonlyArray<RemoteState.PendingIntent<Intent>>>,
91
+ never,
92
+ Store<number> | Reducers | Procedures | Channels | Bus
93
+ > = RemoteState.live(Items, {
94
+ query: () => Effect.succeed<ReadonlyArray<Row>>([]),
95
+ send: () => Effect.void,
96
+ // @ts-expect-error 'TDeleteIntent' is not in the declared intent union
97
+ apply: (rows: ReadonlyArray<Row>, intent: { readonly _tag: 'TDeleteIntent' }) => rows,
98
+ })
99
+
100
+ // --- layer requirement honesty: RIn is complete and exact ---
101
+
102
+ // POSITIVE: the layer's RIn is exactly the input stores, the query/send context,
103
+ // and the engine registries — and its ROut names the overlay + pending stores.
104
+ export const okLayer: Layer.Layer<
105
+ Store<AsyncData<ReadonlyArray<Row>, string, true>> | Store<ReadonlyArray<RemoteState.PendingIntent<Intent>>>,
106
+ never,
107
+ Store<number> | SendDep | Reducers | Procedures | Channels | Bus
108
+ > = okLive
109
+
110
+ // NEGATIVE: dropping the send dependency (R2) from the annotation fails —
111
+ // requirements cannot be silently widened away.
112
+ // @ts-expect-error the send body's R2 must appear in the layer's requirements
113
+ export const badLayer: Layer.Layer<
114
+ Store<AsyncData<ReadonlyArray<Row>, string, true>> | Store<ReadonlyArray<RemoteState.PendingIntent<Intent>>>,
115
+ never,
116
+ Store<number> | Reducers | Procedures | Channels | Bus
117
+ > = okLive
118
+
119
+ // --- alwaysOn / error narrowing: the read carries exactly the possible arms ---
120
+
121
+ const AlwaysItemsBase: RemoteState.RemoteStateClass<
122
+ 'TAlwaysItems',
123
+ readonly [typeof page],
124
+ readonly [typeof AddIntent],
125
+ ReadonlyArray<Row>,
126
+ never,
127
+ false
128
+ > = RemoteState.make('TAlwaysItems', {
129
+ inputs: [page],
130
+ output: S.Array(Row),
131
+ intents: [AddIntent],
132
+ alwaysOn: true,
133
+ })
134
+ class AlwaysItems extends AlwaysItemsBase {}
135
+
136
+ // NEGATIVE: an alwaysOn remote state rejects `disabled` (erased to never).
137
+ export const badDisabled: Layer.Layer<
138
+ | Store<AsyncData<ReadonlyArray<Row>, never, false>>
139
+ | Store<ReadonlyArray<RemoteState.PendingIntent<Event.EventType<typeof AddIntent>>>>,
140
+ never,
141
+ Store<number> | Reducers | Procedures | Channels | Bus
142
+ > = RemoteState.live(AlwaysItems, {
143
+ query: () => Effect.succeed<ReadonlyArray<Row>>([]),
144
+ send: () => Effect.void,
145
+ apply: (rows) => rows,
146
+ // @ts-expect-error an alwaysOn remote state cannot be disabled
147
+ disabled: () => false,
148
+ })
149
+
150
+ declare const alwaysArms: Effect.Effect.Success<typeof AlwaysItems>['_tag']
151
+ declare const gatedArms: Effect.Effect.Success<typeof Items>['_tag']
152
+ declare const idleArm: 'Idle'
153
+ declare const errorArm: 'Error'
154
+ declare const fourArms: 'Idle' | 'Loading' | 'Success' | 'Error'
155
+
156
+ // POSITIVE: alwaysOn + no `error` schema ⇒ at most Loading | Success…
157
+ export const alwaysExact: 'Loading' | 'Success' = alwaysArms
158
+ // …and gated + `error` schema ⇒ exactly all four arms (both directions).
159
+ export const gatedSubset: 'Idle' | 'Loading' | 'Success' | 'Error' = gatedArms
160
+ export const gatedAll: Effect.Effect.Success<typeof Items>['_tag'] = fourArms
161
+
162
+ // NEGATIVE: the excluded arms really are gone from the read type.
163
+ // @ts-expect-error an alwaysOn remote state has no Idle arm
164
+ export const noIdle: Effect.Effect.Success<typeof AlwaysItems>['_tag'] = idleArm
165
+ // @ts-expect-error no `error` schema ⇒ no Error arm
166
+ export const noError: Effect.Effect.Success<typeof AlwaysItems>['_tag'] = errorArm
167
+
168
+ // --- surface shape: Sources where expected, never a reducer target ---
169
+
170
+ // POSITIVE: the class itself is a `Source` (a calc input / boundary coverage),
171
+ // valued at the overlaid lifecycle.
172
+ export const classAsSource: Source<'TItems', AsyncData<ReadonlyArray<Row>, string, true>> = Items
173
+ export const classAsAnySource: AnySource = Items
174
+
175
+ // POSITIVE: `pending` is an ordinary read-only `Source` (badges, "saving…" chrome).
176
+ export const pendingAsSource: Source<'TItems/pending', ReadonlyArray<RemoteState.PendingIntent<Intent>>> =
177
+ Items.pending
178
+
179
+ // POSITIVE: `truth` is the un-overlaid lifecycle under the same arms.
180
+ export const truthAsSource: Source<'TItems/truth', AsyncData<ReadonlyArray<Row>, string, true>> =
181
+ Items.truth
182
+
183
+ // POSITIVE: a boundary covers the remote state like any lifecycle source.
184
+ export const covered: Boundary.BoundaryClass<'TItemsBoundary', readonly [typeof Items]> =
185
+ Boundary.make('TItemsBoundary', { over: [Items] })
186
+
187
+ // NEGATIVE: the pending queue is NOT user state — `Reducer.make` cannot target
188
+ // it (it is a `StateToken`, not a `StateClass`; the hidden queue reducer is the
189
+ // sole writer).
190
+ export const badReducerTarget: Reducer.StateReducerClass<State.AnyState, readonly [typeof AddIntent]> =
191
+ // @ts-expect-error the pending queue is not a reducible state
192
+ Reducer.make('TBadReducer', {
193
+ states: [Items.pending],
194
+ events: [AddIntent],
195
+ })
@@ -22,15 +22,19 @@ export interface Envelope {
22
22
  readonly event: Tagged
23
23
  }
24
24
 
25
+ const BusBase: Context.TagClass<Bus, 'reform/Bus', PubSub.PubSub<Envelope>> = Context.Tag(
26
+ 'reform/Bus',
27
+ )<Bus, PubSub.PubSub<Envelope>>()
28
+
25
29
  /** The single dispatch bus: an unbounded PubSub fanned out to the loop and procedures. */
26
- export class Bus extends Context.Tag('reform/Bus')<Bus, PubSub.PubSub<Envelope>>() {}
30
+ export class Bus extends BusBase {}
27
31
 
28
32
  // Unbounded so `publish` never suspends: UI triggers dispatch synchronously
29
33
  // (`Runtime.runSync`, no fiber fork per event), and an event storm can't apply
30
34
  // backpressure to the UI thread. The single drain loop empties the bus every
31
35
  // microtask, so it does not accumulate. Swap to `PubSub.dropping(N)` if bounded
32
36
  // memory is preferred over never dropping (publish stays synchronous either way).
33
- export const busLayer = Layer.scoped(Bus, PubSub.unbounded<Envelope>())
37
+ export const busLayer: Layer.Layer<Bus> = Layer.scoped(Bus, PubSub.unbounded<Envelope>())
34
38
 
35
39
  export const publish = (priority: Priority, event: Tagged): Effect.Effect<void, never, Bus> =>
36
40
  Effect.flatMap(Bus, (bus) => PubSub.publish(bus, { priority, event }))
@@ -0,0 +1,69 @@
1
+ import { expect, it } from '@effect/vitest'
2
+ import { Cause, Duration, Effect, Exit, Layer, LogLevel, Logger, Schema as S } from 'effect'
3
+ import { Channel, Engine, Event, publish, Reducer, State } from '../index'
4
+
5
+ // Robustness guards for the drain loop and registries: a buggy reducer must not
6
+ // take down the bus, and a name clash that would silently drop a registration
7
+ // must be rejected.
8
+
9
+ it.live('a reducer that throws is isolated; the loop keeps draining', () => {
10
+ class Counter extends State.make('counter', S.Number) {}
11
+ class Good extends Event.make('Good', S.Struct({})) {}
12
+ class Bad extends Event.make('Bad', S.Struct({})) {}
13
+ class GoodReducer extends Reducer.make('GoodReducer', { states: [Counter], events: [Good] }) {}
14
+ class BadReducer extends Reducer.make('BadReducer', { states: [Counter], events: [Bad] }) {}
15
+ const GoodLive = Reducer.live(GoodReducer, (n) => n + 1)
16
+ const BadLive = Reducer.live(BadReducer, () => {
17
+ throw new Error('boom')
18
+ })
19
+
20
+ const TestLayer = Layer.mergeAll(GoodLive, BadLive).pipe(
21
+ Layer.provideMerge(Layer.mergeAll(State.live(Counter, 0), Engine)),
22
+ )
23
+
24
+ return Effect.gen(function* () {
25
+ const store = yield* Counter.store
26
+
27
+ // A fold that throws is caught and logged — the drain fiber survives.
28
+ yield* publish('Normal', Event.construct(Bad, {}))
29
+ yield* Effect.sleep(Duration.millis(10))
30
+ expect(store.get()).toBe(0)
31
+
32
+ // A later, healthy event still processes: the bus did not go deaf.
33
+ yield* publish('Normal', Event.construct(Good, {}))
34
+ yield* Effect.sleep(Duration.millis(10))
35
+ expect(store.get()).toBe(1)
36
+ }).pipe(
37
+ Effect.provide(TestLayer),
38
+ // The isolated failure logs an error; silence it so the passing test is quiet.
39
+ Logger.withMinimumLogLevel(LogLevel.None),
40
+ )
41
+ })
42
+
43
+ it.effect('two different channels registered under one name are rejected', () => {
44
+ class Merge extends Channel.make('Dup', { policy: { _tag: 'merge' } }) {}
45
+ class Latest extends Channel.make('Dup', { policy: { _tag: 'latest' } }) {}
46
+ const layer = Layer.mergeAll(Channel.live(Merge), Channel.live(Latest)).pipe(
47
+ Layer.provideMerge(Engine),
48
+ )
49
+
50
+ return Effect.gen(function* () {
51
+ const exit = yield* Effect.scoped(Layer.build(layer)).pipe(Effect.exit)
52
+ expect(Exit.isFailure(exit)).toBe(true)
53
+ if (Exit.isFailure(exit)) {
54
+ expect(Cause.pretty(exit.cause)).toContain('duplicate Channel')
55
+ }
56
+ })
57
+ })
58
+
59
+ it.effect('the same channel registered twice is idempotent (shared lane)', () => {
60
+ class Lane extends Channel.make('Lane', { policy: { _tag: 'exclusive' } }) {}
61
+ const layer = Layer.mergeAll(Channel.live(Lane), Channel.live(Lane)).pipe(
62
+ Layer.provideMerge(Engine),
63
+ )
64
+
65
+ return Effect.gen(function* () {
66
+ const exit = yield* Effect.scoped(Layer.build(layer)).pipe(Effect.exit)
67
+ expect(Exit.isSuccess(exit)).toBe(true)
68
+ })
69
+ })
@@ -0,0 +1,178 @@
1
+ import { expect, it } from '@effect/vitest'
2
+ import { Duration, Effect, Layer, Schema as S, TestClock } from 'effect'
3
+ import { Channel, Engine, Event, Procedure, publish, Reducer, State } from '../index'
4
+
5
+ // Headless tests for the single frame-batched drain loop and channels, written
6
+ // with `@effect/vitest`: `it.live` for cases that lean on the real microtask
7
+ // queue (the store notifier), `it.effect` + `TestClock` for time-based channels.
8
+
9
+ it.live('a frame of events drains in one pass with a single notification flush', () => {
10
+ class Counter extends State.make('counter', S.Number) {}
11
+ class Bumped extends Event.make('Bumped', S.Struct({ to: S.Number })) {}
12
+ class BumpReducer extends Reducer.make('BumpReducer', { states: [Counter], events: [Bumped] }) {}
13
+ const BumpReducerLive = Reducer.live(BumpReducer, (_, event) => event.to)
14
+
15
+ const TestLayer = BumpReducerLive.pipe(
16
+ Layer.provideMerge(Layer.mergeAll(State.live(Counter, 0), Engine)),
17
+ )
18
+
19
+ return Effect.gen(function* () {
20
+ const store = yield* Counter.store
21
+ const notifications = { n: 0 }
22
+ store.subscribe(() => {
23
+ notifications.n += 1
24
+ })
25
+
26
+ // Three events in one tick batch into one frame, fold in order, and the
27
+ // coalesced store notifier wakes the subscriber exactly once.
28
+ yield* publish('Normal', Event.construct(Bumped, { to: 1 }))
29
+ yield* publish('Normal', Event.construct(Bumped, { to: 2 }))
30
+ yield* publish('Normal', Event.construct(Bumped, { to: 3 }))
31
+ yield* Effect.sleep(Duration.millis(20))
32
+
33
+ expect(store.get()).toBe(3)
34
+ expect(notifications.n).toBe(1)
35
+ }).pipe(Effect.provide(TestLayer))
36
+ })
37
+
38
+ it.live('an exclusive channel serializes procedures that share it', () => {
39
+ class StartA extends Event.make('StartA', S.Struct({})) {}
40
+ class StartB extends Event.make('StartB', S.Struct({})) {}
41
+ class Exclusive extends Channel.make('Exclusive', { policy: { _tag: 'exclusive' } }) {}
42
+
43
+ const concurrency = { active: 0, max: 0 }
44
+ const guarded = function* () {
45
+ concurrency.active += 1
46
+ concurrency.max = Math.max(concurrency.max, concurrency.active)
47
+ yield* Effect.sleep(Duration.millis(15))
48
+ concurrency.active -= 1
49
+ }
50
+
51
+ class ProcA extends Procedure.make('ProcA', { events: [StartA], channel: Exclusive }) {}
52
+ class ProcB extends Procedure.make('ProcB', { events: [StartB], channel: Exclusive }) {}
53
+ const ProcALive = Procedure.live(ProcA, guarded)
54
+ const ProcBLive = Procedure.live(ProcB, guarded)
55
+
56
+ const TestLayer = Layer.mergeAll(Channel.live(Exclusive), ProcALive, ProcBLive).pipe(
57
+ Layer.provideMerge(Engine),
58
+ )
59
+
60
+ return Effect.gen(function* () {
61
+ yield* publish('Normal', Event.construct(StartA, {}))
62
+ yield* publish('Normal', Event.construct(StartB, {}))
63
+ yield* Effect.sleep(Duration.millis(60))
64
+ // Without exclusivity both would run concurrently (max === 2).
65
+ expect(concurrency.max).toBe(1)
66
+ }).pipe(Effect.provide(TestLayer))
67
+ })
68
+
69
+ it.live('two procedures sharing a channel each run once for one shared event', () => {
70
+ // Both procedures handle the SAME event on the SAME channel. The loop must
71
+ // offer the event to the channel once, so each procedure runs exactly once —
72
+ // not once per matching procedure (which would double every run).
73
+ class Shared extends Event.make('Shared', S.Struct({})) {}
74
+ class Lane extends Channel.make('Lane', { policy: { _tag: 'merge' } }) {}
75
+
76
+ const runs = { a: 0, b: 0 }
77
+ class ProcA extends Procedure.make('ProcA', { events: [Shared], channel: Lane }) {}
78
+ class ProcB extends Procedure.make('ProcB', { events: [Shared], channel: Lane }) {}
79
+ const ProcALive = Procedure.live(ProcA, function* () {
80
+ runs.a += 1
81
+ })
82
+ const ProcBLive = Procedure.live(ProcB, function* () {
83
+ runs.b += 1
84
+ })
85
+
86
+ const TestLayer = Layer.mergeAll(Channel.live(Lane), ProcALive, ProcBLive).pipe(
87
+ Layer.provideMerge(Engine),
88
+ )
89
+
90
+ return Effect.gen(function* () {
91
+ yield* publish('High', Event.construct(Shared, {}))
92
+ yield* Effect.sleep(Duration.millis(20))
93
+ expect(runs.a).toBe(1)
94
+ expect(runs.b).toBe(1)
95
+ }).pipe(Effect.provide(TestLayer))
96
+ })
97
+
98
+ it.effect('a debounce channel collapses a burst into a single run', () => {
99
+ class Typed extends Event.make('Typed', S.Struct({})) {}
100
+ class Debounced extends Channel.make('Debounced', {
101
+ policy: { _tag: 'debounce', duration: Duration.millis(30) },
102
+ }) {}
103
+
104
+ const runs = { n: 0 }
105
+ class Search extends Procedure.make('Search', { events: [Typed], channel: Debounced }) {}
106
+ const SearchLive = Procedure.live(Search, function* () {
107
+ runs.n += 1
108
+ })
109
+
110
+ const TestLayer = Layer.mergeAll(Channel.live(Debounced), SearchLive).pipe(
111
+ Layer.provideMerge(Engine),
112
+ )
113
+
114
+ return Effect.gen(function* () {
115
+ yield* publish('Normal', Event.construct(Typed, {}))
116
+ yield* publish('Normal', Event.construct(Typed, {}))
117
+ yield* publish('Normal', Event.construct(Typed, {}))
118
+ // Let the loop route the frame into the channel and arm the debounce window.
119
+ yield* Effect.yieldNow().pipe(Effect.repeatN(20))
120
+ // Advance the simulated clock past the window: the burst emits once.
121
+ yield* TestClock.adjust(Duration.millis(31))
122
+ yield* Effect.yieldNow().pipe(Effect.repeatN(20))
123
+ expect(runs.n).toBe(1)
124
+ }).pipe(Effect.provide(TestLayer))
125
+ })
126
+
127
+ it.live('a reducer ignores events outside its declared set (no-op)', () => {
128
+ class Counter extends State.make('counter', S.Number) {}
129
+ class Bump extends Event.make('Bump', S.Struct({})) {}
130
+ class Unrelated extends Event.make('Unrelated', S.Struct({})) {}
131
+ class BumpReducer extends Reducer.make('BumpReducer', { states: [Counter], events: [Bump] }) {}
132
+ const BumpReducerLive = Reducer.live(BumpReducer, (n) => n + 1)
133
+
134
+ const TestLayer = BumpReducerLive.pipe(
135
+ Layer.provideMerge(Layer.mergeAll(State.live(Counter, 0), Engine)),
136
+ )
137
+
138
+ return Effect.gen(function* () {
139
+ const store = yield* Counter.store
140
+ // An event the reducer doesn't declare never reaches its fold.
141
+ yield* publish('Normal', Event.construct(Unrelated, {}))
142
+ yield* Effect.sleep(Duration.millis(10))
143
+ expect(store.get()).toBe(0)
144
+ // A declared event does.
145
+ yield* publish('Normal', Event.construct(Bump, {}))
146
+ yield* Effect.sleep(Duration.millis(10))
147
+ expect(store.get()).toBe(1)
148
+ }).pipe(Effect.provide(TestLayer))
149
+ })
150
+
151
+ it.effect('a latest channel cancels the in-flight run when a new event arrives', () => {
152
+ class Tick extends Event.make('Tick', S.Struct({})) {}
153
+ class Latest extends Channel.make('Latest', { policy: { _tag: 'latest' } }) {}
154
+
155
+ const counts = { started: 0, completed: 0 }
156
+ class Work extends Procedure.make('Work', { events: [Tick], channel: Latest }) {}
157
+ const WorkLive = Procedure.live(Work, function* () {
158
+ counts.started += 1
159
+ yield* Effect.sleep(Duration.millis(50))
160
+ counts.completed += 1
161
+ })
162
+
163
+ const TestLayer = Layer.mergeAll(Channel.live(Latest), WorkLive).pipe(Layer.provideMerge(Engine))
164
+
165
+ return Effect.gen(function* () {
166
+ // First tick starts a run that parks on its 50ms sleep.
167
+ yield* publish('Normal', Event.construct(Tick, {}))
168
+ yield* Effect.yieldNow().pipe(Effect.repeatN(20))
169
+ // Second tick switches the channel, interrupting the first run mid-sleep.
170
+ yield* publish('Normal', Event.construct(Tick, {}))
171
+ yield* Effect.yieldNow().pipe(Effect.repeatN(20))
172
+ yield* TestClock.adjust(Duration.millis(60))
173
+ yield* Effect.yieldNow().pipe(Effect.repeatN(20))
174
+ // Both runs began, but only the latest survived to completion.
175
+ expect(counts.started).toBe(2)
176
+ expect(counts.completed).toBe(1)
177
+ }).pipe(Effect.provide(TestLayer))
178
+ })
@@ -39,7 +39,9 @@ export interface ReducerRegistry {
39
39
  readonly unregister: (entry: ReducerEntry) => void
40
40
  }
41
41
 
42
- export class Reducers extends Context.Tag('reform/Reducers')<Reducers, ReducerRegistry>() {}
42
+ const ReducersBase: Context.TagClass<Reducers, 'reform/Reducers', ReducerRegistry> =
43
+ Context.Tag('reform/Reducers')<Reducers, ReducerRegistry>()
44
+ export class Reducers extends ReducersBase {}
43
45
 
44
46
  /** Drop an item from a `Map<string, Array>` bucket, pruning the key when it empties. */
45
47
  const dropFromBuckets = <T>(map: Map<string, Array<T>>, key: string, item: T): void => {
@@ -72,7 +74,7 @@ const makeReducerRegistry = (): ReducerRegistry => {
72
74
  }
73
75
  }
74
76
 
75
- export const reducersLayer = Layer.sync(Reducers, makeReducerRegistry)
77
+ export const reducersLayer: Layer.Layer<Reducers> = Layer.sync(Reducers, makeReducerRegistry)
76
78
 
77
79
  /** High before Normal; within a priority class, dispatch order is preserved. */
78
80
  const rank = (priority: Envelope['priority']): number => (priority === 'High' ? 0 : 1)
@@ -0,0 +1,169 @@
1
+ import { Effect, Layer, ManagedRuntime, Schema as S } from 'effect'
2
+ import { expect, test } from 'vitest'
3
+ import {
4
+ type CaptureSinkApi,
5
+ CaptureSink,
6
+ Composition,
7
+ Engine,
8
+ Event,
9
+ provide,
10
+ publish,
11
+ Reducer,
12
+ type RenderEnv,
13
+ type Scene,
14
+ scene,
15
+ seedScene,
16
+ State,
17
+ StateGroup,
18
+ Ui,
19
+ ui,
20
+ } from '../index'
21
+
22
+ // `seedScene` is the tooling seam over CLOSED scenes: the app bundle below seeds
23
+ // `count: 1` itself (no open store), and the overlay must reach the nested
24
+ // `State.live` through `Layer.locally(CurrentSeedOverrides, …)` on the scene's
25
+ // pre-composed layers. The harness mirrors the dev-tool preview: merge the
26
+ // scene's `provide`, mount it in a ManagedRuntime, and read the root
27
+ // composition's rendered props through the headless CaptureSink.
28
+
29
+ class CountState extends State.make('count', S.Number) {}
30
+ class MiniStates extends StateGroup.make(CountState) {}
31
+
32
+ class Bumped extends Event.make('Bumped', S.Struct({})) {}
33
+
34
+ class BumpReducer extends Reducer.make('BumpReducer', {
35
+ states: [CountState],
36
+ events: [Bumped],
37
+ }) {}
38
+ const BumpReducerLive = Reducer.live(BumpReducer, (n) => n + 1)
39
+
40
+ class CounterUi extends ui('Counter')<{ props: { count: number } }>() {}
41
+ class Counter extends Composition.make('Counter', {
42
+ title: 'Counter',
43
+ states: [MiniStates],
44
+ events: [Bumped],
45
+ ui: CounterUi,
46
+ }) {}
47
+ const CounterLive = Composition.live(Counter, function* () {
48
+ const count = yield* StateGroup.select(MiniStates, 'count')
49
+ const view = yield* CounterUi
50
+ return view({ count })
51
+ })
52
+
53
+ // The closed app layer a scene provides: views + logic + Engine, with the state
54
+ // group seeding its AUTHORED value `count: 1` itself.
55
+ const presentations = provide(CounterUi, Ui.make(CounterUi, () => null))
56
+ const Views = CounterLive.pipe(Layer.provideMerge(presentations))
57
+ const Logic = Layer.mergeAll(BumpReducerLive).pipe(
58
+ Layer.provideMerge(Layer.mergeAll(Engine, StateGroup.live(MiniStates, { count: 1 }))),
59
+ )
60
+ const MiniApp = Views.pipe(Layer.provideMerge(Logic))
61
+
62
+ const CounterScene = scene(Counter, { provide: [MiniApp] })
63
+
64
+ const headlessEnv: RenderEnv = {
65
+ props: {},
66
+ tracker: { add: () => {} },
67
+ slots: { slot: () => () => null },
68
+ }
69
+
70
+ // One ManagedRuntime per scene under test, with its own capture cell so two
71
+ // runtimes in the same test (the no-leakage case) cannot bleed into each other.
72
+ // Each `read` re-renders the root composition, repopulating the cell from the
73
+ // stores that runtime actually built.
74
+ const makeHarness = (s: Scene) => {
75
+ const captured: { count: number } = { count: -1 }
76
+ const sink: CaptureSinkApi = {
77
+ record: (capture) => {
78
+ const props = capture.props
79
+ if (
80
+ typeof props === 'object' &&
81
+ props !== null &&
82
+ 'count' in props &&
83
+ typeof props.count === 'number'
84
+ ) {
85
+ captured.count = props.count
86
+ }
87
+ },
88
+ }
89
+ const layer = s.provide
90
+ .reduce((a, b) => Layer.merge(a, b))
91
+ .pipe(Layer.provideMerge(Layer.succeed(CaptureSink, sink)))
92
+ const runtime = ManagedRuntime.make(layer)
93
+ const read = Effect.flatMap(Counter.tag, (c) =>
94
+ Composition.render(c, headlessEnv).pipe(Effect.map(() => captured.count)),
95
+ )
96
+ return { runtime, read }
97
+ }
98
+
99
+ // Drive a read until a predicate holds, handing forked fibers the scheduler.
100
+ const waitUntil = <A, R>(
101
+ read: Effect.Effect<A, never, R>,
102
+ pred: (a: A) => boolean,
103
+ rounds = 1000,
104
+ ): Effect.Effect<A, never, R> =>
105
+ Effect.flatMap(read, (a) =>
106
+ pred(a) || rounds <= 0
107
+ ? Effect.succeed(a)
108
+ : Effect.flatMap(Effect.yieldNow(), () => waitUntil(read, pred, rounds - 1)),
109
+ )
110
+
111
+ test('a seeded scene boots its store from the override', async () => {
112
+ const { runtime, read } = makeHarness(seedScene(CounterScene, { count: 5 }))
113
+ try {
114
+ expect(await runtime.runPromise(read)).toBe(5)
115
+ } finally {
116
+ await runtime.dispose()
117
+ }
118
+ })
119
+
120
+ test('the live reducer drives the SAME overridden store', async () => {
121
+ const { runtime, read } = makeHarness(seedScene(CounterScene, { count: 5 }))
122
+ try {
123
+ const next = await runtime.runPromise(
124
+ Effect.gen(function* () {
125
+ expect(yield* read).toBe(5)
126
+ yield* publish('High', Event.construct(Bumped, {}))
127
+ return yield* waitUntil(read, (n) => n === 6)
128
+ }),
129
+ )
130
+ expect(next).toBe(6)
131
+ } finally {
132
+ await runtime.dispose()
133
+ }
134
+ })
135
+
136
+ test('an absent key leaves the authored seed in place', async () => {
137
+ const { runtime, read } = makeHarness(seedScene(CounterScene, { other: 1 }))
138
+ try {
139
+ expect(await runtime.runPromise(read)).toBe(1)
140
+ } finally {
141
+ await runtime.dispose()
142
+ }
143
+ })
144
+
145
+ test('a schema-invalid override falls back to the authored seed', async () => {
146
+ const { runtime, read } = makeHarness(seedScene(CounterScene, { count: 'not-a-number' }))
147
+ try {
148
+ expect(await runtime.runPromise(read)).toBe(1)
149
+ } finally {
150
+ await runtime.dispose()
151
+ }
152
+ })
153
+
154
+ test('seeding does not leak into the original scene', async () => {
155
+ const seeded = makeHarness(seedScene(CounterScene, { count: 5 }))
156
+ try {
157
+ expect(await seeded.runtime.runPromise(seeded.read)).toBe(5)
158
+ } finally {
159
+ await seeded.runtime.dispose()
160
+ }
161
+ // Building the ORIGINAL scene's layers again boots from the authored seed —
162
+ // the overlay lives only on the seeded variant's wrapped layers.
163
+ const original = makeHarness(CounterScene)
164
+ try {
165
+ expect(await original.runtime.runPromise(original.read)).toBe(1)
166
+ } finally {
167
+ await original.runtime.dispose()
168
+ }
169
+ })
@@ -2,6 +2,7 @@ import { Context, Effect, FiberRef, Layer, Option, Schema } from 'effect'
2
2
  import { type Manifest, yieldableClass } from '../definition/definition'
3
3
  import { resolveScheduler } from '../internal/scheduler'
4
4
  import { CurrentSeedOverrides } from '../internal/seeds'
5
+ import { claimStateTag } from '../internal/stateRegistry'
5
6
  import { makeStore, type Store } from '../internal/store'
6
7
  import { readTracked } from '../internal/track'
7
8
 
@@ -40,7 +41,9 @@ export const make = <const N extends string, A>(
40
41
  schema: Schema.Schema<A, any>,
41
42
  options: StateOptions = {},
42
43
  ): StateClass<N, A> => {
43
- const store = Context.GenericTag<Store<A>, Store<A>>(`reform/state/${name}`)
44
+ const identifier = `reform/state/${name}`
45
+ claimStateTag(identifier)
46
+ const store = Context.GenericTag<Store<A>, Store<A>>(identifier)
44
47
  const manifest: StateManifest<N, A> = {
45
48
  kind: 'State',
46
49
  name,