@playfast/reform 1.0.1 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/README.md +103 -56
  2. package/package.json +17 -17
  3. package/src/boundary/boundary.test.ts +63 -49
  4. package/src/boundary/boundary.ts +144 -113
  5. package/src/calc/asyncCalc.invalidate.test.ts +518 -32
  6. package/src/calc/asyncCalc.test.ts +207 -213
  7. package/src/calc/asyncCalc.ts +131 -154
  8. package/src/calc/asyncData.ts +0 -37
  9. package/src/calc/calc.test.ts +7 -33
  10. package/src/calc/calc.ts +44 -58
  11. package/src/calc/calcFamily.test.ts +80 -34
  12. package/src/calc/calcFamily.ts +54 -65
  13. package/src/calc/compose.test.ts +1 -12
  14. package/src/calc/compose.ts +1 -36
  15. package/src/calc/queryState.ts +0 -23
  16. package/src/channel/channel.ts +126 -111
  17. package/src/compose/composition.test.ts +19 -0
  18. package/src/compose/composition.ts +351 -127
  19. package/src/compose/host.ts +0 -6
  20. package/src/compose/props.ts +13 -12
  21. package/src/compose/provide.ts +189 -62
  22. package/src/compose/slot.ts +158 -49
  23. package/src/compose/structure.test.ts +6 -9
  24. package/src/compose/structure.ts +108 -79
  25. package/src/compose/ui.test.ts +19 -7
  26. package/src/compose/ui.ts +155 -156
  27. package/src/compose/ui.typecheck.ts +40 -18
  28. package/src/definition/definition.ts +22 -41
  29. package/src/event/event.fromSource.test.ts +172 -0
  30. package/src/event/event.test.ts +10 -3
  31. package/src/event/event.ts +102 -27
  32. package/src/event/eventGroup.ts +0 -1
  33. package/src/feature/feature.mount.test.ts +122 -43
  34. package/src/feature/feature.test.ts +46 -21
  35. package/src/feature/feature.ts +1347 -256
  36. package/src/feature/feature.typecheck.ts +273 -68
  37. package/src/graph/closure.ts +403 -0
  38. package/src/index.ts +68 -126
  39. package/src/internal/bucketCache.ts +39 -0
  40. package/src/internal/capture.ts +9 -24
  41. package/src/internal/env.ts +7 -0
  42. package/src/internal/errors.test.ts +0 -6
  43. package/src/internal/errors.ts +37 -60
  44. package/src/internal/inspect.test.ts +0 -6
  45. package/src/internal/inspect.ts +0 -12
  46. package/src/internal/queryDriver.ts +105 -201
  47. package/src/internal/queryDriverStore.ts +156 -0
  48. package/src/internal/queryDriverTypes.ts +59 -0
  49. package/src/internal/queryEvents.ts +0 -12
  50. package/src/internal/queryStore.ts +0 -14
  51. package/src/internal/reuse.test.ts +10 -14
  52. package/src/internal/reuse.ts +5 -30
  53. package/src/internal/scheduler.ts +4 -44
  54. package/src/internal/seeds.ts +0 -14
  55. package/src/internal/sources.ts +26 -49
  56. package/src/internal/stateRegistry.ts +0 -14
  57. package/src/internal/store.test.ts +1 -3
  58. package/src/internal/store.ts +0 -37
  59. package/src/internal/track.ts +3 -20
  60. package/src/internal/variance.ts +5 -0
  61. package/src/internal.ts +222 -0
  62. package/src/namespace/namespace.test.ts +46 -0
  63. package/src/namespace/namespace.ts +106 -0
  64. package/src/procedure/procedure.ts +40 -35
  65. package/src/reducer/reducer.ts +78 -52
  66. package/src/remote/remoteState.test.ts +590 -397
  67. package/src/remote/remoteState.ts +425 -347
  68. package/src/remote/remoteState.typecheck.ts +47 -56
  69. package/src/runtime/appRuntime.activation.test.ts +100 -0
  70. package/src/runtime/appRuntime.test.ts +249 -0
  71. package/src/runtime/appRuntime.ts +415 -97
  72. package/src/runtime/bus.ts +2 -15
  73. package/src/runtime/eventBudget.test.ts +152 -0
  74. package/src/runtime/eventBudget.ts +120 -0
  75. package/src/runtime/hardening.test.ts +73 -13
  76. package/src/runtime/instrumentation.test.ts +55 -52
  77. package/src/runtime/instrumentation.ts +6 -60
  78. package/src/runtime/loop.test.ts +36 -17
  79. package/src/runtime/loop.ts +87 -88
  80. package/src/runtime/queries.ts +0 -17
  81. package/src/scene/featureScene.test.ts +61 -0
  82. package/src/scene/scene.ts +247 -104
  83. package/src/scene/seedScene.test.ts +42 -79
  84. package/src/state/state.nominal.typecheck.ts +68 -0
  85. package/src/state/state.test.ts +17 -0
  86. package/src/state/state.ts +80 -46
  87. package/src/state/stateFamily.test.ts +16 -11
  88. package/src/state/stateFamily.ts +55 -65
  89. package/src/state/stateGroup.ts +53 -64
  90. package/src/state/token.ts +40 -23
  91. package/src/synced/syncedStore.ts +46 -58
  92. package/src/testkit/flight.testkit.ts +75 -0
  93. package/src/ui/node.ts +0 -6
  94. package/src/ui/trigger.ts +0 -5
  95. package/src/wire/tree.test.ts +8 -7
  96. package/src/wire/tree.ts +0 -39
  97. package/src/wire/triggers.test.ts +6 -6
  98. package/src/wire/triggers.ts +14 -32
  99. package/src/internal/ctx.ts +0 -16
