@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.
Files changed (106) hide show
  1. package/README.md +85 -55
  2. package/package.json +17 -17
  3. package/src/boundary/boundary.test.ts +82 -33
  4. package/src/boundary/boundary.ts +141 -32
  5. package/src/calc/asyncCalc.invalidate.test.ts +733 -32
  6. package/src/calc/asyncCalc.test.ts +397 -225
  7. package/src/calc/asyncCalc.ts +115 -100
  8. package/src/calc/asyncCalcTypes.ts +147 -0
  9. package/src/calc/calc.test.ts +13 -16
  10. package/src/calc/calc.ts +44 -18
  11. package/src/calc/calcFamily.test.ts +90 -32
  12. package/src/calc/calcFamily.ts +56 -25
  13. package/src/calc/compose.ts +1 -14
  14. package/src/channel/channel.ts +25 -6
  15. package/src/channel/{procedureRegistry.ts → procedures.ts} +41 -23
  16. package/src/compose/composition.test.ts +19 -0
  17. package/src/compose/composition.ts +168 -81
  18. package/src/compose/compositionTypes.ts +249 -0
  19. package/src/compose/props.ts +13 -6
  20. package/src/compose/provide.ts +187 -46
  21. package/src/compose/slot.ts +156 -19
  22. package/src/compose/structure.test.ts +6 -3
  23. package/src/compose/structure.ts +106 -13
  24. package/src/compose/ui.test.ts +19 -3
  25. package/src/compose/ui.ts +147 -54
  26. package/src/compose/ui.typecheck.ts +40 -4
  27. package/src/definition/definition.ts +22 -9
  28. package/src/event/event.fromSource.test.ts +172 -0
  29. package/src/event/event.test.ts +10 -0
  30. package/src/event/event.ts +102 -18
  31. package/src/feature/feature.mount.test.ts +123 -56
  32. package/src/feature/feature.test.ts +67 -63
  33. package/src/feature/feature.ts +222 -262
  34. package/src/feature/feature.typecheck.ts +224 -91
  35. package/src/feature/featureBinding.ts +130 -20
  36. package/src/feature/featureClass.ts +162 -0
  37. package/src/feature/featureConfig.ts +192 -0
  38. package/src/feature/featureFactory.ts +163 -0
  39. package/src/feature/featureModule.ts +136 -0
  40. package/src/feature/featureModule.typecheck.ts +63 -0
  41. package/src/feature/featureNativeTree.ts +152 -0
  42. package/src/feature/featureRequirement.ts +82 -0
  43. package/src/feature/featureVariants.ts +234 -0
  44. package/src/feature/mountFeature.ts +54 -0
  45. package/src/graph/closure.ts +222 -0
  46. package/src/graph/services.ts +95 -0
  47. package/src/graph/summary.ts +134 -0
  48. package/src/index.ts +171 -245
  49. package/src/internal/bucketCache.ts +39 -0
  50. package/src/internal/capture.ts +9 -4
  51. package/src/internal/env.ts +1 -3
  52. package/src/internal/errors.ts +21 -10
  53. package/src/internal/queryDriver.ts +61 -25
  54. package/src/internal/queryDriverHydrate.ts +45 -0
  55. package/src/internal/queryDriverStore.ts +8 -5
  56. package/src/internal/queryDriverTypes.ts +11 -11
  57. package/src/internal/reuse.test.ts +10 -3
  58. package/src/internal/reuse.ts +5 -2
  59. package/src/internal/scheduler.ts +4 -1
  60. package/src/internal/sources.ts +25 -11
  61. package/src/internal/store.test.ts +27 -6
  62. package/src/internal/track.ts +3 -2
  63. package/src/internal.ts +222 -0
  64. package/src/namespace/namespace.ts +46 -25
  65. package/src/procedure/procedure.ts +38 -11
  66. package/src/reducer/reducer.ts +78 -34
  67. package/src/remote/pendingQueue.ts +145 -0
  68. package/src/remote/remoteState.test.ts +536 -354
  69. package/src/remote/remoteState.ts +145 -106
  70. package/src/remote/remoteState.typecheck.ts +47 -23
  71. package/src/remote/remoteStateMake.ts +97 -0
  72. package/src/remote/remoteStateSend.ts +95 -37
  73. package/src/remote/remoteStateTypes.ts +155 -0
  74. package/src/runtime/appRuntime.activation.test.ts +104 -0
  75. package/src/runtime/appRuntime.test.ts +187 -111
  76. package/src/runtime/appRuntime.ts +36 -130
  77. package/src/runtime/capturedAppRuntime.ts +274 -0
  78. package/src/runtime/eventBudget.test.ts +39 -12
  79. package/src/runtime/eventBudget.ts +9 -8
  80. package/src/runtime/featureMount.ts +94 -0
  81. package/src/runtime/hardening.test.ts +22 -13
  82. package/src/runtime/instrumentation.test.ts +213 -194
  83. package/src/runtime/instrumentation.ts +6 -11
  84. package/src/runtime/loop.test.ts +84 -7
  85. package/src/runtime/loop.ts +69 -40
  86. package/src/runtime/runtimeHandle.ts +124 -0
  87. package/src/scene/featureScene.test.ts +4 -2
  88. package/src/scene/scene.ts +234 -64
  89. package/src/scene/seedScene.test.ts +69 -86
  90. package/src/state/state.nominal.typecheck.ts +68 -0
  91. package/src/state/state.test.ts +17 -0
  92. package/src/state/state.ts +79 -26
  93. package/src/state/stateFamily.test.ts +39 -8
  94. package/src/state/stateFamily.ts +55 -22
  95. package/src/state/stateGroup.ts +53 -17
  96. package/src/state/token.ts +40 -10
  97. package/src/synced/syncedStore.ts +46 -23
  98. package/src/testkit/flight.testkit.ts +84 -0
  99. package/src/wire/tree.test.ts +8 -4
  100. package/src/wire/triggers.test.ts +6 -2
  101. package/src/wire/triggers.ts +14 -10
  102. package/src/calc/asyncCalcDefinitions.ts +0 -48
  103. package/src/internal/ctx.ts +0 -9
  104. package/src/remote/remoteStateDefinition.ts +0 -135
  105. package/src/remote/remoteStateLayers.ts +0 -118
  106. package/src/remote/remoteStateLiveTypes.ts +0 -59
