@playfast/reform 1.1.1 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/README.md +85 -55
  2. package/package.json +17 -17
  3. package/src/boundary/boundary.test.ts +62 -16
  4. package/src/boundary/boundary.ts +141 -32
  5. package/src/calc/asyncCalc.invalidate.test.ts +516 -18
  6. package/src/calc/asyncCalc.test.ts +207 -175
  7. package/src/calc/asyncCalc.ts +168 -39
  8. package/src/calc/calc.test.ts +3 -5
  9. package/src/calc/calc.ts +44 -18
  10. package/src/calc/calcFamily.test.ts +80 -22
  11. package/src/calc/calcFamily.ts +56 -25
  12. package/src/calc/compose.ts +1 -14
  13. package/src/channel/channel.ts +128 -11
  14. package/src/compose/composition.test.ts +19 -0
  15. package/src/compose/composition.ts +346 -62
  16. package/src/compose/props.ts +13 -6
  17. package/src/compose/provide.ts +187 -46
  18. package/src/compose/slot.ts +156 -19
  19. package/src/compose/structure.test.ts +6 -3
  20. package/src/compose/structure.ts +106 -13
  21. package/src/compose/ui.test.ts +19 -3
  22. package/src/compose/ui.ts +147 -54
  23. package/src/compose/ui.typecheck.ts +40 -4
  24. package/src/definition/definition.ts +22 -9
  25. package/src/event/event.fromSource.test.ts +172 -0
  26. package/src/event/event.test.ts +10 -0
  27. package/src/event/event.ts +102 -18
  28. package/src/feature/feature.mount.test.ts +123 -56
  29. package/src/feature/feature.test.ts +67 -63
  30. package/src/feature/feature.ts +1317 -197
  31. package/src/feature/feature.typecheck.ts +253 -61
  32. package/src/graph/closure.ts +403 -0
  33. package/src/index.ts +171 -245
  34. package/src/internal/bucketCache.ts +39 -0
  35. package/src/internal/capture.ts +9 -4
  36. package/src/internal/env.ts +1 -3
  37. package/src/internal/errors.ts +21 -10
  38. package/src/internal/queryDriver.ts +120 -15
  39. package/src/internal/queryDriverStore.ts +8 -5
  40. package/src/internal/queryDriverTypes.ts +3 -1
  41. package/src/internal/reuse.test.ts +10 -3
  42. package/src/internal/reuse.ts +5 -2
  43. package/src/internal/scheduler.ts +4 -1
  44. package/src/internal/sources.ts +25 -11
  45. package/src/internal/track.ts +3 -2
  46. package/src/internal.ts +222 -0
  47. package/src/namespace/namespace.ts +46 -25
  48. package/src/procedure/procedure.ts +38 -11
  49. package/src/reducer/reducer.ts +78 -34
  50. package/src/remote/remoteState.test.ts +515 -339
  51. package/src/remote/remoteState.ts +572 -107
  52. package/src/remote/remoteState.typecheck.ts +47 -23
  53. package/src/runtime/appRuntime.activation.test.ts +100 -0
  54. package/src/runtime/appRuntime.test.ts +157 -105
  55. package/src/runtime/appRuntime.ts +394 -33
  56. package/src/runtime/eventBudget.test.ts +1 -4
  57. package/src/runtime/eventBudget.ts +9 -8
  58. package/src/runtime/hardening.test.ts +4 -9
  59. package/src/runtime/instrumentation.test.ts +174 -192
  60. package/src/runtime/instrumentation.ts +6 -11
  61. package/src/runtime/loop.test.ts +36 -0
  62. package/src/runtime/loop.ts +69 -40
  63. package/src/scene/featureScene.test.ts +4 -2
  64. package/src/scene/scene.ts +234 -64
  65. package/src/scene/seedScene.test.ts +69 -86
  66. package/src/state/state.nominal.typecheck.ts +68 -0
  67. package/src/state/state.test.ts +17 -0
  68. package/src/state/state.ts +79 -26
  69. package/src/state/stateFamily.test.ts +14 -0
  70. package/src/state/stateFamily.ts +55 -22
  71. package/src/state/stateGroup.ts +53 -17
  72. package/src/state/token.ts +40 -10
  73. package/src/synced/syncedStore.ts +46 -23
  74. package/src/testkit/flight.testkit.ts +75 -0
  75. package/src/wire/tree.test.ts +8 -4
  76. package/src/wire/triggers.test.ts +6 -2
  77. package/src/wire/triggers.ts +14 -10
  78. package/src/calc/asyncCalcDefinitions.ts +0 -48
  79. package/src/channel/procedureRegistry.ts +0 -90
  80. package/src/feature/featureBinding.ts +0 -48
  81. package/src/internal/ctx.ts +0 -9
  82. package/src/remote/remoteStateDefinition.ts +0 -135
  83. package/src/remote/remoteStateLayers.ts +0 -118
  84. package/src/remote/remoteStateLiveTypes.ts +0 -59
  85. package/src/remote/remoteStateSend.ts +0 -64
