@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
@@ -1,3 +1,4 @@
1
+ import type { Schema } from 'effect'
1
2
  import * as AsyncCalc from '../calc/asyncCalc'
2
3
  import * as Calc from '../calc/calc'
3
4
  import * as CalcFamily from '../calc/calcFamily'
@@ -8,65 +9,85 @@ import * as RemoteState from '../remote/remoteState'
8
9
  import * as State from '../state/state'
9
10
  import * as StateFamily from '../state/stateFamily'
10
11
  import type { AnySource } from '../state/token'
11
- import type { AnySchema } from '../internal/variance'
12
12
 
13
13
  export interface FeatureNamespace<Prefix extends string> {
14
14
  readonly prefix: Prefix
15
- readonly Event: <const N extends string, P>(
15
+ readonly Event: <const N extends string, P, Encoded>(
16
16
  name: N,
17
- schema: AnySchema<P>,
17
+ schema: Schema.Schema<P, Encoded, never>,
18
18
  ) => Event.EventClass<`${Prefix}.${N}`, P>
19
19
  readonly Channel: (name: string, config: Channel.ChannelConfig) => Channel.ChannelClass
20
- readonly State: <const N extends string, A>(
20
+ readonly State: <const N extends string, StateSchema extends Schema.Schema.AnyNoContext>(
21
21
  name: N,
22
- schema: AnySchema<A>,
22
+ schema: StateSchema,
23
23
  options?: State.StateOptions,
24
- ) => State.StateClass<`${Prefix}.${N}`, A>
25
- readonly StateFamily: <const N extends string, K, V>(
24
+ ) => State.StateDefinition<`${Prefix}.${N}`, Schema.Schema.Type<StateSchema>, StateSchema>
25
+ readonly StateFamily: <const N extends string, K, KeyEncoded, V, ValueEncoded>(
26
26
  name: N,
27
- key: AnySchema<K>,
28
- valueSchema: AnySchema<V>,
27
+ key: Schema.Schema<K, KeyEncoded, never>,
28
+ valueSchema: Schema.Schema<V, ValueEncoded, never>,
29
29
  options?: StateFamily.StateFamilyMakeOptionsExternalApi,
30
30
  ) => StateFamily.StateFamilyClass<`${Prefix}.${N}`, K, V>
31
- readonly Calc: <const N extends string, const Inputs extends ReadonlyArray<AnySource>, Out>(
31
+ readonly Calc: <
32
+ const N extends string,
33
+ const Inputs extends ReadonlyArray<AnySource>,
34
+ OutputSchema extends Schema.Schema.AnyNoContext,
35
+ >(
32
36
  name: N,
33
- config: Calc.CalcConfig<Inputs, Out>,
34
- ) => Calc.CalcClass<`${Prefix}.${N}`, Inputs, Out>
37
+ config: Calc.CalcConfig<Inputs, OutputSchema>,
38
+ ) => Calc.CalcClass<`${Prefix}.${N}`, Inputs, Schema.Schema.Type<OutputSchema>>
35
39
  readonly CalcFamily: <
36
40
  const N extends string,
37
41
  const Inputs extends ReadonlyArray<AnySource>,
38
- K,
39
- Out,
42
+ KeySchema extends Schema.Schema.AnyNoContext,
43
+ OutputSchema extends Schema.Schema.AnyNoContext,
40
44
  >(
41
45
  name: N,
42
- config: CalcFamily.CalcFamilyConfig<Inputs, K, Out>,
43
- ) => CalcFamily.CalcFamilyClass<`${Prefix}.${N}`, Inputs, K, Out>
46
+ config: CalcFamily.CalcFamilyConfig<Inputs, KeySchema, OutputSchema>,
47
+ ) => CalcFamily.CalcFamilyClass<
48
+ `${Prefix}.${N}`,
49
+ Inputs,
50
+ Schema.Schema.Type<KeySchema>,
51
+ Schema.Schema.Type<OutputSchema>
52
+ >
44
53
  readonly AsyncCalc: <
45
54
  const N extends string,
46
55
  const Inputs extends ReadonlyArray<AnySource>,
47
- A,
48
- E = never,
56
+ OutputSchema extends Schema.Schema.AnyNoContext,
57
+ ErrorSchema extends Schema.Schema.AnyNoContext | undefined = undefined,
49
58
  const AlwaysOn extends boolean = false,
50
59
  >(
51
60
  name: N,
52
- config: AsyncCalc.AsyncCalcConfig<Inputs, A, E, AlwaysOn>,
53
- ) => AsyncCalc.AsyncCalcClass<`${Prefix}.${N}`, Inputs, A, E, GatedOf<AlwaysOn>>
61
+ config: AsyncCalc.AsyncCalcConfig<Inputs, OutputSchema, ErrorSchema, AlwaysOn>,
62
+ ) => AsyncCalc.AsyncCalcClass<
63
+ `${Prefix}.${N}`,
64
+ Inputs,
65
+ Schema.Schema.Type<OutputSchema>,
66
+ ErrorSchema extends Schema.Schema.AnyNoContext ? Schema.Schema.Type<ErrorSchema> : never,
67
+ GatedOf<AlwaysOn>
68
+ >
54
69
  readonly RemoteState: <
55
70
  const N extends string,
56
71
  const Inputs extends ReadonlyArray<AnySource>,
57
72
  const Intents extends ReadonlyArray<Event.AnyEvent>,
58
- A,
59
- E = never,
73
+ OutputSchema extends Schema.Schema.AnyNoContext,
74
+ ErrorSchema extends Schema.Schema.AnyNoContext | undefined = undefined,
60
75
  const AlwaysOn extends boolean = false,
61
76
  >(
62
77
  name: N,
63
- config: RemoteState.RemoteStateConfigExternalApi<Inputs, Intents, A, E, AlwaysOn>,
78
+ config: RemoteState.RemoteStateConfigExternalApi<
79
+ Inputs,
80
+ Intents,
81
+ OutputSchema,
82
+ ErrorSchema,
83
+ AlwaysOn
84
+ >,
64
85
  ) => RemoteState.RemoteStateClass<
65
86
  `${Prefix}.${N}`,
66
87
  Inputs,
67
88
  Intents,
68
- A,
69
- E,
89
+ Schema.Schema.Type<OutputSchema>,
90
+ ErrorSchema extends Schema.Schema.AnyNoContext ? Schema.Schema.Type<ErrorSchema> : never,
70
91
  GatedOf<AlwaysOn>
71
92
  >
72
93
  }
