@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.
- package/package.json +1 -1
- package/src/assertions.ts +65 -0
- package/src/driverTypes.ts +19 -0
- package/src/engine.ts +8 -520
- package/src/errors.ts +0 -14
- package/src/execution.ts +100 -0
- package/src/facade.ts +258 -0
- package/src/index.ts +53 -294
- package/src/product.ts +77 -0
- package/src/runner.ts +1 -17
- package/src/runtimeFacade.ts +269 -0
- package/src/runtimeTreeDispose.ts +21 -0
- package/src/runtimeTreeDriver.ts +285 -0
- package/src/runtimeTreeFacade.ts +134 -0
- package/src/runtimeTreeSettle.ts +34 -0
- package/src/runtimeTreeTypes.ts +99 -0
- package/src/sink.ts +73 -0
- package/src/structure-events.test.ts +0 -11
- package/src/structure-locators.test.ts +0 -23
- package/src/treeBindings.ts +121 -0
- package/src/typed-seams.test.ts +0 -11
package/src/execution.ts
ADDED
|
@@ -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))
|