@@ -1,16 +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
- const tick = (ms = 10) => Effect.sleep(Duration.millis(ms))
6
-
7
- const until = <A>(read: () => A, pred: (a: A) => boolean, rounds = 200): Effect.Effect<A> =>
8
- 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))
13
- })
4
+ import { makeFlightGate, tick, until } from '../testkit/flight.testkit'
14
5
 
15
6
  class Boom extends Data.TaggedError('Boom')<{ readonly message: string }> {}
16
7
 
@@ -216,7 +207,10 @@ it.live('reuse: a refetch returning mostly-equal data keeps unchanged element id
216
207
  return Effect.gen(function* () {
217
208
  const source = yield* StateGroup.select(Inputs, 'page').store
218
209
  const store = yield* Rows.store
219
- const first = yield* until(() => store.get(), (v) => v._tag === 'Success')
210
+ const first = yield* until(
211
+ () => store.get(),
212
+ (v) => v._tag === 'Success',
213
+ )
220
214
  if (first._tag !== 'Success') return
221
215
 
222
216
  source.set(2)
@@ -231,80 +225,99 @@ it.live('reuse: a refetch returning mostly-equal data keeps unchanged element id
231
225
  }).pipe(Effect.provide(TestLayer))
232
226
  })
233
227
 
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
- })
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
+ )
308
321
 
309
322
  it.live('coalesce trailing: a disable while a request waits in the slot skips the run', () => {
310
323
  class Count extends State.make('count', S.Number) {}
@@ -314,12 +327,15 @@ it.live('coalesce trailing: a disable while a request waits in the slot skips th
314
327
  inputs: [StateGroup.select(Inputs, 'count')],
315
328
  output: S.Number,
316
329
  }) {}
330
+ const gate = makeFlightGate()
317
331
  const QLive = AsyncCalc.live(Q, {
318
332
  query: ({ count }) =>
319
- Effect.sync(() => {
320
- completed.push(count)
321
- return count * 2
322
- }).pipe(Effect.delay(Duration.millis(25))),
333
+ gate.through(
334
+ Effect.sync(() => {
335
+ completed.push(count)
336
+ return count * 2
337
+ }),
338
+ ),
323
339
  disabled: ({ count }) => count < 0,
324
340
  coalesce: 'trailing',
325
341
  })
@@ -328,19 +344,32 @@ it.live('coalesce trailing: a disable while a request waits in the slot skips th
328
344
  return Effect.gen(function* () {
329
345
  const source = yield* StateGroup.select(Inputs, 'count').store
330
346
  const store = yield* Q.store
331
- yield* until(() => store.get(), (v) => v._tag === 'Success')
347
+ yield* until(
348
+ () => store.get(),
349
+ (v) => v._tag === 'Success',
350
+ )
332
351
 
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
333
355
  source.set(2)
334
- yield* tick(5)
356
+ yield* gate.awaitEntry(2)
335
357
  source.set(3)
336
- yield* tick(2)
358
+ yield* tick(5)
337
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.
338
364
  yield* tick(60)
339
365
  expect(store.get()._tag).toBe('Idle')
340
366
  expect(completed).toEqual([1, 2])
341
367
 
342
368
  source.set(5)
343
- const settled = yield* until(() => store.get(), (v) => v._tag === 'Success')
369
+ const settled = yield* until(
370
+ () => store.get(),
371
+ (v) => v._tag === 'Success',
372
+ )
344
373
  expect(settled).toMatchObject({ _tag: 'Success', value: 10 })
345
374
  expect(completed).toEqual([1, 2, 5])
346
375
  }).pipe(Effect.provide(TestLayer))
@@ -384,86 +413,92 @@ it.live('invalidateOn: dispatching a listed event re-fetches with refetching: tr
384
413
  }).pipe(Effect.provide(TestLayer))
385
414
  })
386
415
 
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()
423
- expect(runs.n).toBe(2)
424
- }).pipe(Effect.provide(TestLayer))
425
- })
426
-
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')
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
+ )
455
442
 
456
- yield* Event.dispatch(Poke, {})
457
- yield* tick()
458
- expect(store.get()._tag).toBe('Idle')
459
- 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
+ )
460
483
 
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
- })
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
+ )
467
502
 
468
503
  it.live('invalidateOn: two async calcs keep independent hidden revisions', () => {
469
504
  class Count extends State.make('count', S.Number) {}
@@ -498,10 +533,7 @@ it.live('invalidateOn: two async calcs keep independent hidden revisions', () =>
498
533
  }),
499
534
  invalidateOn: [PokeB],
500
535
  }),
501
- ).pipe(
502
- Layer.provideMerge(StateGroup.live(Inputs, { count: 1 })),
503
- Layer.provideMerge(Engine),
504
- )
536
+ ).pipe(Layer.provideMerge(StateGroup.live(Inputs, { count: 1 })), Layer.provideMerge(Engine))
505
537
 
506
538
  return Effect.gen(function* () {
507
539
  yield* tick()