@@ -1,111 +1,111 @@
1
- import { Context, Effect, Layer, Match, Option } from 'effect'
2
- import type { Effect as EffectType } from 'effect/Effect'
3
- import { type Manifest } from '../definition/definition'
1
+ import { Context, Effect, Layer, Match, Option, Schema } from 'effect'
2
+ import { yieldableClass } from '../definition/definition'
4
3
  import { type AnyEvent } from '../event/event'
5
- import {
6
- type InputsObject,
7
- type InputStores,
8
- type InvalidateBy,
9
- } from '../internal/sources'
4
+ import { type InputStores } from '../internal/sources'
10
5
  import {
11
6
  bumpRevision,
7
+ type GatedOf,
8
+ gatedFlag,
12
9
  makeQueryDriver,
10
+ type QueryCodec,
13
11
  RevisionSchema,
14
12
  revisionZero,
15
13
  } from '../internal/queryDriver'
16
14
  import { type Store } from '../internal/store'
15
+ import { readTracked } from '../internal/track'
17
16
  import * as Reducer from '../reducer/reducer'
18
17
  import { Reducers } from '../runtime/loop'
18
+ import { Queries } from '../runtime/queries'
19
19
  import * as State from '../state/state'
20
- import { type AnySource } from '../state/token'
20
+ import { type AnySource, type SourceCapture } from '../state/token'
21
21
  import { type AsyncData, narrowStore } from './asyncData'
