@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,28 +1,32 @@
1
- import { Context, Effect, Layer, Match, Option } from 'effect'
1
+ import { Context, Effect, Layer, Match, Option, Schema } from 'effect'
2
2
  import type { Effect as EffectType } from 'effect/Effect'
3
- import { type Manifest } from '../definition/definition'
3
+ import { type Manifest, yieldableClass } from '../definition/definition'
4
4
  import { type AnyEvent } from '../event/event'
5
- import {
6
- type InputsObject,
7
- type InputStores,
8
- type InvalidateBy,
9
- } from '../internal/sources'
5
+ import { type InputsObject, type InputStores, type InvalidateBy } from '../internal/sources'
6
+ import type { AnySchema } from '../internal/variance'
10
7
  import {
11
8
  bumpRevision,
9
+ type GatedOf,
10
+ gatedFlag,
12
11
  makeQueryDriver,
12
+ type QueryCodec,
13
13
  RevisionSchema,
14
14
  revisionZero,
15
15
  } from '../internal/queryDriver'
16
16
  import { type Store } from '../internal/store'
17
+ import { readTracked } from '../internal/track'
17
18
  import * as Reducer from '../reducer/reducer'
18
19
  import { Reducers } from '../runtime/loop'
20
+ import { Queries } from '../runtime/queries'
19
21
  import * as State from '../state/state'
20
- import { type AnySource } from '../state/token'
22
+ import { type AnySource, type Source, type SourceCapture } from '../state/token'
21
23
  import { type AsyncData, narrowStore } from './asyncData'
22
- import type { AnySchema, AnyValue } from '../internal/variance'
23
- export { invalidate, make, type NamedCalc, refetch } from './asyncCalcDefinitions'
24
24
 
