@playfast/reform-proof 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.
@@ -0,0 +1,100 @@
1
+ import { Cause, Effect, Option, Ref } from 'effect'
2
+ import { yieldWrapGet } from 'effect/Utils'
3
+ import {
4
+ Bus,
5
+ type CompositionService,
6
+ publish,
7
+ type Scene,
8
+ sceneInstrumentation,
9
+ } from '@playfast/reform'
10
+ import type { DriveResult, Proof, ProofResult, StepFrame } from './index'
11
+ import { makeFacadeEffect } from './facade'
12
+ import { makeSink, messageOf, proofLayer, type RuntimeServices, type Sink } from './sink'
13
+ import type { PumpStep } from './treeBindings'
14
+
15
+ const missingCoverage = (proof: Proof, dispatched: Set<string>): ReadonlyArray<string> =>
16
+ Option.match(proof.requirement.manifest.events, {
17
+ onNone: () => [],
18
+ onSome: (declared) => declared.map(String).filter((event) => !dispatched.has(event)),
19
+ })
20
+
21
+ const bootScene = (scene: Scene): Effect.Effect<void, never, Bus> =>
22
+ Effect.forEach(
23
+ Option.getOrElse(Option.fromNullable(scene.boot), () => []),
24
+ (event) => publish('High', event),
25
+ ).pipe(Effect.asVoid)
26
+
27
+ const executeProofEffect = Effect.fn('executeProofEffect')(function* (
28
+ proof: Proof,
29
+ sink: Sink,
30
+ ): Effect.fn.Return<ProofResult, never, RuntimeServices> {
31
+ const dispatched = new Set<string>()
32
+ const statement = proof.requirement.manifest.statement
33
+ return yield* Effect.gen(function* () {
34
+ const { facade, settle } = yield* makeFacadeEffect(proof.scene.composition, sink, dispatched)
35
+ yield* bootScene(proof.scene)
36
+ yield* settle
37
+ yield* Effect.gen(() => proof.body(facade))
38
+ const missing = missingCoverage(proof, dispatched)
39
+ const coverageError = `requirement declares events never dispatched: ${missing.join(', ')}`
40
+ return missing.length > 0
41
+ ? { requirement: statement, ok: false, error: coverageError }
42
+ : { requirement: statement, ok: true }
43
+ }).pipe(
44
+ Effect.catchAllCause((cause) =>
45
+ Effect.succeed({ requirement: statement, ok: false, error: messageOf(Cause.squash(cause)) }),
46
+ ),
47
+ )
48
+ })
49
+
50
+ export const executeProof = async (proof: Proof): Promise<ProofResult> => {
51
+ const sink = makeSink(sceneInstrumentation(proof.scene))
52
+ return Effect.runPromise(executeProofEffect(proof, sink).pipe(Effect.provide(proofLayer(proof.scene, sink))))
53
+ }
54
+
55
+ const driveProofEffect = Effect.fn('driveProofEffect')(function* (
56
+ proof: Proof,
57
+ sink: Sink,
58
+ ): Effect.fn.Return<DriveResult, never, RuntimeServices> {
59
+ const frames = yield* Ref.make<ReadonlyArray<StepFrame>>([])
60
+ const statement = proof.requirement.manifest.statement
61
+ const dispatched = new Set<string>()
62
+ const record = (index: number): Effect.Effect<void> =>
63
+ Ref.update(frames, (current) => [...current, { index, captures: [...sink.captures] }])
64
+ const pump = Effect.fn('pump')(function* (
65
+ generator: ReturnType<Proof['body']>,
66
+ step: PumpStep,
67
+ ): Effect.fn.Return<void, unknown, CompositionService> {
68
+ const next = generator.next(step.input)
69
+ if (next.done === true) {
70
+ return
71
+ }
72
+ const output = yield* yieldWrapGet(next.value)
73
+ yield* record(step.index)
74
+ yield* pump(generator, { input: output, index: step.index + 1 })
75
+ })
76
+ return yield* Effect.gen(function* () {
77
+ const { facade, settle } = yield* makeFacadeEffect(proof.scene.composition, sink, dispatched)
78
+ yield* bootScene(proof.scene)
79
+ yield* settle
80
+ yield* record(0)
81
+ yield* pump(proof.body(facade), { input: undefined, index: 1 })
82
+ return { requirement: statement, frames: yield* Ref.get(frames), ok: true }
83
+ }).pipe(
84
+ Effect.catchAllCause((cause) =>
85
+ Ref.get(frames).pipe(
86
+ Effect.map((collected) => ({
87
+ requirement: statement,
88
+ frames: collected,
89
+ ok: false,
90
+ error: messageOf(Cause.squash(cause)),
91
+ })),
92
+ ),
93
+ ),
94
+ )
95
+ })
96
+
97
+ export const driveProof = async (proof: Proof): Promise<DriveResult> => {
98
+ const sink = makeSink(sceneInstrumentation(proof.scene))
99
+ return Effect.runPromise(driveProofEffect(proof, sink).pipe(Effect.provide(proofLayer(proof.scene, sink))))
100
+ }
package/src/facade.ts ADDED
@@ -0,0 +1,258 @@
1
+ import { Array as Arr, Effect, ManagedRuntime, Option, Schema as S } from 'effect'
2
+ import {
3
+ Composition,
4
+ type CompositionClass,
5
+ type CompositionService,
6
+ isStructure,
7
+ type RenderEnv,
8
+ type SlotChild,
9
+ type SlotClass,
10
+ type Trigger,
11
+ type UiCapture,
12
+ type UiContract,
13
+ } from '@playfast/reform'
14
+ import { AssertionFailed, UnknownAction, UnknownSlot } from './errors'
15
+ import { matchProps } from './assertions'
16
+ import type { Action, Facade, SlotFacade } from './index'
17
+ import {
18
+ keyed,
19
+ SETTLE_MAX_RENDERS,
20
+ SETTLE_STEP,
21
+ settleDrain,
22
+ type RuntimeServices,
23
+ type Sink,
24
+ uiNameOf,
25
+ } from './sink'
26
+ import {
27
+ collectBindings,
28
+ driveStructure,
29
+ type Mounted,
30
+ type NodeRef,
31
+ type SettleProgress,
32
+ slotsOf,
33
+ } from './treeBindings'
34
+
35
+ type AnyFacade = Facade<UiContract>
36
+ type AnySlotFacade = SlotFacade<UiContract>
37
+ const encodeUnknown = S.encodeSync(S.parseJson(S.Unknown))
38
+
39
+ const renderMounted = Effect.fn('renderMounted')(function* (
40
+ mounted: Mounted,
41
+ bindings: ReadonlyMap<SlotClass, CompositionClass<unknown>>,
42
+ sink: Sink,
43
+ ): Effect.fn.Return<ReadonlyArray<Mounted>, never, CompositionService> {
44
+ const service = yield* mounted.comp.tag
45
+ const env: RenderEnv = { props: mounted.props, tracker: { add: () => {} } }
46
+ const endRenderSpan = sink.instrumentation.uiRendered(uiNameOf(mounted.comp))
47
+ const frame = yield* Composition.render(service, env)
48
+ endRenderSpan()
49
+ if (!isStructure(frame)) {
50
+ return yield* Effect.dieMessage(
51
+ `reform-proof: composition ${uiNameOf(mounted.comp)} did not return a Structure`,
52
+ )
53
+ }
54
+ return driveStructure(mounted.comp, frame, mounted.key, sink, bindings)
55
+ })
56
+
57
+ const renderLevel: (
58
+ frontier: ReadonlyArray<Mounted>,
59
+ bindings: ReadonlyMap<SlotClass, CompositionClass<unknown>>,
60
+ sink: Sink,
61
+ ) => Effect.Effect<void, never, CompositionService> = Effect.fn('renderLevel')(function* (
62
+ frontier: ReadonlyArray<Mounted>,
63
+ bindings: ReadonlyMap<SlotClass, CompositionClass<unknown>>,
64
+ sink: Sink,
65
+ ): Effect.fn.Return<void, never, CompositionService> {
66
+ if (frontier.length === 0) {
67
+ return
68
+ }
69
+ const levels = yield* Effect.forEach(frontier, (mounted) =>
70
+ renderMounted(mounted, bindings, sink),
71
+ )
72
+ yield* renderLevel(levels.flat(), bindings, sink)
73
+ })
74
+
75
+ const renderTree = Effect.fn('renderTree')(function* (
76
+ root: CompositionClass<unknown>,
77
+ bindings: ReadonlyMap<SlotClass, CompositionClass<unknown>>,
78
+ sink: Sink,
79
+ ): Effect.fn.Return<void, never, CompositionService> {
80
+ sink.reset()
81
+ yield* renderLevel([{ comp: root, props: {}, key: Option.none() }], bindings, sink)
82
+ })
83
+
84
+ export const capturesFor = (sink: Sink, name: string): ReadonlyArray<UiCapture> =>
85
+ sink.captures.filter((capture) => capture.name === name)
86
+
87
+ export const fingerprint = (sink: Sink): string =>
88
+ encodeUnknown(sink.captures.map((capture) => [capture.name, capture.props]))
89
+
90
+ const settleTree = Effect.fn('settleTree')(function* (
91
+ root: CompositionClass<unknown>,
92
+ bindings: ReadonlyMap<SlotClass, CompositionClass<unknown>>,
93
+ sink: Sink,
94
+ ): Effect.fn.Return<void, never, CompositionService> {
95
+ yield* settleDrain
96
+ yield* renderTree(root, bindings, sink)
97
+ yield* Effect.iterate(
98
+ {
99
+ previous: fingerprint(sink),
100
+ remaining: SETTLE_MAX_RENDERS,
101
+ stable: false,
102
+ },
103
+ {
104
+ while: (progress: SettleProgress) => !progress.stable && progress.remaining > 0,
105
+ body: (progress) =>
106
+ Effect.gen(function* () {
107
+ yield* settleDrain
108
+ yield* Effect.sleep(SETTLE_STEP)
109
+ yield* renderTree(root, bindings, sink)
110
+ const current = fingerprint(sink)
111
+ return {
112
+ previous: current,
113
+ remaining: progress.remaining - 1,
114
+ stable: current === progress.previous,
115
+ }
116
+ }),
117
+ },
118
+ )
119
+ })
120
+
121
+ export const makeFacadeEffect: (
122
+ root: CompositionClass<unknown>,
123
+ sink: Sink,
124
+ dispatched: Set<string>,
125
+ ) => Effect.Effect<
126
+ {
127
+ readonly facade: AnyFacade
128
+ readonly settle: Effect.Effect<void, never, CompositionService>
129
+ },
130
+ never,
131
+ SlotChild
132
+ > = Effect.fn('makeFacadeEffect')(function* (
133
+ root: CompositionClass<unknown>,
134
+ sink: Sink,
135
+ dispatched: Set<string>,
136
+ ): Effect.fn.Return<
137
+ {
138
+ readonly facade: AnyFacade
139
+ readonly settle: Effect.Effect<void, never, CompositionService>
140
+ },
141
+ never,
142
+ SlotChild
143
+ > {
144
+ const bindings = yield* collectBindings(root)
145
+ const settle = settleTree(root, bindings, sink)
146
+ const rerender = renderTree(root, bindings, sink)
147
+
148
+ const triggerOf = (ref: NodeRef, event: string): Effect.Effect<Trigger<unknown>> =>
149
+ Option.match(Option.fromNullable(capturesFor(sink, ref.name)[ref.index]?.events[event]), {
150
+ onNone: () =>
151
+ Effect.dieMessage(
152
+ new UnknownAction({
153
+ composition: ref.name,
154
+ action: event,
155
+ rendered: capturesFor(sink, ref.name).length,
156
+ }).message,
157
+ ),
158
+ onSome: Effect.succeed,
159
+ })
160
+
161
+ const propsFor = (ref: NodeRef): Effect.Effect<unknown, never, CompositionService> =>
162
+ Effect.map(rerender, () => capturesFor(sink, ref.name)[ref.index]?.props)
163
+
164
+ const nodeFacade = (ref: NodeRef, comp: CompositionClass<unknown>): AnyFacade => ({
165
+ props: propsFor(ref),
166
+ expectProps: (partial) =>
167
+ propsFor(ref).pipe(
168
+ Effect.flatMap((props) => {
169
+ const mismatch = matchProps({ actual: props, expected: partial })
170
+ return mismatch === undefined
171
+ ? Effect.void
172
+ : Effect.dieMessage(new AssertionFailed({ detail: mismatch }).message)
173
+ }),
174
+ ),
175
+ actions: keyed(
176
+ (event): Action =>
177
+ (payload) =>
178
+ rerender.pipe(
179
+ Effect.flatMap(() => triggerOf(ref, event)),
180
+ Effect.flatMap((trigger) =>
181
+ Effect.sync(() => {
182
+ dispatched.add(event)
183
+ trigger(payload)
184
+ }),
185
+ ),
186
+ Effect.flatMap(() => settle),
187
+ Effect.flatMap(() => propsFor(ref)),
188
+ ),
189
+ ),
190
+ slots: keyed((slotName) => slotFacade(comp, slotName)),
191
+ frame: settle,
192
+ })
193
+
194
+ const slotFacade = (parent: CompositionClass<unknown>, slotName: string): AnySlotFacade => {
195
+ const slotClass = slotsOf(parent)[slotName]
196
+ const child = slotClass && bindings.get(slotClass)
197
+ if (child === undefined) {
198
+ return Effect.runSync(
199
+ Effect.dieMessage(new UnknownSlot({ parent: uiNameOf(parent), slot: slotName }).message),
200
+ )
201
+ }
202
+ const childName = uiNameOf(child)
203
+ const atIndex = (index: number): Effect.Effect<AnyFacade, never, CompositionService> =>
204
+ Effect.as(rerender, nodeFacade({ name: childName, index }, child))
205
+ const first = nodeFacade({ name: childName, index: 0 }, child)
206
+ const indexOfKey = (key: string): Option.Option<number> =>
207
+ Arr.findFirstIndex(capturesFor(sink, childName), (capture) => capture.key === key)
208
+ return {
209
+ first: atIndex(0),
210
+ at: atIndex,
211
+ all: Effect.map(rerender, () =>
212
+ capturesFor(sink, childName).map((_capture, index) =>
213
+ nodeFacade({ name: childName, index }, child),
214
+ ),
215
+ ),
216
+ byKey: (key) =>
217
+ Effect.flatMap(rerender, () =>
218
+ Option.match(indexOfKey(key), {
219
+ onNone: () =>
220
+ Effect.dieMessage(
221
+ new UnknownSlot({
222
+ parent: uiNameOf(parent),
223
+ slot: `${slotName}[key=${key}]`,
224
+ }).message,
225
+ ),
226
+ onSome: (index) => Effect.succeed(nodeFacade({ name: childName, index }, child)),
227
+ }),
228
+ ),
229
+ where: (predicate) =>
230
+ Effect.map(rerender, () =>
231
+ capturesFor(sink, childName).flatMap((capture, index) =>
232
+ predicate(capture.props) ? [nodeFacade({ name: childName, index }, child)] : [],
233
+ ),
234
+ ),
235
+ count: Effect.map(rerender, () => capturesFor(sink, childName).length),
236
+ props: first.props,
237
+ expectProps: first.expectProps,
238
+ actions: first.actions,
239
+ slots: first.slots,
240
+ frame: first.frame,
241
+ }
242
+ }
243
+
244
+ return {
245
+ facade: nodeFacade({ name: uiNameOf(root), index: 0 }, root),
246
+ settle,
247
+ }
248
+ })
249
+
250
+ export const makeFacade = (
251
+ runtime: ManagedRuntime.ManagedRuntime<RuntimeServices, never>,
252
+ root: CompositionClass<unknown>,
253
+ sink: Sink,
254
+ dispatched: Set<string>,
255
+ ): {
256
+ readonly facade: AnyFacade
257
+ readonly settle: Effect.Effect<void, never, CompositionService>
258
+ } => runtime.runSync(makeFacadeEffect(root, sink, dispatched))