@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,19 +1,36 @@
1
1
  import { expect, it } from '@effect/vitest'
2
- import { Data, Duration, Effect, Layer, Schema as S } from 'effect'
3
- import { AsyncCalc, Engine, Event, State, StateGroup } from '../index'
2
+ import { Data, Effect, Layer, MutableRef, Schema as S } from 'effect'
3
+ import { AsyncCalc, Engine, Event, Reducer, State, StateGroup } from '../index'
4
+ import { makeFlightGate, until } from '../testkit/flight.testkit'
4
5
 
5
- const tick = (ms = 10) => Effect.sleep(Duration.millis(ms))
6
+ class Boom extends Data.TaggedError('Boom')<{ readonly message: string }> {}
6
7
 
7
- const until = <A>(read: () => A, pred: (a: A) => boolean, rounds = 200): Effect.Effect<A> =>
8
+ interface Notifier {
9
+ readonly subscribe: (listener: () => void) => () => void
10
+ }
11
+
12
+ // The notification scheduler drains every listener of a write in one pass, and a
13
+ // driver subscribes before the test does: our own notification proves its
14
+ // trigger already ran, so an inert change can be asserted without waiting.
15
+ const afterNotify = <A, E, R>(
16
+ source: Notifier,
17
+ write: Effect.Effect<A, E, R>,
18
+ ): Effect.Effect<void, E, R> =>
8
19
  Effect.suspend(() => {
9
- const a = read()
10
- return pred(a) || rounds <= 0
11
- ? Effect.succeed(a)
12
- : Effect.flatMap(tick(10), () => until(read, pred, rounds - 1))
20
+ const fired = MutableRef.make(false)
21
+ const off = source.subscribe(() => MutableRef.set(fired, true))
22
+ return write.pipe(
23
+ Effect.zipRight(
24
+ until(
25
+ () => MutableRef.get(fired),
26
+ (value) => value,
27
+ ),
28
+ ),
29
+ Effect.ensuring(Effect.sync(off)),
30
+ Effect.asVoid,
31
+ )
13
32
  })
14
33
 