22
- import type { AnySchema, AnyValue } from '../internal/variance'
23
- export { invalidate, make, type NamedCalc, refetch } from './asyncCalcDefinitions'
24
-
25
- export interface AsyncCalcManifest<N extends string, A, E> extends Manifest {
26
- readonly kind: 'AsyncCalc'
27
- readonly name: N
28
- readonly output: AnySchema<A>
29
- // 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
30
- readonly error?: AnySchema<E>
31
- readonly gated: boolean
32
- }
33
-
34
- export interface AsyncCalcClass<
35
- out N extends string,
36
- out Inputs extends ReadonlyArray<AnySource>,
37
- in out A,
38
- in out E,
39
- in out Gated extends boolean,
40
- > extends EffectType<AsyncData<A, E, Gated>, never, Store<AsyncData<A, E, Gated>>> {
41
- new (): {}
42
- readonly manifest: AsyncCalcManifest<N, A, E>
43
- readonly store: Context.Tag<Store<AsyncData<A, E, Gated>>, Store<AsyncData<A, E, Gated>>>
44
- readonly name: N
45
- readonly inputs: Inputs
46
- readonly gated: Gated
47
- }
48
-
49
- export interface AsyncCalcConfig<Inputs extends ReadonlyArray<AnySource>, A, E, AlwaysOn extends boolean> {
50
- readonly inputs: Inputs
51
- readonly output: AnySchema<A>
52
- // 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>
54
- // 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
- readonly alwaysOn?: AlwaysOn
56
- }
22
+ import type {
23
+ AsyncCalcClass,
24
+ AsyncCalcConfig,
25
+ AsyncCalcLive,
26
+ AsyncCalcLiveView,
27
+ AsyncCalcLiveWithInvalidateOn,
28
+ AsyncCalcMakeConfigExternalApi,
29
+ AsyncCalcManifest,
30
+ AsyncCalcStore,
31
+ DefinedAsyncCalcClass,
32
+ } from './asyncCalcTypes'
57
33
 
58
- export type AsyncCalcLive<
59
- Inputs extends ReadonlyArray<AnySource>,
60
- A,
61
- E,
62
- Gated extends boolean,
63
- R,
64
- > = {
65
- 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
- readonly invalidateBy?: InvalidateBy<Inputs>
68
- // 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
- readonly coalesce?: 'switch' | 'trailing'
70
- // 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
71
- readonly reuse?: boolean
72
- // 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
- readonly persist?:
74
- | 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
- | { readonly key?: string | ((inputs: InputsObject<Inputs>) => string) }
77
- } & (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 })
34
+ export { AsyncCalcStoreTypeId } from './asyncCalcTypes'
35
+ export type {
36
+ AsyncCalcClass,
37
+ AsyncCalcConfig,
38
+ AsyncCalcLive,
39
+ AsyncCalcLiveView,
40
+ AsyncCalcLiveWithInvalidateOn,
41
+ AsyncCalcManifest,
42
+ AsyncCalcSchemaReflection,
43
+ AsyncCalcStore,
44
+ DefinedAsyncCalcClass,
45
+ } from './asyncCalcTypes'
82
46
 
83
- export type AsyncCalcLiveWithInvalidateOn<
84
- Inputs extends ReadonlyArray<AnySource>,
85
- A,
86
- E,
87
- Gated extends boolean,
88
- R,
89
- > = 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
- readonly invalidateOn?: ReadonlyArray<AnyEvent>
92
- }
93
47
 
