@playfast/reform 1.1.1 → 1.2.0

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.
Files changed (85) hide show
  1. package/README.md +85 -55
  2. package/package.json +17 -17
  3. package/src/boundary/boundary.test.ts +62 -16
  4. package/src/boundary/boundary.ts +141 -32
  5. package/src/calc/asyncCalc.invalidate.test.ts +516 -18
  6. package/src/calc/asyncCalc.test.ts +207 -175
  7. package/src/calc/asyncCalc.ts +168 -39
  8. package/src/calc/calc.test.ts +3 -5
  9. package/src/calc/calc.ts +44 -18
  10. package/src/calc/calcFamily.test.ts +80 -22
  11. package/src/calc/calcFamily.ts +56 -25
  12. package/src/calc/compose.ts +1 -14
  13. package/src/channel/channel.ts +128 -11
  14. package/src/compose/composition.test.ts +19 -0
  15. package/src/compose/composition.ts +346 -62
  16. package/src/compose/props.ts +13 -6
  17. package/src/compose/provide.ts +187 -46
  18. package/src/compose/slot.ts +156 -19
  19. package/src/compose/structure.test.ts +6 -3
  20. package/src/compose/structure.ts +106 -13
  21. package/src/compose/ui.test.ts +19 -3
  22. package/src/compose/ui.ts +147 -54
  23. package/src/compose/ui.typecheck.ts +40 -4
  24. package/src/definition/definition.ts +22 -9
  25. package/src/event/event.fromSource.test.ts +172 -0
  26. package/src/event/event.test.ts +10 -0
  27. package/src/event/event.ts +102 -18
  28. package/src/feature/feature.mount.test.ts +123 -56
  29. package/src/feature/feature.test.ts +67 -63
  30. package/src/feature/feature.ts +1317 -197
  31. package/src/feature/feature.typecheck.ts +253 -61
  32. package/src/graph/closure.ts +403 -0
  33. package/src/index.ts +171 -245
  34. package/src/internal/bucketCache.ts +39 -0
  35. package/src/internal/capture.ts +9 -4
  36. package/src/internal/env.ts +1 -3
  37. package/src/internal/errors.ts +21 -10
  38. package/src/internal/queryDriver.ts +120 -15
  39. package/src/internal/queryDriverStore.ts +8 -5
  40. package/src/internal/queryDriverTypes.ts +3 -1
  41. package/src/internal/reuse.test.ts +10 -3
  42. package/src/internal/reuse.ts +5 -2
  43. package/src/internal/scheduler.ts +4 -1
  44. package/src/internal/sources.ts +25 -11
  45. package/src/internal/track.ts +3 -2
  46. package/src/internal.ts +222 -0
  47. package/src/namespace/namespace.ts +46 -25
  48. package/src/procedure/procedure.ts +38 -11
  49. package/src/reducer/reducer.ts +78 -34
  50. package/src/remote/remoteState.test.ts +515 -339
  51. package/src/remote/remoteState.ts +572 -107
  52. package/src/remote/remoteState.typecheck.ts +47 -23
  53. package/src/runtime/appRuntime.activation.test.ts +100 -0
  54. package/src/runtime/appRuntime.test.ts +157 -105
  55. package/src/runtime/appRuntime.ts +394 -33
  56. package/src/runtime/eventBudget.test.ts +1 -4
  57. package/src/runtime/eventBudget.ts +9 -8
  58. package/src/runtime/hardening.test.ts +4 -9
  59. package/src/runtime/instrumentation.test.ts +174 -192
  60. package/src/runtime/instrumentation.ts +6 -11
  61. package/src/runtime/loop.test.ts +36 -0
  62. package/src/runtime/loop.ts +69 -40
  63. package/src/scene/featureScene.test.ts +4 -2
  64. package/src/scene/scene.ts +234 -64
  65. package/src/scene/seedScene.test.ts +69 -86
  66. package/src/state/state.nominal.typecheck.ts +68 -0
  67. package/src/state/state.test.ts +17 -0
  68. package/src/state/state.ts +79 -26
  69. package/src/state/stateFamily.test.ts +14 -0
  70. package/src/state/stateFamily.ts +55 -22
  71. package/src/state/stateGroup.ts +53 -17
  72. package/src/state/token.ts +40 -10
  73. package/src/synced/syncedStore.ts +46 -23
  74. package/src/testkit/flight.testkit.ts +75 -0
  75. package/src/wire/tree.test.ts +8 -4
  76. package/src/wire/triggers.test.ts +6 -2
  77. package/src/wire/triggers.ts +14 -10
  78. package/src/calc/asyncCalcDefinitions.ts +0 -48
  79. package/src/channel/procedureRegistry.ts +0 -90
  80. package/src/feature/featureBinding.ts +0 -48
  81. package/src/internal/ctx.ts +0 -9
  82. package/src/remote/remoteStateDefinition.ts +0 -135
  83. package/src/remote/remoteStateLayers.ts +0 -118
  84. package/src/remote/remoteStateLiveTypes.ts +0 -59
  85. package/src/remote/remoteStateSend.ts +0 -64
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 Node, type SlotClass, type SlotInstance, type SlotProps } from './slot'
6
- import type { AnyValue } from '../internal/variance'
6
+ import { type AnySlot, type AnySlotInstance, type Node, type SlotProps } from './slot'
7
7
 