15
- class Boom extends Data.TaggedError('Boom')<{ readonly message: string }> {}
16
-
17
34
  it.live('runs the query and resolves Loading -> Success', () => {
18
35
  class Count extends State.make('count', S.Number) {}
19
36
  class Inputs extends StateGroup.make(Count) {}
@@ -22,16 +39,24 @@ it.live('runs the query and resolves Loading -> Success', () => {
22
39
  output: S.Number,
23
40
  alwaysOn: true,
24
41
  }) {}
42
+ const gate = makeFlightGate()
43
+ // Armed before the layer builds, so the very first flight parks at the gate.
44
+ Effect.runSync(gate.hold)
25
45
  const DoubledLive = AsyncCalc.live(Doubled, {
26
- query: ({ count }) => Effect.succeed(count * 2).pipe(Effect.delay(Duration.millis(20))),
46
+ query: ({ count }) => gate.through(Effect.succeed(count * 2)),
27
47
  })
28
48
  const TestLayer = DoubledLive.pipe(Layer.provideMerge(StateGroup.live(Inputs, { count: 5 })))
29
49
 
30
50
  return Effect.gen(function* () {
31
51
  const store = yield* Doubled.store
52
+ yield* gate.awaitEntry(1)
32
53
  expect(store.get()._tag).toBe('Loading')
33
- yield* tick(40)
34
- const value = store.get()
54
+
55
+ yield* gate.release
56
+ const value = yield* until(
57
+ () => store.get(),
58
+ (state) => state._tag === 'Success',
59
+ )
35
60
  expect(value._tag).toBe('Success')
36
61
  if (value._tag === 'Success') {
37
62
  expect(value.value).toBe(10)
@@ -56,8 +81,10 @@ it.live('a failing query resolves to Error carrying the failure', () => {
56
81
 
57
82
  return Effect.gen(function* () {
58
83
  const store = yield* Q.store
59
- yield* tick()
60
- const value = store.get()
84
+ const value = yield* until(
85
+ () => store.get(),
86
+ (state) => state._tag === 'Error',
87
+ )
61
88
  expect(value._tag).toBe('Error')
62
89
  if (value._tag === 'Error') {
63
90
  expect(value.error.message).toBe('nope')
@@ -74,23 +101,33 @@ it.live('a dependency change re-fetches, keeping the last value with refetching:
74
101
  output: S.Number,
75
102
  alwaysOn: true,
76
103
  }) {}
104
+ const gate = makeFlightGate()
77
105
  const DoubledLive = AsyncCalc.live(Doubled, {
78
- query: ({ count }) => Effect.succeed(count * 2).pipe(Effect.delay(Duration.millis(20))),
106
+ query: ({ count }) => gate.through(Effect.succeed(count * 2)),
79
107
  })
80
108
  const TestLayer = DoubledLive.pipe(Layer.provideMerge(StateGroup.live(Inputs, { count: 5 })))
81
109
 
82
110
  return Effect.gen(function* () {
83
111
  const source = yield* StateGroup.select(Inputs, 'count').store
84
112
  const store = yield* Doubled.store
85
- yield* tick(40)
113
+ yield* until(
114
+ () => store.get(),
115
+ (v) => v._tag === 'Success',
116
+ )
86
117
  expect(store.get()).toMatchObject({ _tag: 'Success', value: 10, refetching: false })
87
118
 
119
+ // Hold the re-fetch at the gate: the previous value stays visible under it.
120
+ yield* gate.hold
88
121
  source.set(7)
89
- yield* tick(5)
122
+ yield* gate.awaitEntry(2)
90
123
  expect(store.get()).toMatchObject({ _tag: 'Success', value: 10, refetching: true })
91
124
 
92
- yield* tick(40)
93
- expect(store.get()).toMatchObject({ _tag: 'Success', value: 14, refetching: false })
125
+ yield* gate.release
126
+ const settled = yield* until(
127
+ () => store.get(),
128
+ (v) => v._tag === 'Success' && v.refetching === false,
129
+ )
130
+ expect(settled).toMatchObject({ _tag: 'Success', value: 14, refetching: false })
94
131
  }).pipe(Effect.provide(TestLayer))
95
132
  })
96
133
 
@@ -103,24 +140,35 @@ it.live('latest-wins: a rapid second change cancels the in-flight run', () => {
103
140
  output: S.Number,
104
141
  alwaysOn: true,
105
142
  }) {}
143
+ const gate = makeFlightGate()
106
144
  const DoubledLive = AsyncCalc.live(Doubled, {
107
145
  query: ({ count }) =>
108
- Effect.sync(() => {
109
- runs.n += 1
110
- return count * 2
111
- }).pipe(Effect.delay(Duration.millis(25))),
146
+ gate.through(
147
+ Effect.sync(() => {
148
+ runs.n += 1
149
+ return count * 2
150
+ }),
151
+ ),
112
152
  })
113
153
  const TestLayer = DoubledLive.pipe(Layer.provideMerge(StateGroup.live(Inputs, { count: 1 })))
114
154
 
115
155
  return Effect.gen(function* () {
116
156
  const source = yield* StateGroup.select(Inputs, 'count').store
117
157
  const store = yield* Doubled.store
118
- yield* tick(40)
158
+ yield* until(
159
+ () => store.get(),
160
+ (v) => v._tag === 'Success',
161
+ )
119
162
  expect(store.get()).toMatchObject({ _tag: 'Success', value: 2 })
120
163
 
164
+ // `4` lands while `3` is still parked at the gate, so it can only win by
165
+ // cancelling it.
166
+ yield* gate.hold
121
167
  source.set(3)
122
- yield* tick(5)
168
+ yield* gate.awaitEntry(2)
123
169
  source.set(4)
170
+ yield* gate.awaitEntry(3)
171
+ yield* gate.release
124
172
  const settled = yield* until(
125
173
  () => store.get(),
126
174
  (v) => v._tag === 'Success' && v.refetching === false && v.value !== 2,
@@ -136,8 +184,9 @@ it.live('a gated query starts Idle while disabled and runs once enabled', () =>
136
184
  inputs: [StateGroup.select(Inputs, 'count')],
137
185
  output: S.Number,
138
186
  }) {}
187
+ const gate = makeFlightGate()
139
188
  const DoubledLive = AsyncCalc.live(Doubled, {
140
- query: ({ count }) => Effect.succeed(count * 2),
189
+ query: ({ count }) => gate.through(Effect.succeed(count * 2)),
141
190
  disabled: ({ count }) => count < 0,
142
191
  })
143
192
  const TestLayer = DoubledLive.pipe(Layer.provideMerge(StateGroup.live(Inputs, { count: -1 })))
@@ -145,12 +194,16 @@ it.live('a gated query starts Idle while disabled and runs once enabled', () =>
145
194
  return Effect.gen(function* () {
146
195
  const source = yield* StateGroup.select(Inputs, 'count').store
147
196
  const store = yield* Doubled.store
148
- yield* tick()
149
197
  expect(store.get()._tag).toBe('Idle')
150
198
 
151
199
  source.set(5)
152
- yield* tick()
153
- expect(store.get()).toMatchObject({ _tag: 'Success', value: 10 })
200
+ const settled = yield* until(
201
+ () => store.get(),
202
+ (v) => v._tag === 'Success',
203
+ )
204
+ expect(settled).toMatchObject({ _tag: 'Success', value: 10 })
205
+ // Exactly one flight ever ran: the disabled window produced none.
206
+ expect(gate.entered()).toBe(1)
154
207
  }).pipe(Effect.provide(TestLayer))
155
208
  })
156
209
 
@@ -164,12 +217,15 @@ it.live('invalidateBy: an input change outside the key does not re-fetch', () =>
164
217
  output: S.Number,
165
218
  alwaysOn: true,
166
219
  }) {}
220
+ const gate = makeFlightGate()
167
221
  const QLive = AsyncCalc.live(Q, {
168
222
  query: ({ page }) =>
169
- Effect.sync(() => {
170
- runs.n += 1
171
- return page
172
- }),
223
+ gate.through(
224
+ Effect.sync(() => {
225
+ runs.n += 1
226
+ return page
227
+ }),
228
+ ),
173
229
  invalidateBy: ({ page }) => [page],
174
230
  })
175
231
  const TestLayer = QLive.pipe(
@@ -180,17 +236,27 @@ it.live('invalidateBy: an input change outside the key does not re-fetch', () =>
180
236
  const status = yield* StateGroup.select(Inputs, 'status').store
181
237
  const page = yield* StateGroup.select(Inputs, 'page').store
182
238
  const store = yield* Q.store
183
- yield* tick()
239
+ yield* until(
240
+ () => store.get(),
241
+ (v) => v._tag === 'Success',
242
+ )
184
243
  expect(runs.n).toBe(1)
185
244
 
186
- status.set('b')
187
- yield* tick()
245
+ // Holding the gate means any flight the status churn triggered would still
246
+ // be marked fetching here, and could not have settled back out of view.
247
+ yield* gate.hold
248
+ yield* afterNotify(status, Effect.sync(() => status.set('b')))
249
+ expect(store.get()).toMatchObject({ _tag: 'Success', refetching: false })
188
250
  expect(runs.n).toBe(1)
251
+ yield* gate.release
189
252
 
190
253
  page.set(2)
191
- yield* tick()
254
+ const settled = yield* until(
255
+ () => store.get(),
256
+ (v) => v._tag === 'Success' && v.refetching === false && v.value === 2,
257
+ )
192
258
  expect(runs.n).toBe(2)
193
- expect(store.get()).toMatchObject({ _tag: 'Success', value: 2 })
259
+ expect(settled).toMatchObject({ _tag: 'Success', value: 2 })
194
260
  }).pipe(Effect.provide(TestLayer))
195
261
  })
196
262
 
@@ -216,7 +282,10 @@ it.live('reuse: a refetch returning mostly-equal data keeps unchanged element id
216
282
  return Effect.gen(function* () {
217
283
  const source = yield* StateGroup.select(Inputs, 'page').store
218
284
  const store = yield* Rows.store
219
- const first = yield* until(() => store.get(), (v) => v._tag === 'Success')
285
+ const first = yield* until(
286
+ () => store.get(),
287
+ (v) => v._tag === 'Success',
288
+ )
220
289
  if (first._tag !== 'Success') return
221
290
 
222
291
  source.set(2)
@@ -231,80 +300,107 @@ it.live('reuse: a refetch returning mostly-equal data keeps unchanged element id
231
300
  }).pipe(Effect.provide(TestLayer))
232
301
  })
233
302
 
234
- it.live('coalesce trailing: a burst during one flight completes it and runs ONE trailing refetch', () => {
235
- class Count extends State.make('count', S.Number) {}
236
- class Inputs extends StateGroup.make(Count) {}
237
- const completed: number[] = []
238
- class Doubled extends AsyncCalc.make('Doubled', {
239
- inputs: [StateGroup.select(Inputs, 'count')],
240
- output: S.Number,
241
- alwaysOn: true,
242
- }) {}
243
- const DoubledLive = AsyncCalc.live(Doubled, {
244
- query: ({ count }) =>
245
- Effect.sync(() => {
246
- completed.push(count)
247
- return count * 2
248
- }).pipe(Effect.delay(Duration.millis(25))),
249
- coalesce: 'trailing',
250
- })
251
- const TestLayer = DoubledLive.pipe(Layer.provideMerge(StateGroup.live(Inputs, { count: 1 })))
252
-
253
- return Effect.gen(function* () {
254
- const source = yield* StateGroup.select(Inputs, 'count').store
255
- const store = yield* Doubled.store
256
- yield* until(() => store.get(), (v) => v._tag === 'Success')
257
- expect(completed).toEqual([1])
258
-
259
- source.set(2)
260
- yield* tick(5)
261
- source.set(3)
262
- yield* tick(5)
263
- source.set(4)
264
- const settled = yield* until(
265
- () => store.get(),
266
- (v) => v._tag === 'Success' && v.refetching === false && v.value === 8,
267
- )
268
- expect(settled).toMatchObject({ _tag: 'Success', value: 8, refetching: false })
269
- expect(completed).toEqual([1, 2, 4])
270
- }).pipe(Effect.provide(TestLayer))
271
- })
272
-
273
- it.live('coalesce trailing: the stale settle re-flags refetching until the trailing run lands', () => {
274
- class Count extends State.make('count', S.Number) {}
275
- class Inputs extends StateGroup.make(Count) {}
276
- class Doubled extends AsyncCalc.make('Doubled', {
277
- inputs: [StateGroup.select(Inputs, 'count')],
278
- output: S.Number,
279
- alwaysOn: true,
280
- }) {}
281
- const DoubledLive = AsyncCalc.live(Doubled, {
282
- query: ({ count }) => Effect.succeed(count * 2).pipe(Effect.delay(Duration.millis(30))),
283
- coalesce: 'trailing',
284
- })
285
- const TestLayer = DoubledLive.pipe(Layer.provideMerge(StateGroup.live(Inputs, { count: 1 })))
286
-
287
- return Effect.gen(function* () {
288
- const source = yield* StateGroup.select(Inputs, 'count').store
289
- const store = yield* Doubled.store
290
- yield* until(() => store.get(), (v) => v._tag === 'Success')
291
-
292
- source.set(2)
293
- yield* tick(5)
294
- source.set(3)
295
- const midflight = yield* until(
296
- () => store.get(),
297
- (v) => v._tag === 'Success' && v.value === 4,
298
- )
299
- expect(midflight).toMatchObject({ _tag: 'Success', value: 4, refetching: true })
300
-
301
- const settled = yield* until(
302
- () => store.get(),
303
- (v) => v._tag === 'Success' && v.refetching === false,
304
- )
305
- expect(settled).toMatchObject({ _tag: 'Success', value: 6 })
306
- }).pipe(Effect.provide(TestLayer))
307
- })
303
+ it.live(
304
+ 'coalesce trailing: a burst during one flight completes it and runs ONE trailing refetch',
305
+ () => {
306
+ class Count extends State.make('count', S.Number) {}
307
+ class Inputs extends StateGroup.make(Count) {}
308
+ const completed: number[] = []
309
+ class Doubled extends AsyncCalc.make('Doubled', {
310
+ inputs: [StateGroup.select(Inputs, 'count')],
311
+ output: S.Number,
312
+ alwaysOn: true,
313
+ }) {}
314
+ const gate = makeFlightGate()
315
+ const DoubledLive = AsyncCalc.live(Doubled, {
316
+ query: ({ count }) =>
317
+ gate.through(
318
+ Effect.sync(() => {
319
+ completed.push(count)
320
+ return count * 2
321
+ }),
322
+ ),
323
+ coalesce: 'trailing',
324
+ })
325
+ const TestLayer = DoubledLive.pipe(Layer.provideMerge(StateGroup.live(Inputs, { count: 1 })))
326
+
327
+ return Effect.gen(function* () {
328
+ const source = yield* StateGroup.select(Inputs, 'count').store
329
+ const store = yield* Doubled.store
330
+ yield* until(
331
+ () => store.get(),
332
+ (v) => v._tag === 'Success',
333
+ )
334
+ expect(completed).toEqual([1])
335
+
336
+ // Hold the `2` flight at the gate so 3 and 4 land, in that order, while it
337
+ // is in flight.
338
+ yield* gate.hold
339
+ source.set(2)
340
+ yield* gate.awaitEntry(2)
341
+ yield* afterNotify(source, Effect.sync(() => source.set(3)))
342
+ yield* afterNotify(source, Effect.sync(() => source.set(4)))
343
+ yield* gate.release
344
+ const settled = yield* until(
345
+ () => store.get(),
346
+ (v) => v._tag === 'Success' && v.refetching === false && v.value === 8,
347
+ )
348
+ expect(settled).toMatchObject({ _tag: 'Success', value: 8, refetching: false })
349
+ expect(completed).toEqual([1, 2, 4])
350
+ }).pipe(Effect.provide(TestLayer))
351
+ },
352
+ )
353
+
354
+ it.live(
355
+ 'coalesce trailing: the stale settle re-flags refetching until the trailing run lands',
356
+ () => {
357
+ class Count extends State.make('count', S.Number) {}
358
+ class Inputs extends StateGroup.make(Count) {}
359
+ class Doubled extends AsyncCalc.make('Doubled', {
360
+ inputs: [StateGroup.select(Inputs, 'count')],
361
+ output: S.Number,
362
+ alwaysOn: true,
363
+ }) {}
364
+ // Two gates: one parks the flight the burst interrupts, the other parks the
365
+ // trailing run, so the stale settle in between is a stable state, not a race.
366
+ const firstGate = makeFlightGate()
367
+ const trailingGate = makeFlightGate()
368
+ const DoubledLive = AsyncCalc.live(Doubled, {
369
+ query: ({ count }) =>
370
+ count === 3
371
+ ? trailingGate.through(Effect.succeed(count * 2))
372
+ : firstGate.through(Effect.succeed(count * 2)),
373
+ coalesce: 'trailing',
374
+ })
375
+ const TestLayer = DoubledLive.pipe(Layer.provideMerge(StateGroup.live(Inputs, { count: 1 })))
376
+
377
+ return Effect.gen(function* () {
378
+ const source = yield* StateGroup.select(Inputs, 'count').store
379
+ const store = yield* Doubled.store
380
+ yield* until(
381
+ () => store.get(),
382
+ (v) => v._tag === 'Success',
383
+ )
384
+
385
+ yield* firstGate.hold
386
+ yield* trailingGate.hold
387
+ source.set(2)
388
+ yield* firstGate.awaitEntry(2)
389
+ yield* afterNotify(source, Effect.sync(() => source.set(3)))
390
+
391
+ yield* firstGate.release
392
+ yield* trailingGate.awaitEntry(1)
393
+ expect(store.get()).toMatchObject({ _tag: 'Success', value: 4, refetching: true })
394
+
395
+ yield* trailingGate.release
396
+ const settled = yield* until(
397
+ () => store.get(),
398
+ (v) => v._tag === 'Success' && v.refetching === false,
399
+ )
400
+ expect(settled).toMatchObject({ _tag: 'Success', value: 6 })
401
+ }).pipe(Effect.provide(TestLayer))
402
+ },
403
+ )
308
404
 
309
405
  it.live('coalesce trailing: a disable while a request waits in the slot skips the run', () => {
310
406
  class Count extends State.make('count', S.Number) {}
@@ -314,12 +410,15 @@ it.live('coalesce trailing: a disable while a request waits in the slot skips th
314
410
  inputs: [StateGroup.select(Inputs, 'count')],
315
411
  output: S.Number,
316
412
  }) {}
413
+ const gate = makeFlightGate()
317
414
  const QLive = AsyncCalc.live(Q, {
318
415
  query: ({ count }) =>
319
- Effect.sync(() => {
320
- completed.push(count)
321
- return count * 2
322
- }).pipe(Effect.delay(Duration.millis(25))),
416
+ gate.through(
417
+ Effect.sync(() => {
418
+ completed.push(count)
419
+ return count * 2
420
+ }),
421
+ ),
323
422
  disabled: ({ count }) => count < 0,
324
423
  coalesce: 'trailing',
325
424
  })
@@ -328,19 +427,29 @@ it.live('coalesce trailing: a disable while a request waits in the slot skips th
328
427
  return Effect.gen(function* () {
329
428
  const source = yield* StateGroup.select(Inputs, 'count').store
330
429
  const store = yield* Q.store
331
- yield* until(() => store.get(), (v) => v._tag === 'Success')
430
+ yield* until(
431
+ () => store.get(),
432
+ (v) => v._tag === 'Success',
433
+ )
332
434
 
435
+ // 3 queues behind the held `2` flight, then the disable overtakes it: the
436
+ // queued run must be dropped, not executed on release.
437
+ yield* gate.hold
333
438
  source.set(2)
334
- yield* tick(5)
335
- source.set(3)
336
- yield* tick(2)
337
- source.set(-1)
338
- yield* tick(60)
439
+ yield* gate.awaitEntry(2)
440
+ yield* afterNotify(source, Effect.sync(() => source.set(3)))
441
+ yield* afterNotify(source, Effect.sync(() => source.set(-1)))
442
+ yield* gate.release
339
443
  expect(store.get()._tag).toBe('Idle')
340
444
  expect(completed).toEqual([1, 2])
341
445
 
446
+ // The queue is FIFO: once 5 has landed, 3's fate is settled, and it must not
447
+ // appear between them.
342
448
  source.set(5)
343
- const settled = yield* until(() => store.get(), (v) => v._tag === 'Success')
449
+ const settled = yield* until(
450
+ () => store.get(),
451
+ (v) => v._tag === 'Success',
452
+ )
344
453
  expect(settled).toMatchObject({ _tag: 'Success', value: 10 })
345
454
  expect(completed).toEqual([1, 2, 5])
346
455
  }).pipe(Effect.provide(TestLayer))
@@ -356,12 +465,15 @@ it.live('invalidateOn: dispatching a listed event re-fetches with refetching: tr
356
465
  output: S.Number,
357
466
  alwaysOn: true,
358
467
  }) {}
468
+ const gate = makeFlightGate()
359
469
  const QLive = AsyncCalc.live(Q, {
360
470
  query: ({ count }) =>
361
- Effect.sync(() => {
362
- runs.n += 1
363
- return count * 2
364
- }).pipe(Effect.delay(Duration.millis(15))),
471
+ gate.through(
472
+ Effect.sync(() => {
473
+ runs.n += 1
474
+ return count * 2
475
+ }),
476
+ ),
365
477
  invalidateOn: [Poke],
366
478
  })
367
479
  const TestLayer = QLive.pipe(
@@ -371,99 +483,135 @@ it.live('invalidateOn: dispatching a listed event re-fetches with refetching: tr
371
483
 
372
484
  return Effect.gen(function* () {
373
485
  const store = yield* Q.store
374
- yield* tick(30)
486
+ yield* until(
487
+ () => store.get(),
488
+ (v) => v._tag === 'Success' && v.refetching === false,
489
+ )
375
490
  expect(store.get()).toMatchObject({ _tag: 'Success', value: 4, refetching: false })
376
491
  expect(runs.n).toBe(1)
377
492
 
493
+ yield* gate.hold
378
494
  yield* Event.dispatch(Poke, {})
379
- yield* tick(5)
495
+ yield* gate.awaitEntry(2)
380
496
  expect(store.get()).toMatchObject({ _tag: 'Success', value: 4, refetching: true })
381
- yield* tick(30)
382
- expect(store.get()).toMatchObject({ _tag: 'Success', value: 4, refetching: false })
383
- expect(runs.n).toBe(2)
384
- }).pipe(Effect.provide(TestLayer))
385
- })
386
497
 
387
- it.live('invalidateOn: composes with invalidateBy — non-key churn stays inert, the event re-fetches', () => {
388
- class Status extends State.make('status', S.String) {}
389
- class Page extends State.make('page', S.Number) {}
390
- class Inputs extends StateGroup.make(Status, Page) {}
391
- class Poke extends Event.make('Poke', S.Struct({})) {}
392
- const runs = { n: 0 }
393
- class Q extends AsyncCalc.make('Q', {
394
- inputs: [StateGroup.select(Inputs, 'status'), StateGroup.select(Inputs, 'page')],
395
- output: S.Number,
396
- alwaysOn: true,
397
- }) {}
398
- const QLive = AsyncCalc.live(Q, {
399
- query: ({ page }) =>
400
- Effect.sync(() => {
401
- runs.n += 1
402
- return page
403
- }),
404
- invalidateBy: ({ page }) => [page],
405
- invalidateOn: [Poke],
406
- })
407
- const TestLayer = QLive.pipe(
408
- Layer.provideMerge(StateGroup.live(Inputs, { status: 'a', page: 1 })),
409
- Layer.provideMerge(Engine),
410
- )
411
-
412
- return Effect.gen(function* () {
413
- const status = yield* StateGroup.select(Inputs, 'status').store
414
- yield* tick()
415
- expect(runs.n).toBe(1)
416
-
417
- status.set('b')
418
- yield* tick()
419
- expect(runs.n).toBe(1)
420
-
421
- yield* Event.dispatch(Poke, {})
422
- yield* tick()
498
+ yield* gate.release
499
+ const settled = yield* until(
500
+ () => store.get(),
501
+ (v) => v._tag === 'Success' && v.refetching === false,
502
+ )
503
+ expect(settled).toMatchObject({ _tag: 'Success', value: 4, refetching: false })
423
504
  expect(runs.n).toBe(2)
424
505
  }).pipe(Effect.provide(TestLayer))
425
506
  })
426
507
 
427
- it.live('invalidateOn: a listed event while disabled stays Idle, and enabling re-fetches once', () => {
428
- class Count extends State.make('count', S.Number) {}
429
- class Inputs extends StateGroup.make(Count) {}
430
- class Poke extends Event.make('Poke', S.Struct({})) {}
431
- const runs = { n: 0 }
432
- class Q extends AsyncCalc.make('Q', {
433
- inputs: [StateGroup.select(Inputs, 'count')],
434
- output: S.Number,
435
- }) {}
436
- const QLive = AsyncCalc.live(Q, {
437
- query: ({ count }) =>
438
- Effect.sync(() => {
439
- runs.n += 1
440
- return count * 2
441
- }),
442
- disabled: ({ count }) => count < 0,
443
- invalidateOn: [Poke],
444
- })
445
- const TestLayer = QLive.pipe(
446
- Layer.provideMerge(StateGroup.live(Inputs, { count: -1 })),
447
- Layer.provideMerge(Engine),
448
- )
449
-
450
- return Effect.gen(function* () {
451
- const source = yield* StateGroup.select(Inputs, 'count').store
452
- const store = yield* Q.store
453
- yield* tick()
454
- expect(store.get()._tag).toBe('Idle')
508
+ it.live(
509
+ 'invalidateOn: composes with invalidateBy — non-key churn stays inert, the event re-fetches',
510
+ () => {
511
+ class Status extends State.make('status', S.String) {}
512
+ class Page extends State.make('page', S.Number) {}
513
+ class Inputs extends StateGroup.make(Status, Page) {}
514
+ class Poke extends Event.make('Poke', S.Struct({})) {}
515
+ const runs = { n: 0 }
516
+ class Q extends AsyncCalc.make('Q', {
517
+ inputs: [StateGroup.select(Inputs, 'status'), StateGroup.select(Inputs, 'page')],
518
+ output: S.Number,
519
+ alwaysOn: true,
520
+ }) {}
521
+ const gate = makeFlightGate()
522
+ const QLive = AsyncCalc.live(Q, {
523
+ query: ({ page }) =>
524
+ gate.through(
525
+ Effect.sync(() => {
526
+ runs.n += 1
527
+ return page
528
+ }),
529
+ ),
530
+ invalidateBy: ({ page }) => [page],
531
+ invalidateOn: [Poke],
532
+ })
533
+ const TestLayer = QLive.pipe(
534
+ Layer.provideMerge(StateGroup.live(Inputs, { status: 'a', page: 1 })),
535
+ Layer.provideMerge(Engine),
536
+ )
455
537
 
456
- yield* Event.dispatch(Poke, {})
457
- yield* tick()
458
- expect(store.get()._tag).toBe('Idle')
459
- expect(runs.n).toBe(0)
538
+ return Effect.gen(function* () {
539
+ const status = yield* StateGroup.select(Inputs, 'status').store
540
+ const store = yield* Q.store
541
+ yield* until(
542
+ () => store.get(),
543
+ (v) => v._tag === 'Success' && v.refetching === false,
544
+ )
545
+ expect(runs.n).toBe(1)
546
+
547
+ yield* gate.hold
548
+ yield* afterNotify(status, Effect.sync(() => status.set('b')))
549
+ expect(store.get()).toMatchObject({ _tag: 'Success', refetching: false })
550
+ expect(runs.n).toBe(1)
551
+ yield* gate.release
552
+
553
+ yield* Event.dispatch(Poke, {})
554
+ yield* gate.awaitEntry(2)
555
+ expect(runs.n).toBe(2)
556
+ }).pipe(Effect.provide(TestLayer))
557
+ },
558
+ )
559
+
560
+ it.live(
561
+ 'invalidateOn: a listed event while disabled stays Idle, and enabling re-fetches once',
562
+ () => {
563
+ class Count extends State.make('count', S.Number) {}
564
+ class Inputs extends StateGroup.make(Count) {}
565
+ class Poke extends Event.make('Poke', S.Struct({})) {}
566
+ // A second reducer on the same event: reducers of one frame apply in a single
567
+ // pass, so this state landing proves the query's revision write landed too.
568
+ class Pokes extends State.make('pokes', S.Number) {}
569
+ const PokesReducer = Reducer.make('pokes', { states: [Pokes], events: [Poke] })
570
+ const runs = { n: 0 }
571
+ class Q extends AsyncCalc.make('Q', {
572
+ inputs: [StateGroup.select(Inputs, 'count')],
573
+ output: S.Number,
574
+ }) {}
575
+ const QLive = AsyncCalc.live(Q, {
576
+ query: ({ count }) =>
577
+ Effect.sync(() => {
578
+ runs.n += 1
579
+ return count * 2
580
+ }),
581
+ disabled: ({ count }) => count < 0,
582
+ invalidateOn: [Poke],
583
+ })
584
+ const TestLayer = Layer.mergeAll(
585
+ QLive,
586
+ Reducer.live(PokesReducer, (seen) => seen + 1),
587
+ ).pipe(
588
+ Layer.provideMerge(StateGroup.live(Inputs, { count: -1 })),
589
+ Layer.provideMerge(State.live(Pokes, 0)),
590
+ Layer.provideMerge(Engine),
591
+ )
460
592
 
461
- source.set(5)
462
- yield* tick()
463
- expect(store.get()).toMatchObject({ _tag: 'Success', value: 10 })
464
- expect(runs.n).toBe(1)
465
- }).pipe(Effect.provide(TestLayer))
466
- })
593
+ return Effect.gen(function* () {
594
+ const source = yield* StateGroup.select(Inputs, 'count').store
595
+ const pokes = yield* Pokes.store
596
+ const store = yield* Q.store
597
+ expect(store.get()._tag).toBe('Idle')
598
+
599
+ // An enqueued run marks the state fetching before it can run, so staying
600
+ // Idle across the frame is proof that nothing was enqueued.
601
+ yield* afterNotify(pokes, Event.dispatch(Poke, {}))
602
+ expect(store.get()._tag).toBe('Idle')
603
+ expect(runs.n).toBe(0)
604
+
605
+ source.set(5)
606
+ const settled = yield* until(
607
+ () => store.get(),
608
+ (v) => v._tag === 'Success',
609
+ )
610
+ expect(settled).toMatchObject({ _tag: 'Success', value: 10 })
611
+ expect(runs.n).toBe(1)
612
+ }).pipe(Effect.provide(TestLayer))
613
+ },
614
+ )
467
615
 
468
616
  it.live('invalidateOn: two async calcs keep independent hidden revisions', () => {
469
617
  class Count extends State.make('count', S.Number) {}
@@ -481,38 +629,62 @@ it.live('invalidateOn: two async calcs keep independent hidden revisions', () =>
481
629
  output: S.Number,
482
630
  alwaysOn: true,
483
631
  }) {}
632
+ const gateA = makeFlightGate()
633
+ const gateB = makeFlightGate()
484
634
  const TestLayer = Layer.mergeAll(
485
635
  AsyncCalc.live(QA, {
486
636
  query: ({ count }) =>
487
- Effect.sync(() => {
488
- runs.a += 1
489
- return count
490
- }),
637
+ gateA.through(
638
+ Effect.sync(() => {
639
+ runs.a += 1
640
+ return count
641
+ }),
642
+ ),
491
643
  invalidateOn: [PokeA],
492
644
  }),
493
645
  AsyncCalc.live(QB, {
494
646
  query: ({ count }) =>
495
- Effect.sync(() => {
496
- runs.b += 1
497
- return count
498
- }),
647
+ gateB.through(
648
+ Effect.sync(() => {
649
+ runs.b += 1
650
+ return count
651
+ }),
652
+ ),
499
653
  invalidateOn: [PokeB],
500
654
  }),
501
- ).pipe(
502
- Layer.provideMerge(StateGroup.live(Inputs, { count: 1 })),
503
- Layer.provideMerge(Engine),
504
- )
655
+ ).pipe(Layer.provideMerge(StateGroup.live(Inputs, { count: 1 })), Layer.provideMerge(Engine))
505
656
 
506
657
  return Effect.gen(function* () {
507
- yield* tick()
658
+ const storeA = yield* QA.store
659
+ const storeB = yield* QB.store
660
+ yield* until(
661
+ () => storeA.get()._tag === 'Success' && storeB.get()._tag === 'Success',
662
+ (both) => both,
663
+ )
508
664
  expect(runs).toEqual({ a: 1, b: 1 })
509
665
 
666
+ // B's gate is held, so a revision leak from PokeA would leave B visibly
667
+ // fetching instead of settling back out of sight.
668
+ yield* gateB.hold
510
669
  yield* Event.dispatch(PokeA, {})
511
- yield* tick()
670
+ yield* gateA.awaitEntry(2)
671
+ yield* until(
672
+ () => storeA.get(),
673
+ (v) => v._tag === 'Success' && v.refetching === false,
674
+ )
675
+ expect(storeB.get()).toMatchObject({ _tag: 'Success', refetching: false })
512
676
  expect(runs).toEqual({ a: 2, b: 1 })
677
+ yield* gateB.release
513
678
 
679
+ yield* gateA.hold
514
680
  yield* Event.dispatch(PokeB, {})
515
- yield* tick()
681
+ yield* gateB.awaitEntry(2)
682
+ yield* until(
683
+ () => storeB.get(),
684
+ (v) => v._tag === 'Success' && v.refetching === false,
685
+ )
686
+ expect(storeA.get()).toMatchObject({ _tag: 'Success', refetching: false })
516
687
  expect(runs).toEqual({ a: 2, b: 2 })
688
+ yield* gateA.release
517
689
  }).pipe(Effect.provide(TestLayer))
518
690
  })