@@ -1,22 +1,7 @@
1
1
  import { expect, it } from '@effect/vitest'
2
2
  import { Data, Duration, Effect, Layer, Schema as S } from 'effect'
3
3
  import { AsyncCalc, Engine, Event, State, StateGroup } from '../index'
4
-
5
- // AsyncCalc is a derived store whose value is the lifecycle of an Effect. It runs
6
- // the query reactively from its inputs (latest-wins), keeps the last value while
7
- // re-fetching (stale-while-revalidate), and goes Idle when a gated query disables.
8
-
9
- const tick = (ms = 10) => Effect.sleep(Duration.millis(ms))
10
-
11
- // Poll until the predicate holds (bounded) — for assertions that race real
12
- // timers under parallel-suite host load, where a fixed `tick` budget flakes.
13
- const until = <A>(read: () => A, pred: (a: A) => boolean, rounds = 200): Effect.Effect<A> =>
14
- Effect.suspend(() => {
15
- const a = read()
16
- return pred(a) || rounds <= 0
17
- ? Effect.succeed(a)
18
- : Effect.flatMap(tick(10), () => until(read, pred, rounds - 1))
19
- })
4
+ import { makeFlightGate, tick, until } from '../testkit/flight.testkit'
20
5
 
21
6
  class Boom extends Data.TaggedError('Boom')<{ readonly message: string }> {}
22
7
 