94
- export interface AsyncCalcLiveView<Inputs extends ReadonlyArray<AnySource>, A, E, R> {
95
- 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
- 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
- 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
- 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
- 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
- readonly persist?:
106
- | 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
- | { readonly key?: string | ((inputs: InputsObject<Inputs>) => string) }
48
+ export function make<
49
+ const N extends string,
50
+ const Inputs extends ReadonlyArray<AnySource>,
51
+ OutputSchema extends Schema.Schema.AnyNoContext,
52
+ ErrorSchema extends Schema.Schema.AnyNoContext | undefined = undefined,
53
+ const AlwaysOn extends boolean = false,
54
+ >(
55
+ name: N,
56
+ config: AsyncCalcConfig<Inputs, OutputSchema, ErrorSchema, AlwaysOn>,
57
+ ): DefinedAsyncCalcClass<
58
+ N,
59
+ Inputs,
60
+ Schema.Schema.Type<OutputSchema>,
61
+ ErrorSchema extends Schema.Schema.AnyNoContext ? Schema.Schema.Type<ErrorSchema> : never,
62
+ GatedOf<AlwaysOn>
63
+ >
64
+ export function make<
65
+ const N extends string,
66
+ const Inputs extends ReadonlyArray<AnySource>,
67
+ OutputSchema extends Schema.Schema.AnyNoContext,
68
+ ErrorSchema extends Schema.Schema.AnyNoContext | undefined = undefined,
69
+ const AlwaysOn extends boolean = false,
70
+ >(
71
+ name: N,
72
+ config: AsyncCalcMakeConfigExternalApi<Inputs, OutputSchema, ErrorSchema, AlwaysOn>,
73
+ ): DefinedAsyncCalcClass<
74
+ N,
75
+ Inputs,
76
+ Schema.Schema.Type<OutputSchema>,
77
+ ErrorSchema extends Schema.Schema.AnyNoContext ? Schema.Schema.Type<ErrorSchema> : never,
78
+ GatedOf<AlwaysOn>
79
+ > {
80
+ type A = Schema.Schema.Type<OutputSchema>
81
+ type E = ErrorSchema extends Schema.Schema.AnyNoContext ? Schema.Schema.Type<ErrorSchema> : never
82
+ const store = Context.GenericTag<
83
+ AsyncCalcStore<N, A, E, GatedOf<AlwaysOn>>,
84
+ Store<AsyncData<A, E, GatedOf<AlwaysOn>>>
85
+ >(`reform/asyncCalc/${name}`)
86
+ const gated = gatedFlag(config.alwaysOn)
87
+ const manifest: AsyncCalcManifest<N, A, E> = {
88
+ kind: 'AsyncCalc',
89
+ name,
90
+ output: config.output,
91
+ gated,
92
+ ...(config.error !== undefined ? { error: config.error } : {}),
93
+ }
94
+ const codec: QueryCodec<A> = {
95
+ encode: Schema.encode(config.output),
96
+ decode: Schema.decodeUnknown(config.output),
97
+ }
98
+ const read = Effect.flatMap(store, readTracked)
99
+ const calc: DefinedAsyncCalcClass<N, Inputs, A, E, GatedOf<AlwaysOn>> = yieldableClass(read, {
100
+ manifest,
101
+ codec,
102
+ store,
103
+ name,
104
+ inputs: config.inputs,
105
+ gated,
106
+ capture: <Result>(visit: SourceCapture<Result>): Result => visit(calc),
107
+ })
108
+ return calc
109
109
  }
110
110
 
111
111
  export function live<
@@ -120,7 +120,7 @@ export function live<
120
120
  config: AsyncCalcLive<Inputs, A, E, Gated, R> & {
121
121
  readonly invalidateOn: ReadonlyArray<AnyEvent>
122
122
  },
123
- ): Layer.Layer<Store<AsyncData<A, E, Gated>>, never, InputStores<Inputs> | R | Reducers>
123
+ ): Layer.Layer<AsyncCalcStore<N, A, E, Gated>, never, InputStores<Inputs> | R | Reducers>
124
124
  export function live<
125
125
  N extends string,
126
126
  Inputs extends ReadonlyArray<AnySource>,
@@ -131,7 +131,7 @@ export function live<
131
131
  >(
132
132
  calc: AsyncCalcClass<N, Inputs, A, E, Gated>,
133
133
  config: AsyncCalcLive<Inputs, A, E, Gated, R>,
134
- ): Layer.Layer<Store<AsyncData<A, E, Gated>>, never, InputStores<Inputs> | R>
134
+ ): Layer.Layer<AsyncCalcStore<N, A, E, Gated>, never, InputStores<Inputs> | R>
135
135
  export function live<
136
136
  N extends string,
137
137
  Inputs extends ReadonlyArray<AnySource>,