8
- export interface UiContract {
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 } ? E : Record<never, never>
19
- // Slots are Partial FunctionComponents: parent may supply props; remote placeholder omits them.
20
- // slotKey selects one keyed list child on the wire; framework-handled, never a child prop.
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
- // oxlint-disable-next-line reform-rules/no-optional-fields -- React/JSX component-prop shape (`<slots.Row slotKey={id} />`); singleton slots omit it, framework-handled
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<never, never>
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 interface UiManifest extends Manifest {
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: Schema.Schema.AnyNoContext
64
+ readonly props: UiSchemaReflection
52
65
  }
53
66
 
54
- // Not yieldable: bodies return Structure; host resolves view via `impl`
55
- export interface UiClass<C extends UiContract> {
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 impl: Context.Tag<ViewImpl<C>, ViewImpl<C>>
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 UiClass<infer C> ? C : never
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
- export type WiredUi<C extends UiContract> = UiClass<C> & { readonly manifest: WiredUiManifest }
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<never, never>
114
+ : Readonly<Record<PropertyKey, never>>
80
115
 
81
116
  type DerivedSlots<Sch extends WireSchemas> = Sch extends {
82
- readonly slots: infer S extends Record<string, SlotClass>
117
+ readonly slots: infer S extends Record<string, AnySlot>
83
118
  }
84
119
  ? { readonly [K in keyof S]: InstanceType<S[K]> }
85
- : Record<never, never>
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 UiClass<UiContract> =>
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
- const buildUi = <C extends UiContract, M extends UiManifest>(
99
- name: string,
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<ViewImpl<C>, ViewImpl<C>>(`reform/ui/${name}`)
103
- return definitionClass<UiClass<C> & { readonly manifest: M }>({
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
- export function ui(name: string): <C extends UiContract>() => UiClass<C>
111
- export function ui<const Sch extends WireSchemas>(name: string, schemas: Sch): WiredUi<DerivedContract<Sch>>
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: string, schemas?: WireSchemas): unknown {
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> => buildUi<C, UiManifest>(name, { kind: 'Ui', name })
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 = { kind: 'Ui', name, props: schemas.props, ...eventFields }
119
- return buildUi<DerivedContract<WireSchemas>, WiredUiManifest>(name, manifest)
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 MadeView<C extends UiContract> = ViewImpl<C> & { readonly [UiViewContract]: UiClass<C> }
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 const make = <C extends UiContract>(contract: UiClass<C>, view: ViewImpl<C>): MadeView<C> =>
128
- // oxlint-disable-next-line reform-rules/no-object-assign -- `view` is a callable function; spread would drop callability, so the brand is attached in place
129
- Object.assign(view, { [UiViewContract]: contract })
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
- declare function acceptsAny<C extends UiContract>(contract: UiClass<C>): void
14
- declare function acceptsWired<C extends UiContract>(contract: WiredUi<C>): void
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>> = ui('Wired', 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
- export const derivedView: MadeView<Contract<typeof WiredUiClass>> = make(
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 const yieldableClass = <A, E, R, Statics extends object>(
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, { value: staticValue, writable: true, enumerable: true, configurable: true })
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
- // oxlint-disable-next-line reform-rules/no-type-assertion -- phantom-shaped static side cannot structurally satisfy the yieldable Effect & Statics type
45
- return Base as never
53
+ return Base
46
54
  }
47
55
 
48
- export const definitionClass = <Class>(statics: object): Class => {
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, { value: staticValue, writable: true, enumerable: true, configurable: true })
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
- // oxlint-disable-next-line reform-rules/no-type-assertion -- the one documented seam: phantom type-only fields mean the assembled class cannot structurally satisfy Class
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 { Duration, Effect, Exit, Layer, Option, Schema as S, Scope } from 'effect'
3
+ import { Engine, Event, Reducer, State } from '../index'
4
+
5
+ // `Event.liveFromSource` turns a Source *transition* into an ordinary event, so
6
+ // a rule can be triggered by a value moving rather than by a UI dispatch. Every
7
+ // case observes through a reducer — the framework's own writer — so what the
8
+ // log holds is exactly what the bus carried, with no spy in the middle.
9
+
10
+ const tick = Effect.sleep(Duration.millis(1))
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* tick
51
+ expect(log.get()).toEqual([])
52
+
53
+ level.set(1)
54
+ yield* tick
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* tick
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* tick
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* tick
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* tick
125
+ expect(log.get()).toEqual([])
126
+
127
+ phase.set('ready')
128
+ yield* tick
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* tick
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* tick
166
+ expect(log.get()).toEqual(['0->1'])
167
+
168
+ level.set(0)
169
+ yield* tick
170
+ expect(log.get()).toEqual(['0->1'])
171
+ }).pipe(Effect.provide(TestLayer))
172
+ })
@@ -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
+ })