@playfast/reform 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/README.md +85 -55
- package/package.json +17 -17
- package/src/boundary/boundary.test.ts +82 -33
- package/src/boundary/boundary.ts +141 -32
- package/src/calc/asyncCalc.invalidate.test.ts +733 -32
- package/src/calc/asyncCalc.test.ts +397 -225
- package/src/calc/asyncCalc.ts +115 -100
- package/src/calc/asyncCalcTypes.ts +147 -0
- package/src/calc/calc.test.ts +13 -16
- package/src/calc/calc.ts +44 -18
- package/src/calc/calcFamily.test.ts +90 -32
- package/src/calc/calcFamily.ts +56 -25
- package/src/calc/compose.ts +1 -14
- package/src/channel/channel.ts +25 -6
- package/src/channel/{procedureRegistry.ts → procedures.ts} +41 -23
- package/src/compose/composition.test.ts +19 -0
- package/src/compose/composition.ts +168 -81
- package/src/compose/compositionTypes.ts +249 -0
- package/src/compose/props.ts +13 -6
- package/src/compose/provide.ts +187 -46
- package/src/compose/slot.ts +156 -19
- package/src/compose/structure.test.ts +6 -3
- package/src/compose/structure.ts +106 -13
- package/src/compose/ui.test.ts +19 -3
- package/src/compose/ui.ts +147 -54
- package/src/compose/ui.typecheck.ts +40 -4
- package/src/definition/definition.ts +22 -9
- package/src/event/event.fromSource.test.ts +172 -0
- package/src/event/event.test.ts +10 -0
- package/src/event/event.ts +102 -18
- package/src/feature/feature.mount.test.ts +123 -56
- package/src/feature/feature.test.ts +67 -63
- package/src/feature/feature.ts +222 -262
- package/src/feature/feature.typecheck.ts +224 -91
- package/src/feature/featureBinding.ts +130 -20
- package/src/feature/featureClass.ts +162 -0
- package/src/feature/featureConfig.ts +192 -0
- package/src/feature/featureFactory.ts +163 -0
- package/src/feature/featureModule.ts +136 -0
- package/src/feature/featureModule.typecheck.ts +63 -0
- package/src/feature/featureNativeTree.ts +152 -0
- package/src/feature/featureRequirement.ts +82 -0
- package/src/feature/featureVariants.ts +234 -0
- package/src/feature/mountFeature.ts +54 -0
- package/src/graph/closure.ts +222 -0
- package/src/graph/services.ts +95 -0
- package/src/graph/summary.ts +134 -0
- package/src/index.ts +171 -245
- package/src/internal/bucketCache.ts +39 -0
- package/src/internal/capture.ts +9 -4
- package/src/internal/env.ts +1 -3
- package/src/internal/errors.ts +21 -10
- package/src/internal/queryDriver.ts +61 -25
- package/src/internal/queryDriverHydrate.ts +45 -0
- package/src/internal/queryDriverStore.ts +8 -5
- package/src/internal/queryDriverTypes.ts +11 -11
- package/src/internal/reuse.test.ts +10 -3
- package/src/internal/reuse.ts +5 -2
- package/src/internal/scheduler.ts +4 -1
- package/src/internal/sources.ts +25 -11
- package/src/internal/store.test.ts +27 -6
- package/src/internal/track.ts +3 -2
- package/src/internal.ts +222 -0
- package/src/namespace/namespace.ts +46 -25
- package/src/procedure/procedure.ts +38 -11
- package/src/reducer/reducer.ts +78 -34
- package/src/remote/pendingQueue.ts +145 -0
- package/src/remote/remoteState.test.ts +536 -354
- package/src/remote/remoteState.ts +145 -106
- package/src/remote/remoteState.typecheck.ts +47 -23
- package/src/remote/remoteStateMake.ts +97 -0
- package/src/remote/remoteStateSend.ts +95 -37
- package/src/remote/remoteStateTypes.ts +155 -0
- package/src/runtime/appRuntime.activation.test.ts +104 -0
- package/src/runtime/appRuntime.test.ts +187 -111
- package/src/runtime/appRuntime.ts +36 -130
- package/src/runtime/capturedAppRuntime.ts +274 -0
- package/src/runtime/eventBudget.test.ts +39 -12
- package/src/runtime/eventBudget.ts +9 -8
- package/src/runtime/featureMount.ts +94 -0
- package/src/runtime/hardening.test.ts +22 -13
- package/src/runtime/instrumentation.test.ts +213 -194
- package/src/runtime/instrumentation.ts +6 -11
- package/src/runtime/loop.test.ts +84 -7
- package/src/runtime/loop.ts +69 -40
- package/src/runtime/runtimeHandle.ts +124 -0
- package/src/scene/featureScene.test.ts +4 -2
- package/src/scene/scene.ts +234 -64
- package/src/scene/seedScene.test.ts +69 -86
- package/src/state/state.nominal.typecheck.ts +68 -0
- package/src/state/state.test.ts +17 -0
- package/src/state/state.ts +79 -26
- package/src/state/stateFamily.test.ts +39 -8
- package/src/state/stateFamily.ts +55 -22
- package/src/state/stateGroup.ts +53 -17
- package/src/state/token.ts +40 -10
- package/src/synced/syncedStore.ts +46 -23
- package/src/testkit/flight.testkit.ts +84 -0
- package/src/wire/tree.test.ts +8 -4
- package/src/wire/triggers.test.ts +6 -2
- package/src/wire/triggers.ts +14 -10
- package/src/calc/asyncCalcDefinitions.ts +0 -48
- package/src/internal/ctx.ts +0 -9
- package/src/remote/remoteStateDefinition.ts +0 -135
- package/src/remote/remoteStateLayers.ts +0 -118
- package/src/remote/remoteStateLiveTypes.ts +0 -59
package/src/compose/ui.ts
CHANGED
|
@@ -1,33 +1,33 @@
|
|
|
1
1
|
import type { FunctionComponent } from 'react'
|
|
2
2
|
import { Context, type Schema } from 'effect'
|
|
3
|
+
import type { AST } from 'effect/SchemaAST'
|
|
3
4
|
import { type Manifest, definitionClass } from '../definition/definition'
|
|
4
5
|
import type { Trigger } from '../ui/trigger'
|
|
5
|
-
import { type
|
|
6
|
-
import type { AnyValue } from '../internal/variance'
|
|
6
|
+
import { type AnySlot, type AnySlotInstance, type Node, type SlotProps } from './slot'
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
// Field presence drives conditional slot/event inference; Option would erase it.
|
|
9
|
+
interface UiContractOptionalFields {
|
|
10
|
+
slots: Record<string, AnySlotInstance>
|
|
11
|
+
events: Record<string, Trigger<never>>
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface UiContract extends Partial<UiContractOptionalFields> {
|
|
9
15
|
props: unknown
|
|
10
|
-
// Optional so local `ui('X')<{ props }>()` stays terse and `C extends { slots/events: … }` discriminates presence
|
|
11
|
-
// oxlint-disable-next-line reform-rules/no-optional-fields -- presence drives `extends {...}` contract inference (see note above)
|
|
12
|
-
slots?: Record<string, SlotInstance<AnyValue>>
|
|
13
|
-
// oxlint-disable-next-line reform-rules/no-optional-fields -- presence drives `extends {...}` contract inference (see note above)
|
|
14
|
-
events?: Record<string, Trigger<AnyValue>>
|
|
15
16
|
}
|
|
16
17
|
|
|
17
18
|
type PropsOf<C extends UiContract> = C['props']
|
|
18
|
-
export type EventsOf<C extends UiContract> = C extends { events: infer E }
|
|
19
|
-
|
|
20
|
-
|
|
19
|
+
export type EventsOf<C extends UiContract> = C extends { events: infer E }
|
|
20
|
+
? E
|
|
21
|
+
: Readonly<Record<PropertyKey, never>>
|
|
22
|
+
|
|
23
|
+
// slotKey is framework-only selection; Partial also permits prop-less remote placeholders.
|
|
21
24
|
type SlotsOf<C extends UiContract> = C extends { slots: infer S }
|
|
22
25
|
? {
|
|
23
|
-
// slotKey inside Partial so `any` child props still collapse to `any` (outer & would break unknown)
|
|
24
|
-
// Handle = renderable FC + inspectable `.props` fills (engine fixes multiplicity)
|
|
25
26
|
readonly [K in keyof S]: FunctionComponent<
|
|
26
|
-
|
|
27
|
-
Partial<SlotProps<S[K]> & { readonly slotKey?: string }>
|
|
27
|
+
Partial<SlotProps<S[K]> & { readonly slotKey: string }>
|
|
28
28
|
> & { readonly props: ReadonlyArray<SlotProps<S[K]>> }
|
|
29
29
|
}
|
|
30
|
-
: Record<
|
|
30
|
+
: Readonly<Record<PropertyKey, never>>
|
|
31
31
|
|
|
32
32
|
export type ViewImpl<C extends UiContract> = (
|
|
33
33
|
props: PropsOf<C>,
|
|
@@ -38,51 +38,86 @@ export type ViewImpl<C extends UiContract> = (
|
|
|
38
38
|
export const UiTypeId: unique symbol = Symbol.for('reform/Ui')
|
|
39
39
|
export type UiTypeId = typeof UiTypeId
|
|
40
40
|
|
|
41
|
-
export
|
|
41
|
+
export const UiServiceTypeId: unique symbol = Symbol.for('reform/UiService')
|
|
42
|
+
export type UiServiceTypeId = typeof UiServiceTypeId
|
|
43
|
+
|
|
44
|
+
export interface UiService<N extends string> {
|
|
45
|
+
readonly [UiServiceTypeId]: N
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export type CallableView = (...args: ReadonlyArray<never>) => unknown
|
|
49
|
+
|
|
50
|
+
export interface UiSchemaReflection {
|
|
51
|
+
readonly ast: AST
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
interface UiManifestOptionalFields {
|
|
55
|
+
readonly props: UiSchemaReflection
|
|
56
|
+
readonly events: Readonly<Record<string, UiSchemaReflection>>
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface UiManifest extends Manifest, Partial<UiManifestOptionalFields> {
|
|
42
60
|
readonly kind: 'Ui'
|
|
43
|
-
// Schema variance needs any in encoded/context; present only on wired form for discrimination
|
|
44
|
-
// oxlint-disable-next-line reform-rules/no-optional-fields -- wire manifest field present only on the wired form (see note above)
|
|
45
|
-
readonly props?: Schema.Schema.AnyNoContext
|
|
46
|
-
// oxlint-disable-next-line reform-rules/no-optional-fields -- wire manifest field present only on the wired form (see note above)
|
|
47
|
-
readonly events?: Readonly<Record<string, Schema.Schema.AnyNoContext>>
|
|
48
61
|
}
|
|
49
62
|
|
|
50
63
|
export interface WiredUiManifest extends UiManifest {
|
|
51
|
-
readonly props:
|
|
64
|
+
readonly props: UiSchemaReflection
|
|
52
65
|
}
|
|
53
66
|
|
|
54
|
-
|
|
55
|
-
|
|
67
|
+
export type UiCapture<Result> = <C extends UiContract, N extends string>(
|
|
68
|
+
contract: UiClass<C, N>,
|
|
69
|
+
) => Result
|
|
70
|
+
|
|
71
|
+
export interface UiReflection<
|
|
72
|
+
out C extends UiContract = UiContract,
|
|
73
|
+
out N extends string = string,
|
|
74
|
+
> {
|
|
75
|
+
new (): {}
|
|
76
|
+
readonly [UiTypeId]: UiTypeId
|
|
77
|
+
readonly manifest: UiManifest & { readonly name: N }
|
|
78
|
+
readonly Contract: C
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface AnyUi {
|
|
56
82
|
new (): {}
|
|
57
83
|
readonly [UiTypeId]: UiTypeId
|
|
58
84
|
readonly manifest: UiManifest
|
|
59
|
-
readonly
|
|
85
|
+
readonly capture: <Result>(visit: UiCapture<Result>) => Result
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface UiClass<C extends UiContract, N extends string> extends UiReflection<C, N> {
|
|
89
|
+
readonly capture: <Result>(visit: UiCapture<Result>) => Result
|
|
90
|
+
readonly impl: Context.Tag<UiService<N>, ViewImpl<C>>
|
|
91
|
+
readonly provider: Context.Tag<UiService<N>, CallableView>
|
|
60
92
|
}
|
|
61
93
|
|
|
62
|
-
export type Contract<U> = U extends
|
|
94
|
+
export type Contract<U> = U extends UiReflection<infer C, infer _N> ? C : never
|
|
95
|
+
export type UiName<U> = U extends UiReflection<infer _C, infer N> ? N : never
|
|
96
|
+
|
|
97
|
+
export type WiredUi<C extends UiContract, N extends string> = UiClass<C, N> & {
|
|
98
|
+
readonly manifest: WiredUiManifest
|
|
99
|
+
}
|
|
63
100
|
|
|
64
|
-
|
|
101
|
+
interface WireSchemasOptionalFields {
|
|
102
|
+
readonly events: Readonly<Record<string, Schema.Schema.AnyNoContext>>
|
|
103
|
+
readonly slots: Readonly<Record<string, AnySlot>>
|
|
104
|
+
}
|
|
65
105
|
|
|
66
|
-
export interface WireSchemas {
|
|
106
|
+
export interface WireSchemas extends Partial<WireSchemasOptionalFields> {
|
|
67
107
|
readonly props: Schema.Schema.AnyNoContext
|
|
68
|
-
// oxlint-disable-next-line reform-rules/no-optional-fields -- presence drives `extends {...}` derivation (DerivedEvents)
|
|
69
|
-
readonly events?: Readonly<Record<string, Schema.Schema.AnyNoContext>>
|
|
70
|
-
// Slot CLASSES (not instances) so the contract facade derives InstanceType
|
|
71
|
-
// oxlint-disable-next-line reform-rules/no-optional-fields -- presence drives `extends {...}` derivation (DerivedSlots)
|
|
72
|
-
readonly slots?: Readonly<Record<string, SlotClass>>
|
|
73
108
|
}
|
|
74
109
|
|
|
75
110
|
type DerivedEvents<Sch extends WireSchemas> = Sch extends {
|
|
76
111
|
readonly events: infer E extends Record<string, Schema.Schema.AnyNoContext>
|
|
77
112
|
}
|
|
78
113
|
? { readonly [K in keyof E]: Trigger<Schema.Schema.Type<E[K]>> }
|
|
79
|
-
: Record<
|
|
114
|
+
: Readonly<Record<PropertyKey, never>>
|
|
80
115
|
|
|
81
116
|
type DerivedSlots<Sch extends WireSchemas> = Sch extends {
|
|
82
|
-
readonly slots: infer S extends Record<string,
|
|
117
|
+
readonly slots: infer S extends Record<string, AnySlot>
|
|
83
118
|
}
|
|
84
119
|
? { readonly [K in keyof S]: InstanceType<S[K]> }
|
|
85
|
-
: Record<
|
|
120
|
+
: Readonly<Record<PropertyKey, never>>
|
|
86
121
|
|
|
87
122
|
export type DerivedContract<Sch extends WireSchemas> = {
|
|
88
123
|
readonly props: Schema.Schema.Type<Sch['props']>
|
|
@@ -90,40 +125,98 @@ export type DerivedContract<Sch extends WireSchemas> = {
|
|
|
90
125
|
readonly events: DerivedEvents<Sch>
|
|
91
126
|
}
|
|
92
127
|
|
|
93
|
-
export const isUi = (candidate: unknown): candidate is
|
|
128
|
+
export const isUi = (candidate: unknown): candidate is AnyUi =>
|
|
94
129
|
(typeof candidate === 'function' || typeof candidate === 'object') &&
|
|
95
130
|
candidate !== null &&
|
|
96
131
|
UiTypeId in candidate
|
|
97
132
|
|
|
98
|
-
|
|
99
|
-
|
|
133
|
+
type UiDefinitionStatics<
|
|
134
|
+
C extends UiContract,
|
|
135
|
+
N extends string,
|
|
136
|
+
M extends UiManifest & { readonly name: N },
|
|
137
|
+
> = Pick<
|
|
138
|
+
UiClass<C, N> & { readonly manifest: M },
|
|
139
|
+
typeof UiTypeId | 'manifest' | 'impl' | 'provider' | 'capture'
|
|
140
|
+
>
|
|
141
|
+
|
|
142
|
+
const buildUi = <
|
|
143
|
+
N extends string,
|
|
144
|
+
C extends UiContract,
|
|
145
|
+
M extends UiManifest & { readonly name: N },
|
|
146
|
+
>(
|
|
147
|
+
name: N,
|
|
100
148
|
manifest: M,
|
|
101
|
-
): UiClass<C> & { readonly manifest: M } => {
|
|
102
|
-
const impl = Context.GenericTag<
|
|
103
|
-
|
|
149
|
+
): UiClass<C, N> & { readonly manifest: M } => {
|
|
150
|
+
const impl = Context.GenericTag<UiService<N>, ViewImpl<C>>(`reform/ui/${name}`)
|
|
151
|
+
const provider = Context.GenericTag<UiService<N>, CallableView>(`reform/ui/${name}`)
|
|
152
|
+
const contract = definitionClass<UiClass<C, N> & { readonly manifest: M }>({
|
|
104
153
|
[UiTypeId]: UiTypeId,
|
|
105
154
|
manifest,
|
|
106
155
|
impl,
|
|
107
|
-
|
|
156
|
+
provider,
|
|
157
|
+
capture: <Result>(visit: UiCapture<Result>): Result => visit(contract),
|
|
158
|
+
} satisfies UiDefinitionStatics<C, N, M>)
|
|
159
|
+
return contract
|
|
108
160
|
}
|
|
109
161
|
|
|
110
|
-
|
|
111
|
-
|
|
162
|
+
type UiDefinition<N extends string> = (<C extends UiContract>() => UiClass<C, N>) | AnyUi
|
|
163
|
+
|
|
164
|
+
export function ui<const N extends string>(name: N): <C extends UiContract>() => UiClass<C, N>
|
|
165
|
+
export function ui<const N extends string, const Sch extends WireSchemas>(
|
|
166
|
+
name: N,
|
|
167
|
+
schemas: Sch,
|
|
168
|
+
): WiredUi<DerivedContract<Sch>, N>
|
|
112
169
|
// oxlint-disable-next-line reform-rules/min-var-name -- `ui` is the public framework contract-authoring API; renaming would break every caller across the monorepo
|
|
113
|
-
export function ui(name:
|
|
170
|
+
export function ui<const N extends string>(name: N, schemas?: WireSchemas): UiDefinition<N> {
|
|
114
171
|
if (schemas === undefined) {
|
|
115
|
-
return <C extends UiContract>(): UiClass<C
|
|
172
|
+
return <C extends UiContract>(): UiClass<C, N> =>
|
|
173
|
+
buildUi<N, C, UiManifest & { readonly name: N }>(name, {
|
|
174
|
+
kind: 'Ui',
|
|
175
|
+
name,
|
|
176
|
+
})
|
|
116
177
|
}
|
|
117
178
|
const eventFields = schemas.events === undefined ? {} : { events: schemas.events }
|
|
118
|
-
const manifest: WiredUiManifest
|
|
119
|
-
|
|
179
|
+
const manifest: WiredUiManifest & { readonly name: N } = {
|
|
180
|
+
kind: 'Ui',
|
|
181
|
+
name,
|
|
182
|
+
props: schemas.props,
|
|
183
|
+
...eventFields,
|
|
184
|
+
}
|
|
185
|
+
return buildUi<N, DerivedContract<WireSchemas>, WiredUiManifest & { readonly name: N }>(
|
|
186
|
+
name,
|
|
187
|
+
manifest,
|
|
188
|
+
)
|
|
120
189
|
}
|
|
121
190
|
|
|
122
191
|
export const UiViewContract: unique symbol = Symbol.for('reform/ui/view-contract')
|
|
123
192
|
export type UiViewContract = typeof UiViewContract
|
|
124
193
|
|
|
125
|
-
export type
|
|
194
|
+
export type MadeViewCapture<Result> = <C extends UiContract, N extends string, U extends AnyUi>(
|
|
195
|
+
view: MadeView<C, N, U>,
|
|
196
|
+
) => Result
|
|
197
|
+
|
|
198
|
+
export type MadeView<
|
|
199
|
+
C extends UiContract,
|
|
200
|
+
N extends string,
|
|
201
|
+
U extends AnyUi = UiClass<C, N>,
|
|
202
|
+
> = ViewImpl<C> & {
|
|
203
|
+
readonly [UiViewContract]: U
|
|
204
|
+
readonly capture: <Result>(visit: MadeViewCapture<Result>) => Result
|
|
205
|
+
}
|
|
126
206
|
|
|
127
|
-
export
|
|
128
|
-
|
|
129
|
-
|
|
207
|
+
export type AnyMadeView = CallableView & {
|
|
208
|
+
readonly [UiViewContract]: AnyUi
|
|
209
|
+
readonly capture: <Result>(visit: MadeViewCapture<Result>) => Result
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export const make = <U extends AnyUi>(
|
|
213
|
+
contract: U,
|
|
214
|
+
view: ViewImpl<Contract<U>>,
|
|
215
|
+
): MadeView<Contract<U>, UiName<U>, U> => {
|
|
216
|
+
// oxlint-disable-next-line reform-rules/no-object-assign -- `view` is callable; spread would drop callability, so its reflection statics are attached in place
|
|
217
|
+
const made: MadeView<Contract<U>, UiName<U>, U> = Object.assign(view, {
|
|
218
|
+
[UiViewContract]: contract,
|
|
219
|
+
capture: <Result>(visit: MadeViewCapture<Result>): Result => visit(made),
|
|
220
|
+
})
|
|
221
|
+
return made
|
|
222
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Schema as S } from 'effect'
|
|
2
|
+
import { slot, type SlotService } from './slot'
|
|
2
3
|
import {
|
|
3
4
|
type Contract,
|
|
4
5
|
type DerivedContract,
|
|
@@ -7,13 +8,32 @@ import {
|
|
|
7
8
|
ui,
|
|
8
9
|
type UiClass,
|
|
9
10
|
type UiContract,
|
|
11
|
+
type UiService,
|
|
10
12
|
type WiredUi,
|
|
11
13
|
} from './ui'
|
|
12
14
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
+
type ExpectFalse<Value extends false> = Value
|
|
16
|
+
|
|
17
|
+
declare function acceptsAny<C extends UiContract, N extends string>(contract: UiClass<C, N>): void
|
|
18
|
+
declare function acceptsWired<C extends UiContract, N extends string>(contract: WiredUi<C, N>): void
|
|
15
19
|
|
|
16
20
|
class LocalUi extends ui('Local')<{ props: { n: number } }>() {}
|
|
21
|
+
class SameShapeUi extends ui('SameShape')<{ props: { n: number } }>() {}
|
|
22
|
+
class FirstSlot extends slot('FirstSlot')<FirstSlot>() {}
|
|
23
|
+
class SecondSlot extends slot('SecondSlot')<SecondSlot>() {}
|
|
24
|
+
|
|
25
|
+
type SameShapedUiDefinitionsRemainDistinct = ExpectFalse<
|
|
26
|
+
typeof LocalUi extends typeof SameShapeUi ? true : false
|
|
27
|
+
>
|
|
28
|
+
type SameShapedSlotDefinitionsRemainDistinct = ExpectFalse<
|
|
29
|
+
typeof FirstSlot extends typeof SecondSlot ? true : false
|
|
30
|
+
>
|
|
31
|
+
type WrongUiDoesNotCloseRequirement = ExpectFalse<
|
|
32
|
+
UiService<'SameShape'> extends UiService<'Local'> ? true : false
|
|
33
|
+
>
|
|
34
|
+
type WrongSlotDoesNotCloseRequirement = ExpectFalse<
|
|
35
|
+
SlotService<'SecondSlot', SecondSlot> extends SlotService<'FirstSlot', FirstSlot> ? true : false
|
|
36
|
+
>
|
|
17
37
|
const wiredSchemas: {
|
|
18
38
|
readonly props: S.Struct<{ n: typeof S.Number }>
|
|
19
39
|
readonly events: { readonly bump: S.Struct<{ by: typeof S.Number }> }
|
|
@@ -21,7 +41,10 @@ const wiredSchemas: {
|
|
|
21
41
|
props: S.Struct({ n: S.Number }),
|
|
22
42
|
events: { bump: S.Struct({ by: S.Number }) },
|
|
23
43
|
}
|
|
24
|
-
const WiredUiClassBase: WiredUi<DerivedContract<typeof wiredSchemas
|
|
44
|
+
const WiredUiClassBase: WiredUi<DerivedContract<typeof wiredSchemas>, 'Wired'> = ui(
|
|
45
|
+
'Wired',
|
|
46
|
+
wiredSchemas,
|
|
47
|
+
)
|
|
25
48
|
class WiredUiClass extends WiredUiClassBase {}
|
|
26
49
|
|
|
27
50
|
acceptsAny(LocalUi)
|
|
@@ -32,7 +55,7 @@ acceptsWired(WiredUiClass)
|
|
|
32
55
|
// @ts-expect-error -- LocalUi is `UiClass`, not `WiredUi`
|
|
33
56
|
acceptsWired(LocalUi)
|
|
34
57
|
|
|
35
|
-
|
|
58
|
+
const derivedView: MadeView<Contract<typeof WiredUiClass>, 'Wired'> = make(
|
|
36
59
|
WiredUiClass,
|
|
37
60
|
({ n: count }, _slots, { bump }) => {
|
|
38
61
|
const numeric: number = count
|
|
@@ -40,3 +63,16 @@ export const derivedView: MadeView<Contract<typeof WiredUiClass>> = make(
|
|
|
40
63
|
return `${numeric}`
|
|
41
64
|
},
|
|
42
65
|
)
|
|
66
|
+
|
|
67
|
+
type UiTypeProofs = [
|
|
68
|
+
SameShapedUiDefinitionsRemainDistinct,
|
|
69
|
+
SameShapedSlotDefinitionsRemainDistinct,
|
|
70
|
+
WrongUiDoesNotCloseRequirement,
|
|
71
|
+
WrongSlotDoesNotCloseRequirement,
|
|
72
|
+
]
|
|
73
|
+
declare const uiTypeProofs: UiTypeProofs
|
|
74
|
+
|
|
75
|
+
void uiTypeProofs
|
|
76
|
+
void derivedView
|
|
77
|
+
|
|
78
|
+
export {}
|
|
@@ -30,28 +30,41 @@ export interface Manifest {
|
|
|
30
30
|
readonly name: string
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
-
export
|
|
33
|
+
export function yieldableClass<A, E, R, Statics extends object>(
|
|
34
34
|
read: Effect.Effect<A, E, R>,
|
|
35
35
|
statics: Statics,
|
|
36
|
-
): { new (): {} } & Effect.Effect<A, E, R> & Statics
|
|
36
|
+
): { new (): {} } & Effect.Effect<A, E, R> & Statics
|
|
37
|
+
export function yieldableClass(
|
|
38
|
+
read: Effect.Effect<unknown, unknown, unknown>,
|
|
39
|
+
statics: object,
|
|
40
|
+
): unknown {
|
|
37
41
|
class Base {}
|
|
38
42
|
Object.setPrototypeOf(Base, read)
|
|
39
43
|
Reflect.ownKeys(statics).forEach((key) => {
|
|
40
44
|
const staticValue = Reflect.get(statics, key)
|
|
41
|
-
Object.defineProperty(Base, key, {
|
|
45
|
+
Object.defineProperty(Base, key, {
|
|
46
|
+
value: staticValue,
|
|
47
|
+
writable: true,
|
|
48
|
+
enumerable: true,
|
|
49
|
+
configurable: true,
|
|
50
|
+
})
|
|
42
51
|
})
|
|
43
52
|
attachInspectable(Base, () => describe(statics))
|
|
44
|
-
|
|
45
|
-
return Base as never
|
|
53
|
+
return Base
|
|
46
54
|
}
|
|
47
55
|
|
|
48
|
-
export
|
|
56
|
+
export function definitionClass<Class>(statics: object): Class
|
|
57
|
+
export function definitionClass(statics: object): unknown {
|
|
49
58
|
const Base = class {}
|
|
50
59
|
Reflect.ownKeys(statics).forEach((key) => {
|
|
51
60
|
const staticValue = Reflect.get(statics, key)
|
|
52
|
-
Object.defineProperty(Base, key, {
|
|
61
|
+
Object.defineProperty(Base, key, {
|
|
62
|
+
value: staticValue,
|
|
63
|
+
writable: true,
|
|
64
|
+
enumerable: true,
|
|
65
|
+
configurable: true,
|
|
66
|
+
})
|
|
53
67
|
})
|
|
54
68
|
attachInspectable(Base, () => describe(statics))
|
|
55
|
-
|
|
56
|
-
return Base as unknown as Class
|
|
69
|
+
return Base
|
|
57
70
|
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { expect, it } from '@effect/vitest'
|
|
2
|
+
import { Effect, Exit, Layer, Option, Schema as S, Scope } from 'effect'
|
|
3
|
+
import { Engine, Event, Reducer, State } from '../index'
|
|
4
|
+
import { flush } from '../testkit/flight.testkit'
|
|
5
|
+
|
|
6
|
+
// `Event.liveFromSource` turns a Source *transition* into an ordinary event, so
|
|
7
|
+
// a rule can be triggered by a value moving rather than by a UI dispatch. Every
|
|
8
|
+
// case observes through a reducer — the framework's own writer — so what the
|
|
9
|
+
// log holds is exactly what the bus carried, with no spy in the middle.
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
// One string carrying both ends of a transition: `none->0` for the initial
|
|
13
|
+
// emission (nothing preceded it), `0->1` for a change.
|
|
14
|
+
const rendered = (change: Event.SourceChange<number>): string => {
|
|
15
|
+
const before = Option.match(change.previous, {
|
|
16
|
+
onNone: () => 'none',
|
|
17
|
+
onSome: (level) => `${level}`,
|
|
18
|
+
})
|
|
19
|
+
return `${before}->${change.current}`
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const append = (entries: ReadonlyArray<string>, event: { readonly label: string }) => [
|
|
23
|
+
...entries,
|
|
24
|
+
event.label,
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
const Label = S.Struct({ label: S.String })
|
|
28
|
+
|
|
29
|
+
it.live('emits nothing for the value present at construction (changes only)', () => {
|
|
30
|
+
class Level extends State.make('changes-only/level', S.Number) {}
|
|
31
|
+
class Moved extends Event.make('ChangesOnlyMoved', Label) {}
|
|
32
|
+
class Log extends State.make('changes-only/log', S.Array(S.String)) {}
|
|
33
|
+
class LogReducer extends Reducer.make('ChangesOnlyLog', { states: [Log], events: [Moved] }) {}
|
|
34
|
+
|
|
35
|
+
// The adapter is wired DOWNSTREAM of the reducer that observes it: an
|
|
36
|
+
// emission is an ordinary dispatch, so — like a scene's boot events — it can
|
|
37
|
+
// only be folded by handlers already registered when it lands.
|
|
38
|
+
const TestLayer = Event.liveFromSource(Moved, Level, {
|
|
39
|
+
payload: (change) => Option.some({ label: rendered(change) }),
|
|
40
|
+
}).pipe(
|
|
41
|
+
Layer.provideMerge(Reducer.live(LogReducer, append)),
|
|
42
|
+
Layer.provideMerge(Layer.mergeAll(State.live(Level, 0), State.live(Log, []))),
|
|
43
|
+
Layer.provideMerge(Engine),
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
return Effect.gen(function* () {
|
|
47
|
+
const level = yield* Level.store
|
|
48
|
+
const log = yield* Log.store
|
|
49
|
+
|
|
50
|
+
yield* flush()
|
|
51
|
+
expect(log.get()).toEqual([])
|
|
52
|
+
|
|
53
|
+
level.set(1)
|
|
54
|
+
yield* flush()
|
|
55
|
+
expect(log.get()).toEqual(['0->1'])
|
|
56
|
+
|
|
57
|
+
// Store equality is the framework's, not this adapter's: re-setting the same
|
|
58
|
+
// value is not a change, so nothing notifies and nothing is emitted.
|
|
59
|
+
level.set(1)
|
|
60
|
+
yield* flush()
|
|
61
|
+
expect(log.get()).toEqual(['0->1'])
|
|
62
|
+
}).pipe(Effect.provide(TestLayer))
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it.live('emitInitial reports the construction value once, with no previous', () => {
|
|
66
|
+
class Level extends State.make('emit-initial/level', S.Number) {}
|
|
67
|
+
class Moved extends Event.make('EmitInitialMoved', Label) {}
|
|
68
|
+
class Log extends State.make('emit-initial/log', S.Array(S.String)) {}
|
|
69
|
+
class LogReducer extends Reducer.make('EmitInitialLog', { states: [Log], events: [Moved] }) {}
|
|
70
|
+
|
|
71
|
+
const TestLayer = Event.liveFromSource(Moved, Level, {
|
|
72
|
+
emitInitial: true,
|
|
73
|
+
payload: (change) => Option.some({ label: rendered(change) }),
|
|
74
|
+
}).pipe(
|
|
75
|
+
Layer.provideMerge(Reducer.live(LogReducer, append)),
|
|
76
|
+
Layer.provideMerge(Layer.mergeAll(State.live(Level, 2), State.live(Log, []))),
|
|
77
|
+
Layer.provideMerge(Engine),
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
return Effect.gen(function* () {
|
|
81
|
+
const level = yield* Level.store
|
|
82
|
+
const log = yield* Log.store
|
|
83
|
+
|
|
84
|
+
yield* flush()
|
|
85
|
+
expect(log.get()).toEqual(['none->2'])
|
|
86
|
+
|
|
87
|
+
// The initial emission seeds `previous`: the next change reports the
|
|
88
|
+
// construction value as its left-hand side, not another `none`.
|
|
89
|
+
level.set(1)
|
|
90
|
+
yield* flush()
|
|
91
|
+
expect(log.get()).toEqual(['none->2', '2->1'])
|
|
92
|
+
}).pipe(Effect.provide(TestLayer))
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it.live('a payload of Option.none() suppresses a non-qualifying transition', () => {
|
|
96
|
+
class Phase extends State.make('suppressed/phase', S.Literal('idle', 'busy', 'ready')) {}
|
|
97
|
+
class BecameReady extends Event.make('SuppressedBecameReady', S.Struct({ from: S.String })) {}
|
|
98
|
+
class Log extends State.make('suppressed/log', S.Array(S.String)) {}
|
|
99
|
+
class LogReducer extends Reducer.make('SuppressedLog', {
|
|
100
|
+
states: [Log],
|
|
101
|
+
events: [BecameReady],
|
|
102
|
+
}) {}
|
|
103
|
+
|
|
104
|
+
// The exact transition predicate lives in the projection: only a move INTO
|
|
105
|
+
// 'ready' yields a payload — every other transition emits nothing at all.
|
|
106
|
+
const TestLayer = Event.liveFromSource(BecameReady, Phase, {
|
|
107
|
+
payload: (change) =>
|
|
108
|
+
change.current === 'ready'
|
|
109
|
+
? Option.some({
|
|
110
|
+
from: Option.match(change.previous, { onNone: () => 'none', onSome: (phase) => phase }),
|
|
111
|
+
})
|
|
112
|
+
: Option.none(),
|
|
113
|
+
}).pipe(
|
|
114
|
+
Layer.provideMerge(Reducer.live(LogReducer, (entries, event) => [...entries, event.from])),
|
|
115
|
+
Layer.provideMerge(Layer.mergeAll(State.live(Phase, 'idle'), State.live(Log, []))),
|
|
116
|
+
Layer.provideMerge(Engine),
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
return Effect.gen(function* () {
|
|
120
|
+
const phase = yield* Phase.store
|
|
121
|
+
const log = yield* Log.store
|
|
122
|
+
|
|
123
|
+
phase.set('busy')
|
|
124
|
+
yield* flush()
|
|
125
|
+
expect(log.get()).toEqual([])
|
|
126
|
+
|
|
127
|
+
phase.set('ready')
|
|
128
|
+
yield* flush()
|
|
129
|
+
expect(log.get()).toEqual(['busy'])
|
|
130
|
+
}).pipe(Effect.provide(TestLayer))
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it.live('closing the scope ends the emissions, including one already in flight', () => {
|
|
134
|
+
class Level extends State.make('scoped/level', S.Number) {}
|
|
135
|
+
class Moved extends Event.make('ScopedMoved', Label) {}
|
|
136
|
+
class Log extends State.make('scoped/log', S.Array(S.String)) {}
|
|
137
|
+
class LogReducer extends Reducer.make('ScopedLog', { states: [Log], events: [Moved] }) {}
|
|
138
|
+
|
|
139
|
+
const AdapterLive = Event.liveFromSource(Moved, Level, {
|
|
140
|
+
payload: (change) => Option.some({ label: rendered(change) }),
|
|
141
|
+
})
|
|
142
|
+
// Everything except the adapter outlives the closed scope, so a lost emission
|
|
143
|
+
// can only mean the adapter stopped — not that its observer went away.
|
|
144
|
+
const TestLayer = Reducer.live(LogReducer, append).pipe(
|
|
145
|
+
Layer.provideMerge(Layer.mergeAll(State.live(Level, 0), State.live(Log, []))),
|
|
146
|
+
Layer.provideMerge(Engine),
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
return Effect.gen(function* () {
|
|
150
|
+
const scope = yield* Scope.make()
|
|
151
|
+
yield* Layer.buildWithScope(AdapterLive, scope)
|
|
152
|
+
const level = yield* Level.store
|
|
153
|
+
const log = yield* Log.store
|
|
154
|
+
|
|
155
|
+
level.set(1)
|
|
156
|
+
yield* flush()
|
|
157
|
+
expect(log.get()).toEqual(['0->1'])
|
|
158
|
+
|
|
159
|
+
// A write that already handed the listener to the notification scheduler,
|
|
160
|
+
// released before that flush runs: unsubscribing alone would not stop it.
|
|
161
|
+
yield* Effect.zipRight(
|
|
162
|
+
Effect.sync(() => level.set(2)),
|
|
163
|
+
Scope.close(scope, Exit.void),
|
|
164
|
+
)
|
|
165
|
+
yield* flush()
|
|
166
|
+
expect(log.get()).toEqual(['0->1'])
|
|
167
|
+
|
|
168
|
+
level.set(0)
|
|
169
|
+
yield* flush()
|
|
170
|
+
expect(log.get()).toEqual(['0->1'])
|
|
171
|
+
}).pipe(Effect.provide(TestLayer))
|
|
172
|
+
})
|
package/src/event/event.test.ts
CHANGED
|
@@ -18,3 +18,13 @@ test('empty-payload events construct to just their tag', () => {
|
|
|
18
18
|
class Pinged extends Event.make('Pinged', S.Struct({})) {}
|
|
19
19
|
expect(Event.construct(Pinged, {})).toEqual({ _tag: 'Pinged' })
|
|
20
20
|
})
|
|
21
|
+
|
|
22
|
+
test('an existential event validates payloads before constructing', () => {
|
|
23
|
+
class Added extends Event.make('Added', S.Struct({ id: S.String })) {}
|
|
24
|
+
const reflected: Event.AnyEvent = Added
|
|
25
|
+
expect(reflected.buildUnknown({ id: 'safe' })).toEqual({
|
|
26
|
+
_tag: 'Added',
|
|
27
|
+
id: 'safe',
|
|
28
|
+
})
|
|
29
|
+
expect(() => reflected.buildUnknown({ id: 1 })).toThrow()
|
|
30
|
+
})
|