@playfast/reform-proof 1.2.0 → 1.2.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,247 @@
1
+ import { Effect, Match, Option, Record as Rec } from 'effect'
2
+ import { isFeatureBinding } from '@playfast/reform'
3
+ import { formatFingerprint } from './fingerprint'
4
+ import {
5
+ eventsOf,
6
+ type HostRuntime,
7
+ renderCompositionRuntime,
8
+ SETTLE_MAX_RENDERS,
9
+ settleDrain,
10
+ settleStep,
11
+ type Sink,
12
+ uiNameOf,
13
+ } from './engineSink'
14
+ import type { AnyComposition } from '@playfast/reform/internal'
15
+ import {
16
+ ensureSettled,
17
+ readRuntimeSlotChild,
18
+ type SettleProgress,
19
+ slotsOf,
20
+ structureFills,
21
+ } from './engineTree'
22
+ import { makeFeatureMountRegistry } from './engineMounts'
23
+ import {
24
+ encodeIdentityPart,
25
+ fillEntries,
26
+ type RenderCompositionInput,
27
+ type TreeDriver,
28
+ type TreeNode,
29
+ type TreeRenderState,
30
+ treeFingerprint,
31
+ } from './engineTreeTypes'
32
+
33
+ export const makeTreeDriver = (
34
+ runtime: HostRuntime,
35
+ root: AnyComposition,
36
+ rootProps: unknown,
37
+ sink: Sink,
38
+ ): TreeDriver => { const status = { disposed: false }
39
+ const registry = makeFeatureMountRegistry(status)
40
+ const { disposeStale, mountId, retryMount } = registry
41
+ const featureRecordForRender = registry.recordForRender
42
+
43
+ const renderComposition = ({
44
+ runtime: currentRuntime,
45
+ comp,
46
+ props,
47
+ key,
48
+ path,
49
+ depth,
50
+ seen,
51
+ mountMissing,
52
+ }: RenderCompositionInput): TreeNode => {
53
+ const endRenderSpan = sink.instrumentation.uiRendered(uiNameOf(comp))
54
+ const frame = renderCompositionRuntime(currentRuntime, comp, props)
55
+ endRenderSpan()
56
+ sink.api.record({
57
+ name: uiNameOf(comp),
58
+ props: frame.props,
59
+ events: eventsOf(frame),
60
+ ...(Option.isSome(key) ? { key: key.value } : {}),
61
+ })
62
+
63
+ const fills = structureFills(frame)
64
+ const renderedSlots = Rec.map(slotsOf(comp), (slotClass, slotName) => {
65
+ const fill = fills[slotName]
66
+ if (fill === undefined) {
67
+ return []
68
+ }
69
+ const child = readRuntimeSlotChild({
70
+ runtime: currentRuntime,
71
+ slotDefinition: slotClass,
72
+ parent: comp,
73
+ })
74
+ return fillEntries(slotName, fill).flatMap((entry) => {
75
+ const childPath = `${path}${encodeIdentityPart(slotName)}${encodeIdentityPart(entry.key)}`
76
+ if (!isFeatureBinding(child)) {
77
+ return [
78
+ renderComposition({
79
+ runtime: currentRuntime,
80
+ comp: child,
81
+ props: entry.props,
82
+ key: Option.some(entry.key),
83
+ path: childPath,
84
+ depth: depth + 1,
85
+ seen,
86
+ mountMissing,
87
+ }),
88
+ ]
89
+ }
90
+ const id = mountId({
91
+ parent: currentRuntime,
92
+ parentPath: path,
93
+ slotName,
94
+ key: entry.key,
95
+ binding: child,
96
+ })
97
+ seen.add(id)
98
+ const record = featureRecordForRender(
99
+ {
100
+ id,
101
+ parent: currentRuntime,
102
+ binding: child,
103
+ depth: depth + 1,
104
+ props: entry.props,
105
+ },
106
+ mountMissing,
107
+ )
108
+ if (record === undefined) {
109
+ return []
110
+ }
111
+ return child.capture<ReadonlyArray<TreeNode>>((binding) =>
112
+ Match.value(record.state).pipe(
113
+ Match.when({ _tag: 'Live' }, (live) => [
114
+ renderComposition({
115
+ runtime: live.runtime,
116
+ comp: binding.composition,
117
+ props: entry.props,
118
+ key: Option.some(entry.key),
119
+ path: childPath,
120
+ depth: depth + 1,
121
+ seen,
122
+ mountMissing,
123
+ }),
124
+ ]),
125
+ Match.when({ _tag: 'Failed' }, (failed) =>
126
+ Option.match(binding.placeholder, {
127
+ onNone: () => [],
128
+ onSome: (placeholder) => [
129
+ renderComposition({
130
+ runtime: currentRuntime,
131
+ comp: placeholder.failed,
132
+ props: {
133
+ error: failed.error,
134
+ retry: () => retryMount(record),
135
+ },
136
+ key: Option.some(entry.key),
137
+ path: `${childPath}/failed`,
138
+ depth: depth + 1,
139
+ seen,
140
+ mountMissing,
141
+ }),
142
+ ],
143
+ }),
144
+ ),
145
+ Match.when({ _tag: 'Loading' }, () =>
146
+ Option.match(binding.placeholder, {
147
+ onNone: () => [],
148
+ onSome: (placeholder) => [
149
+ renderComposition({
150
+ runtime: currentRuntime,
151
+ comp: placeholder.loading,
152
+ props: entry.props,
153
+ key: Option.some(entry.key),
154
+ path: `${childPath}/loading`,
155
+ depth: depth + 1,
156
+ seen,
157
+ mountMissing,
158
+ }),
159
+ ],
160
+ }),
161
+ ),
162
+ Match.exhaustive,
163
+ ),
164
+ )
165
+ })
166
+ })
167
+
168
+ return {
169
+ path,
170
+ depth,
171
+ key,
172
+ comp,
173
+ props: frame.props,
174
+ events: eventsOf(frame),
175
+ slots: renderedSlots,
176
+ }
177
+ }
178
+ const render = (): TreeRenderState => {
179
+ // Discover the desired graph without acquiring missing Features, then release
180
+ // removed scopes deepest-first before the live pass mounts replacements.
181
+ sink.reset()
182
+ const retained = new Set<string>()
183
+ renderComposition({
184
+ runtime,
185
+ comp: root,
186
+ props: rootProps,
187
+ key: Option.none(),
188
+ path: 'root',
189
+ depth: 0,
190
+ seen: retained,
191
+ mountMissing: false,
192
+ })
193
+ disposeStale(retained)
194
+ sink.reset()
195
+ const seen = new Set<string>()
196
+ const rootNode = renderComposition({
197
+ runtime,
198
+ comp: root,
199
+ props: rootProps,
200
+ key: Option.none(),
201
+ path: 'root',
202
+ depth: 0,
203
+ seen,
204
+ mountMissing: true,
205
+ })
206
+ return {
207
+ root: rootNode,
208
+ fingerprint: formatFingerprint(treeFingerprint(rootNode)),
209
+ }
210
+ }
211
+
212
+ const settle = Effect.gen(function* () {
213
+ yield* settleDrain
214
+ const first = render()
215
+ const progress = yield* Effect.iterate(
216
+ {
217
+ previous: first.fingerprint,
218
+ remaining: SETTLE_MAX_RENDERS,
219
+ stable: false,
220
+ },
221
+ {
222
+ while: (progress: SettleProgress) => !progress.stable && progress.remaining > 0,
223
+ body: (progress) =>
224
+ Effect.gen(function* () {
225
+ yield* settleDrain
226
+ yield* settleStep
227
+ const current = render()
228
+ return {
229
+ previous: current.fingerprint,
230
+ remaining: progress.remaining - 1,
231
+ stable: current.fingerprint === progress.previous,
232
+ }
233
+ }),
234
+ },
235
+ )
236
+ yield* ensureSettled(progress)
237
+ })
238
+ const dispose = (): void => {
239
+ if (status.disposed) {
240
+ return
241
+ }
242
+ status.disposed = true
243
+ registry.disposeAll()
244
+ }
245
+
246
+ return { render, settle, dispose }
247
+ }
@@ -0,0 +1,183 @@
1
+ import { Array as Arr, type Duration, Effect, Option, TestClock } from 'effect'
2
+ import { AssertionFailed, NoTestClock, UnknownAction, UnknownSlot } from './errors'
3
+ import { matchProps } from './assert'
4
+ import { type RuntimeHandle, type UiContract } from '@playfast/reform'
5
+ import type { AnyComposition } from '@playfast/reform/internal'
6
+ import {
7
+ type HostRuntime,
8
+ keyed,
9
+ type MountedFacadeErasure,
10
+ type MountedSlotFacadeErasure,
11
+ type RuntimeRootComposition,
12
+ type Sink,
13
+ uiNameOf,
14
+ } from './engineSink'
15
+ import { makeTreeDriver } from './engineTreeDriver'
16
+ import type { RuntimeTreeFacade, RuntimeTreeFacadeErasure, TreeNode } from './engineTreeTypes'
17
+
18
+ export function makeRuntimeTreeFacade<
19
+ Services,
20
+ RootIdentifier extends NoInfer<Services>,
21
+ P,
22
+ C extends UiContract,
23
+ S extends ReadonlyArray<unknown>,
24
+ N extends string,
25
+ Identity,
26
+ >(
27
+ runtime: RuntimeHandle<Services>,
28
+ root: RuntimeRootComposition<RootIdentifier, P, C, S, N, Identity>,
29
+ rootProps: P,
30
+ sink: Sink,
31
+ dispatched: Set<string>,
32
+ ): RuntimeTreeFacade<C>
33
+ export function makeRuntimeTreeFacade(
34
+ runtime: HostRuntime,
35
+ root: AnyComposition,
36
+ rootProps: unknown,
37
+ sink: Sink,
38
+ dispatched: Set<string>,
39
+ ): RuntimeTreeFacadeErasure {
40
+ const driver = makeTreeDriver(runtime, root, rootProps, sink)
41
+ const currentRoot = (): TreeNode => driver.render().root
42
+
43
+ // The nested-Feature twin of the mounted facade's `advance`: same virtual clock
44
+ // off the same runtime, settled through this driver instead.
45
+ const advance = (duration: Duration.DurationInput): Effect.Effect<void, never, never> =>
46
+ Option.match(runtime.readOption(TestClock.TestClock), {
47
+ onNone: () => Effect.dieMessage(new NoTestClock().message),
48
+ onSome: (clock) => clock.adjust(duration).pipe(Effect.flatMap(() => driver.settle)),
49
+ })
50
+
51
+ // `peek` is the non-dying half of `resolve`: None once the node has left the
52
+ // tree, so an action that removed its own node can still report a result.
53
+ const nodeFacade = (
54
+ resolve: () => TreeNode,
55
+ peek: () => Option.Option<TreeNode>,
56
+ ): MountedFacadeErasure => ({
57
+ props: Effect.sync(() => resolve().props),
58
+ expectProps: (partial) =>
59
+ Effect.sync(() => {
60
+ const mismatch = matchProps({
61
+ actual: resolve().props,
62
+ expected: partial,
63
+ })
64
+ if (mismatch !== undefined) {
65
+ return Effect.runSync(
66
+ Effect.dieMessage(new AssertionFailed({ detail: mismatch }).message),
67
+ )
68
+ }
69
+ }),
70
+ actions: keyed(
71
+ (event) => (payload: unknown) =>
72
+ Effect.gen(function* () {
73
+ const node = resolve()
74
+ const trigger = node.events[event]
75
+ if (trigger === undefined) {
76
+ return yield* Effect.dieMessage(
77
+ new UnknownAction({
78
+ composition: uiNameOf(node.comp),
79
+ action: event,
80
+ rendered: 1,
81
+ }).message,
82
+ )
83
+ }
84
+ dispatched.add(event)
85
+ trigger(payload)
86
+ yield* driver.settle
87
+ // A self-removing action leaves nothing to re-read; report what the
88
+ // node last rendered instead of dying on the vanished child.
89
+ return Option.match(peek(), {
90
+ onNone: () => node.props,
91
+ onSome: (settled) => settled.props,
92
+ })
93
+ }),
94
+ ),
95
+ slots: keyed((slotName) => slotFacade(resolve, slotName)),
96
+ frame: driver.settle,
97
+ advance,
98
+ })
99
+
100
+ const slotFacade = (
101
+ resolveParent: () => TreeNode,
102
+ slotName: string,
103
+ ): MountedSlotFacadeErasure => {
104
+ const children = (): ReadonlyArray<TreeNode> => {
105
+ const parent = resolveParent()
106
+ const resolved = parent.slots[slotName]
107
+ if (resolved === undefined) {
108
+ return Effect.runSync(
109
+ Effect.dieMessage(
110
+ new UnknownSlot({ parent: uiNameOf(parent.comp), slot: slotName }).message,
111
+ ),
112
+ )
113
+ }
114
+ return resolved
115
+ }
116
+ const peekAt = (index: number): Option.Option<TreeNode> =>
117
+ Option.fromNullable(children()[index])
118
+ const peekByKey = (key: string): Option.Option<TreeNode> =>
119
+ Arr.findFirst(children(), (candidate) => Option.contains(candidate.key, key))
120
+ const childAt = (index: number): MountedFacadeErasure =>
121
+ nodeFacade(
122
+ () => children()[index] ?? missingChild(index),
123
+ () => peekAt(index),
124
+ )
125
+ const childByKey = (key: string): MountedFacadeErasure =>
126
+ nodeFacade(
127
+ () => Option.getOrElse(peekByKey(key), () => missingKey(key)),
128
+ () => peekByKey(key),
129
+ )
130
+ const missingChild = (index: number): never => {
131
+ const parent = resolveParent()
132
+ return Effect.runSync(
133
+ Effect.dieMessage(
134
+ new UnknownSlot({
135
+ parent: uiNameOf(parent.comp),
136
+ slot: `${slotName}[${index}]`,
137
+ }).message,
138
+ ),
139
+ )
140
+ }
141
+ const missingKey = (key: string): never => {
142
+ const parent = resolveParent()
143
+ return Effect.runSync(
144
+ Effect.dieMessage(
145
+ new UnknownSlot({
146
+ parent: uiNameOf(parent.comp),
147
+ slot: `${slotName}[key=${key}]`,
148
+ }).message,
149
+ ),
150
+ )
151
+ }
152
+ const first = childAt(0)
153
+ return {
154
+ first: Effect.sync(() => childAt(0)),
155
+ at: (index) => Effect.sync(() => childAt(index)),
156
+ all: Effect.sync(() => children().map((_child, index) => childAt(index))),
157
+ byKey: (key) =>
158
+ Effect.sync(() => {
159
+ const child = Arr.findFirst(children(), (candidate) =>
160
+ Option.contains(candidate.key, key),
161
+ )
162
+ return Option.isNone(child) ? missingKey(key) : childByKey(key)
163
+ }),
164
+ where: (predicate) =>
165
+ Effect.sync(() =>
166
+ children().flatMap((child, index) => (predicate(child.props) ? [childAt(index)] : [])),
167
+ ),
168
+ count: Effect.sync(() => children().length),
169
+ props: first.props,
170
+ expectProps: first.expectProps,
171
+ actions: first.actions,
172
+ slots: first.slots,
173
+ frame: driver.settle,
174
+ advance,
175
+ }
176
+ }
177
+
178
+ return {
179
+ facade: nodeFacade(currentRoot, () => Option.some(currentRoot())),
180
+ settle: driver.settle,
181
+ dispose: driver.dispose,
182
+ }
183
+ }
@@ -0,0 +1,103 @@
1
+ import { type Effect, Match, type Option, Record as Rec } from 'effect'
2
+ import type { FeatureLoadFailed, SlotFill, Trigger, UiContract } from '@playfast/reform'
3
+ import type { AnyComposition, AnyFeatureBinding } from '@playfast/reform/internal'
4
+ import {
5
+ type HostRuntime,
6
+ type MountedFacade,
7
+ type MountedFacadeHandleErasure,
8
+ uiNameOf,
9
+ } from './engineSink'
10
+
11
+ export type FeatureMountState =
12
+ | { readonly _tag: 'Loading' }
13
+ | { readonly _tag: 'Live'; readonly runtime: HostRuntime }
14
+ | { readonly _tag: 'Failed'; readonly error: FeatureLoadFailed }
15
+
16
+ export interface TreeFeatureMount {
17
+ readonly id: string
18
+ readonly binding: AnyFeatureBinding
19
+ readonly parent: HostRuntime
20
+ readonly depth: number
21
+ readonly props: unknown
22
+ attempt: number
23
+ state: FeatureMountState
24
+ dispose: () => void
25
+ }
26
+
27
+ export interface TreeNode {
28
+ readonly path: string
29
+ readonly depth: number
30
+ readonly key: Option.Option<string>
31
+ readonly comp: AnyComposition
32
+ readonly props: unknown
33
+ readonly events: Record<string, Trigger<unknown>>
34
+ readonly slots: Readonly<Record<string, ReadonlyArray<TreeNode>>>
35
+ }
36
+
37
+ export interface TreeRenderState {
38
+ readonly root: TreeNode
39
+ readonly fingerprint: string
40
+ }
41
+
42
+ export interface RuntimeTreeFacade<C extends UiContract> {
43
+ readonly facade: MountedFacade<C>
44
+ readonly settle: Effect.Effect<void, never, never>
45
+ dispose(): void
46
+ }
47
+
48
+ export interface RuntimeTreeFacadeErasure extends MountedFacadeHandleErasure {
49
+ dispose(): void
50
+ }
51
+
52
+ export interface TreeDriver {
53
+ readonly render: () => TreeRenderState
54
+ readonly settle: Effect.Effect<void, never, never>
55
+ readonly dispose: () => void
56
+ }
57
+
58
+ export interface MountIdentityInput {
59
+ readonly parent: HostRuntime
60
+ readonly parentPath: string
61
+ readonly slotName: string
62
+ readonly key: string
63
+ readonly binding: AnyFeatureBinding
64
+ }
65
+
66
+ export interface FeatureRecordInput {
67
+ readonly id: string
68
+ readonly parent: HostRuntime
69
+ readonly binding: AnyFeatureBinding
70
+ readonly depth: number
71
+ readonly props: unknown
72
+ }
73
+
74
+ export interface RenderCompositionInput {
75
+ readonly runtime: HostRuntime
76
+ readonly comp: AnyComposition
77
+ readonly props: unknown
78
+ readonly key: Option.Option<string>
79
+ readonly path: string
80
+ readonly depth: number
81
+ readonly seen: Set<string>
82
+ readonly mountMissing: boolean
83
+ }
84
+
85
+ export const encodeIdentityPart = (part: string): string => `${part.length}:${part}`
86
+
87
+ export const fillEntries = (
88
+ slotName: string,
89
+ fill: SlotFill<unknown>,
90
+ ): ReadonlyArray<{ readonly key: string; readonly props: unknown }> =>
91
+ Match.value(fill).pipe(
92
+ Match.when({ _tag: 'Each' }, (each) => each.items),
93
+ Match.when({ _tag: 'One' }, (oneFill) => [{ key: `${slotName}.0`, props: oneFill.props }]),
94
+ Match.when({ _tag: 'Absent' }, () => []),
95
+ Match.exhaustive,
96
+ )
97
+
98
+ export const treeFingerprint = (node: TreeNode): unknown => [
99
+ uiNameOf(node.comp),
100
+ node.key,
101
+ node.props,
102
+ Rec.map(node.slots, (children) => children.map(treeFingerprint)),
103
+ ]
package/src/errors.ts CHANGED
@@ -40,3 +40,22 @@ export class UnknownSlot extends UnknownSlotBase {
40
40
  return `reform-proof: '${this.parent}' has no slot '${this.slot}'`
41
41
  }
