@playfast/reform 1.0.1 → 1.2.0
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/README.md +103 -56
- package/package.json +17 -17
- package/src/boundary/boundary.test.ts +63 -49
- package/src/boundary/boundary.ts +144 -113
- package/src/calc/asyncCalc.invalidate.test.ts +518 -32
- package/src/calc/asyncCalc.test.ts +207 -213
- package/src/calc/asyncCalc.ts +131 -154
- package/src/calc/asyncData.ts +0 -37
- package/src/calc/calc.test.ts +7 -33
- package/src/calc/calc.ts +44 -58
- package/src/calc/calcFamily.test.ts +80 -34
- package/src/calc/calcFamily.ts +54 -65
- package/src/calc/compose.test.ts +1 -12
- package/src/calc/compose.ts +1 -36
- package/src/calc/queryState.ts +0 -23
- package/src/channel/channel.ts +126 -111
- package/src/compose/composition.test.ts +19 -0
- package/src/compose/composition.ts +351 -127
- package/src/compose/host.ts +0 -6
- package/src/compose/props.ts +13 -12
- package/src/compose/provide.ts +189 -62
- package/src/compose/slot.ts +158 -49
- package/src/compose/structure.test.ts +6 -9
- package/src/compose/structure.ts +108 -79
- package/src/compose/ui.test.ts +19 -7
- package/src/compose/ui.ts +155 -156
- package/src/compose/ui.typecheck.ts +40 -18
- package/src/definition/definition.ts +22 -41
- package/src/event/event.fromSource.test.ts +172 -0
- package/src/event/event.test.ts +10 -3
- package/src/event/event.ts +102 -27
- package/src/event/eventGroup.ts +0 -1
- package/src/feature/feature.mount.test.ts +122 -43
- package/src/feature/feature.test.ts +46 -21
- package/src/feature/feature.ts +1347 -256
- package/src/feature/feature.typecheck.ts +273 -68
- package/src/graph/closure.ts +403 -0
- package/src/index.ts +68 -126
- package/src/internal/bucketCache.ts +39 -0
- package/src/internal/capture.ts +9 -24
- package/src/internal/env.ts +7 -0
- package/src/internal/errors.test.ts +0 -6
- package/src/internal/errors.ts +37 -60
- package/src/internal/inspect.test.ts +0 -6
- package/src/internal/inspect.ts +0 -12
- package/src/internal/queryDriver.ts +105 -201
- package/src/internal/queryDriverStore.ts +156 -0
- package/src/internal/queryDriverTypes.ts +59 -0
- package/src/internal/queryEvents.ts +0 -12
- package/src/internal/queryStore.ts +0 -14
- package/src/internal/reuse.test.ts +10 -14
- package/src/internal/reuse.ts +5 -30
- package/src/internal/scheduler.ts +4 -44
- package/src/internal/seeds.ts +0 -14
- package/src/internal/sources.ts +26 -49
- package/src/internal/stateRegistry.ts +0 -14
- package/src/internal/store.test.ts +1 -3
- package/src/internal/store.ts +0 -37
- package/src/internal/track.ts +3 -20
- package/src/internal/variance.ts +5 -0
- package/src/internal.ts +222 -0
- package/src/namespace/namespace.test.ts +46 -0
- package/src/namespace/namespace.ts +106 -0
- package/src/procedure/procedure.ts +40 -35
- package/src/reducer/reducer.ts +78 -52
- package/src/remote/remoteState.test.ts +590 -397
- package/src/remote/remoteState.ts +425 -347
- package/src/remote/remoteState.typecheck.ts +47 -56
- package/src/runtime/appRuntime.activation.test.ts +100 -0
- package/src/runtime/appRuntime.test.ts +249 -0
- package/src/runtime/appRuntime.ts +415 -97
- package/src/runtime/bus.ts +2 -15
- package/src/runtime/eventBudget.test.ts +152 -0
- package/src/runtime/eventBudget.ts +120 -0
- package/src/runtime/hardening.test.ts +73 -13
- package/src/runtime/instrumentation.test.ts +55 -52
- package/src/runtime/instrumentation.ts +6 -60
- package/src/runtime/loop.test.ts +36 -17
- package/src/runtime/loop.ts +87 -88
- package/src/runtime/queries.ts +0 -17
- package/src/scene/featureScene.test.ts +61 -0
- package/src/scene/scene.ts +247 -104
- package/src/scene/seedScene.test.ts +42 -79
- package/src/state/state.nominal.typecheck.ts +68 -0
- package/src/state/state.test.ts +17 -0
- package/src/state/state.ts +80 -46
- package/src/state/stateFamily.test.ts +16 -11
- package/src/state/stateFamily.ts +55 -65
- package/src/state/stateGroup.ts +53 -64
- package/src/state/token.ts +40 -23
- package/src/synced/syncedStore.ts +46 -58
- package/src/testkit/flight.testkit.ts +75 -0
- package/src/ui/node.ts +0 -6
- package/src/ui/trigger.ts +0 -5
- package/src/wire/tree.test.ts +8 -7
- package/src/wire/tree.ts +0 -39
- package/src/wire/triggers.test.ts +6 -6
- package/src/wire/triggers.ts +14 -32
- package/src/internal/ctx.ts +0 -16
package/src/channel/channel.ts
CHANGED
|
@@ -1,19 +1,21 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
Chunk,
|
|
3
|
+
Context,
|
|
4
|
+
Duration,
|
|
5
|
+
Effect,
|
|
6
|
+
Exit,
|
|
7
|
+
Layer,
|
|
8
|
+
Match,
|
|
9
|
+
Option,
|
|
10
|
+
Queue,
|
|
11
|
+
Scope,
|
|
12
|
+
Stream,
|
|
13
|
+
} from 'effect'
|
|
2
14
|
import { type Manifest, definitionClass } from '../definition/definition'
|
|
15
|
+
import { makeBucketCache } from '../internal/bucketCache'
|
|
3
16
|
import type { Tagged } from '../runtime/bus'
|
|
4
17
|
import { resolveInstrumentation } from '../runtime/instrumentation'
|
|
5
18
|
|
|
6
|
-
/**
|
|
7
|
-
* How a channel schedules the procedure work routed to it. Channels generalize
|
|
8
|
-
* the old per-procedure `concurrency`: a named lane with one policy, shared by
|
|
9
|
-
* every procedure that declares it.
|
|
10
|
-
*
|
|
11
|
-
* - `merge` run every routed event concurrently (the default).
|
|
12
|
-
* - `latest` a new event cancels the in-flight run (switch).
|
|
13
|
-
* - `debounce` collapse a burst, run only after `duration` of quiet.
|
|
14
|
-
* - `throttle` rate-limit to `units` per `duration` (shape or enforce).
|
|
15
|
-
* - `exclusive` one run at a time across the whole channel (mutual exclusion).
|
|
16
|
-
*/
|
|
17
19
|
export type ChannelPolicy =
|
|
18
20
|
| { readonly _tag: 'merge' }
|
|
19
21
|
| { readonly _tag: 'latest' }
|
|
@@ -28,7 +30,6 @@ export type ChannelPolicy =
|
|
|
28
30
|
}
|
|
29
31
|
| { readonly _tag: 'exclusive' }
|
|
30
32
|
|
|
31
|
-
/** The reflectable descriptor a channel carries. */
|
|
32
33
|
export interface ChannelManifest extends Manifest {
|
|
33
34
|
readonly kind: 'Channel'
|
|
34
35
|
}
|
|
@@ -43,44 +44,21 @@ export interface ChannelConfig {
|
|
|
43
44
|
readonly policy: ChannelPolicy
|
|
44
45
|
}
|
|
45
46
|
|
|
46
|
-
/**
|
|
47
|
-
* A registered procedure body, with its trigger events and the channel it runs
|
|
48
|
-
* on. `run`'s context (RPC clients, etc.) is already provided at registration
|
|
49
|
-
* (see `Procedure.live`), so the channel fiber can run it with no R.
|
|
50
|
-
*/
|
|
51
47
|
export interface ProcedureEntry {
|
|
52
|
-
/** The procedure's name — used only to warn on duplicate registration. */
|
|
53
48
|
readonly name: string
|
|
54
49
|
readonly channelName: string
|
|
55
50
|
readonly handles: ReadonlySet<string>
|
|
56
51
|
readonly run: (event: Tagged) => Effect.Effect<unknown, unknown, never>
|
|
57
52
|
}
|
|
58
53
|
|
|
59
|
-
/**
|
|
60
|
-
* Mutable collector procedures register into; channels read it to find their
|
|
61
|
-
* bodies. `byTag` indexes entries by trigger tag, so a channel routes an event
|
|
62
|
-
* to O(procedures handling it) rather than scanning every registered procedure.
|
|
63
|
-
*/
|
|
64
54
|
export interface ProcedureRegistry {
|
|
65
55
|
readonly entries: Array<ProcedureEntry>
|
|
66
|
-
readonly
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
*/
|
|
72
|
-
readonly channelsByTag: Map<string, Array<string>>
|
|
73
|
-
/**
|
|
74
|
-
* Procedures keyed by (channel, tag), so a channel's `runMatching` is a direct
|
|
75
|
-
* lookup instead of scanning `byTag` and re-filtering by channel per event.
|
|
76
|
-
*/
|
|
77
|
-
readonly byChannelTag: Map<string, Map<string, Array<ProcedureEntry>>>
|
|
56
|
+
readonly byName: Map<string, Set<ProcedureEntry>>
|
|
57
|
+
readonly byTag: Map<string, Set<ProcedureEntry>>
|
|
58
|
+
readonly channelsByTag: Map<string, Set<string>>
|
|
59
|
+
readonly channelsFor: (tag: string) => ReadonlyArray<string>
|
|
60
|
+
readonly byChannelTag: Map<string, Map<string, Set<ProcedureEntry>>>
|
|
78
61
|
readonly register: (entry: ProcedureEntry) => void
|
|
79
|
-
/**
|
|
80
|
-
* Remove a previously-registered entry — the inverse of `register`, used when a
|
|
81
|
-
* lazy feature unmounts (scope close) so its procedures stop running and their
|
|
82
|
-
* index entries are reclaimed. Eager procedures (root scope) never call it.
|
|
83
|
-
*/
|
|
84
62
|
readonly unregister: (entry: ProcedureEntry) => void
|
|
85
63
|
}
|
|
86
64
|
|
|
@@ -88,7 +66,6 @@ const ProceduresBase: Context.TagClass<Procedures, 'reform/Procedures', Procedur
|
|
|
88
66
|
Context.Tag('reform/Procedures')<Procedures, ProcedureRegistry>()
|
|
89
67
|
export class Procedures extends ProceduresBase {}
|
|
90
68
|
|
|
91
|
-
/** Get a map entry, creating and inserting it on first access (avoids `let`). */
|
|
92
69
|
const getOrCreate = <K, V>(map: Map<K, V>, key: K, make: () => V): V => {
|
|
93
70
|
const existing = map.get(key)
|
|
94
71
|
if (existing !== undefined) {
|
|
@@ -99,57 +76,64 @@ const getOrCreate = <K, V>(map: Map<K, V>, key: K, make: () => V): V => {
|
|
|
99
76
|
return created
|
|
100
77
|
}
|
|
101
78
|
|
|
102
|
-
|
|
103
|
-
const dropFrom = <K, T>(map: Map<K, Array<T>>, key: K, target: T): void => {
|
|
79
|
+
const dropFrom = <K, T>(map: Map<K, Set<T>>, key: K, target: T): void => {
|
|
104
80
|
const bucket = map.get(key)
|
|
105
81
|
if (bucket === undefined) {
|
|
106
82
|
return
|
|
107
83
|
}
|
|
108
|
-
|
|
109
|
-
if (
|
|
84
|
+
bucket.delete(target)
|
|
85
|
+
if (bucket.size === 0) {
|
|
110
86
|
map.delete(key)
|
|
111
|
-
} else {
|
|
112
|
-
map.set(key, next)
|
|
113
87
|
}
|
|
114
88
|
}
|
|
115
89
|
|
|
116
90
|
const makeProcedureRegistry = (): ProcedureRegistry => {
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
const
|
|
120
|
-
const
|
|
121
|
-
const
|
|
122
|
-
const byChannelTag = new Map<string, Map<string,
|
|
91
|
+
const entries = new Set<ProcedureEntry>()
|
|
92
|
+
const byName = new Map<string, Set<ProcedureEntry>>()
|
|
93
|
+
const byTag = new Map<string, Set<ProcedureEntry>>()
|
|
94
|
+
const channelsByTag = new Map<string, Set<string>>()
|
|
95
|
+
const channelsFor = makeBucketCache(channelsByTag)
|
|
96
|
+
const byChannelTag = new Map<string, Map<string, Set<ProcedureEntry>>>()
|
|
97
|
+
const snapshot: { value: Option.Option<Array<ProcedureEntry>> } = {
|
|
98
|
+
value: Option.some([]),
|
|
99
|
+
}
|
|
123
100
|
return {
|
|
124
101
|
get entries() {
|
|
125
|
-
return
|
|
102
|
+
return Option.getOrElse(snapshot.value, () => {
|
|
103
|
+
const current = Array.from(entries)
|
|
104
|
+
snapshot.value = Option.some(current)
|
|
105
|
+
return current
|
|
106
|
+
})
|
|
126
107
|
},
|
|
108
|
+
byName,
|
|
127
109
|
byTag,
|
|
128
110
|
channelsByTag,
|
|
111
|
+
channelsFor: channelsFor.read,
|
|
129
112
|
byChannelTag,
|
|
130
113
|
register: (entry) => {
|
|
131
|
-
|
|
114
|
+
entries.add(entry)
|
|
115
|
+
snapshot.value = Option.none()
|
|
116
|
+
getOrCreate(byName, entry.name, () => new Set()).add(entry)
|
|
132
117
|
entry.handles.forEach((tag) => {
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
channelsByTag.set(tag, [...channels, entry.channelName])
|
|
137
|
-
}
|
|
118
|
+
getOrCreate(byTag, tag, () => new Set()).add(entry)
|
|
119
|
+
getOrCreate(channelsByTag, tag, () => new Set()).add(entry.channelName)
|
|
120
|
+
channelsFor.invalidate(tag)
|
|
138
121
|
const channelMap = getOrCreate(byChannelTag, entry.channelName, () => new Map())
|
|
139
|
-
|
|
122
|
+
getOrCreate(channelMap, tag, () => new Set()).add(entry)
|
|
140
123
|
})
|
|
141
124
|
},
|
|
142
125
|
unregister: (entry) => {
|
|
143
|
-
|
|
126
|
+
entries.delete(entry)
|
|
127
|
+
snapshot.value = Option.none()
|
|
128
|
+
dropFrom(byName, entry.name, entry)
|
|
144
129
|
entry.handles.forEach((tag) => {
|
|
145
130
|
dropFrom(byTag, tag, entry)
|
|
146
131
|
const channelMap = byChannelTag.get(entry.channelName)
|
|
147
132
|
if (channelMap !== undefined) {
|
|
148
133
|
dropFrom(channelMap, tag, entry)
|
|
149
|
-
// If no procedure on this channel still handles the tag, drop the
|
|
150
|
-
// channel from `channelsByTag[tag]` so the loop stops offering to it.
|
|
151
134
|
if (channelMap.get(tag) === undefined) {
|
|
152
135
|
dropFrom(channelsByTag, tag, entry.channelName)
|
|
136
|
+
channelsFor.invalidate(tag)
|
|
153
137
|
}
|
|
154
138
|
if (channelMap.size === 0) {
|
|
155
139
|
byChannelTag.delete(entry.channelName)
|
|
@@ -160,17 +144,19 @@ const makeProcedureRegistry = (): ProcedureRegistry => {
|
|
|
160
144
|
}
|
|
161
145
|
}
|
|
162
146
|
|
|
163
|
-
export const proceduresLayer: Layer.Layer<Procedures> = Layer.sync(
|
|
147
|
+
export const proceduresLayer: Layer.Layer<Procedures> = Layer.sync(
|
|
148
|
+
Procedures,
|
|
149
|
+
makeProcedureRegistry,
|
|
150
|
+
)
|
|
164
151
|
|
|
165
|
-
/** A live channel: the single loop offers events here; the channel drives them. */
|
|
166
152
|
export interface ChannelRuntime {
|
|
167
153
|
readonly name: string
|
|
168
|
-
/** The policy this channel was built with — used to reject a name clash. */
|
|
169
154
|
readonly policy: ChannelPolicy
|
|
170
155
|
readonly offer: (event: Tagged) => void
|
|
156
|
+
readonly leases: { count: number }
|
|
157
|
+
readonly close: Effect.Effect<void>
|
|
171
158
|
}
|
|
172
159
|
|
|
173
|
-
/** Registry of live channels, keyed by name. The single loop routes through it. */
|
|
174
160
|
const ChannelsBase: Context.TagClass<
|
|
175
161
|
Channels,
|
|
176
162
|
'reform/Channels',
|
|
@@ -178,25 +164,66 @@ const ChannelsBase: Context.TagClass<
|
|
|
178
164
|
> = Context.Tag('reform/Channels')<Channels, { readonly byName: Map<string, ChannelRuntime> }>()
|
|
179
165
|
export class Channels extends ChannelsBase {}
|
|
180
166
|
|
|
181
|
-
export const channelsLayer: Layer.Layer<Channels> = Layer.sync(Channels, () => ({
|
|
167
|
+
export const channelsLayer: Layer.Layer<Channels> = Layer.sync(Channels, () => ({
|
|
168
|
+
byName: new Map(),
|
|
169
|
+
}))
|
|
182
170
|
|
|
183
|
-
/**
|
|
184
|
-
* A scheduling lane for procedures. The *definition* (`Channel.make`) is a
|
|
185
|
-
* reflectable name + policy; the *implementation* (`Channel.live`) builds the
|
|
186
|
-
* queue + fiber that applies the policy. Procedures reference a channel by value
|
|
187
|
-
* and register into it; `Channel.live` is merged into the app once per channel.
|
|
188
|
-
*/
|
|
189
171
|
export const make = (name: string, config: ChannelConfig): ChannelClass =>
|
|
190
172
|
definitionClass<ChannelClass>({
|
|
191
173
|
manifest: { kind: 'Channel' as const, name } satisfies ChannelManifest,
|
|
192
174
|
policy: config.policy,
|
|
193
175
|
})
|
|
194
176
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
177
|
+
const durationMillis = (duration: Duration.DurationInput): number =>
|
|
178
|
+
Duration.toMillis(Duration.decode(duration))
|
|
179
|
+
|
|
180
|
+
const sameOptionValue = <A>(left: Option.Option<A>, right: Option.Option<A>): boolean =>
|
|
181
|
+
Option.match(left, {
|
|
182
|
+
onNone: () => Option.isNone(right),
|
|
183
|
+
onSome: (leftValue) =>
|
|
184
|
+
Option.match(right, {
|
|
185
|
+
onNone: () => false,
|
|
186
|
+
onSome: (rightValue) => Object.is(leftValue, rightValue),
|
|
187
|
+
}),
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
const samePolicy = (left: ChannelPolicy, right: ChannelPolicy): boolean =>
|
|
191
|
+
Match.value(left).pipe(
|
|
192
|
+
Match.tag('merge', () => right._tag === 'merge'),
|
|
193
|
+
Match.tag('latest', () => right._tag === 'latest'),
|
|
194
|
+
Match.tag(
|
|
195
|
+
'debounce',
|
|
196
|
+
(policy) =>
|
|
197
|
+
right._tag === 'debounce' &&
|
|
198
|
+
durationMillis(policy.duration) === durationMillis(right.duration),
|
|
199
|
+
),
|
|
200
|
+
Match.tag(
|
|
201
|
+
'throttle',
|
|
202
|
+
(policy) =>
|
|
203
|
+
right._tag === 'throttle' &&
|
|
204
|
+
policy.units === right.units &&
|
|
205
|
+
durationMillis(policy.duration) === durationMillis(right.duration) &&
|
|
206
|
+
sameOptionValue(policy.cost, right.cost) &&
|
|
207
|
+
sameOptionValue(policy.burst, right.burst) &&
|
|
208
|
+
sameOptionValue(policy.strategy, right.strategy),
|
|
209
|
+
),
|
|
210
|
+
Match.tag('exclusive', () => right._tag === 'exclusive'),
|
|
211
|
+
Match.exhaustive,
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
const release = Effect.fn('release')(function* (
|
|
215
|
+
byName: Map<string, ChannelRuntime>,
|
|
216
|
+
registration: ChannelRuntime,
|
|
217
|
+
): Effect.fn.Return<void> {
|
|
218
|
+
registration.leases.count -= 1
|
|
219
|
+
if (registration.leases.count === 0) {
|
|
220
|
+
if (byName.get(registration.name) === registration) {
|
|
221
|
+
byName.delete(registration.name)
|
|
222
|
+
}
|
|
223
|
+
yield* registration.close
|
|
224
|
+
}
|
|
225
|
+
})
|
|
226
|
+
|
|
200
227
|
export const live = (channel: ChannelClass): Layer.Layer<never, never, Channels | Procedures> =>
|
|
201
228
|
Layer.scopedDiscard(
|
|
202
229
|
Effect.gen(function* () {
|
|
@@ -204,28 +231,20 @@ export const live = (channel: ChannelClass): Layer.Layer<never, never, Channels
|
|
|
204
231
|
const channels = yield* Channels
|
|
205
232
|
const existing = channels.byName.get(name)
|
|
206
233
|
if (existing !== undefined) {
|
|
207
|
-
|
|
208
|
-
// (shared `exclusive`/`latest` lane). A *different* channel reusing the
|
|
209
|
-
// name is a silent footgun — the second policy would be dropped — so
|
|
210
|
-
// reject it loudly instead.
|
|
211
|
-
if (existing.policy !== channel.policy) {
|
|
212
|
-
// A different channel reusing a live name is a programmer footgun, not a
|
|
213
|
-
// recoverable failure — surface it as a defect to keep the layer E = never.
|
|
234
|
+
if (!samePolicy(existing.policy, channel.policy)) {
|
|
214
235
|
yield* Effect.dieMessage(
|
|
215
|
-
`reform: channel '${name}' is already live with a different policy`,
|
|
236
|
+
`reform: channel '${name}' is already live with a different policy (existing: ${existing.policy._tag}, incoming: ${channel.policy._tag})`,
|
|
216
237
|
)
|
|
217
238
|
}
|
|
239
|
+
existing.leases.count += 1
|
|
240
|
+
yield* Effect.addFinalizer(() => release(channels.byName, existing))
|
|
218
241
|
return
|
|
219
242
|
}
|
|
220
243
|
const procedures = yield* Procedures
|
|
221
244
|
const instrumentation = yield* resolveInstrumentation
|
|
245
|
+
const machineryScope = yield* Scope.make()
|
|
222
246
|
const queue = yield* Queue.unbounded<Tagged>()
|
|
223
247
|
|
|
224
|
-
// Run every procedure on THIS channel that handles the event. Read the live
|
|
225
|
-
// index each time so procedures registered later are still seen.
|
|
226
|
-
// A procedure is expected to model expected failures as events; a leaked
|
|
227
|
-
// defect (a bug in the body) is logged before being isolated, so one
|
|
228
|
-
// procedure's crash never tears down the channel fiber — but is observable.
|
|
229
248
|
const runMatching = (event: Tagged): Effect.Effect<void> => {
|
|
230
249
|
const matching = Option.fromNullable(procedures.byChannelTag.get(name)).pipe(
|
|
231
250
|
Option.flatMapNullable((byTag) => byTag.get(event._tag)),
|
|
@@ -234,8 +253,6 @@ export const live = (channel: ChannelClass): Layer.Layer<never, never, Channels
|
|
|
234
253
|
return Effect.forEach(
|
|
235
254
|
matching,
|
|
236
255
|
(procedure) =>
|
|
237
|
-
// The `procedureRun` span covers the whole body, success/failure/cancel
|
|
238
|
-
// alike (`ensuring` fires on all three).
|
|
239
256
|
Effect.sync(() => instrumentation.procedureRun(procedure.name, name, event._tag)).pipe(
|
|
240
257
|
Effect.flatMap((endSpan) =>
|
|
241
258
|
procedure.run(event).pipe(Effect.ensuring(Effect.sync(endSpan))),
|
|
@@ -249,13 +266,15 @@ export const live = (channel: ChannelClass): Layer.Layer<never, never, Channels
|
|
|
249
266
|
)
|
|
250
267
|
}
|
|
251
268
|
|
|
252
|
-
// Map the policy to its scheduling stream. `Match.exhaustive` makes a new
|
|
253
|
-
// policy variant a compile error, and each arm is a self-contained const.
|
|
254
269
|
const events = Stream.fromQueue(queue)
|
|
255
270
|
const driven: Stream.Stream<unknown> = Match.value(channel.policy).pipe(
|
|
256
|
-
Match.tag('merge', () =>
|
|
271
|
+
Match.tag('merge', () =>
|
|
272
|
+
Stream.mapEffect(events, runMatching, { concurrency: 'unbounded' }),
|
|
273
|
+
),
|
|
257
274
|
Match.tag('latest', () =>
|
|
258
|
-
Stream.flatMap(events, (event) => Stream.fromEffect(runMatching(event)), {
|
|
275
|
+
Stream.flatMap(events, (event) => Stream.fromEffect(runMatching(event)), {
|
|
276
|
+
switch: true,
|
|
277
|
+
}),
|
|
259
278
|
),
|
|
260
279
|
Match.tag('debounce', (policy) =>
|
|
261
280
|
events.pipe(
|
|
@@ -278,25 +297,21 @@ export const live = (channel: ChannelClass): Layer.Layer<never, never, Channels
|
|
|
278
297
|
Stream.mapEffect(runMatching, { concurrency: 'unbounded' }),
|
|
279
298
|
),
|
|
280
299
|
),
|
|
281
|
-
// `concurrency: 1` runs one event at a time AND in offer order — a true
|
|
282
|
-
// serialized lane. (An unbounded fork racing for a single semaphore permit
|
|
283
|
-
// would serialize but could reorder under contention.)
|
|
284
300
|
Match.tag('exclusive', () => Stream.mapEffect(events, runMatching, { concurrency: 1 })),
|
|
285
301
|
Match.exhaustive,
|
|
286
302
|
)
|
|
287
303
|
|
|
288
|
-
yield*
|
|
289
|
-
|
|
304
|
+
yield* Stream.runDrain(driven).pipe(Effect.forkIn(machineryScope))
|
|
305
|
+
const registration: ChannelRuntime = {
|
|
290
306
|
name,
|
|
291
307
|
policy: channel.policy,
|
|
292
308
|
offer: (event) => {
|
|
293
309
|
Queue.unsafeOffer(queue, event)
|
|
294
310
|
},
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
yield* Effect.addFinalizer(() => Effect.sync(() => channels.byName.delete(name)))
|
|
311
|
+
leases: { count: 1 },
|
|
312
|
+
close: Scope.close(machineryScope, Exit.void),
|
|
313
|
+
}
|
|
314
|
+
channels.byName.set(name, registration)
|
|
315
|
+
yield* Effect.addFinalizer(() => release(channels.byName, registration))
|
|
301
316
|
}),
|
|
302
317
|
)
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { Option, Schema } from 'effect'
|
|
2
|
+
import { expect, it } from '@effect/vitest'
|
|
3
|
+
import { make } from './composition'
|
|
4
|
+
import { ui } from './ui'
|
|
5
|
+
|
|
6
|
+
class ItemUi extends ui('composition.parser.ItemUi')<{
|
|
7
|
+
props: { readonly label: string }
|
|
8
|
+
}>() {}
|
|
9
|
+
|
|
10
|
+
class Item extends make('composition.parser.Item', {
|
|
11
|
+
title: 'Item',
|
|
12
|
+
props: Schema.Struct({ label: Schema.String }),
|
|
13
|
+
ui: ItemUi,
|
|
14
|
+
})<Item>() {}
|
|
15
|
+
|
|
16
|
+
it('validates reflected props against the composition schema', () => {
|
|
17
|
+
expect(Item.parseProps({ label: 'ready' })).toEqual(Option.some({ label: 'ready' }))
|
|
18
|
+
expect(Option.isNone(Item.parseProps({ label: 1 }))).toBe(true)
|
|
19
|
+
})
|