@playfast/reform 1.0.1 → 1.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +18 -1
- package/package.json +1 -1
- package/src/boundary/boundary.test.ts +1 -33
- package/src/boundary/boundary.ts +4 -82
- package/src/calc/asyncCalc.invalidate.test.ts +2 -14
- package/src/calc/asyncCalc.test.ts +0 -38
- package/src/calc/asyncCalc.ts +11 -163
- package/src/calc/asyncCalcDefinitions.ts +48 -0
- package/src/calc/asyncData.ts +0 -37
- package/src/calc/calc.test.ts +4 -28
- package/src/calc/calc.ts +6 -46
- package/src/calc/calcFamily.test.ts +0 -12
- package/src/calc/calcFamily.ts +6 -48
- package/src/calc/compose.test.ts +1 -12
- package/src/calc/compose.ts +0 -22
- package/src/calc/queryState.ts +0 -23
- package/src/channel/channel.ts +72 -174
- package/src/channel/procedureRegistry.ts +90 -0
- package/src/compose/composition.ts +7 -67
- package/src/compose/host.ts +0 -6
- package/src/compose/props.ts +3 -9
- package/src/compose/provide.ts +19 -33
- package/src/compose/slot.ts +2 -30
- package/src/compose/structure.test.ts +0 -6
- package/src/compose/structure.ts +3 -67
- package/src/compose/ui.test.ts +0 -4
- package/src/compose/ui.ts +17 -111
- package/src/compose/ui.typecheck.ts +0 -14
- package/src/definition/definition.ts +0 -32
- package/src/event/event.test.ts +0 -3
- package/src/event/event.ts +5 -14
- package/src/event/eventGroup.ts +0 -1
- package/src/feature/feature.mount.test.ts +66 -54
- package/src/feature/feature.test.ts +67 -46
- package/src/feature/feature.ts +185 -214
- package/src/feature/feature.typecheck.ts +29 -16
- package/src/feature/featureBinding.ts +48 -0
- package/src/index.ts +245 -229
- package/src/internal/capture.ts +0 -20
- package/src/internal/ctx.ts +2 -9
- package/src/internal/env.ts +9 -0
- package/src/internal/errors.test.ts +0 -6
- package/src/internal/errors.ts +14 -48
- package/src/internal/inspect.test.ts +0 -6
- package/src/internal/inspect.ts +0 -12
- package/src/internal/queryDriver.ts +42 -243
- package/src/internal/queryDriverStore.ts +153 -0
- package/src/internal/queryDriverTypes.ts +57 -0
- package/src/internal/queryEvents.ts +0 -12
- package/src/internal/queryStore.ts +0 -14
- package/src/internal/reuse.test.ts +0 -11
- package/src/internal/reuse.ts +0 -28
- package/src/internal/scheduler.ts +0 -43
- package/src/internal/seeds.ts +0 -14
- package/src/internal/sources.ts +4 -41
- 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 +0 -18
- package/src/internal/variance.ts +5 -0
- package/src/namespace/namespace.test.ts +46 -0
- package/src/namespace/namespace.ts +85 -0
- package/src/procedure/procedure.ts +2 -24
- package/src/reducer/reducer.ts +3 -21
- package/src/remote/remoteState.test.ts +92 -75
- package/src/remote/remoteState.ts +99 -486
- package/src/remote/remoteState.typecheck.ts +0 -33
- package/src/remote/remoteStateDefinition.ts +135 -0
- package/src/remote/remoteStateLayers.ts +118 -0
- package/src/remote/remoteStateLiveTypes.ts +59 -0
- package/src/remote/remoteStateSend.ts +64 -0
- package/src/runtime/appRuntime.test.ts +197 -0
- package/src/runtime/appRuntime.ts +40 -83
- package/src/runtime/bus.ts +2 -15
- package/src/runtime/eventBudget.test.ts +155 -0
- package/src/runtime/eventBudget.ts +119 -0
- package/src/runtime/hardening.test.ts +74 -9
- package/src/runtime/instrumentation.test.ts +213 -192
- package/src/runtime/instrumentation.ts +0 -49
- package/src/runtime/loop.test.ts +0 -17
- package/src/runtime/loop.ts +25 -55
- package/src/runtime/queries.ts +0 -17
- package/src/scene/featureScene.test.ts +59 -0
- package/src/scene/scene.ts +32 -59
- package/src/scene/seedScene.test.ts +96 -116
- package/src/state/state.ts +7 -26
- package/src/state/stateFamily.test.ts +2 -11
- package/src/state/stateFamily.ts +9 -52
- package/src/state/stateGroup.ts +2 -49
- package/src/state/token.ts +4 -17
- package/src/synced/syncedStore.ts +7 -42
- package/src/ui/node.ts +0 -6
- package/src/ui/trigger.ts +0 -5
- package/src/wire/tree.test.ts +0 -3
- package/src/wire/tree.ts +0 -39
- package/src/wire/triggers.test.ts +0 -4
- package/src/wire/triggers.ts +5 -27
package/src/channel/channel.ts
CHANGED
|
@@ -1,19 +1,15 @@
|
|
|
1
|
-
import { Chunk, Context,
|
|
1
|
+
import { Chunk, Context, Duration, Effect, Exit, Layer, Match, Option, Queue, Scope, Stream } from 'effect'
|
|
2
2
|
import { type Manifest, definitionClass } from '../definition/definition'
|
|
3
3
|
import type { Tagged } from '../runtime/bus'
|
|
4
4
|
import { resolveInstrumentation } from '../runtime/instrumentation'
|
|
5
|
+
import { type ProcedureEntry, Procedures } from './procedureRegistry'
|
|
6
|
+
export {
|
|
7
|
+
type ProcedureEntry,
|
|
8
|
+
type ProcedureRegistry,
|
|
9
|
+
Procedures,
|
|
10
|
+
proceduresLayer,
|
|
11
|
+
} from './procedureRegistry'
|
|
5
12
|
|
|
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
13
|
export type ChannelPolicy =
|
|
18
14
|
| { readonly _tag: 'merge' }
|
|
19
15
|
| { readonly _tag: 'latest' }
|
|
@@ -28,7 +24,6 @@ export type ChannelPolicy =
|
|
|
28
24
|
}
|
|
29
25
|
| { readonly _tag: 'exclusive' }
|
|
30
26
|
|
|
31
|
-
/** The reflectable descriptor a channel carries. */
|
|
32
27
|
export interface ChannelManifest extends Manifest {
|
|
33
28
|
readonly kind: 'Channel'
|
|
34
29
|
}
|
|
@@ -43,134 +38,14 @@ export interface ChannelConfig {
|
|
|
43
38
|
readonly policy: ChannelPolicy
|
|
44
39
|
}
|
|
45
40
|
|
|
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
|
-
export interface ProcedureEntry {
|
|
52
|
-
/** The procedure's name — used only to warn on duplicate registration. */
|
|
53
|
-
readonly name: string
|
|
54
|
-
readonly channelName: string
|
|
55
|
-
readonly handles: ReadonlySet<string>
|
|
56
|
-
readonly run: (event: Tagged) => Effect.Effect<unknown, unknown, never>
|
|
57
|
-
}
|
|
58
|
-
|
|
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
|
-
export interface ProcedureRegistry {
|
|
65
|
-
readonly entries: Array<ProcedureEntry>
|
|
66
|
-
readonly byTag: Map<string, Array<ProcedureEntry>>
|
|
67
|
-
/**
|
|
68
|
-
* Distinct channel names per event tag. The drain loop routes through this so
|
|
69
|
-
* it offers each event to each channel exactly once — not once per matching
|
|
70
|
-
* procedure, which would re-run every procedure on a shared channel per offer.
|
|
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>>>
|
|
78
|
-
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
|
-
readonly unregister: (entry: ProcedureEntry) => void
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
const ProceduresBase: Context.TagClass<Procedures, 'reform/Procedures', ProcedureRegistry> =
|
|
88
|
-
Context.Tag('reform/Procedures')<Procedures, ProcedureRegistry>()
|
|
89
|
-
export class Procedures extends ProceduresBase {}
|
|
90
|
-
|
|
91
|
-
/** Get a map entry, creating and inserting it on first access (avoids `let`). */
|
|
92
|
-
const getOrCreate = <K, V>(map: Map<K, V>, key: K, make: () => V): V => {
|
|
93
|
-
const existing = map.get(key)
|
|
94
|
-
if (existing !== undefined) {
|
|
95
|
-
return existing
|
|
96
|
-
}
|
|
97
|
-
const created = make()
|
|
98
|
-
map.set(key, created)
|
|
99
|
-
return created
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
/** Drop an item from a `Map<K, Array>` bucket, pruning the key when it empties. */
|
|
103
|
-
const dropFrom = <K, T>(map: Map<K, Array<T>>, key: K, target: T): void => {
|
|
104
|
-
const bucket = map.get(key)
|
|
105
|
-
if (bucket === undefined) {
|
|
106
|
-
return
|
|
107
|
-
}
|
|
108
|
-
const next = bucket.filter((candidate) => candidate !== target)
|
|
109
|
-
if (next.length === 0) {
|
|
110
|
-
map.delete(key)
|
|
111
|
-
} else {
|
|
112
|
-
map.set(key, next)
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
const makeProcedureRegistry = (): ProcedureRegistry => {
|
|
117
|
-
// `entries` is reassigned immutably (new array per change) rather than mutated
|
|
118
|
-
// in place, so the holder keeps a stable reference the getter reads through.
|
|
119
|
-
const state: { entries: Array<ProcedureEntry> } = { entries: [] }
|
|
120
|
-
const byTag = new Map<string, Array<ProcedureEntry>>()
|
|
121
|
-
const channelsByTag = new Map<string, Array<string>>()
|
|
122
|
-
const byChannelTag = new Map<string, Map<string, Array<ProcedureEntry>>>()
|
|
123
|
-
return {
|
|
124
|
-
get entries() {
|
|
125
|
-
return state.entries
|
|
126
|
-
},
|
|
127
|
-
byTag,
|
|
128
|
-
channelsByTag,
|
|
129
|
-
byChannelTag,
|
|
130
|
-
register: (entry) => {
|
|
131
|
-
state.entries = [...state.entries, entry]
|
|
132
|
-
entry.handles.forEach((tag) => {
|
|
133
|
-
byTag.set(tag, [...getOrCreate(byTag, tag, () => []), entry])
|
|
134
|
-
const channels = getOrCreate(channelsByTag, tag, () => [])
|
|
135
|
-
if (!channels.includes(entry.channelName)) {
|
|
136
|
-
channelsByTag.set(tag, [...channels, entry.channelName])
|
|
137
|
-
}
|
|
138
|
-
const channelMap = getOrCreate(byChannelTag, entry.channelName, () => new Map())
|
|
139
|
-
channelMap.set(tag, [...getOrCreate(channelMap, tag, () => []), entry])
|
|
140
|
-
})
|
|
141
|
-
},
|
|
142
|
-
unregister: (entry) => {
|
|
143
|
-
state.entries = state.entries.filter((existing) => existing !== entry)
|
|
144
|
-
entry.handles.forEach((tag) => {
|
|
145
|
-
dropFrom(byTag, tag, entry)
|
|
146
|
-
const channelMap = byChannelTag.get(entry.channelName)
|
|
147
|
-
if (channelMap !== undefined) {
|
|
148
|
-
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
|
-
if (channelMap.get(tag) === undefined) {
|
|
152
|
-
dropFrom(channelsByTag, tag, entry.channelName)
|
|
153
|
-
}
|
|
154
|
-
if (channelMap.size === 0) {
|
|
155
|
-
byChannelTag.delete(entry.channelName)
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
})
|
|
159
|
-
},
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
export const proceduresLayer: Layer.Layer<Procedures> = Layer.sync(Procedures, makeProcedureRegistry)
|
|
164
|
-
|
|
165
|
-
/** A live channel: the single loop offers events here; the channel drives them. */
|
|
166
41
|
export interface ChannelRuntime {
|
|
167
42
|
readonly name: string
|
|
168
|
-
/** The policy this channel was built with — used to reject a name clash. */
|
|
169
43
|
readonly policy: ChannelPolicy
|
|
170
44
|
readonly offer: (event: Tagged) => void
|
|
45
|
+
readonly leases: { count: number }
|
|
46
|
+
readonly close: Effect.Effect<void>
|
|
171
47
|
}
|
|
172
48
|
|
|
173
|
-
/** Registry of live channels, keyed by name. The single loop routes through it. */
|
|
174
49
|
const ChannelsBase: Context.TagClass<
|
|
175
50
|
Channels,
|
|
176
51
|
'reform/Channels',
|
|
@@ -180,23 +55,62 @@ export class Channels extends ChannelsBase {}
|
|
|
180
55
|
|
|
181
56
|
export const channelsLayer: Layer.Layer<Channels> = Layer.sync(Channels, () => ({ byName: new Map() }))
|
|
182
57
|
|
|
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
58
|
export const make = (name: string, config: ChannelConfig): ChannelClass =>
|
|
190
59
|
definitionClass<ChannelClass>({
|
|
191
60
|
manifest: { kind: 'Channel' as const, name } satisfies ChannelManifest,
|
|
192
61
|
policy: config.policy,
|
|
193
62
|
})
|
|
194
63
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
64
|
+
const durationMillis = (duration: Duration.DurationInput): number =>
|
|
65
|
+
Duration.toMillis(Duration.decode(duration))
|
|
66
|
+
|
|
67
|
+
const sameOptionValue = <A>(left: Option.Option<A>, right: Option.Option<A>): boolean =>
|
|
68
|
+
Option.match(left, {
|
|
69
|
+
onNone: () => Option.isNone(right),
|
|
70
|
+
onSome: (leftValue) =>
|
|
71
|
+
Option.match(right, {
|
|
72
|
+
onNone: () => false,
|
|
73
|
+
onSome: (rightValue) => Object.is(leftValue, rightValue),
|
|
74
|
+
}),
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
const samePolicy = (left: ChannelPolicy, right: ChannelPolicy): boolean =>
|
|
78
|
+
Match.value(left).pipe(
|
|
79
|
+
Match.tag('merge', () => right._tag === 'merge'),
|
|
80
|
+
Match.tag('latest', () => right._tag === 'latest'),
|
|
81
|
+
Match.tag(
|
|
82
|
+
'debounce',
|
|
83
|
+
(policy) =>
|
|
84
|
+
right._tag === 'debounce' &&
|
|
85
|
+
durationMillis(policy.duration) === durationMillis(right.duration),
|
|
86
|
+
),
|
|
87
|
+
Match.tag(
|
|
88
|
+
'throttle',
|
|
89
|
+
(policy) =>
|
|
90
|
+
right._tag === 'throttle' &&
|
|
91
|
+
policy.units === right.units &&
|
|
92
|
+
durationMillis(policy.duration) === durationMillis(right.duration) &&
|
|
93
|
+
sameOptionValue(policy.cost, right.cost) &&
|
|
94
|
+
sameOptionValue(policy.burst, right.burst) &&
|
|
95
|
+
sameOptionValue(policy.strategy, right.strategy),
|
|
96
|
+
),
|
|
97
|
+
Match.tag('exclusive', () => right._tag === 'exclusive'),
|
|
98
|
+
Match.exhaustive,
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
const release = Effect.fn('release')(function* (
|
|
102
|
+
byName: Map<string, ChannelRuntime>,
|
|
103
|
+
registration: ChannelRuntime,
|
|
104
|
+
): Effect.fn.Return<void> {
|
|
105
|
+
registration.leases.count -= 1
|
|
106
|
+
if (registration.leases.count === 0) {
|
|
107
|
+
if (byName.get(registration.name) === registration) {
|
|
108
|
+
byName.delete(registration.name)
|
|
109
|
+
}
|
|
110
|
+
yield* registration.close
|
|
111
|
+
}
|
|
112
|
+
})
|
|
113
|
+
|
|
200
114
|
export const live = (channel: ChannelClass): Layer.Layer<never, never, Channels | Procedures> =>
|
|
201
115
|
Layer.scopedDiscard(
|
|
202
116
|
Effect.gen(function* () {
|
|
@@ -204,28 +118,20 @@ export const live = (channel: ChannelClass): Layer.Layer<never, never, Channels
|
|
|
204
118
|
const channels = yield* Channels
|
|
205
119
|
const existing = channels.byName.get(name)
|
|
206
120
|
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.
|
|
121
|
+
if (!samePolicy(existing.policy, channel.policy)) {
|
|
214
122
|
yield* Effect.dieMessage(
|
|
215
|
-
`reform: channel '${name}' is already live with a different policy`,
|
|
123
|
+
`reform: channel '${name}' is already live with a different policy (existing: ${existing.policy._tag}, incoming: ${channel.policy._tag})`,
|
|
216
124
|
)
|
|
217
125
|
}
|
|
126
|
+
existing.leases.count += 1
|
|
127
|
+
yield* Effect.addFinalizer(() => release(channels.byName, existing))
|
|
218
128
|
return
|
|
219
129
|
}
|
|
220
130
|
const procedures = yield* Procedures
|
|
221
131
|
const instrumentation = yield* resolveInstrumentation
|
|
132
|
+
const machineryScope = yield* Scope.make()
|
|
222
133
|
const queue = yield* Queue.unbounded<Tagged>()
|
|
223
134
|
|
|
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
135
|
const runMatching = (event: Tagged): Effect.Effect<void> => {
|
|
230
136
|
const matching = Option.fromNullable(procedures.byChannelTag.get(name)).pipe(
|
|
231
137
|
Option.flatMapNullable((byTag) => byTag.get(event._tag)),
|
|
@@ -234,8 +140,6 @@ export const live = (channel: ChannelClass): Layer.Layer<never, never, Channels
|
|
|
234
140
|
return Effect.forEach(
|
|
235
141
|
matching,
|
|
236
142
|
(procedure) =>
|
|
237
|
-
// The `procedureRun` span covers the whole body, success/failure/cancel
|
|
238
|
-
// alike (`ensuring` fires on all three).
|
|
239
143
|
Effect.sync(() => instrumentation.procedureRun(procedure.name, name, event._tag)).pipe(
|
|
240
144
|
Effect.flatMap((endSpan) =>
|
|
241
145
|
procedure.run(event).pipe(Effect.ensuring(Effect.sync(endSpan))),
|
|
@@ -249,8 +153,6 @@ export const live = (channel: ChannelClass): Layer.Layer<never, never, Channels
|
|
|
249
153
|
)
|
|
250
154
|
}
|
|
251
155
|
|
|
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
156
|
const events = Stream.fromQueue(queue)
|
|
255
157
|
const driven: Stream.Stream<unknown> = Match.value(channel.policy).pipe(
|
|
256
158
|
Match.tag('merge', () => Stream.mapEffect(events, runMatching, { concurrency: 'unbounded' })),
|
|
@@ -278,25 +180,21 @@ export const live = (channel: ChannelClass): Layer.Layer<never, never, Channels
|
|
|
278
180
|
Stream.mapEffect(runMatching, { concurrency: 'unbounded' }),
|
|
279
181
|
),
|
|
280
182
|
),
|
|
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
183
|
Match.tag('exclusive', () => Stream.mapEffect(events, runMatching, { concurrency: 1 })),
|
|
285
184
|
Match.exhaustive,
|
|
286
185
|
)
|
|
287
186
|
|
|
288
|
-
yield*
|
|
289
|
-
|
|
187
|
+
yield* Stream.runDrain(driven).pipe(Effect.forkIn(machineryScope))
|
|
188
|
+
const registration: ChannelRuntime = {
|
|
290
189
|
name,
|
|
291
190
|
policy: channel.policy,
|
|
292
191
|
offer: (event) => {
|
|
293
192
|
Queue.unsafeOffer(queue, event)
|
|
294
193
|
},
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
yield* Effect.addFinalizer(() => Effect.sync(() => channels.byName.delete(name)))
|
|
194
|
+
leases: { count: 1 },
|
|
195
|
+
close: Scope.close(machineryScope, Exit.void),
|
|
196
|
+
}
|
|
197
|
+
channels.byName.set(name, registration)
|
|
198
|
+
yield* Effect.addFinalizer(() => release(channels.byName, registration))
|
|
301
199
|
}),
|
|
302
200
|
)
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { Context, Effect, Layer } from 'effect'
|
|
2
|
+
import type { Tagged } from '../runtime/bus'
|
|
3
|
+
|
|
4
|
+
export interface ProcedureEntry {
|
|
5
|
+
readonly name: string
|
|
6
|
+
readonly channelName: string
|
|
7
|
+
readonly handles: ReadonlySet<string>
|
|
8
|
+
readonly run: (event: Tagged) => Effect.Effect<unknown, unknown, never>
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface ProcedureRegistry {
|
|
12
|
+
readonly entries: Array<ProcedureEntry>
|
|
13
|
+
readonly byTag: Map<string, Array<ProcedureEntry>>
|
|
14
|
+
readonly channelsByTag: Map<string, Array<string>>
|
|
15
|
+
readonly byChannelTag: Map<string, Map<string, Array<ProcedureEntry>>>
|
|
16
|
+
readonly register: (entry: ProcedureEntry) => void
|
|
17
|
+
readonly unregister: (entry: ProcedureEntry) => void
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const ProceduresBase: Context.TagClass<Procedures, 'reform/Procedures', ProcedureRegistry> =
|
|
21
|
+
Context.Tag('reform/Procedures')<Procedures, ProcedureRegistry>()
|
|
22
|
+
export class Procedures extends ProceduresBase {}
|
|
23
|
+
|
|
24
|
+
const getOrCreate = <K, V>(map: Map<K, V>, key: K, make: () => V): V => {
|
|
25
|
+
const existing = map.get(key)
|
|
26
|
+
if (existing !== undefined) {
|
|
27
|
+
return existing
|
|
28
|
+
}
|
|
29
|
+
const created = make()
|
|
30
|
+
map.set(key, created)
|
|
31
|
+
return created
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const dropFrom = <K, T>(map: Map<K, Array<T>>, key: K, target: T): void => {
|
|
35
|
+
const bucket = map.get(key)
|
|
36
|
+
if (bucket === undefined) {
|
|
37
|
+
return
|
|
38
|
+
}
|
|
39
|
+
const next = bucket.filter((candidate) => candidate !== target)
|
|
40
|
+
if (next.length === 0) {
|
|
41
|
+
map.delete(key)
|
|
42
|
+
} else {
|
|
43
|
+
map.set(key, next)
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const makeProcedureRegistry = (): ProcedureRegistry => {
|
|
48
|
+
const state: { entries: Array<ProcedureEntry> } = { entries: [] }
|
|
49
|
+
const byTag = new Map<string, Array<ProcedureEntry>>()
|
|
50
|
+
const channelsByTag = new Map<string, Array<string>>()
|
|
51
|
+
const byChannelTag = new Map<string, Map<string, Array<ProcedureEntry>>>()
|
|
52
|
+
return {
|
|
53
|
+
get entries() {
|
|
54
|
+
return state.entries
|
|
55
|
+
},
|
|
56
|
+
byTag,
|
|
57
|
+
channelsByTag,
|
|
58
|
+
byChannelTag,
|
|
59
|
+
register: (entry) => {
|
|
60
|
+
state.entries = [...state.entries, entry]
|
|
61
|
+
entry.handles.forEach((tag) => {
|
|
62
|
+
byTag.set(tag, [...getOrCreate(byTag, tag, () => []), entry])
|
|
63
|
+
const channels = getOrCreate(channelsByTag, tag, () => [])
|
|
64
|
+
if (!channels.includes(entry.channelName)) {
|
|
65
|
+
channelsByTag.set(tag, [...channels, entry.channelName])
|
|
66
|
+
}
|
|
67
|
+
const channelMap = getOrCreate(byChannelTag, entry.channelName, () => new Map())
|
|
68
|
+
channelMap.set(tag, [...getOrCreate(channelMap, tag, () => []), entry])
|
|
69
|
+
})
|
|
70
|
+
},
|
|
71
|
+
unregister: (entry) => {
|
|
72
|
+
state.entries = state.entries.filter((existing) => existing !== entry)
|
|
73
|
+
entry.handles.forEach((tag) => {
|
|
74
|
+
dropFrom(byTag, tag, entry)
|
|
75
|
+
const channelMap = byChannelTag.get(entry.channelName)
|
|
76
|
+
if (channelMap !== undefined) {
|
|
77
|
+
dropFrom(channelMap, tag, entry)
|
|
78
|
+
if (channelMap.get(tag) === undefined) {
|
|
79
|
+
dropFrom(channelsByTag, tag, entry.channelName)
|
|
80
|
+
}
|
|
81
|
+
if (channelMap.size === 0) {
|
|
82
|
+
byChannelTag.delete(entry.channelName)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
})
|
|
86
|
+
},
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export const proceduresLayer: Layer.Layer<Procedures> = Layer.sync(Procedures, makeProcedureRegistry)
|
|
@@ -8,41 +8,27 @@ import type { SlotClass } from './slot'
|
|
|
8
8
|
import type { Structure } from './structure'
|
|
9
9
|
import type { UiClass, UiContract } from './ui'
|
|
10
10
|
import { Props } from './props'
|
|
11
|
+
import type { AnyValue } from '../internal/variance'
|
|
11
12
|
|
|
12
|
-
/**
|
|
13
|
-
* What a composition's logic returns for one frame: a serializable {@link Structure}
|
|
14
|
-
* value — its computed props, per-slot fills, and event triggers, all as DATA. A body
|
|
15
|
-
* never returns (nor evaluates) a React node, so the engine, the wire server, and the
|
|
16
|
-
* proof harness consume the frame without React; presentation resolves the contract's
|
|
17
|
-
* view separately, by name, on a host.
|
|
18
|
-
*/
|
|
19
13
|
export type Frame<C extends UiContract = UiContract> = Structure<C>
|
|
20
14
|
|
|
21
|
-
/** Everything the host injects per render: the instance props and the dependency tracker. */
|
|
22
15
|
export interface RenderEnv {
|
|
23
16
|
readonly props: unknown
|
|
24
17
|
readonly tracker: Tracker
|
|
25
18
|
}
|
|
26
19
|
|
|
27
|
-
/** A mounted composition: given a render environment, produces the current frame —
|
|
28
|
-
* a {@link Structure} value (see `Frame`). */
|
|
29
20
|
export interface CompositionService {
|
|
30
21
|
readonly render: (env: RenderEnv) => Effect.Effect<Frame, never, never>
|
|
31
22
|
}
|
|
32
23
|
|
|
33
|
-
//
|
|
34
|
-
// optional members drive `Config extends { props: … }` / `{ states: … }` inference
|
|
35
|
-
// (PropsOf/StatesOf) plus the ergonomic `Composition.make({ title, ui })` form, so
|
|
36
|
-
// they cannot become `Option<T>` (would break inference and every call site) or be
|
|
37
|
-
// renamed `ExternalApi` (would break external importers of `CompositionConfig`).
|
|
24
|
+
// Optional fields drive Config extends { props/states: … } inference — cannot be Option
|
|
38
25
|
export interface CompositionConfig {
|
|
39
26
|
readonly title: string
|
|
40
27
|
// oxlint-disable-next-line reform-rules/no-optional-fields -- optional config field on a reflectable manifest carrier (see note above)
|
|
41
28
|
readonly description?: string
|
|
42
|
-
//
|
|
43
|
-
// branded/Class schemas (e.g. `Todo`); only the decoded type `P` is used.
|
|
29
|
+
// Schema variance needs any in encoded/context; only decoded P is used
|
|
44
30
|
// oxlint-disable-next-line reform-rules/no-optional-fields -- drives `Config extends { props: … }` inference (PropsOf)
|
|
45
|
-
readonly props?: Schema.Schema
|
|
31
|
+
readonly props?: Schema.Schema.AnyNoContext
|
|
46
32
|
// oxlint-disable-next-line reform-rules/no-optional-fields -- drives `Config extends { states: … }` inference (StatesOf)
|
|
47
33
|
readonly states?: ReadonlyArray<unknown>
|
|
48
34
|
// oxlint-disable-next-line reform-rules/no-optional-fields -- optional config field on a reflectable manifest carrier (see note above)
|
|
@@ -51,19 +37,12 @@ export interface CompositionConfig {
|
|
|
51
37
|
readonly events?: ReadonlyArray<unknown>
|
|
52
38
|
// oxlint-disable-next-line reform-rules/no-optional-fields -- optional config field on a reflectable manifest carrier (see note above)
|
|
53
39
|
readonly slots?: Record<string, SlotClass>
|
|
54
|
-
//
|
|
55
|
-
// the full `UiClass`, whose invariant `impl` tag would force the contract type
|
|
56
|
-
// to leak here) so reflection — the dev tool, proofs — can read `ui.manifest`.
|
|
40
|
+
// Manifest carrier only — full UiClass's invariant impl tag would leak C
|
|
57
41
|
readonly ui: { readonly manifest: Manifest & { readonly kind: 'Ui' } }
|
|
58
42
|
}
|
|
59
43
|
|
|
60
|
-
type PropsOf<Config> = Config extends { props: Schema.Schema<infer P,
|
|
44
|
+
type PropsOf<Config> = Config extends { props: Schema.Schema<infer P, AnyValue> } ? P : unknown
|
|
61
45
|
|
|
62
|
-
/**
|
|
63
|
-
* The composition's state-group tuple, recovered from the `const`-inferred config
|
|
64
|
-
* so consumers can type its seeds. Erased to `readonly []` when no `states` are
|
|
65
|
-
* declared; entries that are not state groups contribute no seed keys (`SeedsOf`).
|
|
66
|
-
*/
|
|
67
46
|
type StatesOf<Config> = Config extends { states: infer S extends ReadonlyArray<unknown> }
|
|
68
47
|
? S
|
|
69
48
|
: readonly []
|
|
@@ -75,41 +54,19 @@ export interface CompositionClass<
|
|
|
75
54
|
> {
|
|
76
55
|
new (): {}
|
|
77
56
|
readonly manifest: Manifest & { readonly kind: 'Composition' } & CompositionConfig
|
|
78
|
-
/** Phantom carrying the props type for `provide(slot, composition)` checks. */
|
|
79
57
|
readonly Props: P
|
|
80
|
-
/**
|
|
81
|
-
* Phantom carrying the resolved UI contract (props + events + slots). It lets
|
|
82
|
-
* reflection consumers — proofs, the dev tool — recover the full contract from
|
|
83
|
-
* a composition value and stay fully typed. Covariant (output only); the
|
|
84
|
-
* invariant `impl` tag is never stored here, so no variance regression.
|
|
85
|
-
*/
|
|
86
58
|
readonly Contract: C
|
|
87
|
-
/**
|
|
88
|
-
* Phantom carrying the state-group tuple, so `seedScene` can type a scene's
|
|
89
|
-
* seeds against the actual state members. Same erasure trick as `Contract`.
|
|
90
|
-
*/
|
|
91
59
|
readonly States: S
|
|
92
60
|
readonly tag: Context.Tag<CompositionService, CompositionService>
|
|
93
61
|
}
|
|
94
62
|
|
|
95
|
-
/**
|
|
96
|
-
* A component's logic. The *definition* (`Composition.make`) is a reflectable
|
|
97
|
-
* manifest that doubles as a requirement/tag; the *implementation*
|
|
98
|
-
* (`Composition.live`) is the synchronous body, provided separately and
|
|
99
|
-
* checked against it.
|
|
100
|
-
*/
|
|
101
|
-
/** The contract-carrying overlay on a composition config — keeps `C` inferable at
|
|
102
|
-
* the `Composition.make` call site while the stored manifest sees only the erased
|
|
103
|
-
* `{ manifest }` carrier. */
|
|
104
63
|
interface UiCarrier<C extends UiContract> {
|
|
105
64
|
readonly ui: UiClass<C>
|
|
106
65
|
}
|
|
107
66
|
|
|
108
67
|
export const make = <const Config extends CompositionConfig, C extends UiContract>(
|
|
109
68
|
name: string,
|
|
110
|
-
// Constrain
|
|
111
|
-
// manifest still sees only the erased `{ manifest }` carrier (CompositionConfig),
|
|
112
|
-
// so the manifest type — and its variance — is unchanged.
|
|
69
|
+
// Constrain ui to full UiClass<C> for inference; stored manifest keeps erased carrier
|
|
113
70
|
config: Config & UiCarrier<C>,
|
|
114
71
|
): CompositionClass<PropsOf<Config>, C, StatesOf<Config>> => {
|
|
115
72
|
const tag = Context.GenericTag<CompositionService, CompositionService>(
|
|
@@ -119,19 +76,11 @@ export const make = <const Config extends CompositionConfig, C extends UiContrac
|
|
|
119
76
|
return definitionClass<CompositionClass<PropsOf<Config>, C, StatesOf<Config>>>({ manifest, tag })
|
|
120
77
|
}
|
|
121
78
|
|
|
122
|
-
/**
|
|
123
|
-
* Wire a composition's synchronous logic (D2): read state, acquire triggers,
|
|
124
|
-
* resolve the UI, return the view. Its requirements (minus `Props`, which the
|
|
125
|
-
* host injects per render) surface as the Layer's `RIn`; the runtime runs the
|
|
126
|
-
* body per render within the captured context.
|
|
127
|
-
*/
|
|
128
79
|
export const live = <
|
|
129
80
|
P,
|
|
130
81
|
C extends UiContract,
|
|
131
82
|
Eff extends YieldWrap<Effect.Effect<unknown, never, unknown>>,
|
|
132
83
|
>(
|
|
133
|
-
// The contract `C` is captured (not erased to `any`) so the body's returned
|
|
134
|
-
// `Structure` is checked against the composition's contract.
|
|
135
84
|
composition: CompositionClass<P, C>,
|
|
136
85
|
body: () => Generator<Eff, Frame<C>, never>,
|
|
137
86
|
): Layer.Layer<CompositionService, never, Exclude<Ctx<Eff>, Props>> => {
|
|
@@ -139,16 +88,8 @@ export const live = <
|
|
|
139
88
|
return Layer.effect(
|
|
140
89
|
composition.tag,
|
|
141
90
|
Effect.gen(function* () {
|
|
142
|
-
// Capture the build-time context (state stores, calc, ui, bus). `Props` and
|
|
143
|
-
// the tracker are injected per render below.
|
|
144
91
|
const context = yield* Effect.context<Exclude<Ctx<Eff>, Props>>()
|
|
145
92
|
const render = (env: RenderEnv): Effect.Effect<Frame, never, never> =>
|
|
146
|
-
// The per-render erasure seam: the body is total once fully provided (it is
|
|
147
|
-
// synchronous, reads can't fail, every requirement is now satisfied — D2) and
|
|
148
|
-
// its `Structure<C>` erases to the service's contract-agnostic `Frame`. `gen`
|
|
149
|
-
// widens the error channel to the body's inferred `E` for a generic body, so
|
|
150
|
-
// narrowing it (and the contract) back to the erased service type needs the
|
|
151
|
-
// `unknown` bridge — the one cast this render boundary has always required.
|
|
152
93
|
// oxlint-disable-next-line reform-rules/no-type-assertion -- the one documented render-boundary erasure: a fully-provided generic body's Structure<C> narrowed back to the contract-agnostic Frame service type
|
|
153
94
|
logic.pipe(
|
|
154
95
|
Effect.provideService(Props, env.props),
|
|
@@ -160,6 +101,5 @@ export const live = <
|
|
|
160
101
|
)
|
|
161
102
|
}
|
|
162
103
|
|
|
163
|
-
/** Run a mounted composition for one frame, producing its `Frame` (a `Structure`). */
|
|
164
104
|
export const render = (service: CompositionService, env: RenderEnv): Effect.Effect<Frame, never, never> =>
|
|
165
105
|
service.render(env)
|
package/src/compose/host.ts
CHANGED
|
@@ -1,11 +1,5 @@
|
|
|
1
1
|
import type { Node } from './slot'
|
|
2
2
|
|
|
3
|
-
/**
|
|
4
|
-
* A slot handle the host hands a presentation: BOTH renderable (call it / use as a
|
|
5
|
-
* component to place the keyed children) AND inspectable (`.props` — the array of
|
|
6
|
-
* per-child fill props the structure carried, one entry per mounted child). The
|
|
7
|
-
* structure host (`renderStructure`) populates `.props` from the fills. Plan 06.
|
|
8
|
-
*/
|
|
9
3
|
export interface SlotRenderer {
|
|
10
4
|
(props: unknown): Node
|
|
11
5
|
readonly props: ReadonlyArray<unknown>
|
package/src/compose/props.ts
CHANGED
|
@@ -1,14 +1,8 @@
|
|
|
1
1
|
import { Context } from 'effect'
|
|
2
|
+
import type { AnyValue } from '../internal/variance'
|
|
2
3
|
|
|
3
|
-
|
|
4
|
-
* The current composition instance's props. Bound per render by the runtime; a
|
|
5
|
-
* composition body reads them with `const { todo } = yield* Props`.
|
|
6
|
-
*
|
|
7
|
-
* (Per-instance typing of Props is DESIGN #26 — for now the value is opaque and
|
|
8
|
-
* narrowed at the use site against the composition's declared props schema.)
|
|
9
|
-
*/
|
|
10
|
-
const PropsBase: Context.TagClass<Props, 'reform/Props', any> = Context.Tag('reform/Props')<
|
|
4
|
+
const PropsBase: Context.TagClass<Props, 'reform/Props', AnyValue> = Context.Tag('reform/Props')<
|
|
11
5
|
Props,
|
|
12
|
-
|
|
6
|
+
AnyValue
|
|
13
7
|
>()
|
|
14
8
|
export class Props extends PropsBase {}
|