42
42
  }
43
+
44
+ /** The `Data.TaggedError(tag)` shape for a payload-free error: a `void` argument. */
45
+ type TaggedErrorClassVoid<Tag extends string> = new (
46
+ args: void,
47
+ ) => Cause.YieldableError & { readonly _tag: Tag }
48
+
49
+ const NoTestClockBase: TaggedErrorClassVoid<'reform-proof/NoTestClock'> = Data.TaggedError(
50
+ 'reform-proof/NoTestClock',
51
+ )
52
+
53
+ /**
54
+ * `app.advance(...)` ran against a scene whose runtime still holds the real clock,
55
+ * so there is no product time to move.
56
+ */
57
+ export class NoTestClock extends NoTestClockBase {
58
+ override get message(): string {
59
+ return "reform-proof: app.advance(...) needs a virtual clock — wrap the scene's layers with `Proof.withTestClock(...)`. Without it the scene's procedures sleep on the real clock and only elapse in real time."
60
+ }
61
+ }
package/src/facade.ts ADDED
@@ -0,0 +1,78 @@
1
+ import type { Duration, Effect } from 'effect'
2
+ import type {
3
+ SlotContract as ReformSlotContract,
4
+ Trigger,
5
+ UiContract,
6
+ } from '@playfast/reform'
7
+
8
+ export type EventsOf<C extends UiContract> = C extends {
9
+ readonly events: infer E extends NonNullable<UiContract['events']>
10
+ }
11
+ ? E
12
+ : Readonly<Record<PropertyKey, never>>
13
+ export type EventNamesOf<C extends UiContract> = C extends {
14
+ readonly events: NonNullable<UiContract['events']>
15
+ }
16
+ ? keyof EventsOf<C>
17
+ : never
18
+ export type SlotsOf<C extends UiContract> = C extends {
19
+ readonly slots: infer S extends NonNullable<UiContract['slots']>
20
+ }
21
+ ? S
22
+ : Readonly<Record<PropertyKey, never>>
23
+ export type PayloadOf<T> = T extends Trigger<infer P> ? P : never
24
+ export type ContractOf<Comp> = Comp extends {
25
+ readonly Contract: infer C extends UiContract
26
+ }
27
+ ? C
28
+ : never
29
+ export type ContractOfSlot<S> = ReformSlotContract<S>
30
+
31
+ export type ActionsOf<C extends UiContract, R = never> = C extends {
32
+ readonly events: NonNullable<UiContract['events']>
33
+ }
34
+ ? {
35
+ readonly [K in keyof EventsOf<C>]: (
36
+ payload: PayloadOf<EventsOf<C>[K]>,
37
+ ) => Effect.Effect<C['props'], never, R>
38
+ }
39
+ : Readonly<Record<PropertyKey, never>>
40
+ export type SlotFacadesOf<C extends UiContract, R = never> = C extends {
41
+ readonly slots: NonNullable<UiContract['slots']>
42
+ }
43
+ ? {
44
+ readonly [K in keyof SlotsOf<C>]: SlotFacade<ContractOfSlot<SlotsOf<C>[K]>, R>
45
+ }
46
+ : Readonly<Record<PropertyKey, never>>
47
+
48
+ export type Action<R = never> = (payload: unknown) => Effect.Effect<unknown, never, R>
49
+
50
+ export interface Facade<C extends UiContract, R = never> {
51
+ readonly props: Effect.Effect<C['props'], never, R>
52
+ readonly expectProps: (partial: Partial<C['props']>) => Effect.Effect<void, never, R>
53
+ readonly actions: ActionsOf<C, R>
54
+ readonly slots: SlotFacadesOf<C, R>
55
+ readonly frame: Effect.Effect<void, never, R>
56
+ /**
57
+ * Elapse `duration` of the scene's product time, then settle. Every timer the
58
+ * scene's procedures hold — `Effect.sleep`, `Schedule`, `Clock` reads — moves by
59
+ * exactly this much and no real time passes, so a 1500ms poll interval is observed
60
+ * in microseconds and never races the runner.
61
+ *
62
+ * Requires the scene's layers to be wrapped in `Proof.withTestClock(...)`. Against
63
+ * the real clock there is no virtual time to move, so the proof dies with
64
+ * `NoTestClock` instead of silently doing nothing.
65
+ */
66
+ readonly advance: (duration: Duration.DurationInput) => Effect.Effect<void, never, R>
67
+ }
68
+
69
+ export interface SlotFacade<C extends UiContract, R = never> extends Facade<C, R> {
70
+ readonly first: Effect.Effect<Facade<C, R>, never, R>
71
+ readonly at: (index: number) => Effect.Effect<Facade<C, R>, never, R>
72
+ readonly all: Effect.Effect<ReadonlyArray<Facade<C, R>>, never, R>
73
+ readonly byKey: (key: string) => Effect.Effect<Facade<C, R>, never, R>
74
+ readonly where: (
75
+ predicate: (props: C['props']) => boolean,
76
+ ) => Effect.Effect<ReadonlyArray<Facade<C, R>>, never, R>
77
+ readonly count: Effect.Effect<number, never, R>
78
+ }