@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,8 +1,8 @@
1
1
  import { expect, it } from '@effect/vitest'
2
- import { Context, Duration, Effect, Exit, Layer, Schema as S, Scope } from 'effect'
2
+ import { Context, Effect, Exit, Layer, Schema as S, Scope } from 'effect'
3
3
  import { CalcFamily, State, StateGroup } from '../index'
4
+ import { flush } from '../testkit/flight.testkit'
4
5
 
5
- const tick = Effect.sleep(Duration.millis(1))
6
6
 
7
7
  const Board = S.Record({ key: S.String, value: S.Number })
8
8
 
@@ -33,7 +33,7 @@ it.live('a member derives from the shared inputs and recomputes when they change
33
33
  expect(yield* CalcFamily.read(Slice, 'b')).toBe(10)
34
34
 
35
35
  feed.set({ a: 2, b: 10 })
36
- yield* tick
36
+ yield* flush()
37
37
  expect(yield* CalcFamily.read(Slice, 'a')).toBe(2)
38
38
  }).pipe(Effect.provide(TestLayer))
39
39
  })
@@ -55,28 +55,88 @@ it.live('a member only notifies when its own slice of the projection moves', ()
55
55
  })
56
56
 
57
57
  feed.set({ a: 2, b: 10 })
58
- yield* tick
58
+ yield* flush()
59
59
  expect(wakes).toEqual({ a: 1, b: 0 })
60
60
 
61
61
  feed.set({ a: 2, b: 11 })
62
- yield* tick
62
+ yield* flush()
63
63
  expect(wakes).toEqual({ a: 1, b: 1 })
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* flush()
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* flush()
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(
@@ -104,7 +165,7 @@ it.live('invalidateBy: shared-input churn outside the key recomputes no member',
104
165
  expect(runs.get('a')).toBe(1)
105
166
 
106
167
  noise.set(99)
107
- yield* tick
168
+ yield* flush()
108
169
  expect(yield* CalcFamily.read(Slice, 'a')).toBe(1)
109
170
  expect(runs.get('a')).toBe(1)
110
171
  }).pipe(Effect.provide(TestLayer))
@@ -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
@@ -125,7 +184,7 @@ it.live('forget releases the member and its upstream subscription', () => {
125
184
  expect(family.size()).toBe(0)
126
185
  const before = runs.get('a') ?? 0
127
186
  feed.set({ a: 2 })
128
- yield* tick
187
+ yield* flush()
129
188
  expect(runs.get('a') ?? 0).toBe(before)
130
189
 
131
190
  expect(family.at('a').get()).toBe(2)
@@ -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
@@ -159,11 +217,11 @@ it.live('evictWhenUnused: an idle member is dropped on the next microtask and un
159
217
  expect(family.size()).toBe(1)
160
218
 
161
219
  off()
162
- yield* tick
220
+ yield* flush()
163
221
  expect(family.size()).toBe(0)
164
222
  const before = runs.get('a') ?? 0
165
223
  feed.set({ a: 2 })
166
- yield* tick
224
+ yield* flush()
167
225
  expect(runs.get('a') ?? 0).toBe(before)
168
226
  }).pipe(Effect.provide(TestLayer))
169
227
  })
@@ -187,7 +245,7 @@ it.live('the layer finalizer clears every member (no live subscriptions leak)',
187
245
  expect(family.size()).toBe(0)
188
246
  const before = [runs.get('a') ?? 0, runs.get('b') ?? 0]
189
247
  feed.set({ a: 9, b: 9 })
190
- yield* tick
248
+ yield* flush()
191
249
  expect([runs.get('a') ?? 0, runs.get('b') ?? 0]).toEqual(before)
192
250
  }),
193
251
  )
