@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.
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 +16 -44
  12. package/src/calc/calcFamily.test.ts +0 -12
  13. package/src/calc/calcFamily.ts +12 -49
  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 +78 -173
  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 -202
  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 +48 -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 +10 -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 +131 -0
  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 +324 -0
  79. package/src/runtime/instrumentation.ts +44 -0
  80. package/src/runtime/loop.test.ts +0 -17
  81. package/src/runtime/loop.ts +41 -58
  82. package/src/runtime/queries.ts +0 -17
  83. package/src/scene/featureScene.test.ts +59 -0
  84. package/src/scene/scene.ts +51 -44
  85. package/src/scene/seedScene.test.ts +96 -116
  86. package/src/state/state.ts +12 -28
  87. package/src/state/stateFamily.test.ts +2 -11
  88. package/src/state/stateFamily.ts +20 -54
  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,18 +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
+ 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
- * Build and register the live machinery for a channel. Idempotent by name, so
196
- * procedures sharing an `exclusive`/`latest` channel share one queue + fiber +
197
- * semaphore (and therefore truly serialize / cancel across each other).
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
- // Idempotent for the *same* channel `.live`'d through several sub-layers
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.run(event).pipe(
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* Effect.forkScoped(Stream.runDrain(driven))
282
- channels.byName.set(name, {
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
- // On scope close (a lazy feature unmounting) remove the live channel so the
290
- // loop stops routing to it; the drain fiber is already torn down by
291
- // `forkScoped`. Eager channels live on the root scope, so this is a no-op
292
- // until app dispose.
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
- // 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 {}