25
- export interface AsyncCalcManifest<N extends string, A, E> extends Manifest {
25
+ export interface AsyncCalcSchemaReflection {
26
+ readonly ast: Schema.Schema<unknown, unknown, unknown>['ast']
27
+ }
28
+
29
+ export interface AsyncCalcManifest<N extends string, A = unknown, E = never> extends Manifest {
26
30
  readonly kind: 'AsyncCalc'
27
31
  readonly name: N
28
32
  readonly output: AnySchema<A>
@@ -31,30 +35,88 @@ export interface AsyncCalcManifest<N extends string, A, E> extends Manifest {
31
35
  readonly gated: boolean
32
36
  }
33
37
 
34
- export interface AsyncCalcClass<
35
- out N extends string,
36
- out Inputs extends ReadonlyArray<AnySource>,
38
+ export const AsyncCalcStoreTypeId: unique symbol = Symbol.for('reform/AsyncCalcStore')
39
+ export type AsyncCalcStoreTypeId = typeof AsyncCalcStoreTypeId
40
+
41
+ interface AsyncCalcStoreCompatibilityExternalApi<
42
+ N extends string,
37
43
  in out A,
38
44
  in out E,
39
45
  in out Gated extends boolean,
40
- > extends EffectType<AsyncData<A, E, Gated>, never, Store<AsyncData<A, E, Gated>>> {
46
+ > extends Store<AsyncData<A, E, Gated>> {
47
+ readonly [AsyncCalcStoreTypeId]?: N
48
+ }
49
+
50
+ export type AsyncCalcStore<
51
+ N extends string,
52
+ A,
53
+ E,
54
+ Gated extends boolean,
55
+ > = AsyncCalcStoreCompatibilityExternalApi<N, A, E, Gated>
56
+
57
+ export interface AsyncCalcClass<
58
+ N extends string,
59
+ Inputs extends ReadonlyArray<AnySource>,
60
+ A,
61
+ E,
62
+ Gated extends boolean,
63
+ >
64
+ extends
65
+ EffectType<AsyncData<A, E, Gated>, never, AsyncCalcStore<N, A, E, Gated>>,
66
+ Source<N, AsyncData<A, E, Gated>, AsyncCalcStore<N, A, E, Gated>> {
41
67
  new (): {}
42
68
  readonly manifest: AsyncCalcManifest<N, A, E>
43
- readonly store: Context.Tag<Store<AsyncData<A, E, Gated>>, Store<AsyncData<A, E, Gated>>>
69
+ readonly store: Context.Tag<AsyncCalcStore<N, A, E, Gated>, Store<AsyncData<A, E, Gated>>>
44
70
  readonly name: N
45
71
  readonly inputs: Inputs
46
72
  readonly gated: Gated
47
73
  }
48
74
 
49
- export interface AsyncCalcConfig<Inputs extends ReadonlyArray<AnySource>, A, E, AlwaysOn extends boolean> {
75
+ export interface DefinedAsyncCalcClass<
76
+ N extends string,
77
+ Inputs extends ReadonlyArray<AnySource>,
78
+ A,
79
+ E,
80
+ Gated extends boolean,
81
+ > extends AsyncCalcClass<N, Inputs, A, E, Gated> {
82
+ readonly codec: QueryCodec<A>
83
+ readonly capture: <Result>(visit: SourceCapture<Result>) => Result
84
+ }
85
+
86
+ type ConfigSchema<ValueOrSchema> = [ValueOrSchema] extends [Schema.Schema.AnyNoContext]
87
+ ? ValueOrSchema
88
+ : AnySchema<ValueOrSchema>
89
+
90
+ type ConfigErrorSchema<ValueOrSchema> = [ValueOrSchema] extends [undefined]
91
+ ? ValueOrSchema
92
+ : ConfigSchema<ValueOrSchema>
93
+
94
+ export interface AsyncCalcConfig<
95
+ Inputs extends ReadonlyArray<AnySource>,
96
+ OutputSchema,
97
+ ErrorSchema,
98
+ AlwaysOn extends boolean,
99
+ > {
50
100
  readonly inputs: Inputs
51
- readonly output: AnySchema<A>
101
+ readonly output: ConfigSchema<OutputSchema>
52
102
  // oxlint-disable-next-line reform-rules/no-optional-fields -- caller-omitted option built at the asyncCalc/remoteState construction site; Option<T> would change that call shape
53
- readonly error?: AnySchema<E>
103
+ readonly error?: ConfigErrorSchema<ErrorSchema>
54
104
  // oxlint-disable-next-line reform-rules/no-optional-fields -- caller-omitted option built at the asyncCalc/remoteState construction site; Option<T> would change that call shape
55
105
  readonly alwaysOn?: AlwaysOn
56
106
  }
57
107
 
108
+ interface AsyncCalcMakeConfigExternalApi<
109
+ Inputs extends ReadonlyArray<AnySource>,
110
+ OutputSchema extends Schema.Schema.AnyNoContext,
111
+ ErrorSchema extends Schema.Schema.AnyNoContext | undefined,
112
+ AlwaysOn extends boolean,
113
+ > {
114
+ readonly inputs: Inputs
115
+ readonly output: OutputSchema
116
+ readonly error?: ErrorSchema
117
+ readonly alwaysOn?: AlwaysOn
118
+ }
119
+
58
120
  export type AsyncCalcLive<
59
121
  Inputs extends ReadonlyArray<AnySource>,
60
122
  A,
@@ -63,7 +125,6 @@ export type AsyncCalcLive<
63
125
  R,
64
126
  > = {
65
127
  readonly query: (inputs: InputsObject<Inputs>) => Effect.Effect<A, E, R>
66
- // oxlint-disable-next-line reform-rules/no-optional-fields -- caller-omitted option built at the asyncCalc/remoteState construction site; Option<T> would change that call shape
67
128
  readonly invalidateBy?: InvalidateBy<Inputs>
68
129
  // oxlint-disable-next-line reform-rules/no-optional-fields -- caller-omitted option built at the asyncCalc/remoteState construction site; Option<T> would change that call shape
69
130
  readonly coalesce?: 'switch' | 'trailing'
@@ -72,13 +133,10 @@ export type AsyncCalcLive<
72
133
  // oxlint-disable-next-line reform-rules/no-optional-fields -- caller-omitted option built at the asyncCalc/remoteState construction site; Option<T> would change that call shape
73
134
  readonly persist?:
74
135
  | boolean
75
- // oxlint-disable-next-line reform-rules/no-optional-fields -- caller-omitted option built at the asyncCalc/remoteState construction site; Option<T> would change that call shape
76
136
  | { readonly key?: string | ((inputs: InputsObject<Inputs>) => string) }
77
137
  } & (Gated extends true
78
- ? // oxlint-disable-next-line reform-rules/no-optional-fields -- caller-omitted option built at the asyncCalc/remoteState construction site; Option<T> would change that call shape
79
- { readonly disabled?: (inputs: InputsObject<Inputs>) => boolean }
80
- : // oxlint-disable-next-line reform-rules/no-optional-fields -- caller-omitted option built at the asyncCalc/remoteState construction site; Option<T> would change that call shape
81
- { readonly disabled?: never })
138
+ ? { readonly disabled?: (inputs: InputsObject<Inputs>) => boolean }
139
+ : { readonly disabled?: never })
82
140
 
83
141
  export type AsyncCalcLiveWithInvalidateOn<
84
142
  Inputs extends ReadonlyArray<AnySource>,
@@ -87,27 +145,83 @@ export type AsyncCalcLiveWithInvalidateOn<
87
145
  Gated extends boolean,
88
146
  R,
89
147
  > = AsyncCalcLive<Inputs, A, E, Gated, R> & {
90
- // oxlint-disable-next-line reform-rules/no-optional-fields -- caller-omitted option built at the asyncCalc/remoteState construction site; Option<T> would change that call shape
91
148
  readonly invalidateOn?: ReadonlyArray<AnyEvent>
92
149
  }
93
150
 
94
151
  export interface AsyncCalcLiveView<Inputs extends ReadonlyArray<AnySource>, A, E, R> {
95
152
  readonly query: (inputs: InputsObject<Inputs>) => Effect.Effect<A, E, R>
96
- // oxlint-disable-next-line reform-rules/no-optional-fields -- caller-omitted option built at the asyncCalc/remoteState construction site; Option<T> would change that call shape
97
153
  readonly invalidateBy?: InvalidateBy<Inputs>
98
- // oxlint-disable-next-line reform-rules/no-optional-fields -- caller-omitted option built at the asyncCalc/remoteState construction site; Option<T> would change that call shape
99
154
  readonly disabled?: (inputs: InputsObject<Inputs>) => boolean
100
- // oxlint-disable-next-line reform-rules/no-optional-fields -- caller-omitted option built at the asyncCalc/remoteState construction site; Option<T> would change that call shape
101
155
  readonly coalesce?: 'switch' | 'trailing'
102
- // oxlint-disable-next-line reform-rules/no-optional-fields -- caller-omitted option built at the asyncCalc/remoteState construction site; Option<T> would change that call shape
103
156
  readonly reuse?: boolean
104
- // oxlint-disable-next-line reform-rules/no-optional-fields -- caller-omitted option built at the asyncCalc/remoteState construction site; Option<T> would change that call shape
105
157
  readonly persist?:
106
158
  | boolean
107
- // oxlint-disable-next-line reform-rules/no-optional-fields -- caller-omitted option built at the asyncCalc/remoteState construction site; Option<T> would change that call shape
108
159
  | { readonly key?: string | ((inputs: InputsObject<Inputs>) => string) }
109
160
  }
110
161
 
162
+ export function make<
163
+ const N extends string,
164
+ const Inputs extends ReadonlyArray<AnySource>,
165
+ OutputSchema extends Schema.Schema.AnyNoContext,
166
+ ErrorSchema extends Schema.Schema.AnyNoContext | undefined = undefined,
167
+ const AlwaysOn extends boolean = false,
168
+ >(
169
+ name: N,
170
+ config: AsyncCalcConfig<Inputs, OutputSchema, ErrorSchema, AlwaysOn>,
171
+ ): DefinedAsyncCalcClass<
172
+ N,
173
+ Inputs,
174
+ Schema.Schema.Type<OutputSchema>,
175
+ ErrorSchema extends Schema.Schema.AnyNoContext ? Schema.Schema.Type<ErrorSchema> : never,
176
+ GatedOf<AlwaysOn>
177
+ >
178
+ export function make<
179
+ const N extends string,
180
+ const Inputs extends ReadonlyArray<AnySource>,
181
+ OutputSchema extends Schema.Schema.AnyNoContext,
182
+ ErrorSchema extends Schema.Schema.AnyNoContext | undefined = undefined,
183
+ const AlwaysOn extends boolean = false,
184
+ >(
185
+ name: N,
186
+ config: AsyncCalcMakeConfigExternalApi<Inputs, OutputSchema, ErrorSchema, AlwaysOn>,
187
+ ): DefinedAsyncCalcClass<
188
+ N,
189
+ Inputs,
190
+ Schema.Schema.Type<OutputSchema>,
191
+ ErrorSchema extends Schema.Schema.AnyNoContext ? Schema.Schema.Type<ErrorSchema> : never,
192
+ GatedOf<AlwaysOn>
193
+ > {
194
+ type A = Schema.Schema.Type<OutputSchema>
195
+ type E = ErrorSchema extends Schema.Schema.AnyNoContext ? Schema.Schema.Type<ErrorSchema> : never
196
+ const store = Context.GenericTag<
197
+ AsyncCalcStore<N, A, E, GatedOf<AlwaysOn>>,
198
+ Store<AsyncData<A, E, GatedOf<AlwaysOn>>>
199
+ >(`reform/asyncCalc/${name}`)
200
+ const gated = gatedFlag(config.alwaysOn)
201
+ const manifest: AsyncCalcManifest<N, A, E> = {
202
+ kind: 'AsyncCalc',
203
+ name,
204
+ output: config.output,
205
+ gated,
206
+ ...(config.error !== undefined ? { error: config.error } : {}),
207
+ }
208
+ const codec: QueryCodec<A> = {
209
+ encode: Schema.encode(config.output),
210
+ decode: Schema.decodeUnknown(config.output),
211
+ }
212
+ const read = Effect.flatMap(store, readTracked)
213
+ const calc: DefinedAsyncCalcClass<N, Inputs, A, E, GatedOf<AlwaysOn>> = yieldableClass(read, {
214
+ manifest,
215
+ codec,
216
+ store,
217
+ name,
218
+ inputs: config.inputs,
219
+ gated,
220
+ capture: <Result>(visit: SourceCapture<Result>): Result => visit(calc),
221
+ })
222
+ return calc
223
+ }
224
+
111
225
  export function live<
112
226
  N extends string,
113
227
  Inputs extends ReadonlyArray<AnySource>,
@@ -120,7 +234,7 @@ export function live<
120
234
  config: AsyncCalcLive<Inputs, A, E, Gated, R> & {
121
235
  readonly invalidateOn: ReadonlyArray<AnyEvent>
122
236
  },
123
- ): Layer.Layer<Store<AsyncData<A, E, Gated>>, never, InputStores<Inputs> | R | Reducers>
237
+ ): Layer.Layer<AsyncCalcStore<N, A, E, Gated>, never, InputStores<Inputs> | R | Reducers>
124
238
  export function live<
125
239
  N extends string,
126
240
  Inputs extends ReadonlyArray<AnySource>,
@@ -131,7 +245,7 @@ export function live<
131
245
  >(
132
246
  calc: AsyncCalcClass<N, Inputs, A, E, Gated>,
133
247
  config: AsyncCalcLive<Inputs, A, E, Gated, R>,
134
- ): Layer.Layer<Store<AsyncData<A, E, Gated>>, never, InputStores<Inputs> | R>
248
+ ): Layer.Layer<AsyncCalcStore<N, A, E, Gated>, never, InputStores<Inputs> | R>
135
249
  export function live<