@@ -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 Manifest, definitionClass } from '../definition/definition'
3
3
  import {
4
4
  type InputsObject,
@@ -9,45 +9,70 @@ import {
9
9
  } from '../internal/sources'
10
10
  import { resolveScheduler } from '../internal/scheduler'
11
11
  import { resolveInstrumentation, stateUpdateHook } from '../runtime/instrumentation'
12
+ import { reuse } from '../internal/reuse'
12
13
  import { makeDerivedStore, type Store } from '../internal/store'
13
14
  import { readTracked } from '../internal/track'
14
15
  import { type FamilyOptions, type FamilyStore } from '../state/stateFamily'
15
16
  import { type AnySource } from '../state/token'
16
- import type { AnySchema } from '../internal/variance'
17
17
 
18
- export interface CalcFamilyManifest<N extends string, K, Out> extends Manifest {
18
+ export interface CalcFamilySchemaReflection {
19
+ readonly ast: Schema.Schema<unknown, unknown, unknown>['ast']
20
+ }
21
+
22
+ export interface CalcFamilyManifest<N extends string> extends Manifest {
19
23
  readonly kind: 'CalcFamily'
20
24
  readonly name: N
21
- readonly key: AnySchema<K>
22
- readonly output: AnySchema<Out>
25
+ readonly key: CalcFamilySchemaReflection
26
+ readonly output: CalcFamilySchemaReflection
27
+ }
28
+
29
+ export const CalcFamilyStoreTypeId: unique symbol = Symbol.for('reform/CalcFamilyStore')
30
+ export type CalcFamilyStoreTypeId = typeof CalcFamilyStoreTypeId
31
+
32
+ export interface CalcFamilyStore<N extends string, in out K, in out Out> extends FamilyStore<
33
+ K,
34
+ Out
35
+ > {
36
+ readonly [CalcFamilyStoreTypeId]: N
23
37
  }
24
38
 
25
39
  export interface CalcFamilyClass<
26
- out N extends string,
27
- out Inputs extends ReadonlyArray<AnySource>,
28
- in out K,
29
- in out Out,
40
+ N extends string,
41
+ Inputs extends ReadonlyArray<AnySource>,
42
+ K,
43
+ Out,
30
44
  > {
31
45
  new (): { readonly Key: K }
32
- readonly manifest: CalcFamilyManifest<N, K, Out>
33
- readonly store: Context.Tag<FamilyStore<K, Out>, FamilyStore<K, Out>>
46
+ readonly manifest: CalcFamilyManifest<N>
47
+ readonly store: Context.Tag<CalcFamilyStore<N, K, Out>, FamilyStore<K, Out>>
34
48
  readonly inputs: Inputs
35
49
  }
36
50
 
37
- export interface CalcFamilyConfig<Inputs extends ReadonlyArray<AnySource>, K, Out> {
38
- readonly key: AnySchema<K>
51
+ export interface CalcFamilyConfig<
52
+ Inputs extends ReadonlyArray<AnySource>,
53
+ KeySchema extends Schema.Schema.AnyNoContext,
54
+ OutputSchema extends Schema.Schema.AnyNoContext,
55
+ > {
56
+ readonly key: KeySchema
39
57
  readonly inputs: Inputs
40
- readonly output: AnySchema<Out>
58
+ readonly output: OutputSchema
41
59
  }
42
60
 
43
- export const make = <const N extends string, const Inputs extends ReadonlyArray<AnySource>, K, Out>(
61
+ export const make = <
62
+ const N extends string,
63
+ const Inputs extends ReadonlyArray<AnySource>,
64
+ KeySchema extends Schema.Schema.AnyNoContext,
65
+ OutputSchema extends Schema.Schema.AnyNoContext,
66
+ >(
44
67
  name: N,
45
- config: CalcFamilyConfig<Inputs, K, Out>,
46
- ): CalcFamilyClass<N, Inputs, K, Out> => {
47
- const store = Context.GenericTag<FamilyStore<K, Out>, FamilyStore<K, Out>>(
68
+ config: CalcFamilyConfig<Inputs, KeySchema, OutputSchema>,
69
+ ): CalcFamilyClass<N, Inputs, Schema.Schema.Type<KeySchema>, Schema.Schema.Type<OutputSchema>> => {
70
+ type K = Schema.Schema.Type<KeySchema>
71
+ type Out = Schema.Schema.Type<OutputSchema>
72
+ const store = Context.GenericTag<CalcFamilyStore<N, K, Out>, FamilyStore<K, Out>>(
48
73
  `reform/calcFamily/${name}`,
49
74
  )
50
- const manifest: CalcFamilyManifest<N, K, Out> = {
75
+ const manifest: CalcFamilyManifest<N> = {
51
76
  kind: 'CalcFamily',
52
77
  name,
53
78
  key: config.key,
@@ -63,19 +88,21 @@ export const make = <const N extends string, const Inputs extends ReadonlyArray<
63
88
  export const read = <N extends string, Inputs extends ReadonlyArray<AnySource>, K, Out>(
64
89
  family: CalcFamilyClass<N, Inputs, K, Out>,
65
90
  key: K,
66
- ): Effect.Effect<Out, never, FamilyStore<K, Out>> =>
91
+ ): Effect.Effect<Out, never, CalcFamilyStore<N, K, Out>> =>
67
92
  Effect.flatMap(family.store, (familyStore) => readTracked(familyStore.at(key)))
68
93
 
69
- export interface CalcFamilyLiveOptionsExternalApi<Inputs extends ReadonlyArray<AnySource>>
70
- extends FamilyOptions {
94
+ export interface CalcFamilyLiveOptionsExternalApi<
95
+ Inputs extends ReadonlyArray<AnySource>,
96
+ > extends FamilyOptions {
71
97
  readonly invalidateBy?: InvalidateBy<Inputs>
98
+ readonly reuse?: boolean
72
99
  }
73
100
 
74
101
  export const live = <N extends string, Inputs extends ReadonlyArray<AnySource>, K, Out>(
75
102
  family: CalcFamilyClass<N, Inputs, K, Out>,
76
103
  compute: (key: K) => (inputs: InputsObject<Inputs>) => Out,
77
104
  options: CalcFamilyLiveOptionsExternalApi<Inputs> = {},
78
- ): Layer.Layer<FamilyStore<K, Out>, never, InputStores<Inputs>> =>
105
+ ): Layer.Layer<CalcFamilyStore<N, K, Out>, never, InputStores<Inputs>> =>
79
106
  Layer.scoped(
80
107
  family.store,
81
108
  Effect.gen(function* () {
@@ -96,7 +123,9 @@ export const live = <N extends string, Inputs extends ReadonlyArray<AnySource>,
96
123
  subscribers.delete(key)
97
124
  }
98
125
 
99
- const createMember = (key: K): { readonly store: Store<Out>; readonly unsubscribe: () => void } => {
126
+ const createMember = (
127
+ key: K,
128
+ ): { readonly store: Store<Out>; readonly unsubscribe: () => void } => {
100
129
  const body = compute(key)
101
130
  const memo = MutableRef.make<
102
131
  { readonly key: ReadonlyArray<unknown>; readonly output: Out } | undefined
@@ -109,7 +138,9 @@ export const live = <N extends string, Inputs extends ReadonlyArray<AnySource>,
109
138
  return prev.output
110
139
  }
111
140
  const end = instrumentation.calcRecomputed(family.manifest.name)
112
- const output = body(args)
141
+ const fresh = body(args)
142
+ const output =
143
+ options.reuse === true && prev !== undefined ? reuse(prev.output, fresh) : fresh
113
144
  end()
114
145
  MutableRef.set(memo, { key: memoKey, output })
115
146
  return output
@@ -13,20 +13,7 @@ export function composeCalcs<LOut, LErr, LIn, AOut, AErr, AIn, BOut, BErr, BIn>(
13
13
  LErr | AErr | BErr,
14
14
  BIn | Exclude<AIn | Exclude<LIn, AOut>, BOut>
15
15
  >
16
- export function composeCalcs<
17
- LOut,
18
- LErr,
19
- LIn,
20
- AOut,
21
- AErr,
22
- AIn,
23
- BOut,
24
- BErr,
25
- BIn,
26
- COut,
27
- CErr,
28
- CIn,
29
- >(
16
+ export function composeCalcs<LOut, LErr, LIn, AOut, AErr, AIn, BOut, BErr, BIn, COut, CErr, CIn>(
30
17
  leaf: Layer.Layer<LOut, LErr, LIn>,
31
18
  upstream1: Layer.Layer<AOut, AErr, AIn>,
32
19
  upstream2: Layer.Layer<BOut, BErr, BIn>,
@@ -1,14 +1,27 @@
1
- import { Chunk, Context, Duration, Effect, Exit, Layer, Match, Option, Queue, Scope, Stream } from 'effect'
1
+ import {
2
+ Chunk,
3
+ Context,
4
+ Duration,
5
+ Effect,
6
+ Exit,
7
+ Layer,
8
+ Match,
9
+ Option,
10
+ Queue,
11
+ Scope,
12
+ Stream,
13
+ } from 'effect'
2
14
  import { type Manifest, definitionClass } from '../definition/definition'
3
15
  import type { Tagged } from '../runtime/bus'
4
16
  import { resolveInstrumentation } from '../runtime/instrumentation'
5
- import { type ProcedureEntry, Procedures } from './procedureRegistry'
17
+ import { type ProcedureEntry, Procedures } from './procedures'
18
+
6
19
  export {
7
20
  type ProcedureEntry,
8
21
  type ProcedureRegistry,
9
22
  Procedures,
10
23
  proceduresLayer,
11
- } from './procedureRegistry'
24
+ } from './procedures'
12
25
 
13
26
  export type ChannelPolicy =
14
27
  | { readonly _tag: 'merge' }
@@ -53,7 +66,9 @@ const ChannelsBase: Context.TagClass<
53
66
  > = Context.Tag('reform/Channels')<Channels, { readonly byName: Map<string, ChannelRuntime> }>()
54
67
  export class Channels extends ChannelsBase {}
55
68
 
56
- export const channelsLayer: Layer.Layer<Channels> = Layer.sync(Channels, () => ({ byName: new Map() }))
69
+ export const channelsLayer: Layer.Layer<Channels> = Layer.sync(Channels, () => ({
70
+ byName: new Map(),
71
+ }))
57
72
 
58
73
  export const make = (name: string, config: ChannelConfig): ChannelClass =>
59
74
  definitionClass<ChannelClass>({
@@ -155,9 +170,13 @@ export const live = (channel: ChannelClass): Layer.Layer<never, never, Channels
155
170
 
156
171
  const events = Stream.fromQueue(queue)
157
172
  const driven: Stream.Stream<unknown> = Match.value(channel.policy).pipe(
158
- Match.tag('merge', () => Stream.mapEffect(events, runMatching, { concurrency: 'unbounded' })),
173
+ Match.tag('merge', () =>
174
+ Stream.mapEffect(events, runMatching, { concurrency: 'unbounded' }),
175
+ ),
159
176
  Match.tag('latest', () =>
160
- Stream.flatMap(events, (event) => Stream.fromEffect(runMatching(event)), { switch: true }),
177
+ Stream.flatMap(events, (event) => Stream.fromEffect(runMatching(event)), {
178
+ switch: true,
179
+ }),
161
180
  ),
162
181
  Match.tag('debounce', (policy) =>
163
182
  events.pipe(
@@ -1,4 +1,5 @@
1
- import { Context, Effect, Layer } from 'effect'
1
+ import { Context, type Effect, Layer, Option } from 'effect'
2
+ import { makeBucketCache } from '../internal/bucketCache'
2
3
  import type { Tagged } from '../runtime/bus'
3
4
 
4
5
  export interface ProcedureEntry {
@@ -10,9 +11,11 @@ export interface ProcedureEntry {
10
11
 
11
12
  export interface ProcedureRegistry {
12
13
  readonly entries: Array<ProcedureEntry>
13
- readonly byTag: Map<string, Array<ProcedureEntry>>
14
- readonly channelsByTag: Map<string, Array<string>>
15
- readonly byChannelTag: Map<string, Map<string, Array<ProcedureEntry>>>
14
+ readonly byName: Map<string, Set<ProcedureEntry>>
15
+ readonly byTag: Map<string, Set<ProcedureEntry>>
16
+ readonly channelsByTag: Map<string, Set<string>>
17
+ readonly channelsFor: (tag: string) => ReadonlyArray<string>
18
+ readonly byChannelTag: Map<string, Map<string, Set<ProcedureEntry>>>
16
19
  readonly register: (entry: ProcedureEntry) => void
17
20
  readonly unregister: (entry: ProcedureEntry) => void
18
21
  }
@@ -31,45 +34,56 @@ const getOrCreate = <K, V>(map: Map<K, V>, key: K, make: () => V): V => {
31
34
  return created
32
35
  }
33
36
 
34
- const dropFrom = <K, T>(map: Map<K, Array<T>>, key: K, target: T): void => {
37
+ const dropFrom = <K, T>(map: Map<K, Set<T>>, key: K, target: T): void => {
35
38
  const bucket = map.get(key)
36
39
  if (bucket === undefined) {
37
40
  return
38
41
  }
39
- const next = bucket.filter((candidate) => candidate !== target)
40
- if (next.length === 0) {
42
+ bucket.delete(target)
43
+ if (bucket.size === 0) {
41
44
  map.delete(key)
42
- } else {
43
- map.set(key, next)
44
45
  }
45
46
  }
46
47
 
47
48
  const makeProcedureRegistry = (): ProcedureRegistry => {
48
- const state: { entries: Array<ProcedureEntry> } = { entries: [] }
49
- const byTag = new Map<string, Array<ProcedureEntry>>()
50
- const channelsByTag = new Map<string, Array<string>>()
51
- const byChannelTag = new Map<string, Map<string, Array<ProcedureEntry>>>()
49
+ const entries = new Set<ProcedureEntry>()
50
+ const byName = new Map<string, Set<ProcedureEntry>>()
51
+ const byTag = new Map<string, Set<ProcedureEntry>>()
52
+ const channelsByTag = new Map<string, Set<string>>()
53
+ const channelsFor = makeBucketCache(channelsByTag)
54
+ const byChannelTag = new Map<string, Map<string, Set<ProcedureEntry>>>()
55
+ const snapshot: { value: Option.Option<Array<ProcedureEntry>> } = {
56
+ value: Option.some([]),
57
+ }
52
58
  return {
53
59
  get entries() {
54
- return state.entries
60
+ return Option.getOrElse(snapshot.value, () => {
61
+ const current = Array.from(entries)
62
+ snapshot.value = Option.some(current)
63
+ return current
64
+ })
55
65
  },
66
+ byName,
56
67
  byTag,
57
68
  channelsByTag,
69
+ channelsFor: channelsFor.read,
58
70
  byChannelTag,
59
71
  register: (entry) => {
60
- state.entries = [...state.entries, entry]
72
+ entries.add(entry)
73
+ snapshot.value = Option.none()
74
+ getOrCreate(byName, entry.name, () => new Set()).add(entry)
61
75
  entry.handles.forEach((tag) => {
62
- byTag.set(tag, [...getOrCreate(byTag, tag, () => []), entry])
63
- const channels = getOrCreate(channelsByTag, tag, () => [])
64
- if (!channels.includes(entry.channelName)) {
65
- channelsByTag.set(tag, [...channels, entry.channelName])
66
- }
76
+ getOrCreate(byTag, tag, () => new Set()).add(entry)
77
+ getOrCreate(channelsByTag, tag, () => new Set()).add(entry.channelName)
78
+ channelsFor.invalidate(tag)
67
79
  const channelMap = getOrCreate(byChannelTag, entry.channelName, () => new Map())
68
- channelMap.set(tag, [...getOrCreate(channelMap, tag, () => []), entry])
80
+ getOrCreate(channelMap, tag, () => new Set()).add(entry)
69
81
  })
70
82
  },
71
83
  unregister: (entry) => {
72
- state.entries = state.entries.filter((existing) => existing !== entry)
84
+ entries.delete(entry)
85
+ snapshot.value = Option.none()
86
+ dropFrom(byName, entry.name, entry)
73
87
  entry.handles.forEach((tag) => {
74
88
  dropFrom(byTag, tag, entry)
75
89
  const channelMap = byChannelTag.get(entry.channelName)
@@ -77,6 +91,7 @@ const makeProcedureRegistry = (): ProcedureRegistry => {
77
91
  dropFrom(channelMap, tag, entry)
78
92
  if (channelMap.get(tag) === undefined) {
79
93
  dropFrom(channelsByTag, tag, entry.channelName)
94
+ channelsFor.invalidate(tag)
80
95
  }
81
96
  if (channelMap.size === 0) {
82
97
  byChannelTag.delete(entry.channelName)
@@ -87,4 +102,7 @@ const makeProcedureRegistry = (): ProcedureRegistry => {
87
102
  }
88
103
  }
89
104
 
90
- export const proceduresLayer: Layer.Layer<Procedures> = Layer.sync(Procedures, makeProcedureRegistry)
105
+ export const proceduresLayer: Layer.Layer<Procedures> = Layer.sync(
106
+ Procedures,
107
+ makeProcedureRegistry,
108
+ )
@@ -0,0 +1,19 @@
1
+ import { Option, Schema } from 'effect'
2
+ import { expect, it } from '@effect/vitest'
3
+ import { make } from './composition'
4
+ import { ui } from './ui'
5
+
6
+ class ItemUi extends ui('composition.parser.ItemUi')<{
7
+ props: { readonly label: string }
8
+ }>() {}
9
+
10
+ class Item extends make('composition.parser.Item', {
11
+ title: 'Item',
12
+ props: Schema.Struct({ label: Schema.String }),
13
+ ui: ItemUi,
14
+ })<Item>() {}
15
+
16
+ it('validates reflected props against the composition schema', () => {
17
+ expect(Item.parseProps({ label: 'ready' })).toEqual(Option.some({ label: 'ready' }))
18
+ expect(Option.isNone(Item.parseProps({ label: 1 }))).toBe(true)
19
+ })