@@ -3,7 +3,6 @@ import type { YieldWrap } from 'effect/Utils'
3
3
  import { type ChannelClass, type ProcedureEntry, Procedures } from '../channel/channel'
4
4
  import { type Manifest, definitionClass } from '../definition/definition'
5
5
  import { type AnyEvent, type EventType } from '../event/event'
6
- import { type Ctx } from '../internal/ctx'
7
6
  import { Bus, narrowHandled } from '../runtime/bus'
8
7
  import type { AnyValue } from '../internal/variance'
9
8
 
@@ -11,29 +10,52 @@ export interface ProcedureManifest extends Manifest {
11
10
  readonly kind: 'Procedure'
12
11
  }
13
12
 
14
- export interface ProcedureClass<Events extends ReadonlyArray<AnyEvent>> {
13
+ export interface ProcedureReflection<
14
+ out Events extends ReadonlyArray<AnyEvent> = ReadonlyArray<AnyEvent>,
15
+ > {
15
16
  new (): {}
16
17
  readonly manifest: ProcedureManifest
17
18
  readonly events: Events
18
19
  readonly channel: ChannelClass
19
20
  }
20
21
 
22
+ export type ProcedureCapture<Result> = <Events extends ReadonlyArray<AnyEvent>>(
23
+ procedure: ProcedureClass<Events>,
24
+ ) => Result
25
+
26
+ export interface AnyProcedure extends ProcedureReflection {
27
+ readonly capture: <Result>(visit: ProcedureCapture<Result>) => Result
28
+ }
29
+
30
+ export interface ProcedureClass<
31
+ Events extends ReadonlyArray<AnyEvent>,
32
+ > extends ProcedureReflection<Events> {
33
+ readonly capture: <Result>(visit: ProcedureCapture<Result>) => Result
34
+ }
35
+
21
36
  export interface ProcedureConfig<Events extends ReadonlyArray<AnyEvent>> {
22
37
  readonly events: Events
23
38
  readonly channel: ChannelClass
24
39
  }
25
40
 
26
- export type AnyProcedure = ProcedureClass<ReadonlyArray<AnyEvent>>
27
-
28
41
  export const make = <const Events extends ReadonlyArray<AnyEvent>>(
29
42
  name: string,
30
43
  config: ProcedureConfig<Events>,
31
- ): ProcedureClass<Events> =>
32
- definitionClass<ProcedureClass<Events>>({
44
+ ): ProcedureClass<Events> => {
45
+ const procedure: ProcedureClass<Events> = definitionClass<ProcedureClass<Events>>({
33
46
  manifest: { kind: 'Procedure' as const, name } satisfies ProcedureManifest,
34
47
  events: config.events,
35
48
  channel: config.channel,
49
+ capture: <Result>(visit: ProcedureCapture<Result>): Result => visit(procedure),
36
50
  })
51
+ return procedure
52
+ }
53
+
54
+ type GeneratorContext<Eff> = [Eff] extends [never]
55
+ ? never
56
+ : [Eff] extends [YieldWrap<Effect.Effect<AnyValue, AnyValue, infer Context>>]
57
+ ? Context
58
+ : never
37
59
 
38
60
  export const live = <
39
61
  Events extends ReadonlyArray<AnyEvent>,
@@ -42,7 +64,7 @@ export const live = <
42
64
  >(
43
65
  procedure: ProcedureClass<Events>,
44
66
  body: (event: EventType<Events[number]>) => Generator<Eff, Done, never>,
45
- ): Layer.Layer<never, never, Procedures | Bus | Ctx<Eff>> => {
67
+ ): Layer.Layer<never, never, Procedures | Bus | GeneratorContext<Eff>> => {
46
68
  const name = procedure.manifest.name
47
69
  const channelName = procedure.channel.manifest.name
48
70
  const handles = new Set(procedure.events.map((event) => event.tag))
@@ -52,15 +74,20 @@ export const live = <
52
74
  if (procedures.entries.some((entry) => entry.name === name)) {
53
75
  yield* Effect.logWarning(`reform: duplicate procedure name '${name}' registered`)
54
76
  }
55
- const runtime = yield* Effect.runtime<Bus | Ctx<Eff>>()
77
+ const runtime = yield* Effect.runtime<Bus | GeneratorContext<Eff>>()
56
78
  const entry: ProcedureEntry = {
57
79
  name,
58
80
  channelName,
59
81
  handles,
60
- run: (event) => Effect.provide(Effect.gen(() => body(narrowHandled(event))), runtime),
82
+ run: (event) =>
83
+ Effect.provide(
84
+ Effect.gen(() => body(narrowHandled(event))),
85
+ runtime,
86
+ ),
61
87
  }
62
- yield* Effect.acquireRelease(Effect.sync(() => procedures.register(entry)), () =>
63
- Effect.sync(() => procedures.unregister(entry)),
88
+ yield* Effect.acquireRelease(
89
+ Effect.sync(() => procedures.register(entry)),
90
+ () => Effect.sync(() => procedures.unregister(entry)),
64
91
  )
65
92
  }),
66
93
  )
@@ -2,18 +2,17 @@ import { Effect, Layer, Predicate } from 'effect'
2
2
  import { type Manifest, definitionClass } from '../definition/definition'
3
3
  import { AsyncReducer } from '../internal/errors'
4
4
  import { type AnyEvent, type EventType } from '../event/event'
5
- import type { Store } from '../internal/store'
6
- import { type AnyState, type StateValue } from '../state/state'
5
+ import { type AnyState, type StateName, type StateStore, type StateValue } from '../state/state'
7
6
  import {
8
7
  type AnyFamily,
9
8
  type FamilyKey,
10
- type FamilyStore,
9
+ type FamilyName,
11
10
  type FamilyValue,
11
+ type StateFamilyStore,
12
12
  Tombstone,
13
13
  } from '../state/stateFamily'
14
14
  import { narrowHandled, type Tagged } from '../runtime/bus'
15
15
  import { type ReducerEntry, Reducers } from '../runtime/loop'
16
- import type { AnyValue } from '../internal/variance'
17
16
 
18
17
  export interface ReducerManifest extends Manifest {
19
18
  readonly kind: 'Reducer'
@@ -44,6 +43,26 @@ export interface FamilyReducerClass<F extends AnyFamily, Events extends Readonly
44
43
  readonly handles: ReadonlySet<string>
45
44
  }
46
45
 
46
+ type ErasedFold = (...args: ReadonlyArray<never>) => unknown
47
+
48
+ interface RuntimeStateReducerConfig {
49
+ readonly states: readonly [AnyState]
50
+ readonly events: ReadonlyArray<AnyEvent>
51
+ }
52
+
53
+ interface RuntimeFamilyReducerConfig {
54
+ readonly family: AnyFamily
55
+ readonly keyOf: ErasedFold
56
+ readonly events: ReadonlyArray<AnyEvent>
57
+ }
58
+
59
+ interface RuntimeReducerClass {
60
+ new (): {}
61
+ readonly manifest: ReducerManifest
62
+ readonly config: RuntimeStateReducerConfig | RuntimeFamilyReducerConfig
63
+ readonly handles: ReadonlySet<string>
64
+ }
65
+
47
66
  export function make<S extends AnyState, const Events extends ReadonlyArray<AnyEvent>>(
48
67
  name: string,
49
68
  config: StateReducerConfig<S, Events>,
@@ -54,9 +73,7 @@ export function make<F extends AnyFamily, const Events extends ReadonlyArray<Any
54
73
  ): FamilyReducerClass<F, Events>
55
74
  export function make(
56
75
  name: string,
57
- config:
58
- | StateReducerConfig<AnyState, ReadonlyArray<AnyEvent>>
59
- | FamilyReducerConfig<AnyFamily, ReadonlyArray<AnyEvent>>,
76
+ config: RuntimeStateReducerConfig | RuntimeFamilyReducerConfig,
60
77
  ): unknown {
61
78
  return definitionClass<unknown>({
62
79
  manifest: { kind: 'Reducer' as const, name } satisfies ReducerManifest,
@@ -79,48 +96,75 @@ const sync = <A>(candidate: A): A => {
79
96
  export function live<S extends AnyState, Events extends ReadonlyArray<AnyEvent>>(
80
97
  reducer: StateReducerClass<S, Events>,
81
98
  fold: (value: StateValue<S>, event: EventType<Events[number]>) => StateValue<S>,
82
- ): Layer.Layer<never, never, Reducers | Store<StateValue<S>>>
99
+ ): Layer.Layer<never, never, Reducers | StateStore<StateName<S>, StateValue<S>>>
83
100
  export function live<F extends AnyFamily, Events extends ReadonlyArray<AnyEvent>>(
84
101
  reducer: FamilyReducerClass<F, Events>,
85
102
  fold: (entry: FamilyValue<F>, event: EventType<Events[number]>) => FamilyValue<F> | Tombstone,
86
- ): Layer.Layer<never, never, Reducers | FamilyStore<FamilyKey<F>, FamilyValue<F>>>
103
+ ): Layer.Layer<
104
+ never,
105
+ never,
106
+ Reducers | StateFamilyStore<FamilyName<F>, FamilyKey<F>, FamilyValue<F>>
107
+ >
87
108
  export function live(
88
- reducer:
89
- | StateReducerClass<AnyState, ReadonlyArray<AnyEvent>>
90
- | FamilyReducerClass<AnyFamily, ReadonlyArray<AnyEvent>>,
91
- // oxlint-disable-next-line reform-rules/no-explicit-any-value -- overload-impl signature: `unknown` params fail contravariance against the State/Family folds (TS2394); only `any` subsumes both
92
- fold: (acc: AnyValue, event: AnyValue) => AnyValue,
93
- ): Layer.Layer<never, never, AnyValue> {
109
+ reducer: RuntimeReducerClass,
110
+ fold: ErasedFold,
111
+ ): Layer.Layer<never, never, unknown> {
94
112
  const { config, handles } = reducer
95
113
  const name = reducer.manifest.name
96
114
  return Layer.scopedDiscard(
97
115
  Effect.gen(function* () {
98
116
  const reducers = yield* Reducers
99
- if (reducers.entries.some((existing) => existing.name === name)) {
117
+ if (reducers.byName.has(name)) {
100
118
  yield* Effect.logWarning(`reform: duplicate reducer name '${name}' registered`)
101
119
  }
102
120
  const entry: ReducerEntry = yield* Effect.gen(function* () {
103
121
  if ('family' in config) {
104
- const family = yield* config.family.store
105
- return {
106
- name,
107
- handles,
108
- apply: (event: Tagged) => {
109
- const key = config.keyOf(narrowHandled(event))
110
- const next = fold(family.at(key).get(), event)
111
- if (next === Tombstone) {
112
- family.forget(key)
113
- } else {
114
- family.at(key).set(sync(next))
115
- }
116
- },
117
- }
122
+ return yield* config.family.capture<Effect.Effect<ReducerEntry, never, unknown>>(
123
+ (familyDefinition) =>
124
+ Effect.map(familyDefinition.store, (family) => ({
125
+ name,
126
+ handles,
127
+ apply: (event: Tagged) => {
128
+ // The loop calls entries only for handled events. At this
129
+ // existential boundary the family's decoders restore the
130
+ // exact key/value types before either touches its store.
131
+ const key = familyDefinition.decodeKeySync(
132
+ sync(Reflect.apply(config.keyOf, undefined, [narrowHandled(event)])),
133
+ )
134
+ const previous = family.at(key).get()
135
+ const next = sync(Reflect.apply(fold, undefined, [previous, event]))
136
+ if (next === Tombstone) {
137
+ family.forget(key)
138
+ } else if (next !== previous) {
139
+ // Same-reference return means "no change": skip the nominal re-decode,
140
+ // which would mint a fresh value and repaint every tracker of this slice.
141
+ family.at(key).set(familyDefinition.decodeValueSync(next))
142
+ }
143
+ },
144
+ })),
145
+ )
118
146
  }
119
- const store = yield* config.states[0].store
120
- return { name, handles, apply: (event: Tagged) => store.set(sync(fold(store.get(), event))) }
147
+ return yield* config.states[0].capture<Effect.Effect<ReducerEntry, never, unknown>>(
148
+ (state) =>
149
+ Effect.map(state.store, (store) => ({
150
+ name,
151
+ handles,
152
+ apply: (event: Tagged) => {
153
+ const previous = store.get()
154
+ const next = sync(Reflect.apply(fold, undefined, [previous, event]))
155
+ // Same-reference return means "no change": skip the nominal re-decode,
156
+ // which would mint a fresh value and repaint every tracker of this slice.
157
+ if (next === previous) {
158
+ return
159
+ }
160
+ store.set(state.decodeUnknownSync(next))
161
+ },
162
+ })),
163
+ )
121
164
  })
122
- yield* Effect.acquireRelease(Effect.sync(() => reducers.register(entry)), () =>
123
- Effect.sync(() => reducers.unregister(entry)),
165
+ yield* Effect.acquireRelease(
166
+ Effect.sync(() => reducers.register(entry)),
167
+ () => Effect.sync(() => reducers.unregister(entry)),
124
168
  )
125
169
  }),
126
170
  )