@@ -142,7 +142,7 @@ export function live<
142
142
  >(
143
143
  calc: AsyncCalcClass<N, Inputs, A, E, Gated>,
144
144
  config: AsyncCalcLiveWithInvalidateOn<Inputs, A, E, Gated, R>,
145
- ): Layer.Layer<Store<AsyncData<A, E, Gated>>, never, AnyValue> {
145
+ ): Layer.Layer<AsyncCalcStore<N, A, E, Gated>, never, InputStores<Inputs> | R | Reducers> {
146
146
  const revisionState =
147
147
  config.invalidateOn === undefined
148
148
  ? undefined
@@ -151,18 +151,21 @@ export function live<
151
151
  const driver = Layer.scoped(
152
152
  calc.store,
153
153
  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>
154
+ const cfg: AsyncCalcLiveView<Inputs, A, E, R> = config
156
155
 
157
156
  const persistOption = cfg.persist
158
157
  const persistKey =
159
158
  persistOption === true || persistOption === undefined || persistOption === false
160
159
  ? calc.manifest.name
161
- : persistOption.key ?? calc.manifest.name
160
+ : (persistOption.key ?? calc.manifest.name)
161
+ const persistCodec: QueryCodec<A> = {
162
+ encode: Schema.encode(calc.manifest.output),
163
+ decode: Schema.decodeUnknown(calc.manifest.output),
164
+ }
162
165
  const persist =
163
166
  persistOption === undefined || persistOption === false
164
167
  ? undefined
165
- : { key: persistKey, schema: calc.manifest.output }
168
+ : { key: persistKey, codec: persistCodec }
166
169
 
167
170
  const revision = yield* Match.value(revisionState).pipe(
168
171
  Match.when(undefined, () => Effect.succeed(undefined)),
@@ -204,3 +207,15 @@ export function live<
204
207
  Layer.provide(State.live(revisionState, revisionZero)),
205
208
  )
206
209
  }
210
+
211
+ export interface NamedCalc {
212
+ readonly name: string
213
+ }
214
+
215
+ export const invalidate = (calc: NamedCalc): Effect.Effect<void, never, Queries> =>
216
+ Effect.flatMap(Queries, (queries) =>
217
+ Effect.sync(() => queries.byName.get(calc.name)?.invalidate()),
218
+ )
219
+
220
+ export const refetch = (calc: NamedCalc): Effect.Effect<void, never, Queries> =>
221
+ Effect.flatMap(Queries, (queries) => Effect.sync(() => queries.byName.get(calc.name)?.refetch()))
@@ -0,0 +1,147 @@
1
+ import type { Context, Effect, Schema } from 'effect'
2
+ import type { Effect as EffectType } from 'effect/Effect'
3
+ import type { Manifest } from '../definition/definition'
4
+ import type { AnyEvent } from '../event/event'
5
+ import type { InputsObject, InvalidateBy } from '../internal/sources'
6
+ import type { QueryCodec } from '../internal/queryDriver'
7
+ import type { Store } from '../internal/store'
8
+ import type { AnySchema } from '../internal/variance'
9
+ import type { AnySource, Source, SourceCapture } from '../state/token'
10
+ import type { AsyncData } from './asyncData'
11
+
12
+ export interface AsyncCalcSchemaReflection {
13
+ readonly ast: Schema.Schema<unknown, unknown, unknown>['ast']
14
+ }
15
+
16
+ export interface AsyncCalcManifest<N extends string, A = unknown, E = never> extends Manifest {
17
+ readonly kind: 'AsyncCalc'
18
+ readonly name: N
19
+ readonly output: AnySchema<A>
20
+ // 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
21
+ readonly error?: AnySchema<E>
22
+ readonly gated: boolean
23
+ }
24
+
25
+ export const AsyncCalcStoreTypeId: unique symbol = Symbol.for('reform/AsyncCalcStore')
26
+ export type AsyncCalcStoreTypeId = typeof AsyncCalcStoreTypeId
27
+
28
+ interface AsyncCalcStoreCompatibilityExternalApi<
29
+ N extends string,
30
+ in out A,
31
+ in out E,
32
+ in out Gated extends boolean,
33
+ > extends Store<AsyncData<A, E, Gated>> {
34
+ readonly [AsyncCalcStoreTypeId]?: N
35
+ }
36
+
37
+ export type AsyncCalcStore<
38
+ N extends string,
39
+ A,
40
+ E,
41
+ Gated extends boolean,
42
+ > = AsyncCalcStoreCompatibilityExternalApi<N, A, E, Gated>
43
+
44
+ export interface AsyncCalcClass<
45
+ N extends string,
46
+ Inputs extends ReadonlyArray<AnySource>,
47
+ A,
48
+ E,
49
+ Gated extends boolean,
50
+ >
51
+ extends
52
+ EffectType<AsyncData<A, E, Gated>, never, AsyncCalcStore<N, A, E, Gated>>,
53
+ Source<N, AsyncData<A, E, Gated>, AsyncCalcStore<N, A, E, Gated>> {
54
+ new (): {}
55
+ readonly manifest: AsyncCalcManifest<N, A, E>
56
+ readonly store: Context.Tag<AsyncCalcStore<N, A, E, Gated>, Store<AsyncData<A, E, Gated>>>
57
+ readonly name: N
58
+ readonly inputs: Inputs
59
+ readonly gated: Gated
60
+ }
61
+
62
+ export interface DefinedAsyncCalcClass<
63
+ N extends string,
64
+ Inputs extends ReadonlyArray<AnySource>,
65
+ A,
66
+ E,
67
+ Gated extends boolean,
68
+ > extends AsyncCalcClass<N, Inputs, A, E, Gated> {
69
+ readonly codec: QueryCodec<A>
70
+ readonly capture: <Result>(visit: SourceCapture<Result>) => Result
71
+ }
72
+
73
+ type ConfigSchema<ValueOrSchema> = [ValueOrSchema] extends [Schema.Schema.AnyNoContext]
74
+ ? ValueOrSchema
75
+ : AnySchema<ValueOrSchema>
76
+
77
+ type ConfigErrorSchema<ValueOrSchema> = [ValueOrSchema] extends [undefined]
78
+ ? ValueOrSchema
79
+ : ConfigSchema<ValueOrSchema>
80
+
81
+ export interface AsyncCalcConfig<
82
+ Inputs extends ReadonlyArray<AnySource>,
83
+ OutputSchema,
84
+ ErrorSchema,
85
+ AlwaysOn extends boolean,
86
+ > {
87
+ readonly inputs: Inputs
88
+ readonly output: ConfigSchema<OutputSchema>
89
+ // 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
90
+ readonly error?: ConfigErrorSchema<ErrorSchema>
91
+ // 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
92
+ readonly alwaysOn?: AlwaysOn
93
+ }
94
+
95
+ export interface AsyncCalcMakeConfigExternalApi<
96
+ Inputs extends ReadonlyArray<AnySource>,
97
+ OutputSchema extends Schema.Schema.AnyNoContext,
98
+ ErrorSchema extends Schema.Schema.AnyNoContext | undefined,
99
+ AlwaysOn extends boolean,
100
+ > {
101
+ readonly inputs: Inputs
102
+ readonly output: OutputSchema
103
+ readonly error?: ErrorSchema
104
+ readonly alwaysOn?: AlwaysOn
105
+ }
106
+
107
+ export type AsyncCalcLive<
108
+ Inputs extends ReadonlyArray<AnySource>,
109
+ A,
110
+ E,
111
+ Gated extends boolean,
112
+ R,
113
+ > = {
114
+ readonly query: (inputs: InputsObject<Inputs>) => Effect.Effect<A, E, R>
115
+ readonly invalidateBy?: InvalidateBy<Inputs>
116
+ // 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
117
+ readonly coalesce?: 'switch' | 'trailing'
118
+ // 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
119
+ readonly reuse?: boolean
120
+ // 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
121
+ readonly persist?:
122
+ | boolean
123
+ | { readonly key?: string | ((inputs: InputsObject<Inputs>) => string) }
124
+ } & (Gated extends true
125
+ ? { readonly disabled?: (inputs: InputsObject<Inputs>) => boolean }
126
+ : { readonly disabled?: never })
127
+
128
+ export type AsyncCalcLiveWithInvalidateOn<
129
+ Inputs extends ReadonlyArray<AnySource>,
130
+ A,
131
+ E,
132
+ Gated extends boolean,
133
+ R,
134
+ > = AsyncCalcLive<Inputs, A, E, Gated, R> & {
135
+ readonly invalidateOn?: ReadonlyArray<AnyEvent>
136
+ }
137
+
138
+ export interface AsyncCalcLiveView<Inputs extends ReadonlyArray<AnySource>, A, E, R> {
139
+ readonly query: (inputs: InputsObject<Inputs>) => Effect.Effect<A, E, R>
140
+ readonly invalidateBy?: InvalidateBy<Inputs>
141
+ readonly disabled?: (inputs: InputsObject<Inputs>) => boolean
142
+ readonly coalesce?: 'switch' | 'trailing'
143
+ readonly reuse?: boolean
144
+ readonly persist?:
145
+ | boolean
146
+ | { readonly key?: string | ((inputs: InputsObject<Inputs>) => string) }
147
+ }
@@ -1,8 +1,7 @@
1
1
  import { expect, it } from '@effect/vitest'
2
- import { Duration, Effect, Layer, Schema as S } from 'effect'
2
+ import { Effect, Layer, Schema as S } from 'effect'
3
3
  import { Calc, State, StateGroup } from '../index'
4
-
5
- const tick = Effect.sleep(Duration.millis(1))
4
+ import { flush } from '../testkit/flight.testkit'
6
5
 
7
6
  it.live('a source change that does not move the output notifies no subscriber', () => {
8
7
  class Count extends State.make('count', S.Number) {}
@@ -30,12 +29,12 @@ it.live('a source change that does not move the output notifies no subscriber',
30
29
  expect(derived.get()).toBe(true)
31
30
 
32
31
  source.set(2)
33
- yield* tick
32
+ yield* flush()
34
33
  expect(notifications.n).toBe(0)
35
34
  expect(derived.get()).toBe(true)
36
35
 
37
36
  source.set(-1)
38
- yield* tick
37
+ yield* flush()
39
38
  expect(notifications.n).toBe(1)
40
39
  expect(derived.get()).toBe(false)
41
40
  }).pipe(Effect.provide(TestLayer))
@@ -89,7 +88,7 @@ it.live('a calc can depend on another calc as an input source', () => {
89
88
  const derived = yield* PlusDoubled.store
90
89
  expect(derived.get()).toBe(15)
91
90
  source.set(3)
92
- yield* tick
91
+ yield* flush()
93
92
  expect(derived.get()).toBe(9)
94
93
  }).pipe(Effect.provide(TestLayer))
95
94
  })
