@playfast/reform-proof 1.1.1 → 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.
package/src/facade.ts CHANGED
@@ -1,258 +1,78 @@
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,
1
+ import type { Duration, Effect } from 'effect'
2
+ import type {
3
+ SlotContract as ReformSlotContract,
4
+ Trigger,
5
+ UiContract,
13
6
  } 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
7
 
35
- type AnyFacade = Facade<UiContract>
36
- type AnySlotFacade = SlotFacade<UiContract>
37
- const encodeUnknown = S.encodeSync(S.parseJson(S.Unknown))
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>
38
30
 
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
- )
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>
201
38
  }
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,
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>
241
45
  }
242
- }
46
+ : Readonly<Record<PropertyKey, never>>
47
+
48
+ export type Action<R = never> = (payload: unknown) => Effect.Effect<unknown, never, R>
243
49
 
244
- return {
245
- facade: nodeFacade({ name: uiNameOf(root), index: 0 }, root),
246
- settle,
247
- }
248
- })
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
+ }
249
68
 
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))
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
+ }
@@ -0,0 +1,24 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { formatFingerprint } from './fingerprint'
3
+
4
+ describe('proof render fingerprints', () => {
5
+ it('distinguishes bigint props from strings without throwing', () => {
6
+ expect(formatFingerprint({ sequence: 1n })).not.toBe(formatFingerprint({ sequence: '1' }))
7
+ })
8
+
9
+ it('stabilizes equivalent cyclic render props', () => {
10
+ const left: Array<unknown> = []
11
+ left.push(left)
12
+ const right: Array<unknown> = []
13
+ right.push(right)
14
+
15
+ expect(formatFingerprint(left)).toBe(formatFingerprint(right))
16
+ })
17
+
18
+ it('tracks Map and Set contents', () => {
19
+ expect(formatFingerprint(new Map([['feature', 'home']]))).not.toBe(
20
+ formatFingerprint(new Map([['feature', 'tasks']])),
21
+ )
22
+ expect(formatFingerprint(new Set(['home']))).not.toBe(formatFingerprint(new Set(['tasks'])))
23
+ })
24
+ })
@@ -0,0 +1,73 @@
1
+ import * as Option from 'effect/Option'
2
+
3
+ const readPropertyOption = Option.liftThrowable((target: object, key: PropertyKey): unknown =>
4
+ Reflect.get(target, key),
5
+ )
6
+
7
+ const readProperty = (target: object, key: PropertyKey): unknown =>
8
+ Option.getOrElse(readPropertyOption(target, key), () => '[Property read threw]')
9
+
10
+ const propertyKeyFingerprint = (key: PropertyKey): string =>
11
+ typeof key === 'symbol'
12
+ ? `symbol:${String(key.description)}`
13
+ : typeof key === 'number'
14
+ ? `number-key:${String(key)}`
15
+ : `key:${key.length}:${key}`
16
+
17
+ const valueFingerprint = (candidate: unknown, seen: WeakSet<object>): string => {
18
+ if (typeof candidate === 'bigint') {
19
+ return `bigint:${String(candidate)}`
20
+ }
21
+ if (typeof candidate === 'boolean') {
22
+ return candidate ? 'boolean:true' : 'boolean:false'
23
+ }
24
+ if (typeof candidate === 'function') {
25
+ return `function:${String(candidate)}`
26
+ }
27
+ if (typeof candidate === 'number') {
28
+ return `number:${String(candidate)}`
29
+ }
30
+ if (typeof candidate === 'string') {
31
+ return `string:${candidate.length}:${candidate}`
32
+ }
33
+ if (typeof candidate === 'symbol') {
34
+ return `symbol:${String(candidate.description)}`
35
+ }
36
+ if (typeof candidate === 'undefined') {
37
+ return 'undefined'
38
+ }
39
+ if (candidate === null) {
40
+ return 'null'
41
+ }
42
+ if (seen.has(candidate)) {
43
+ return '[Circular]'
44
+ }
45
+ seen.add(candidate)
46
+ if (Array.isArray(candidate)) {
47
+ return `[${candidate.map((arrayEntry) => valueFingerprint(arrayEntry, seen)).join(',')}]`
48
+ }
49
+ if (candidate instanceof Date) {
50
+ return `date:${String(candidate.getTime())}`
51
+ }
52
+ if (candidate instanceof Map) {
53
+ return `map:[${Array.from(
54
+ candidate,
55
+ ([mapKey, mapEntry]) =>
56
+ `${valueFingerprint(mapKey, seen)}=>${valueFingerprint(mapEntry, seen)}`,
57
+ ).join(',')}]`
58
+ }
59
+ if (candidate instanceof Set) {
60
+ return `set:[${Array.from(candidate, (setEntry) => valueFingerprint(setEntry, seen)).join(
61
+ ',',
62
+ )}]`
63
+ }
64
+ return `{${Reflect.ownKeys(candidate)
65
+ .map(
66
+ (key) =>
67
+ `${propertyKeyFingerprint(key)}:${valueFingerprint(readProperty(candidate, key), seen)}`,
68
+ )
69
+ .join(',')}}`
70
+ }
71
+
72
+ export const formatFingerprint = (fingerprintInput: unknown): string =>
73
+ valueFingerprint(fingerprintInput, new WeakSet())