@@ -35,7 +20,6 @@ it.live('runs the query and resolves Loading -> Success', () => {
35
20
 
36
21
  return Effect.gen(function* () {
37
22
  const store = yield* Doubled.store
38
- // Before the forked query resolves, the value is Loading.
39
23
  expect(store.get()._tag).toBe('Loading')
40
24
  yield* tick(40)
41
25
  const value = store.get()
@@ -92,7 +76,6 @@ it.live('a dependency change re-fetches, keeping the last value with refetching:
92
76
  yield* tick(40)
93
77
  expect(store.get()).toMatchObject({ _tag: 'Success', value: 10, refetching: false })
94
78
 
95
- // Move the input: the prior value is kept while the new fetch is in flight.
96
79
  source.set(7)
97
80
  yield* tick(5)
98
81
  expect(store.get()).toMatchObject({ _tag: 'Success', value: 10, refetching: true })
@@ -126,9 +109,6 @@ it.live('latest-wins: a rapid second change cancels the in-flight run', () => {
126
109
  yield* tick(40)
127
110
  expect(store.get()).toMatchObject({ _tag: 'Success', value: 2 })
128
111
 
129
- // Two changes in quick succession: the first run is switched out before it
130
- // can write, so only the latest value lands (poll — fixed sleeps flake when
131
- // a loaded host starves the 25ms query timer).
132
112
  source.set(3)
133
113
  yield* tick(5)
134
114
  source.set(4)
@@ -181,7 +161,6 @@ it.live('invalidateBy: an input change outside the key does not re-fetch', () =>
181
161
  runs.n += 1
182
162
  return page
183
163
  }),
184
- // Only `page` is part of the key; `status` churn must not re-fetch.
185
164
  invalidateBy: ({ page }) => [page],
186
165
  })
187
166
  const TestLayer = QLive.pipe(
@@ -195,12 +174,10 @@ it.live('invalidateBy: an input change outside the key does not re-fetch', () =>
195
174
  yield* tick()
196
175
  expect(runs.n).toBe(1)
197
176
 
198
- // status moves, key unchanged -> no re-fetch.
199
177
  status.set('b')
200
178
  yield* tick()
201
179
  expect(runs.n).toBe(1)
202
180
 
203
- // page moves, key changes -> one re-fetch.
204
181
  page.set(2)
205
182
  yield* tick()
206
183
  expect(runs.n).toBe(2)
@@ -218,7 +195,6 @@ it.live('reuse: a refetch returning mostly-equal data keeps unchanged element id
218
195
  alwaysOn: true,
219
196
  }) {}
220
197
  const RowsLive = AsyncCalc.live(Rows, {
221
- // Fresh objects every run — only the row matching `page` differs.
222
198
  query: ({ page }) =>
223
199
  Effect.succeed([
224
200
  { id: 1, name: 'one' },
@@ -231,7 +207,10 @@ it.live('reuse: a refetch returning mostly-equal data keeps unchanged element id
231
207
  return Effect.gen(function* () {
232
208
  const source = yield* StateGroup.select(Inputs, 'page').store
233
209
  const store = yield* Rows.store
234
- const first = yield* until(() => store.get(), (v) => v._tag === 'Success')
210
+ const first = yield* until(
211
+ () => store.get(),
212
+ (v) => v._tag === 'Success',
213
+ )
235
214
  if (first._tag !== 'Success') return
236
215
 
237
216
  source.set(2)
@@ -240,94 +219,105 @@ it.live('reuse: a refetch returning mostly-equal data keeps unchanged element id
240
219
  (v) => v._tag === 'Success' && v.refetching === false && v.value !== first.value,
241
220
  )
242
221
  if (second._tag !== 'Success') return
243
- // Row 1 didn't move: same identity. Row 2 did: fresh.
244
222
  expect(second.value[0]).toBe(first.value[0])
245
223
  expect(second.value[1]).not.toBe(first.value[1])
246
224
  expect(second.value[1]).toEqual({ id: 2, name: 'two@2' })
247
225
  }).pipe(Effect.provide(TestLayer))
248
226
  })
249
227
 
250
- it.live('coalesce trailing: a burst during one flight completes it and runs ONE trailing refetch', () => {
251
- class Count extends State.make('count', S.Number) {}
252
- class Inputs extends StateGroup.make(Count) {}
253
- // Completion log: the sync body runs AFTER the delay, so the order proves
254
- // which runs actually finished (and that the in-flight one was not cancelled).
255
- const completed: number[] = []
256
- class Doubled extends AsyncCalc.make('Doubled', {
257
- inputs: [StateGroup.select(Inputs, 'count')],
258
- output: S.Number,
259
- alwaysOn: true,
260
- }) {}
261
- const DoubledLive = AsyncCalc.live(Doubled, {
262
- query: ({ count }) =>
263
- Effect.sync(() => {
264
- completed.push(count)
265
- return count * 2
266
- }).pipe(Effect.delay(Duration.millis(25))),
267
- coalesce: 'trailing',
268
- })
269
- const TestLayer = DoubledLive.pipe(Layer.provideMerge(StateGroup.live(Inputs, { count: 1 })))
270
-
271
- return Effect.gen(function* () {
272
- const source = yield* StateGroup.select(Inputs, 'count').store
273
- const store = yield* Doubled.store
274
- yield* until(() => store.get(), (v) => v._tag === 'Success')
275
- expect(completed).toEqual([1])
276
-
277
- // Three key changes during the first change's flight: the in-flight run (2)
278
- // completes and lands, the middle change (3) is conflated away, and exactly
279
- // one trailing run fetches the latest key (4).
280
- source.set(2)
281
- yield* tick(5)
282
- source.set(3)
283
- yield* tick(5)
284
- source.set(4)
285
- const settled = yield* until(
286
- () => store.get(),
287
- (v) => v._tag === 'Success' && v.refetching === false && v.value === 8,
288
- )
289
- expect(settled).toMatchObject({ _tag: 'Success', value: 8, refetching: false })
290
- expect(completed).toEqual([1, 2, 4])
291
- }).pipe(Effect.provide(TestLayer))
292
- })
293
-
294
- it.live('coalesce trailing: the stale settle re-flags refetching until the trailing run lands', () => {
295
- class Count extends State.make('count', S.Number) {}
296
- class Inputs extends StateGroup.make(Count) {}
297
- class Doubled extends AsyncCalc.make('Doubled', {
298
- inputs: [StateGroup.select(Inputs, 'count')],
299
- output: S.Number,
300
- alwaysOn: true,
301
- }) {}
302
- const DoubledLive = AsyncCalc.live(Doubled, {
303
- query: ({ count }) => Effect.succeed(count * 2).pipe(Effect.delay(Duration.millis(30))),
304
- coalesce: 'trailing',
305
- })
306
- const TestLayer = DoubledLive.pipe(Layer.provideMerge(StateGroup.live(Inputs, { count: 1 })))
307
-
308
- return Effect.gen(function* () {
309
- const source = yield* StateGroup.select(Inputs, 'count').store
310
- const store = yield* Doubled.store
311
- yield* until(() => store.get(), (v) => v._tag === 'Success')
312
-
313
- // Two changes: when the in-flight result (4) lands, a trailing run is still
314
- // pending — the value must read as syncing, not settled.
315
- source.set(2)
316
- yield* tick(5)
317
- source.set(3)
318
- const midflight = yield* until(
319
- () => store.get(),
320
- (v) => v._tag === 'Success' && v.value === 4,
321
- )
322
- expect(midflight).toMatchObject({ _tag: 'Success', value: 4, refetching: true })
323
-
324
- const settled = yield* until(
325
- () => store.get(),
326
- (v) => v._tag === 'Success' && v.refetching === false,
327
- )
328
- expect(settled).toMatchObject({ _tag: 'Success', value: 6 })
329
- }).pipe(Effect.provide(TestLayer))
330
- })
228
+ it.live(
229
+ 'coalesce trailing: a burst during one flight completes it and runs ONE trailing refetch',
230
+ () => {
231
+ class Count extends State.make('count', S.Number) {}
232
+ class Inputs extends StateGroup.make(Count) {}
233
+ const completed: number[] = []
234
+ class Doubled extends AsyncCalc.make('Doubled', {
235
+ inputs: [StateGroup.select(Inputs, 'count')],
236
+ output: S.Number,
237
+ alwaysOn: true,
238
+ }) {}
239
+ const gate = makeFlightGate()
240
+ const DoubledLive = AsyncCalc.live(Doubled, {
241
+ query: ({ count }) =>
242
+ gate.through(
243
+ Effect.sync(() => {
244
+ completed.push(count)
245
+ return count * 2
246
+ }),
247
+ ),
248
+ coalesce: 'trailing',
249
+ })
250
+ const TestLayer = DoubledLive.pipe(Layer.provideMerge(StateGroup.live(Inputs, { count: 1 })))
251
+
252
+ return Effect.gen(function* () {
253
+ const source = yield* StateGroup.select(Inputs, 'count').store
254
+ const store = yield* Doubled.store
255
+ yield* until(
256
+ () => store.get(),
257
+ (v) => v._tag === 'Success',
258
+ )
259
+ expect(completed).toEqual([1])
260
+
261
+ // Hold the `2` flight at the gate so 3 and 4 land while it is in flight.
262
+ yield* gate.hold
263
+ source.set(2)
264
+ yield* gate.awaitEntry(2)
265
+ source.set(3)
266
+ yield* tick(5)
267
+ source.set(4)
268
+ yield* tick(5)
269
+ yield* gate.release
270
+ const settled = yield* until(
271
+ () => store.get(),
272
+ (v) => v._tag === 'Success' && v.refetching === false && v.value === 8,
273
+ )
274
+ expect(settled).toMatchObject({ _tag: 'Success', value: 8, refetching: false })
275
+ expect(completed).toEqual([1, 2, 4])
276
+ }).pipe(Effect.provide(TestLayer))
277
+ },
278
+ )
279
+
280
+ it.live(
281
+ 'coalesce trailing: the stale settle re-flags refetching until the trailing run lands',
282
+ () => {
283
+ class Count extends State.make('count', S.Number) {}
284
+ class Inputs extends StateGroup.make(Count) {}
285
+ class Doubled extends AsyncCalc.make('Doubled', {
286
+ inputs: [StateGroup.select(Inputs, 'count')],
287
+ output: S.Number,
288
+ alwaysOn: true,
289
+ }) {}
290
+ const DoubledLive = AsyncCalc.live(Doubled, {
291
+ query: ({ count }) => Effect.succeed(count * 2).pipe(Effect.delay(Duration.millis(30))),
292
+ coalesce: 'trailing',
293
+ })
294
+ const TestLayer = DoubledLive.pipe(Layer.provideMerge(StateGroup.live(Inputs, { count: 1 })))
295
+
296
+ return Effect.gen(function* () {
297
+ const source = yield* StateGroup.select(Inputs, 'count').store
298
+ const store = yield* Doubled.store
299
+ yield* until(
300
+ () => store.get(),
301
+ (v) => v._tag === 'Success',
302
+ )
303
+
304
+ source.set(2)
305
+ yield* tick(5)
306
+ source.set(3)
307
+ const midflight = yield* until(
308
+ () => store.get(),
309
+ (v) => v._tag === 'Success' && v.value === 4,
310
+ )
311
+ expect(midflight).toMatchObject({ _tag: 'Success', value: 4, refetching: true })
312
+
313
+ const settled = yield* until(
314
+ () => store.get(),
315
+ (v) => v._tag === 'Success' && v.refetching === false,
316
+ )
317
+ expect(settled).toMatchObject({ _tag: 'Success', value: 6 })
318
+ }).pipe(Effect.provide(TestLayer))
319
+ },
320
+ )
331
321
 
332
322
  it.live('coalesce trailing: a disable while a request waits in the slot skips the run', () => {
333
323
  class Count extends State.make('count', S.Number) {}
@@ -337,12 +327,15 @@ it.live('coalesce trailing: a disable while a request waits in the slot skips th
337
327
  inputs: [StateGroup.select(Inputs, 'count')],
338
328
  output: S.Number,
339
329
  }) {}
330
+ const gate = makeFlightGate()
340
331
  const QLive = AsyncCalc.live(Q, {
341
332
  query: ({ count }) =>
342
- Effect.sync(() => {
343
- completed.push(count)
344
- return count * 2
345
- }).pipe(Effect.delay(Duration.millis(25))),
333
+ gate.through(
334
+ Effect.sync(() => {
335
+ completed.push(count)
336
+ return count * 2
337
+ }),
338
+ ),
346
339
  disabled: ({ count }) => count < 0,
347
340
  coalesce: 'trailing',
348
341
  })
@@ -351,30 +344,37 @@ it.live('coalesce trailing: a disable while a request waits in the slot skips th
351
344
  return Effect.gen(function* () {
352
345
  const source = yield* StateGroup.select(Inputs, 'count').store
353
346
  const store = yield* Q.store
354
- yield* until(() => store.get(), (v) => v._tag === 'Success')
347
+ yield* until(
348
+ () => store.get(),
349
+ (v) => v._tag === 'Success',
350
+ )
355
351
 
356
- // 2 starts a flight, 3 waits in the slot, then the gate closes: the flight's
357
- // stale result must not land (Idle), and the queued request must not run.
352
+ // 3 queues behind the held `2` flight, then the disable overtakes it: the
353
+ // queued run must be dropped, not executed on release.
354
+ yield* gate.hold
358
355
  source.set(2)
359
- yield* tick(5)
356
+ yield* gate.awaitEntry(2)
360
357
  source.set(3)
361
- yield* tick(2)
358
+ yield* tick(5)
362
359
  source.set(-1)
360
+ yield* tick(5)
361
+ yield* gate.release
362
+ // Asserting an absence: give the consumer a real window to wake and (wrongly)
363
+ // run the queued key. A slow machine only widens the window, never narrows it.
363
364
  yield* tick(60)
364
365
  expect(store.get()._tag).toBe('Idle')
365
366
  expect(completed).toEqual([1, 2])
366
367
 
367
- // Re-enabling re-runs (the driver forgot its key when switched off).
368
368
  source.set(5)
369
- const settled = yield* until(() => store.get(), (v) => v._tag === 'Success')
369
+ const settled = yield* until(
370
+ () => store.get(),
371
+ (v) => v._tag === 'Success',
372
+ )
370
373
  expect(settled).toMatchObject({ _tag: 'Success', value: 10 })
371
374
  expect(completed).toEqual([1, 2, 5])
372
375
  }).pipe(Effect.provide(TestLayer))
373
376
  })
374
377
 
375
- // `invalidateOn` is sugar for a hidden revision state + reducer appended to the
376
- // key — these proofs need the Engine (the Reducers registry + bus + drain loop).
377
-
378
378
  it.live('invalidateOn: dispatching a listed event re-fetches with refetching: true', () => {
379
379
  class Count extends State.make('count', S.Number) {}
380
380
  class Inputs extends StateGroup.make(Count) {}
@@ -404,8 +404,6 @@ it.live('invalidateOn: dispatching a listed event re-fetches with refetching: tr
404
404
  expect(store.get()).toMatchObject({ _tag: 'Success', value: 4, refetching: false })
405
405
  expect(runs.n).toBe(1)
406
406
 
407
- // The fact lands: the hidden revision bumps, the key moves, the value is
408
- // kept while the re-fetch is in flight (SWR), then settles.
409
407
  yield* Event.dispatch(Poke, {})
410
408
  yield* tick(5)
411
409
  expect(store.get()).toMatchObject({ _tag: 'Success', value: 4, refetching: true })
@@ -415,91 +413,92 @@ it.live('invalidateOn: dispatching a listed event re-fetches with refetching: tr
415
413
  }).pipe(Effect.provide(TestLayer))
416
414
  })
417
415
 
418
- it.live('invalidateOn: composes with invalidateBy — non-key churn stays inert, the event re-fetches', () => {
419
- class Status extends State.make('status', S.String) {}
420
- class Page extends State.make('page', S.Number) {}
421
- class Inputs extends StateGroup.make(Status, Page) {}
422
- class Poke extends Event.make('Poke', S.Struct({})) {}
423
- const runs = { n: 0 }
424
- class Q extends AsyncCalc.make('Q', {
425
- inputs: [StateGroup.select(Inputs, 'status'), StateGroup.select(Inputs, 'page')],
426
- output: S.Number,
427
- alwaysOn: true,
428
- }) {}
429
- const QLive = AsyncCalc.live(Q, {
430
- query: ({ page }) =>
431
- Effect.sync(() => {
432
- runs.n += 1
433
- return page
434
- }),
435
- invalidateBy: ({ page }) => [page],
436
- invalidateOn: [Poke],
437
- })
438
- const TestLayer = QLive.pipe(
439
- Layer.provideMerge(StateGroup.live(Inputs, { status: 'a', page: 1 })),
440
- Layer.provideMerge(Engine),
441
- )
442
-
443
- return Effect.gen(function* () {
444
- const status = yield* StateGroup.select(Inputs, 'status').store
445
- yield* tick()
446
- expect(runs.n).toBe(1)
447
-
448
- // Outside the user key: still no re-fetch (the hidden revision rides along
449
- // WITHOUT widening the key to every input).
450
- status.set('b')
451
- yield* tick()
452
- expect(runs.n).toBe(1)
453
-
454
- // The listed event always re-fetches.
455
- yield* Event.dispatch(Poke, {})
456
- yield* tick()
457
- expect(runs.n).toBe(2)
458
- }).pipe(Effect.provide(TestLayer))
459
- })
460
-
461
- it.live('invalidateOn: a listed event while disabled stays Idle, and enabling re-fetches once', () => {
462
- class Count extends State.make('count', S.Number) {}
463
- class Inputs extends StateGroup.make(Count) {}
464
- class Poke extends Event.make('Poke', S.Struct({})) {}
465
- const runs = { n: 0 }
466
- class Q extends AsyncCalc.make('Q', {
467
- inputs: [StateGroup.select(Inputs, 'count')],
468
- output: S.Number,
469
- }) {}
470
- const QLive = AsyncCalc.live(Q, {
471
- query: ({ count }) =>
472
- Effect.sync(() => {
473
- runs.n += 1
474
- return count * 2
475
- }),
476
- disabled: ({ count }) => count < 0,
477
- invalidateOn: [Poke],
478
- })
479
- const TestLayer = QLive.pipe(
480
- Layer.provideMerge(StateGroup.live(Inputs, { count: -1 })),
481
- Layer.provideMerge(Engine),
482
- )
483
-
484
- return Effect.gen(function* () {
485
- const source = yield* StateGroup.select(Inputs, 'count').store
486
- const store = yield* Q.store
487
- yield* tick()
488
- expect(store.get()._tag).toBe('Idle')
416
+ it.live(
417
+ 'invalidateOn: composes with invalidateBy — non-key churn stays inert, the event re-fetches',
418
+ () => {
419
+ class Status extends State.make('status', S.String) {}
420
+ class Page extends State.make('page', S.Number) {}
421
+ class Inputs extends StateGroup.make(Status, Page) {}
422
+ class Poke extends Event.make('Poke', S.Struct({})) {}
423
+ const runs = { n: 0 }
424
+ class Q extends AsyncCalc.make('Q', {
425
+ inputs: [StateGroup.select(Inputs, 'status'), StateGroup.select(Inputs, 'page')],
426
+ output: S.Number,
427
+ alwaysOn: true,
428
+ }) {}
429
+ const QLive = AsyncCalc.live(Q, {
430
+ query: ({ page }) =>
431
+ Effect.sync(() => {
432
+ runs.n += 1
433
+ return page
434
+ }),
435
+ invalidateBy: ({ page }) => [page],
436
+ invalidateOn: [Poke],
437
+ })
438
+ const TestLayer = QLive.pipe(
439
+ Layer.provideMerge(StateGroup.live(Inputs, { status: 'a', page: 1 })),
440
+ Layer.provideMerge(Engine),
441
+ )
489
442
 
490
- // A bump while gated off runs nothing — the driver shows Idle and forgets
491
- // its key, so the eventual enable always re-runs.
492
- yield* Event.dispatch(Poke, {})
493
- yield* tick()
494
- expect(store.get()._tag).toBe('Idle')
495
- expect(runs.n).toBe(0)
443
+ return Effect.gen(function* () {
444
+ const status = yield* StateGroup.select(Inputs, 'status').store
445
+ yield* tick()
446
+ expect(runs.n).toBe(1)
447
+
448
+ status.set('b')
449
+ yield* tick()
450
+ expect(runs.n).toBe(1)
451
+
452
+ yield* Event.dispatch(Poke, {})
453
+ yield* tick()
454
+ expect(runs.n).toBe(2)
455
+ }).pipe(Effect.provide(TestLayer))
456
+ },
457
+ )
458
+
459
+ it.live(
460
+ 'invalidateOn: a listed event while disabled stays Idle, and enabling re-fetches once',
461
+ () => {
462
+ class Count extends State.make('count', S.Number) {}
463
+ class Inputs extends StateGroup.make(Count) {}
464
+ class Poke extends Event.make('Poke', S.Struct({})) {}
465
+ const runs = { n: 0 }
466
+ class Q extends AsyncCalc.make('Q', {
467
+ inputs: [StateGroup.select(Inputs, 'count')],
468
+ output: S.Number,
469
+ }) {}
470
+ const QLive = AsyncCalc.live(Q, {
471
+ query: ({ count }) =>
472
+ Effect.sync(() => {
473
+ runs.n += 1
474
+ return count * 2
475
+ }),
476
+ disabled: ({ count }) => count < 0,
477
+ invalidateOn: [Poke],
478
+ })
479
+ const TestLayer = QLive.pipe(
480
+ Layer.provideMerge(StateGroup.live(Inputs, { count: -1 })),
481
+ Layer.provideMerge(Engine),
482
+ )
496
483
 
497
- source.set(5)
498
- yield* tick()
499
- expect(store.get()).toMatchObject({ _tag: 'Success', value: 10 })
500
- expect(runs.n).toBe(1)
501
- }).pipe(Effect.provide(TestLayer))
502
- })
484
+ return Effect.gen(function* () {
485
+ const source = yield* StateGroup.select(Inputs, 'count').store
486
+ const store = yield* Q.store
487
+ yield* tick()
488
+ expect(store.get()._tag).toBe('Idle')
489
+
490
+ yield* Event.dispatch(Poke, {})
491
+ yield* tick()
492
+ expect(store.get()._tag).toBe('Idle')
493
+ expect(runs.n).toBe(0)
494
+
495
+ source.set(5)
496
+ yield* tick()
497
+ expect(store.get()).toMatchObject({ _tag: 'Success', value: 10 })
498
+ expect(runs.n).toBe(1)
499
+ }).pipe(Effect.provide(TestLayer))
500
+ },
501
+ )
503
502
 
504
503
  it.live('invalidateOn: two async calcs keep independent hidden revisions', () => {
505
504
  class Count extends State.make('count', S.Number) {}
@@ -534,17 +533,12 @@ it.live('invalidateOn: two async calcs keep independent hidden revisions', () =>
534
533
  }),
535
534
  invalidateOn: [PokeB],
536
535
  }),
537
- ).pipe(
538
- Layer.provideMerge(StateGroup.live(Inputs, { count: 1 })),
539
- Layer.provideMerge(Engine),
540
- )
536
+ ).pipe(Layer.provideMerge(StateGroup.live(Inputs, { count: 1 })), Layer.provideMerge(Engine))
541
537
 
542
538
  return Effect.gen(function* () {
543
539
  yield* tick()
544
540
  expect(runs).toEqual({ a: 1, b: 1 })
545
541
 
546
- // Each calc's hidden revision is namespaced by its own name: A's fact moves
547
- // only A.
548
542
  yield* Event.dispatch(PokeA, {})
549
543
  yield* tick()
550
544
  expect(runs).toEqual({ a: 2, b: 1 })