@@ -125,7 +124,7 @@ it.live('a diamond dependency resolves in one flush with a single leaf notificat
125
124
  expect(c.get()).toBe(11)
126
125
 
127
126
  source.set(5)
128
- yield* tick
127
+ yield* flush()
129
128
  expect(c.get()).toBe(21)
130
129
  expect(wakes.n).toBe(1)
131
130
  }).pipe(Effect.provide(TestLayer))
@@ -158,12 +157,12 @@ it.live('invalidateBy: a calc recomputes only when the projected key moves', ()
158
157
  expect(sum.get()).toBe(2)
159
158
 
160
159
  sb.set(100)
161
- yield* tick
160
+ yield* flush()
162
161
  expect(runs.n).toBe(base)
163
162
  expect(sum.get()).toBe(2)
164
163
 
165
164
  sa.set(5)
166
- yield* tick
165
+ yield* flush()
167
166
  expect(sum.get()).toBe(105)
168
167
  }).pipe(Effect.provide(TestLayer))
169
168
  })
@@ -211,11 +210,9 @@ it.live('reuse: an unchanged subtree keeps its identity across recompute', () =>
211
210
  inputs: [StateGroup.select(Inputs, 'a'), StateGroup.select(Inputs, 'b')],
212
211
  output: Pair,
213
212
  }) {}
