@playfast/reform 0.0.7 → 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.
- package/package.json +2 -2
- package/src/boundary/boundary.test.ts +301 -0
- package/src/boundary/boundary.ts +192 -0
- package/src/calc/asyncCalc.test.ts +556 -0
- package/src/calc/asyncCalc.ts +267 -0
- package/src/calc/asyncData.ts +96 -0
- package/src/calc/calc.test.ts +287 -0
- package/src/calc/calc.ts +120 -0
- package/src/calc/calcFamily.test.ts +206 -0
- package/src/calc/calcFamily.ts +194 -0
- package/src/calc/compose.test.ts +68 -0
- package/src/calc/compose.ts +69 -0
- package/src/channel/channel.ts +269 -0
- package/src/compose/composition.ts +112 -0
- package/{dist/compose/host.d.ts → src/compose/host.ts} +9 -8
- package/{dist/compose/props.js → src/compose/props.ts} +7 -5
- package/src/compose/provide.ts +70 -0
- package/{dist/compose/slot.d.ts → src/compose/slot.ts} +36 -19
- package/src/compose/ui.test.ts +62 -0
- package/src/compose/ui.ts +226 -0
- package/src/compose/ui.typecheck.ts +56 -0
- package/src/definition/definition.ts +76 -0
- package/src/event/event.test.ts +23 -0
- package/src/event/event.ts +65 -0
- package/src/event/eventGroup.ts +14 -0
- package/src/feature/feature.mount.test.ts +82 -0
- package/src/feature/feature.test.ts +60 -0
- package/{dist/feature/feature.d.ts → src/feature/feature.ts} +178 -92
- package/src/feature/feature.typecheck.ts +108 -0
- package/src/index.ts +176 -0
- package/{dist/internal/capture.d.ts → src/internal/capture.ts} +13 -11
- package/{dist/internal/ctx.d.ts → src/internal/ctx.ts} +8 -4
- package/src/internal/errors.test.ts +33 -0
- package/src/internal/errors.ts +145 -0
- package/src/internal/inspect.test.ts +28 -0
- package/src/internal/inspect.ts +34 -0
- package/src/internal/queryDriver.ts +247 -0
- package/src/internal/reuse.test.ts +116 -0
- package/src/internal/reuse.ts +73 -0
- package/src/internal/scheduler.ts +93 -0
- package/{dist/internal/seeds.d.ts → src/internal/seeds.ts} +6 -3
- package/src/internal/sources.ts +104 -0
- package/{dist/internal/stateRegistry.js → src/internal/stateRegistry.ts} +18 -14
- package/src/internal/store.test.ts +80 -0
- package/src/internal/store.ts +105 -0
- package/{dist/internal/track.d.ts → src/internal/track.ts} +17 -11
- package/src/procedure/procedure.ts +89 -0
- package/src/reducer/reducer.ts +137 -0
- package/src/remote/remoteState.test.ts +695 -0
- package/src/remote/remoteState.ts +571 -0
- package/src/remote/remoteState.typecheck.ts +195 -0
- package/src/runtime/bus.ts +40 -0
- package/src/runtime/hardening.test.ts +69 -0
- package/src/runtime/loop.test.ts +178 -0
- package/src/runtime/loop.ts +171 -0
- package/src/scene/scene.ts +76 -0
- package/src/scene/seedScene.test.ts +169 -0
- package/src/state/state.ts +94 -0
- package/src/state/stateFamily.test.ts +138 -0
- package/src/state/stateFamily.ts +174 -0
- package/src/state/stateGroup.ts +105 -0
- package/{dist/state/token.d.ts → src/state/token.ts} +22 -13
- package/src/synced/syncedStore.ts +99 -0
- package/{dist/ui/node.d.ts → src/ui/node.ts} +3 -3
- package/{dist/ui/trigger.d.ts → src/ui/trigger.ts} +1 -2
- package/src/wire/tree.test.ts +81 -0
- package/src/wire/tree.ts +129 -0
- package/src/wire/triggers.test.ts +76 -0
- package/src/wire/triggers.ts +98 -0
- package/dist/boundary/boundary.d.ts +0 -72
- package/dist/boundary/boundary.d.ts.map +0 -1
- package/dist/boundary/boundary.js +0 -83
- package/dist/boundary/boundary.js.map +0 -1
- package/dist/calc/asyncCalc.d.ts +0 -91
- package/dist/calc/asyncCalc.d.ts.map +0 -1
- package/dist/calc/asyncCalc.js +0 -95
- package/dist/calc/asyncCalc.js.map +0 -1
- package/dist/calc/asyncData.d.ts +0 -57
- package/dist/calc/asyncData.d.ts.map +0 -1
- package/dist/calc/asyncData.js +0 -34
- package/dist/calc/asyncData.js.map +0 -1
- package/dist/calc/calc.d.ts +0 -57
- package/dist/calc/calc.d.ts.map +0 -1
- package/dist/calc/calc.js +0 -58
- package/dist/calc/calc.js.map +0 -1
- package/dist/calc/calcFamily.d.ts +0 -57
- package/dist/calc/calcFamily.d.ts.map +0 -1
- package/dist/calc/calcFamily.js +0 -124
- package/dist/calc/calcFamily.js.map +0 -1
- package/dist/calc/compose.d.ts +0 -16
- package/dist/calc/compose.d.ts.map +0 -1
- package/dist/calc/compose.js +0 -7
- package/dist/calc/compose.js.map +0 -1
- package/dist/channel/channel.d.ts +0 -115
- package/dist/channel/channel.d.ts.map +0 -1
- package/dist/channel/channel.js +0 -138
- package/dist/channel/channel.js.map +0 -1
- package/dist/compose/composition.d.ts +0 -72
- package/dist/compose/composition.d.ts.map +0 -1
- package/dist/compose/composition.js +0 -46
- package/dist/compose/composition.js.map +0 -1
- package/dist/compose/host.d.ts.map +0 -1
- package/dist/compose/host.js +0 -6
- package/dist/compose/host.js.map +0 -1
- package/dist/compose/props.d.ts +0 -13
- package/dist/compose/props.d.ts.map +0 -1
- package/dist/compose/props.js.map +0 -1
- package/dist/compose/provide.d.ts +0 -22
- package/dist/compose/provide.d.ts.map +0 -1
- package/dist/compose/provide.js +0 -28
- package/dist/compose/provide.js.map +0 -1
- package/dist/compose/slot.d.ts.map +0 -1
- package/dist/compose/slot.js +0 -23
- package/dist/compose/slot.js.map +0 -1
- package/dist/compose/ui.d.ts +0 -135
- package/dist/compose/ui.d.ts.map +0 -1
- package/dist/compose/ui.js +0 -63
- package/dist/compose/ui.js.map +0 -1
- package/dist/definition/definition.d.ts +0 -33
- package/dist/definition/definition.d.ts.map +0 -1
- package/dist/definition/definition.js +0 -42
- package/dist/definition/definition.js.map +0 -1
- package/dist/event/event.d.ts +0 -33
- package/dist/event/event.d.ts.map +0 -1
- package/dist/event/event.js +0 -30
- package/dist/event/event.js.map +0 -1
- package/dist/event/eventGroup.d.ts +0 -9
- package/dist/event/eventGroup.d.ts.map +0 -1
- package/dist/event/eventGroup.js +0 -4
- package/dist/event/eventGroup.js.map +0 -1
- package/dist/feature/feature.d.ts.map +0 -1
- package/dist/feature/feature.js +0 -98
- package/dist/feature/feature.js.map +0 -1
- package/dist/index.d.ts +0 -53
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js +0 -62
- package/dist/index.js.map +0 -1
- package/dist/internal/capture.d.ts.map +0 -1
- package/dist/internal/capture.js +0 -12
- package/dist/internal/capture.js.map +0 -1
- package/dist/internal/ctx.d.ts.map +0 -1
- package/dist/internal/ctx.js +0 -2
- package/dist/internal/ctx.js.map +0 -1
- package/dist/internal/errors.d.ts +0 -89
- package/dist/internal/errors.d.ts.map +0 -1
- package/dist/internal/errors.js +0 -102
- package/dist/internal/errors.js.map +0 -1
- package/dist/internal/inspect.d.ts +0 -17
- package/dist/internal/inspect.d.ts.map +0 -1
- package/dist/internal/inspect.js +0 -32
- package/dist/internal/inspect.js.map +0 -1
- package/dist/internal/queryDriver.d.ts +0 -65
- package/dist/internal/queryDriver.d.ts.map +0 -1
- package/dist/internal/queryDriver.js +0 -134
- package/dist/internal/queryDriver.js.map +0 -1
- package/dist/internal/reuse.d.ts +0 -10
- package/dist/internal/reuse.d.ts.map +0 -1
- package/dist/internal/reuse.js +0 -68
- package/dist/internal/reuse.js.map +0 -1
- package/dist/internal/scheduler.d.ts +0 -47
- package/dist/internal/scheduler.d.ts.map +0 -1
- package/dist/internal/scheduler.js +0 -70
- package/dist/internal/scheduler.js.map +0 -1
- package/dist/internal/seeds.d.ts.map +0 -1
- package/dist/internal/seeds.js +0 -17
- package/dist/internal/seeds.js.map +0 -1
- package/dist/internal/sources.d.ts +0 -39
- package/dist/internal/sources.d.ts.map +0 -1
- package/dist/internal/sources.js +0 -59
- package/dist/internal/sources.js.map +0 -1
- package/dist/internal/stateRegistry.d.ts +0 -2
- package/dist/internal/stateRegistry.d.ts.map +0 -1
- package/dist/internal/stateRegistry.js.map +0 -1
- package/dist/internal/store.d.ts +0 -47
- package/dist/internal/store.d.ts.map +0 -1
- package/dist/internal/store.js +0 -73
- package/dist/internal/store.js.map +0 -1
- package/dist/internal/track.d.ts.map +0 -1
- package/dist/internal/track.js +0 -19
- package/dist/internal/track.js.map +0 -1
- package/dist/procedure/procedure.d.ts +0 -40
- package/dist/procedure/procedure.d.ts.map +0 -1
- package/dist/procedure/procedure.js +0 -50
- package/dist/procedure/procedure.js.map +0 -1
- package/dist/reducer/reducer.d.ts +0 -44
- package/dist/reducer/reducer.d.ts.map +0 -1
- package/dist/reducer/reducer.js +0 -63
- package/dist/reducer/reducer.js.map +0 -1
- package/dist/remote/remoteState.d.ts +0 -119
- package/dist/remote/remoteState.d.ts.map +0 -1
- package/dist/remote/remoteState.js +0 -270
- package/dist/remote/remoteState.js.map +0 -1
- package/dist/runtime/bus.d.ts +0 -27
- package/dist/runtime/bus.d.ts.map +0 -1
- package/dist/runtime/bus.js +0 -21
- package/dist/runtime/bus.js.map +0 -1
- package/dist/runtime/loop.d.ts +0 -45
- package/dist/runtime/loop.d.ts.map +0 -1
- package/dist/runtime/loop.js +0 -117
- package/dist/runtime/loop.js.map +0 -1
- package/dist/scene/scene.d.ts +0 -44
- package/dist/scene/scene.d.ts.map +0 -1
- package/dist/scene/scene.js +0 -31
- package/dist/scene/scene.js.map +0 -1
- package/dist/state/state.d.ts +0 -37
- package/dist/state/state.d.ts.map +0 -1
- package/dist/state/state.js +0 -46
- package/dist/state/state.js.map +0 -1
- package/dist/state/stateFamily.d.ts +0 -79
- package/dist/state/stateFamily.d.ts.map +0 -1
- package/dist/state/stateFamily.js +0 -99
- package/dist/state/stateFamily.js.map +0 -1
- package/dist/state/stateGroup.d.ts +0 -50
- package/dist/state/stateGroup.d.ts.map +0 -1
- package/dist/state/stateGroup.js +0 -54
- package/dist/state/stateGroup.js.map +0 -1
- package/dist/state/token.d.ts.map +0 -1
- package/dist/state/token.js +0 -20
- package/dist/state/token.js.map +0 -1
- package/dist/synced/syncedStore.d.ts +0 -51
- package/dist/synced/syncedStore.d.ts.map +0 -1
- package/dist/synced/syncedStore.js +0 -43
- package/dist/synced/syncedStore.js.map +0 -1
- package/dist/ui/node.d.ts.map +0 -1
- package/dist/ui/node.js +0 -2
- package/dist/ui/node.js.map +0 -1
- package/dist/ui/trigger.d.ts.map +0 -1
- package/dist/ui/trigger.js +0 -2
- package/dist/ui/trigger.js.map +0 -1
- package/dist/wire/tree.d.ts +0 -66
- package/dist/wire/tree.d.ts.map +0 -1
- package/dist/wire/tree.js +0 -60
- package/dist/wire/tree.js.map +0 -1
- package/dist/wire/triggers.d.ts +0 -44
- package/dist/wire/triggers.d.ts.map +0 -1
- package/dist/wire/triggers.js +0 -31
- package/dist/wire/triggers.js.map +0 -1
|
@@ -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
|
+
})
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { Context, Effect, Layer, PubSub } from 'effect'
|
|
2
|
+
|
|
3
|
+
/** A dispatched event: a tagged data record (`{ _tag, ...payload }`). */
|
|
4
|
+
export interface Tagged {
|
|
5
|
+
readonly _tag: string
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Re-narrow an erased `Tagged` envelope to a handler's declared event union. The
|
|
10
|
+
* loop and channels only route events whose tag is in a handler's `handles`, so
|
|
11
|
+
* at runtime the envelope IS one of those events; the registries store it as the
|
|
12
|
+
* generic `Tagged`, and this restores the typed view. The one documented home
|
|
13
|
+
* for that boundary cast, shared by `Reducer.live` and `Procedure.live`.
|
|
14
|
+
*/
|
|
15
|
+
export const narrowHandled = <E>(event: Tagged): E => event as never
|
|
16
|
+
|
|
17
|
+
/** UI-originated events are High; procedure-emitted follow-ups are Normal (DESIGN #46). */
|
|
18
|
+
export type Priority = 'High' | 'Normal'
|
|
19
|
+
|
|
20
|
+
export interface Envelope {
|
|
21
|
+
readonly priority: Priority
|
|
22
|
+
readonly event: Tagged
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const BusBase: Context.TagClass<Bus, 'reform/Bus', PubSub.PubSub<Envelope>> = Context.Tag(
|
|
26
|
+
'reform/Bus',
|
|
27
|
+
)<Bus, PubSub.PubSub<Envelope>>()
|
|
28
|
+
|
|
29
|
+
/** The single dispatch bus: an unbounded PubSub fanned out to the loop and procedures. */
|
|
30
|
+
export class Bus extends BusBase {}
|
|
31
|
+
|
|
32
|
+
// Unbounded so `publish` never suspends: UI triggers dispatch synchronously
|
|
33
|
+
// (`Runtime.runSync`, no fiber fork per event), and an event storm can't apply
|
|
34
|
+
// backpressure to the UI thread. The single drain loop empties the bus every
|
|
35
|
+
// microtask, so it does not accumulate. Swap to `PubSub.dropping(N)` if bounded
|
|
36
|
+
// memory is preferred over never dropping (publish stays synchronous either way).
|
|
37
|
+
export const busLayer: Layer.Layer<Bus> = Layer.scoped(Bus, PubSub.unbounded<Envelope>())
|
|
38
|
+
|
|
39
|
+
export const publish = (priority: Priority, event: Tagged): Effect.Effect<void, never, Bus> =>
|
|
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
|
+
})
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { Chunk, Context, Effect, Layer, PubSub, Queue } from 'effect'
|
|
2
|
+
import {
|
|
3
|
+
Channels,
|
|
4
|
+
channelsLayer,
|
|
5
|
+
Procedures,
|
|
6
|
+
proceduresLayer,
|
|
7
|
+
} from '../channel/channel'
|
|
8
|
+
import { notificationsLayer } from '../internal/scheduler'
|
|
9
|
+
import { Bus, busLayer, type Envelope, type Tagged } from './bus'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* A registered reducer, with its target store(s) already captured at
|
|
13
|
+
* registration. `apply` is a pure synchronous write, so the loop needs no
|
|
14
|
+
* state services in its own context — it just routes events to writers.
|
|
15
|
+
*/
|
|
16
|
+
export interface ReducerEntry {
|
|
17
|
+
/** The reducer's name — used only to warn on duplicate registration. */
|
|
18
|
+
readonly name: string
|
|
19
|
+
readonly handles: ReadonlySet<string>
|
|
20
|
+
readonly apply: (event: Tagged) => void
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Mutable collector the central loop drains; reducer `.live` layers register
|
|
25
|
+
* here. `byTag` indexes entries by the event tags they handle, so dispatch is
|
|
26
|
+
* O(matching reducers) per event rather than O(all reducers) — the difference
|
|
27
|
+
* that matters once an app has hundreds of reducers.
|
|
28
|
+
*/
|
|
29
|
+
export interface ReducerRegistry {
|
|
30
|
+
readonly entries: Array<ReducerEntry>
|
|
31
|
+
readonly byTag: Map<string, Array<ReducerEntry>>
|
|
32
|
+
readonly register: (entry: ReducerEntry) => void
|
|
33
|
+
/**
|
|
34
|
+
* Remove a previously-registered entry. Eager reducers (built on the root scope)
|
|
35
|
+
* never call this; a lazy feature's `Reducer.live` registers on mount and
|
|
36
|
+
* unregisters on unmount (scope close), so an unmounted feature stops folding and
|
|
37
|
+
* its entry is reclaimed instead of leaking + writing to an orphaned store.
|
|
38
|
+
*/
|
|
39
|
+
readonly unregister: (entry: ReducerEntry) => void
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const ReducersBase: Context.TagClass<Reducers, 'reform/Reducers', ReducerRegistry> =
|
|
43
|
+
Context.Tag('reform/Reducers')<Reducers, ReducerRegistry>()
|
|
44
|
+
export class Reducers extends ReducersBase {}
|
|
45
|
+
|
|
46
|
+
/** Drop an item from a `Map<string, Array>` bucket, pruning the key when it empties. */
|
|
47
|
+
const dropFromBuckets = <T>(map: Map<string, Array<T>>, key: string, item: T): void => {
|
|
48
|
+
const bucket = map.get(key)
|
|
49
|
+
if (bucket === undefined) return
|
|
50
|
+
const next = bucket.filter((entry) => entry !== item)
|
|
51
|
+
if (next.length === 0) map.delete(key)
|
|
52
|
+
else map.set(key, next)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const makeReducerRegistry = (): ReducerRegistry => {
|
|
56
|
+
const entries: Array<ReducerEntry> = []
|
|
57
|
+
const byTag = new Map<string, Array<ReducerEntry>>()
|
|
58
|
+
return {
|
|
59
|
+
entries,
|
|
60
|
+
byTag,
|
|
61
|
+
register: (entry) => {
|
|
62
|
+
entries.push(entry)
|
|
63
|
+
for (const tag of entry.handles) {
|
|
64
|
+
const bucket = byTag.get(tag)
|
|
65
|
+
if (bucket === undefined) byTag.set(tag, [entry])
|
|
66
|
+
else bucket.push(entry)
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
unregister: (entry) => {
|
|
70
|
+
const index = entries.indexOf(entry)
|
|
71
|
+
if (index >= 0) entries.splice(index, 1)
|
|
72
|
+
for (const tag of entry.handles) dropFromBuckets(byTag, tag, entry)
|
|
73
|
+
},
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export const reducersLayer: Layer.Layer<Reducers> = Layer.sync(Reducers, makeReducerRegistry)
|
|
78
|
+
|
|
79
|
+
/** High before Normal; within a priority class, dispatch order is preserved. */
|
|
80
|
+
const rank = (priority: Envelope['priority']): number => (priority === 'High' ? 0 : 1)
|
|
81
|
+
|
|
82
|
+
// The single drain loop: one fiber that frame-batches the bus. Each tick it
|
|
83
|
+
// harvests every event dispatched in that microtask, runs all matching reducers
|
|
84
|
+
// in ONE synchronous pass (so the store notifier flushes once for the batch),
|
|
85
|
+
// then routes each event to the channels its procedures run on.
|
|
86
|
+
const drain = Effect.gen(function* () {
|
|
87
|
+
const bus = yield* Bus
|
|
88
|
+
const reducers = yield* Reducers
|
|
89
|
+
const { byName: channels } = yield* Channels
|
|
90
|
+
const procedures = yield* Procedures
|
|
91
|
+
const subscription = yield* PubSub.subscribe(bus)
|
|
92
|
+
|
|
93
|
+
yield* Effect.forkScoped(
|
|
94
|
+
Effect.forever(
|
|
95
|
+
Effect.gen(function* () {
|
|
96
|
+
// Block until the first event, then drain the rest of this tick.
|
|
97
|
+
const first = yield* Queue.take(subscription)
|
|
98
|
+
const rest = yield* Queue.takeAll(subscription)
|
|
99
|
+
const frame = [first, ...Chunk.toArray(rest)]
|
|
100
|
+
|
|
101
|
+
// Stable priority order: High-dispatched (UI) events fold before
|
|
102
|
+
// Normal-dispatched (procedure follow-up) events in the same frame. A
|
|
103
|
+
// two-bucket partition does this in O(m) with no comparator — push order
|
|
104
|
+
// preserves intra-priority dispatch order — instead of sorting the frame.
|
|
105
|
+
const high: Array<Envelope> = []
|
|
106
|
+
const normal: Array<Envelope> = []
|
|
107
|
+
for (const envelope of frame) {
|
|
108
|
+
if (rank(envelope.priority) === 0) high.push(envelope)
|
|
109
|
+
else normal.push(envelope)
|
|
110
|
+
}
|
|
111
|
+
const ordered = high.concat(normal)
|
|
112
|
+
|
|
113
|
+
// One synchronous block: every reducer write for the frame coalesces into
|
|
114
|
+
// a single notification flush. Routing to channels happens here too, so
|
|
115
|
+
// procedure bodies (run later, off their channel fibers) see post-batch
|
|
116
|
+
// state. No `yield*` in between, or the flush could fire early.
|
|
117
|
+
const failures: Array<{ readonly event: Tagged; readonly error: unknown }> = []
|
|
118
|
+
yield* Effect.sync(() => {
|
|
119
|
+
for (const envelope of ordered) {
|
|
120
|
+
for (const reducer of reducers.byTag.get(envelope.event._tag) ?? []) {
|
|
121
|
+
// Isolate each fold: a synchronous throw in one reducer must not
|
|
122
|
+
// abandon the rest of the frame nor (via the `forever` below) kill
|
|
123
|
+
// the drain fiber and freeze the whole app. Collect and log after.
|
|
124
|
+
try {
|
|
125
|
+
reducer.apply(envelope.event)
|
|
126
|
+
} catch (error) {
|
|
127
|
+
failures.push({ event: envelope.event, error })
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
for (const envelope of ordered) {
|
|
132
|
+
// Offer to each DISTINCT channel once. Routing per-procedure would
|
|
133
|
+
// offer a shared channel multiple times for one event, and each offer
|
|
134
|
+
// re-runs every procedure on it — duplicate execution.
|
|
135
|
+
for (const channelName of procedures.channelsByTag.get(envelope.event._tag) ?? []) {
|
|
136
|
+
channels.get(channelName)?.offer(envelope.event)
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
})
|
|
140
|
+
yield* Effect.forEach(
|
|
141
|
+
failures,
|
|
142
|
+
(failure) =>
|
|
143
|
+
Effect.logError(
|
|
144
|
+
`reform: reducer threw handling '${failure.event._tag}'`,
|
|
145
|
+
failure.error,
|
|
146
|
+
),
|
|
147
|
+
{ discard: true },
|
|
148
|
+
)
|
|
149
|
+
}).pipe(
|
|
150
|
+
// Belt-and-suspenders: even a defect outside the isolated fold (a bug in
|
|
151
|
+
// the loop itself) is logged and the tick restarts, so the bus never goes
|
|
152
|
+
// permanently deaf.
|
|
153
|
+
Effect.catchAllCause((cause) => Effect.logError('reform: drain tick crashed', cause)),
|
|
154
|
+
),
|
|
155
|
+
),
|
|
156
|
+
)
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* The reform engine: provides the bus + reducer/channel/procedure registries +
|
|
161
|
+
* the per-runtime notification scheduler to the application and forks the single
|
|
162
|
+
* drain loop. Registration mutates the live collections, so reducers/procedures/
|
|
163
|
+
* channels merged alongside are picked up before the first dispatch (boot).
|
|
164
|
+
*/
|
|
165
|
+
export const Engine: Layer.Layer<Bus | Reducers | Channels | Procedures> = Layer.scopedDiscard(
|
|
166
|
+
drain,
|
|
167
|
+
).pipe(
|
|
168
|
+
Layer.provideMerge(
|
|
169
|
+
Layer.mergeAll(busLayer, reducersLayer, channelsLayer, proceduresLayer, notificationsLayer),
|
|
170
|
+
),
|
|
171
|
+
)
|