@playfast/reform 0.1.0 → 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 +16 -44
- package/src/calc/calcFamily.test.ts +0 -12
- package/src/calc/calcFamily.ts +12 -49
- 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 +78 -173
- 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 -202
- 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 +48 -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 +10 -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 +131 -0
- 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 +324 -0
- package/src/runtime/instrumentation.ts +44 -0
- package/src/runtime/loop.test.ts +0 -17
- package/src/runtime/loop.ts +41 -58
- package/src/runtime/queries.ts +0 -17
- package/src/scene/featureScene.test.ts +59 -0
- package/src/scene/scene.ts +51 -44
- package/src/scene/seedScene.test.ts +96 -116
- package/src/state/state.ts +12 -28
- package/src/state/stateFamily.test.ts +2 -11
- package/src/state/stateFamily.ts +20 -54
- 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,18 +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
|
+
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'
|
|
4
12
|
|
|
5
|
-
/**
|
|
6
|
-
* How a channel schedules the procedure work routed to it. Channels generalize
|
|
7
|
-
* the old per-procedure `concurrency`: a named lane with one policy, shared by
|
|
8
|
-
* every procedure that declares it.
|
|
9
|
-
*
|
|
10
|
-
* - `merge` run every routed event concurrently (the default).
|
|
11
|
-
* - `latest` a new event cancels the in-flight run (switch).
|
|
12
|
-
* - `debounce` collapse a burst, run only after `duration` of quiet.
|
|
13
|
-
* - `throttle` rate-limit to `units` per `duration` (shape or enforce).
|
|
14
|
-
* - `exclusive` one run at a time across the whole channel (mutual exclusion).
|
|
15
|
-
*/
|
|
16
13
|
export type ChannelPolicy =
|
|
17
14
|
| { readonly _tag: 'merge' }
|
|
18
15
|
| { readonly _tag: 'latest' }
|
|
@@ -27,7 +24,6 @@ export type ChannelPolicy =
|
|
|
27
24
|
}
|
|
28
25
|
| { readonly _tag: 'exclusive' }
|
|
29
26
|
|
|
30
|
-
/** The reflectable descriptor a channel carries. */
|
|
31
27
|
export interface ChannelManifest extends Manifest {
|
|
32
28
|
readonly kind: 'Channel'
|
|
33
29
|
}
|
|
@@ -42,134 +38,14 @@ export interface ChannelConfig {
|
|
|
42
38
|
readonly policy: ChannelPolicy
|
|
43
39
|
}
|
|
44
40
|
|
|
45
|
-
/**
|
|
46
|
-
* A registered procedure body, with its trigger events and the channel it runs
|
|
47
|
-
* on. `run`'s context (RPC clients, etc.) is already provided at registration
|
|
48
|
-
* (see `Procedure.live`), so the channel fiber can run it with no R.
|
|
49
|
-
*/
|
|
50
|
-
export interface ProcedureEntry {
|
|
51
|
-
/** The procedure's name — used only to warn on duplicate registration. */
|
|
52
|
-
readonly name: string
|
|
53
|
-
readonly channelName: string
|
|
54
|
-
readonly handles: ReadonlySet<string>
|
|
55
|
-
readonly run: (event: Tagged) => Effect.Effect<unknown, unknown, never>
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
/**
|
|
59
|
-
* Mutable collector procedures register into; channels read it to find their
|
|
60
|
-
* bodies. `byTag` indexes entries by trigger tag, so a channel routes an event
|
|
61
|
-
* to O(procedures handling it) rather than scanning every registered procedure.
|
|
62
|
-
*/
|
|
63
|
-
export interface ProcedureRegistry {
|
|
64
|
-
readonly entries: Array<ProcedureEntry>
|
|
65
|
-
readonly byTag: Map<string, Array<ProcedureEntry>>
|
|
66
|
-
/**
|
|
67
|
-
* Distinct channel names per event tag. The drain loop routes through this so
|
|
68
|
-
* it offers each event to each channel exactly once — not once per matching
|
|
69
|
-
* procedure, which would re-run every procedure on a shared channel per offer.
|
|
70
|
-
*/
|
|
71
|
-
readonly channelsByTag: Map<string, Array<string>>
|
|
72
|
-
/**
|
|
73
|
-
* Procedures keyed by (channel, tag), so a channel's `runMatching` is a direct
|
|
74
|
-
* lookup instead of scanning `byTag` and re-filtering by channel per event.
|
|
75
|
-
*/
|
|
76
|
-
readonly byChannelTag: Map<string, Map<string, Array<ProcedureEntry>>>
|
|
77
|
-
readonly register: (entry: ProcedureEntry) => void
|
|
78
|
-
/**
|
|
79
|
-
* Remove a previously-registered entry — the inverse of `register`, used when a
|
|
80
|
-
* lazy feature unmounts (scope close) so its procedures stop running and their
|
|
81
|
-
* index entries are reclaimed. Eager procedures (root scope) never call it.
|
|
82
|
-
*/
|
|
83
|
-
readonly unregister: (entry: ProcedureEntry) => void
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
const ProceduresBase: Context.TagClass<Procedures, 'reform/Procedures', ProcedureRegistry> =
|
|
87
|
-
Context.Tag('reform/Procedures')<Procedures, ProcedureRegistry>()
|
|
88
|
-
export class Procedures extends ProceduresBase {}
|
|
89
|
-
|
|
90
|
-
/** Get a map entry, creating and inserting it on first access (avoids `let`). */
|
|
91
|
-
const getOrCreate = <K, V>(map: Map<K, V>, key: K, make: () => V): V => {
|
|
92
|
-
const existing = map.get(key)
|
|
93
|
-
if (existing !== undefined) {
|
|
94
|
-
return existing
|
|
95
|
-
}
|
|
96
|
-
const created = make()
|
|
97
|
-
map.set(key, created)
|
|
98
|
-
return created
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
/** Drop an item from a `Map<K, Array>` bucket, pruning the key when it empties. */
|
|
102
|
-
const dropFrom = <K, T>(map: Map<K, Array<T>>, key: K, target: T): void => {
|
|
103
|
-
const bucket = map.get(key)
|
|
104
|
-
if (bucket === undefined) {
|
|
105
|
-
return
|
|
106
|
-
}
|
|
107
|
-
const next = bucket.filter((candidate) => candidate !== target)
|
|
108
|
-
if (next.length === 0) {
|
|
109
|
-
map.delete(key)
|
|
110
|
-
} else {
|
|
111
|
-
map.set(key, next)
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
const makeProcedureRegistry = (): ProcedureRegistry => {
|
|
116
|
-
// `entries` is reassigned immutably (new array per change) rather than mutated
|
|
117
|
-
// in place, so the holder keeps a stable reference the getter reads through.
|
|
118
|
-
const state: { entries: Array<ProcedureEntry> } = { entries: [] }
|
|
119
|
-
const byTag = new Map<string, Array<ProcedureEntry>>()
|
|
120
|
-
const channelsByTag = new Map<string, Array<string>>()
|
|
121
|
-
const byChannelTag = new Map<string, Map<string, Array<ProcedureEntry>>>()
|
|
122
|
-
return {
|
|
123
|
-
get entries() {
|
|
124
|
-
return state.entries
|
|
125
|
-
},
|
|
126
|
-
byTag,
|
|
127
|
-
channelsByTag,
|
|
128
|
-
byChannelTag,
|
|
129
|
-
register: (entry) => {
|
|
130
|
-
state.entries = [...state.entries, entry]
|
|
131
|
-
entry.handles.forEach((tag) => {
|
|
132
|
-
byTag.set(tag, [...getOrCreate(byTag, tag, () => []), entry])
|
|
133
|
-
const channels = getOrCreate(channelsByTag, tag, () => [])
|
|
134
|
-
if (!channels.includes(entry.channelName)) {
|
|
135
|
-
channelsByTag.set(tag, [...channels, entry.channelName])
|
|
136
|
-
}
|
|
137
|
-
const channelMap = getOrCreate(byChannelTag, entry.channelName, () => new Map())
|
|
138
|
-
channelMap.set(tag, [...getOrCreate(channelMap, tag, () => []), entry])
|
|
139
|
-
})
|
|
140
|
-
},
|
|
141
|
-
unregister: (entry) => {
|
|
142
|
-
state.entries = state.entries.filter((existing) => existing !== entry)
|
|
143
|
-
entry.handles.forEach((tag) => {
|
|
144
|
-
dropFrom(byTag, tag, entry)
|
|
145
|
-
const channelMap = byChannelTag.get(entry.channelName)
|
|
146
|
-
if (channelMap !== undefined) {
|
|
147
|
-
dropFrom(channelMap, tag, entry)
|
|
148
|
-
// If no procedure on this channel still handles the tag, drop the
|
|
149
|
-
// channel from `channelsByTag[tag]` so the loop stops offering to it.
|
|
150
|
-
if (channelMap.get(tag) === undefined) {
|
|
151
|
-
dropFrom(channelsByTag, tag, entry.channelName)
|
|
152
|
-
}
|
|
153
|
-
if (channelMap.size === 0) {
|
|
154
|
-
byChannelTag.delete(entry.channelName)
|
|
155
|
-
}
|
|
156
|
-
}
|
|
157
|
-
})
|
|
158
|
-
},
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
export const proceduresLayer: Layer.Layer<Procedures> = Layer.sync(Procedures, makeProcedureRegistry)
|
|
163
|
-
|
|
164
|
-
/** A live channel: the single loop offers events here; the channel drives them. */
|
|
165
41
|
export interface ChannelRuntime {
|
|
166
42
|
readonly name: string
|
|
167
|
-
/** The policy this channel was built with — used to reject a name clash. */
|
|
168
43
|
readonly policy: ChannelPolicy
|
|
169
44
|
readonly offer: (event: Tagged) => void
|
|
45
|
+
readonly leases: { count: number }
|
|
46
|
+
readonly close: Effect.Effect<void>
|
|
170
47
|
}
|
|
171
48
|
|
|
172
|
-
/** Registry of live channels, keyed by name. The single loop routes through it. */
|
|
173
49
|
const ChannelsBase: Context.TagClass<
|
|
174
50
|
Channels,
|
|
175
51
|
'reform/Channels',
|
|
@@ -179,23 +55,62 @@ export class Channels extends ChannelsBase {}
|
|
|
179
55
|
|
|
180
56
|
export const channelsLayer: Layer.Layer<Channels> = Layer.sync(Channels, () => ({ byName: new Map() }))
|
|
181
57
|
|
|
182
|
-
/**
|
|
183
|
-
* A scheduling lane for procedures. The *definition* (`Channel.make`) is a
|
|
184
|
-
* reflectable name + policy; the *implementation* (`Channel.live`) builds the
|
|
185
|
-
* queue + fiber that applies the policy. Procedures reference a channel by value
|
|
186
|
-
* and register into it; `Channel.live` is merged into the app once per channel.
|
|
187
|
-
*/
|
|
188
58
|
export const make = (name: string, config: ChannelConfig): ChannelClass =>
|
|
189
59
|
definitionClass<ChannelClass>({
|
|
190
60
|
manifest: { kind: 'Channel' as const, name } satisfies ChannelManifest,
|
|
191
61
|
policy: config.policy,
|
|
192
62
|
})
|
|
193
63
|
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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
|
+
|
|
199
114
|
export const live = (channel: ChannelClass): Layer.Layer<never, never, Channels | Procedures> =>
|
|
200
115
|
Layer.scopedDiscard(
|
|
201
116
|
Effect.gen(function* () {
|
|
@@ -203,27 +118,20 @@ export const live = (channel: ChannelClass): Layer.Layer<never, never, Channels
|
|
|
203
118
|
const channels = yield* Channels
|
|
204
119
|
const existing = channels.byName.get(name)
|
|
205
120
|
if (existing !== undefined) {
|
|
206
|
-
|
|
207
|
-
// (shared `exclusive`/`latest` lane). A *different* channel reusing the
|
|
208
|
-
// name is a silent footgun — the second policy would be dropped — so
|
|
209
|
-
// reject it loudly instead.
|
|
210
|
-
if (existing.policy !== channel.policy) {
|
|
211
|
-
// A different channel reusing a live name is a programmer footgun, not a
|
|
212
|
-
// recoverable failure — surface it as a defect to keep the layer E = never.
|
|
121
|
+
if (!samePolicy(existing.policy, channel.policy)) {
|
|
213
122
|
yield* Effect.dieMessage(
|
|
214
|
-
`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})`,
|
|
215
124
|
)
|
|
216
125
|
}
|
|
126
|
+
existing.leases.count += 1
|
|
127
|
+
yield* Effect.addFinalizer(() => release(channels.byName, existing))
|
|
217
128
|
return
|
|
218
129
|
}
|
|
219
130
|
const procedures = yield* Procedures
|
|
131
|
+
const instrumentation = yield* resolveInstrumentation
|
|
132
|
+
const machineryScope = yield* Scope.make()
|
|
220
133
|
const queue = yield* Queue.unbounded<Tagged>()
|
|
221
134
|
|
|
222
|
-
// Run every procedure on THIS channel that handles the event. Read the live
|
|
223
|
-
// index each time so procedures registered later are still seen.
|
|
224
|
-
// A procedure is expected to model expected failures as events; a leaked
|
|
225
|
-
// defect (a bug in the body) is logged before being isolated, so one
|
|
226
|
-
// procedure's crash never tears down the channel fiber — but is observable.
|
|
227
135
|
const runMatching = (event: Tagged): Effect.Effect<void> => {
|
|
228
136
|
const matching = Option.fromNullable(procedures.byChannelTag.get(name)).pipe(
|
|
229
137
|
Option.flatMapNullable((byTag) => byTag.get(event._tag)),
|
|
@@ -232,7 +140,10 @@ export const live = (channel: ChannelClass): Layer.Layer<never, never, Channels
|
|
|
232
140
|
return Effect.forEach(
|
|
233
141
|
matching,
|
|
234
142
|
(procedure) =>
|
|
235
|
-
procedure.
|
|
143
|
+
Effect.sync(() => instrumentation.procedureRun(procedure.name, name, event._tag)).pipe(
|
|
144
|
+
Effect.flatMap((endSpan) =>
|
|
145
|
+
procedure.run(event).pipe(Effect.ensuring(Effect.sync(endSpan))),
|
|
146
|
+
),
|
|
236
147
|
Effect.tapErrorCause((cause) =>
|
|
237
148
|
Effect.logError(`reform: procedure on channel '${name}' failed`, cause),
|
|
238
149
|
),
|
|
@@ -242,8 +153,6 @@ export const live = (channel: ChannelClass): Layer.Layer<never, never, Channels
|
|
|
242
153
|
)
|
|
243
154
|
}
|
|
244
155
|
|
|
245
|
-
// Map the policy to its scheduling stream. `Match.exhaustive` makes a new
|
|
246
|
-
// policy variant a compile error, and each arm is a self-contained const.
|
|
247
156
|
const events = Stream.fromQueue(queue)
|
|
248
157
|
const driven: Stream.Stream<unknown> = Match.value(channel.policy).pipe(
|
|
249
158
|
Match.tag('merge', () => Stream.mapEffect(events, runMatching, { concurrency: 'unbounded' })),
|
|
@@ -271,25 +180,21 @@ export const live = (channel: ChannelClass): Layer.Layer<never, never, Channels
|
|
|
271
180
|
Stream.mapEffect(runMatching, { concurrency: 'unbounded' }),
|
|
272
181
|
),
|
|
273
182
|
),
|
|
274
|
-
// `concurrency: 1` runs one event at a time AND in offer order — a true
|
|
275
|
-
// serialized lane. (An unbounded fork racing for a single semaphore permit
|
|
276
|
-
// would serialize but could reorder under contention.)
|
|
277
183
|
Match.tag('exclusive', () => Stream.mapEffect(events, runMatching, { concurrency: 1 })),
|
|
278
184
|
Match.exhaustive,
|
|
279
185
|
)
|
|
280
186
|
|
|
281
|
-
yield*
|
|
282
|
-
|
|
187
|
+
yield* Stream.runDrain(driven).pipe(Effect.forkIn(machineryScope))
|
|
188
|
+
const registration: ChannelRuntime = {
|
|
283
189
|
name,
|
|
284
190
|
policy: channel.policy,
|
|
285
191
|
offer: (event) => {
|
|
286
192
|
Queue.unsafeOffer(queue, event)
|
|
287
193
|
},
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
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))
|
|
294
199
|
}),
|
|
295
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 {}
|