214
- const SplitLive = Calc.live(
215
- Split,
216
- ({ a, b }) => ({ left: { value: a }, right: { value: b } }),
217
- { reuse: true },
218
- )
213
+ const SplitLive = Calc.live(Split, ({ a, b }) => ({ left: { value: a }, right: { value: b } }), {
214
+ reuse: true,
215
+ })
219
216
  const TestLayer = SplitLive.pipe(Layer.provideMerge(StateGroup.live(Inputs, { a: 1, b: 1 })))
220
217
 
221
218
  return Effect.gen(function* () {
@@ -224,7 +221,7 @@ it.live('reuse: an unchanged subtree keeps its identity across recompute', () =>
224
221
  const before = derived.get()
225
222
 
226
223
  b.set(2)
227
- yield* tick
224
+ yield* flush()
228
225
  const after = derived.get()
229
226
  expect(after).not.toBe(before)
230
227
  expect(after.left).toBe(before.left)
@@ -256,7 +253,7 @@ it.live('reuse: a value-equal recompute keeps the previous reference and wakes n
256
253
  const before = derived.get()
257
254
 
258
255
  source.set(2)
259
- yield* tick
256
+ yield* flush()
260
257
  expect(derived.get()).toBe(before)
261
258
  expect(notifications.n).toBe(0)
262
259
  }).pipe(Effect.provide(TestLayer))
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* () {