136
250
  N extends string,
137
251
  Inputs extends ReadonlyArray<AnySource>,
@@ -142,7 +256,7 @@ export function live<
142
256
  >(
143
257
  calc: AsyncCalcClass<N, Inputs, A, E, Gated>,
144
258
  config: AsyncCalcLiveWithInvalidateOn<Inputs, A, E, Gated, R>,
145
- ): Layer.Layer<Store<AsyncData<A, E, Gated>>, never, AnyValue> {
259
+ ): Layer.Layer<AsyncCalcStore<N, A, E, Gated>, never, InputStores<Inputs> | R | Reducers> {
146
260
  const revisionState =
147
261
  config.invalidateOn === undefined
148
262
  ? undefined
@@ -151,18 +265,21 @@ export function live<
151
265
  const driver = Layer.scoped(
152
266
  calc.store,
153
267
  Effect.gen(function* () {
154
- // oxlint-disable-next-line reform-rules/no-type-assertion -- loose runtime view: `disabled` is type-erased to `never` for non-gated calcs, no guard recovers it
155
- const cfg = config as AsyncCalcLiveView<Inputs, A, E, R>
268
+ const cfg: AsyncCalcLiveView<Inputs, A, E, R> = config
156
269
 
157
270
  const persistOption = cfg.persist
158
271
  const persistKey =
159
272
  persistOption === true || persistOption === undefined || persistOption === false
160
273
  ? calc.manifest.name
161
- : persistOption.key ?? calc.manifest.name
274
+ : (persistOption.key ?? calc.manifest.name)
275
+ const persistCodec: QueryCodec<A> = {
276
+ encode: Schema.encode(calc.manifest.output),
277
+ decode: Schema.decodeUnknown(calc.manifest.output),
278
+ }
162
279
  const persist =
163
280
  persistOption === undefined || persistOption === false
164
281
  ? undefined
165
- : { key: persistKey, schema: calc.manifest.output }
282
+ : { key: persistKey, codec: persistCodec }
166
283
 
167
284
  const revision = yield* Match.value(revisionState).pipe(
168
285
  Match.when(undefined, () => Effect.succeed(undefined)),
@@ -204,3 +321,15 @@ export function live<
204
321
  Layer.provide(State.live(revisionState, revisionZero)),
205
322
  )
206
323
  }
324
+
325
+ export interface NamedCalc {
326
+ readonly name: string
327
+ }
328
+
329
+ export const invalidate = (calc: NamedCalc): Effect.Effect<void, never, Queries> =>
330
+ Effect.flatMap(Queries, (queries) =>
331
+ Effect.sync(() => queries.byName.get(calc.name)?.invalidate()),
332
+ )
333
+
334
+ export const refetch = (calc: NamedCalc): Effect.Effect<void, never, Queries> =>
335
+ Effect.flatMap(Queries, (queries) => Effect.sync(() => queries.byName.get(calc.name)?.refetch()))
@@ -211,11 +211,9 @@ it.live('reuse: an unchanged subtree keeps its identity across recompute', () =>
211
211
  inputs: [StateGroup.select(Inputs, 'a'), StateGroup.select(Inputs, 'b')],
212
212
  output: Pair,
213
213
  }) {}
214
- const SplitLive = Calc.live(
215
- Split,
216
- ({ a, b }) => ({ left: { value: a }, right: { value: b } }),
217
- { reuse: true },
218
- )
214
+ const SplitLive = Calc.live(Split, ({ a, b }) => ({ left: { value: a }, right: { value: b } }), {
215
+ reuse: true,
216
+ })
219
217
  const TestLayer = SplitLive.pipe(Layer.provideMerge(StateGroup.live(Inputs, { a: 1, b: 1 })))
220
218
 
221
219
  return Effect.gen(function* () {
package/src/calc/calc.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { Context, Effect, Layer, MutableRef } from 'effect'
1
+ import { Context, Effect, Layer, MutableRef, type Schema } from 'effect'
2
2
  import type { Effect as EffectType } from 'effect/Effect'
3
3
  import { type Manifest, yieldableClass } from '../definition/definition'
4
4
  import {
@@ -13,8 +13,7 @@ import { resolveInstrumentation, stateUpdateHook } from '../runtime/instrumentat
13
13
  import { reuse } from '../internal/reuse'
14
14
  import { makeDerivedStore, type Store } from '../internal/store'
15
15
  import { readTracked } from '../internal/track'
16
- import { type AnySource } from '../state/token'
17
- import type { AnySchema } from '../internal/variance'
16
+ import { type AnySource, type Source, type SourceCapture } from '../state/token'
18
17
 
19
18
  export { type InputsObject, type InputStores, type InvalidateBy } from '../internal/sources'
20
19
 
@@ -23,41 +22,68 @@ export interface CalcOptionsExternalApi<Inputs extends ReadonlyArray<AnySource>>
23
22
  readonly reuse?: boolean
24
23
  }
25
24
 
26
- export interface CalcManifest<N extends string, Out> extends Manifest {
25
+ export interface CalcSchemaReflection {
26
+ readonly ast: Schema.Schema<unknown, unknown, unknown>['ast']
27
+ }
28
+
29
+ export interface CalcManifest<N extends string> extends Manifest {
27
30
  readonly kind: 'Calc'
28
31
  readonly name: N
29
- readonly output: AnySchema<Out>
32
+ readonly output: CalcSchemaReflection
33
+ }
34
+
35
+ export const CalcStoreTypeId: unique symbol = Symbol.for('reform/CalcStore')
36
+ export type CalcStoreTypeId = typeof CalcStoreTypeId
37
+
38
+ export interface CalcStore<N extends string, in out Out> extends Store<Out> {
39
+ readonly [CalcStoreTypeId]: N
30
40
  }
31
41
 
32
- export interface CalcClass<out N extends string, out Inputs extends ReadonlyArray<AnySource>, in out Out>
33
- extends EffectType<Out, never, Store<Out>> {
42
+ export interface CalcClass<N extends string, Inputs extends ReadonlyArray<AnySource>, Out>
43
+ extends EffectType<Out, never, CalcStore<N, Out>>, Source<N, Out, CalcStore<N, Out>> {
34
44
  new (): {}
35
- readonly manifest: CalcManifest<N, Out>
36
- readonly store: Context.Tag<Store<Out>, Store<Out>>
45
+ readonly manifest: CalcManifest<N>
46
+ readonly store: Context.Tag<CalcStore<N, Out>, Store<Out>>
37
47
  readonly name: N
38
48
  readonly inputs: Inputs
49
+ readonly capture: <Result>(visit: SourceCapture<Result>) => Result
39
50
  }
40
51
 
41
- export interface CalcConfig<Inputs extends ReadonlyArray<AnySource>, Out> {
52
+ export interface CalcConfig<
53
+ Inputs extends ReadonlyArray<AnySource>,
54
+ OutputSchema extends Schema.Schema.AnyNoContext,
55
+ > {
42
56
  readonly inputs: Inputs
43
- readonly output: AnySchema<Out>
57
+ readonly output: OutputSchema
44
58
  }
45
59
 
46
- export const make = <const N extends string, const Inputs extends ReadonlyArray<AnySource>, Out>(
60
+ export const make = <
61
+ const N extends string,
62
+ const Inputs extends ReadonlyArray<AnySource>,
63
+ OutputSchema extends Schema.Schema.AnyNoContext,
64
+ >(
47
65
  name: N,
48
- config: CalcConfig<Inputs, Out>,
49
- ): CalcClass<N, Inputs, Out> => {
50
- const store = Context.GenericTag<Store<Out>, Store<Out>>(`reform/calc/${name}`)
51
- const manifest: CalcManifest<N, Out> = { kind: 'Calc', name, output: config.output }
66
+ config: CalcConfig<Inputs, OutputSchema>,
67
+ ): CalcClass<N, Inputs, Schema.Schema.Type<OutputSchema>> => {
68
+ type Out = Schema.Schema.Type<OutputSchema>
69
+ const store = Context.GenericTag<CalcStore<N, Out>, Store<Out>>(`reform/calc/${name}`)
70
+ const manifest: CalcManifest<N> = { kind: 'Calc', name, output: config.output }
52
71
  const read = Effect.flatMap(store, readTracked)
53
- return yieldableClass(read, { manifest, store, name, inputs: config.inputs })
72
+ const calc: CalcClass<N, Inputs, Out> = yieldableClass(read, {
73
+ manifest,
74
+ store,
75
+ name,
76
+ inputs: config.inputs,
77
+ capture: <Result>(visit: SourceCapture<Result>): Result => visit(calc),
78
+ })
79
+ return calc
54
80
  }
55
81
 
56
82
  export const live = <N extends string, Inputs extends ReadonlyArray<AnySource>, Out>(
57
83
  calc: CalcClass<N, Inputs, Out>,
58
84
  compute: (inputs: InputsObject<Inputs>) => Out,
59
85
  options: CalcOptionsExternalApi<Inputs> = {},
60
- ): Layer.Layer<Store<Out>, never, InputStores<Inputs>> =>
86
+ ): Layer.Layer<CalcStore<N, Out>, never, InputStores<Inputs>> =>
61
87
  Layer.scoped(
62
88
  calc.store,
63
89
  Effect.gen(function* () {
@@ -64,19 +64,79 @@ it.live('a member only notifies when its own slice of the projection moves', ()
64
64
  }).pipe(Effect.provide(TestLayer))
65
65
  })
66
66
 
67
- it.live('reading does not recompute while the shared inputs are unchanged (memoized per member)', () => {
68
- const { Inputs, Slice, SliceLive, runs } = fixture()
69
- const TestLayer = SliceLive.pipe(
70
- Layer.provideMerge(StateGroup.live(Inputs, { feed: { a: 1 } })),
67
+ const reuseFixture = () => {
68
+ class Feed extends State.make('reuse-feed', Board) {}
69
+ class Revision extends State.make('reuse-revision', S.Number) {}
70
+ class Inputs extends StateGroup.make(Feed, Revision) {}
71
+ class Presentation extends CalcFamily.make('ReusePresentation', {
72
+ key: S.String,
73
+ inputs: [StateGroup.select(Inputs, 'reuse-feed'), StateGroup.select(Inputs, 'reuse-revision')],
74
+ output: S.Struct({ nested: S.Array(S.Struct({ value: S.Number })) }),
75
+ }) {}
76
+ const PresentationLive = CalcFamily.live(
77
+ Presentation,
78
+ (key) =>
79
+ ({ 'reuse-feed': feed }) => ({
80
+ nested: [{ value: feed[key] ?? 0 }],
81
+ }),
82
+ { reuse: true },
83
+ )
84
+ return { Inputs, Presentation, PresentationLive }
85
+ }
86
+
87
+ it.live('reuse preserves each member output when equal and replaces it when changed', () => {
88
+ const { Inputs, Presentation, PresentationLive } = reuseFixture()
89
+ const TestLayer = PresentationLive.pipe(
90
+ Layer.provideMerge(
91
+ StateGroup.live(Inputs, { 'reuse-feed': { a: 1, b: 1 }, 'reuse-revision': 0 }),
92
+ ),
71
93
  )
72
94
  return Effect.gen(function* () {
73
- yield* CalcFamily.read(Slice, 'a')
74
- yield* CalcFamily.read(Slice, 'a')
75
- yield* CalcFamily.read(Slice, 'a')
76
- expect(runs.get('a')).toBe(1)
95
+ const feed = yield* StateGroup.select(Inputs, 'reuse-feed').store
96
+ const revision = yield* StateGroup.select(Inputs, 'reuse-revision').store
97
+ const family = yield* Presentation.store
98
+ const initialA = family.at('a').get()
99
+ const initialB = family.at('b').get()
100
+ const wakes = { a: 0, b: 0 }
101
+ family.at('a').subscribe(() => {
102
+ wakes.a += 1
103
+ })
104
+ family.at('b').subscribe(() => {
105
+ wakes.b += 1
106
+ })
107
+
108
+ expect(Object.is(initialA.nested, initialB.nested)).toBe(false)
109
+ revision.set(1)
110
+ yield* tick
111
+ expect(Object.is(family.at('a').get().nested, initialA.nested)).toBe(true)
112
+ expect(Object.is(family.at('b').get().nested, initialB.nested)).toBe(true)
113
+ expect(wakes).toEqual({ a: 0, b: 0 })
114
+
115
+ feed.set({ a: 2, b: 1 })
116
+ yield* tick
117
+ expect(family.at('a').get().nested).toEqual([{ value: 2 }])
118
+ expect(Object.is(family.at('a').get().nested, initialA.nested)).toBe(false)
119
+ expect(Object.is(family.at('b').get().nested, initialB.nested)).toBe(true)
120
+ expect(wakes).toEqual({ a: 1, b: 0 })
77
121
  }).pipe(Effect.provide(TestLayer))
78
122
  })
79
123
 
124
+ it.live(
125
+ 'reading does not recompute while the shared inputs are unchanged (memoized per member)',
126
+ () => {
127
+ const { Inputs, Slice, SliceLive, runs } = fixture()
128
+ const TestLayer = SliceLive.pipe(
129
+ Layer.provideMerge(StateGroup.live(Inputs, { feed: { a: 1 } })),
130
+ )
131
+ return Effect.gen(function* () {
132
+ yield* CalcFamily.read(Slice, 'a')
133
+ yield* CalcFamily.read(Slice, 'a')
134
+ yield* CalcFamily.read(Slice, 'a')
135
+ expect(runs.get('a')).toBe(1)
136
+ }).pipe(Effect.provide(TestLayer))
137
+ },
138
+ )
139
+
80
140
  it.live('invalidateBy: shared-input churn outside the key recomputes no member', () => {
81
141
  class Feed extends State.make('feed', Board) {}
82
142
  class Noise extends State.make('noise', S.Number) {}
@@ -89,10 +149,11 @@ it.live('invalidateBy: shared-input churn outside the key recomputes no member',
89
149
  }) {}
90
150
  const SliceLive = CalcFamily.live(
91
151
  Slice,
92
- (key) => ({ feed }) => {
93
- runs.set(key, (runs.get(key) ?? 0) + 1)
94
- return feed[key] ?? 0
95
- },
152
+ (key) =>
153
+ ({ feed }) => {
154
+ runs.set(key, (runs.get(key) ?? 0) + 1)
155
+ return feed[key] ?? 0
156
+ },
96
157
  { invalidateBy: ({ feed }) => [feed] },
97
158
  )
98
159
  const TestLayer = SliceLive.pipe(
@@ -112,9 +173,7 @@ it.live('invalidateBy: shared-input churn outside the key recomputes no member',
112
173
 
113
174
  it.live('forget releases the member and its upstream subscription', () => {
114
175
  const { Inputs, Slice, SliceLive, runs } = fixture()
115
- const TestLayer = SliceLive.pipe(
116
- Layer.provideMerge(StateGroup.live(Inputs, { feed: { a: 1 } })),
117
- )
176
+ const TestLayer = SliceLive.pipe(Layer.provideMerge(StateGroup.live(Inputs, { feed: { a: 1 } })))
118
177
  return Effect.gen(function* () {
119
178
  const feed = yield* StateGroup.select(Inputs, 'feed').store
120
179
  const family = yield* Slice.store
@@ -143,15 +202,14 @@ it.live('evictWhenUnused: an idle member is dropped on the next microtask and un
143
202
  }) {}
144
203
  const SliceLive = CalcFamily.live(
145
204
  Slice,
146
- (key) => ({ feed }) => {
147
- runs.set(key, (runs.get(key) ?? 0) + 1)
148
- return feed[key] ?? 0
149
- },
205
+ (key) =>
206
+ ({ feed }) => {
207
+ runs.set(key, (runs.get(key) ?? 0) + 1)
208
+ return feed[key] ?? 0
209
+ },
150
210
  { evictWhenUnused: true },
151
211
  )
152
- const TestLayer = SliceLive.pipe(
153
- Layer.provideMerge(StateGroup.live(Inputs, { feed: { a: 1 } })),
154
- )
212
+ const TestLayer = SliceLive.pipe(Layer.provideMerge(StateGroup.live(Inputs, { feed: { a: 1 } })))
155
213
  return Effect.gen(function* () {
156
214
  const feed = yield* StateGroup.select(Inputs, 'feed').store
157
215
  const family = yield* Slice.store