@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.
Files changed (97) hide show
  1. package/README.md +18 -1
  2. package/package.json +1 -1
  3. package/src/boundary/boundary.test.ts +1 -33
  4. package/src/boundary/boundary.ts +4 -82
  5. package/src/calc/asyncCalc.invalidate.test.ts +2 -14
  6. package/src/calc/asyncCalc.test.ts +0 -38
  7. package/src/calc/asyncCalc.ts +11 -163
  8. package/src/calc/asyncCalcDefinitions.ts +48 -0
  9. package/src/calc/asyncData.ts +0 -37
  10. package/src/calc/calc.test.ts +4 -28
  11. package/src/calc/calc.ts +6 -46
  12. package/src/calc/calcFamily.test.ts +0 -12
  13. package/src/calc/calcFamily.ts +6 -48
  14. package/src/calc/compose.test.ts +1 -12
  15. package/src/calc/compose.ts +0 -22
  16. package/src/calc/queryState.ts +0 -23
  17. package/src/channel/channel.ts +72 -174
  18. package/src/channel/procedureRegistry.ts +90 -0
  19. package/src/compose/composition.ts +7 -67
  20. package/src/compose/host.ts +0 -6
  21. package/src/compose/props.ts +3 -9
  22. package/src/compose/provide.ts +19 -33
  23. package/src/compose/slot.ts +2 -30
  24. package/src/compose/structure.test.ts +0 -6
  25. package/src/compose/structure.ts +3 -67
  26. package/src/compose/ui.test.ts +0 -4
  27. package/src/compose/ui.ts +17 -111
  28. package/src/compose/ui.typecheck.ts +0 -14
  29. package/src/definition/definition.ts +0 -32
  30. package/src/event/event.test.ts +0 -3
  31. package/src/event/event.ts +5 -14
  32. package/src/event/eventGroup.ts +0 -1
  33. package/src/feature/feature.mount.test.ts +66 -54
  34. package/src/feature/feature.test.ts +67 -46
  35. package/src/feature/feature.ts +185 -214
  36. package/src/feature/feature.typecheck.ts +29 -16
  37. package/src/feature/featureBinding.ts +48 -0
  38. package/src/index.ts +245 -229
  39. package/src/internal/capture.ts +0 -20
  40. package/src/internal/ctx.ts +2 -9
  41. package/src/internal/env.ts +9 -0
  42. package/src/internal/errors.test.ts +0 -6
  43. package/src/internal/errors.ts +14 -48
  44. package/src/internal/inspect.test.ts +0 -6
  45. package/src/internal/inspect.ts +0 -12
  46. package/src/internal/queryDriver.ts +42 -243
  47. package/src/internal/queryDriverStore.ts +153 -0
  48. package/src/internal/queryDriverTypes.ts +57 -0
  49. package/src/internal/queryEvents.ts +0 -12
  50. package/src/internal/queryStore.ts +0 -14
  51. package/src/internal/reuse.test.ts +0 -11
  52. package/src/internal/reuse.ts +0 -28
  53. package/src/internal/scheduler.ts +0 -43
  54. package/src/internal/seeds.ts +0 -14
  55. package/src/internal/sources.ts +4 -41
  56. package/src/internal/stateRegistry.ts +0 -14
  57. package/src/internal/store.test.ts +1 -3
  58. package/src/internal/store.ts +0 -37
  59. package/src/internal/track.ts +0 -18
  60. package/src/internal/variance.ts +5 -0
  61. package/src/namespace/namespace.test.ts +46 -0
  62. package/src/namespace/namespace.ts +85 -0
  63. package/src/procedure/procedure.ts +2 -24
  64. package/src/reducer/reducer.ts +3 -21
  65. package/src/remote/remoteState.test.ts +92 -75
  66. package/src/remote/remoteState.ts +99 -486
  67. package/src/remote/remoteState.typecheck.ts +0 -33
  68. package/src/remote/remoteStateDefinition.ts +135 -0
  69. package/src/remote/remoteStateLayers.ts +118 -0
  70. package/src/remote/remoteStateLiveTypes.ts +59 -0
  71. package/src/remote/remoteStateSend.ts +64 -0
  72. package/src/runtime/appRuntime.test.ts +197 -0
  73. package/src/runtime/appRuntime.ts +40 -83
  74. package/src/runtime/bus.ts +2 -15
  75. package/src/runtime/eventBudget.test.ts +155 -0
  76. package/src/runtime/eventBudget.ts +119 -0
  77. package/src/runtime/hardening.test.ts +74 -9
  78. package/src/runtime/instrumentation.test.ts +213 -192
  79. package/src/runtime/instrumentation.ts +0 -49
  80. package/src/runtime/loop.test.ts +0 -17
  81. package/src/runtime/loop.ts +25 -55
  82. package/src/runtime/queries.ts +0 -17
  83. package/src/scene/featureScene.test.ts +59 -0
  84. package/src/scene/scene.ts +32 -59
  85. package/src/scene/seedScene.test.ts +96 -116
  86. package/src/state/state.ts +7 -26
  87. package/src/state/stateFamily.test.ts +2 -11
  88. package/src/state/stateFamily.ts +9 -52
  89. package/src/state/stateGroup.ts +2 -49
  90. package/src/state/token.ts +4 -17
  91. package/src/synced/syncedStore.ts +7 -42
  92. package/src/ui/node.ts +0 -6
  93. package/src/ui/trigger.ts +0 -5
  94. package/src/wire/tree.test.ts +0 -3
  95. package/src/wire/tree.ts +0 -39
  96. package/src/wire/triggers.test.ts +0 -4
  97. package/src/wire/triggers.ts +5 -27
@@ -1,19 +1,15 @@
1
- import { Chunk, Context, type Duration, Effect, Layer, Match, Option, Queue, Stream } from 'effect'
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
- * Build and register the live machinery for a channel. Idempotent by name, so
197
- * procedures sharing an `exclusive`/`latest` channel share one queue + fiber +
198
- * semaphore (and therefore truly serialize / cancel across each other).
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
- // Idempotent for the *same* channel `.live`'d through several sub-layers
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* Effect.forkScoped(Stream.runDrain(driven))
289
- channels.byName.set(name, {
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
- // On scope close (a lazy feature unmounting) remove the live channel so the
297
- // loop stops routing to it; the drain fiber is already torn down by
298
- // `forkScoped`. Eager channels live on the root scope, so this is a no-op
299
- // until app dispose.
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
- // Reflectable manifest/config carrier: only `title` + `ui` are required, and the
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
- // `any` in the encoded/context slots is required by Schema's variance for
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<any, any>
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
- // The UI contract this composition resolves. Typed as a manifest carrier (not
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, any> } ? P : unknown
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 `ui` to the full `UiClass<C>` so `C` is inferred here; the stored
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)
@@ -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>
@@ -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
- any
6
+ AnyValue
13
7
  >()
14
8
  export class Props extends PropsBase {}