@playfast/reform 0.1.0 → 1.1.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 (97) hide show
  1. package/README.md +18 -1
  2. package/package.json +1 -1
  3. package/src/boundary/boundary.test.ts +1 -33
  4. package/src/boundary/boundary.ts +4 -82
  5. package/src/calc/asyncCalc.invalidate.test.ts +2 -14
  6. package/src/calc/asyncCalc.test.ts +0 -38
  7. package/src/calc/asyncCalc.ts +11 -163
  8. package/src/calc/asyncCalcDefinitions.ts +48 -0
  9. package/src/calc/asyncData.ts +0 -37
  10. package/src/calc/calc.test.ts +4 -28
  11. package/src/calc/calc.ts +16 -44
  12. package/src/calc/calcFamily.test.ts +0 -12
  13. package/src/calc/calcFamily.ts +12 -49
  14. package/src/calc/compose.test.ts +1 -12
  15. package/src/calc/compose.ts +0 -22
  16. package/src/calc/queryState.ts +0 -23
  17. package/src/channel/channel.ts +78 -173
  18. package/src/channel/procedureRegistry.ts +90 -0
  19. package/src/compose/composition.ts +7 -67
  20. package/src/compose/host.ts +0 -6
  21. package/src/compose/props.ts +3 -9
  22. package/src/compose/provide.ts +19 -33
  23. package/src/compose/slot.ts +2 -30
  24. package/src/compose/structure.test.ts +0 -6
  25. package/src/compose/structure.ts +3 -67
  26. package/src/compose/ui.test.ts +0 -4
  27. package/src/compose/ui.ts +17 -111
  28. package/src/compose/ui.typecheck.ts +0 -14
  29. package/src/definition/definition.ts +0 -32
  30. package/src/event/event.test.ts +0 -3
  31. package/src/event/event.ts +5 -14
  32. package/src/event/eventGroup.ts +0 -1
  33. package/src/feature/feature.mount.test.ts +66 -54
  34. package/src/feature/feature.test.ts +67 -46
  35. package/src/feature/feature.ts +185 -214
  36. package/src/feature/feature.typecheck.ts +29 -16
  37. package/src/feature/featureBinding.ts +48 -0
  38. package/src/index.ts +245 -202
  39. package/src/internal/capture.ts +0 -20
  40. package/src/internal/ctx.ts +2 -9
  41. package/src/internal/env.ts +9 -0
  42. package/src/internal/errors.test.ts +0 -6
  43. package/src/internal/errors.ts +14 -48
  44. package/src/internal/inspect.test.ts +0 -6
  45. package/src/internal/inspect.ts +0 -12
  46. package/src/internal/queryDriver.ts +48 -243
  47. package/src/internal/queryDriverStore.ts +153 -0
  48. package/src/internal/queryDriverTypes.ts +57 -0
  49. package/src/internal/queryEvents.ts +0 -12
  50. package/src/internal/queryStore.ts +0 -14
  51. package/src/internal/reuse.test.ts +0 -11
  52. package/src/internal/reuse.ts +0 -28
  53. package/src/internal/scheduler.ts +0 -43
  54. package/src/internal/seeds.ts +0 -14
  55. package/src/internal/sources.ts +4 -41
  56. package/src/internal/stateRegistry.ts +0 -14
  57. package/src/internal/store.test.ts +1 -3
  58. package/src/internal/store.ts +10 -37
  59. package/src/internal/track.ts +0 -18
  60. package/src/internal/variance.ts +5 -0
  61. package/src/namespace/namespace.test.ts +46 -0
  62. package/src/namespace/namespace.ts +85 -0
  63. package/src/procedure/procedure.ts +2 -24
  64. package/src/reducer/reducer.ts +3 -21
  65. package/src/remote/remoteState.test.ts +92 -75
  66. package/src/remote/remoteState.ts +99 -486
  67. package/src/remote/remoteState.typecheck.ts +0 -33
  68. package/src/remote/remoteStateDefinition.ts +135 -0
  69. package/src/remote/remoteStateLayers.ts +118 -0
  70. package/src/remote/remoteStateLiveTypes.ts +59 -0
  71. package/src/remote/remoteStateSend.ts +64 -0
  72. package/src/runtime/appRuntime.test.ts +197 -0
  73. package/src/runtime/appRuntime.ts +131 -0
  74. package/src/runtime/bus.ts +2 -15
  75. package/src/runtime/eventBudget.test.ts +155 -0
  76. package/src/runtime/eventBudget.ts +119 -0
  77. package/src/runtime/hardening.test.ts +74 -9
  78. package/src/runtime/instrumentation.test.ts +324 -0
  79. package/src/runtime/instrumentation.ts +44 -0
  80. package/src/runtime/loop.test.ts +0 -17
  81. package/src/runtime/loop.ts +41 -58
  82. package/src/runtime/queries.ts +0 -17
  83. package/src/scene/featureScene.test.ts +59 -0
  84. package/src/scene/scene.ts +51 -44
  85. package/src/scene/seedScene.test.ts +96 -116
  86. package/src/state/state.ts +12 -28
  87. package/src/state/stateFamily.test.ts +2 -11
  88. package/src/state/stateFamily.ts +20 -54
  89. package/src/state/stateGroup.ts +2 -49
  90. package/src/state/token.ts +4 -17
  91. package/src/synced/syncedStore.ts +7 -42
  92. package/src/ui/node.ts +0 -6
  93. package/src/ui/trigger.ts +0 -5
  94. package/src/wire/tree.test.ts +0 -3
  95. package/src/wire/tree.ts +0 -39
  96. package/src/wire/triggers.test.ts +0 -4
  97. package/src/wire/triggers.ts +5 -27
@@ -1,11 +1,13 @@
1
1
  import { expect, it } from '@effect/vitest'
2
2
  import { Duration, Effect, Layer, Match, Option, Schema as S } from 'effect'
3
3
  import {
4
+ Channel,
4
5
  Channels,
5
6
  Engine,
6
7
  Event,
7
8
  Procedures,
8
9
  QueryStore,
10
+ Procedure,
9
11
  Reducer,
10
12
  Reducers,
11
13
  RemoteState,
@@ -13,17 +15,9 @@ import {
13
15
  StateGroup,
14
16
  } from '../index'
15
17
 
16
- // RemoteState is server-owned state with optimistic mutations as one primitive:
17
- // visible = pending.reduce(apply, serverTruth). Dispatching an intent event is
18
- // the only write surface; a settled or failed intent leaves the queue and the
19
- // derivation converges. The headline guarantee is the GENERATION RULE: an
20
- // intent acked at query-run generation g settles only on a Success run with a
21
- // later generation — the optimistic change never flickers out mid-convergence.
22
-
23
18
  const tick = (ms = 10) => Effect.sleep(Duration.millis(ms))
24
19
 
25
- // Poll until the predicate holds (bounded) for assertions that race real
26
- // timers under parallel-suite host load, where a fixed `tick` budget flakes.
20
+ // Bounded poll fixed tick budgets flake under parallel-suite host load
27
21
  const until = <A>(read: () => A, pred: (a: A) => boolean, rounds = 200): Effect.Effect<A> =>
28
22
  Effect.suspend(() => {
29
23
  const a = read()
@@ -32,8 +26,6 @@ const until = <A>(read: () => A, pred: (a: A) => boolean, rounds = 200): Effect.
32
26
  : Effect.flatMap(tick(10), () => until(read, pred, rounds - 1))
33
27
  })
34
28
 
35
- // ── Shared fixture: a tiny mutable item server. ─────────────────────────────
36
-
37
29
  const Item = S.Struct({ id: S.String, name: S.String })
38
30
  type Item = typeof Item.Type
39
31
 
@@ -41,8 +33,7 @@ class Added extends Event.make('Added', S.Struct({ id: S.String, name: S.String
41
33
  class Renamed extends Event.make('Renamed', S.Struct({ id: S.String, name: S.String })) {}
42
34
  type Intent = Event.EventType<typeof Added> | Event.EventType<typeof Renamed>
43
35
 
44
- // The per-intent fold idempotent against truth that already carries the
45
- // intent (an add whose id landed is skipped; a rename converges naturally).
36
+ // apply must be idempotent against truth that already carries the intent
46
37
  const applyIntent = (items: ReadonlyArray<Item>, intent: Intent): ReadonlyArray<Item> =>
47
38
  Match.value(intent).pipe(
48
39
  Match.tag('Added', ({ id, name }) =>
@@ -54,8 +45,7 @@ const applyIntent = (items: ReadonlyArray<Item>, intent: Intent): ReadonlyArray<
54
45
  Match.exhaustive,
55
46
  )
56
47
 
57
- // A server double whose query SNAPSHOTS state at request arrival (like a real
58
- // DB read) and whose send applies the mutation right before acking.
48
+ // Query snapshots at request arrival; send mutates then acks
59
49
  const makeServer = (initial: ReadonlyArray<Item>) => {
60
50
  const state = { items: initial, fetches: 0 }
61
51
  return {
@@ -75,8 +65,6 @@ const makeServer = (initial: ReadonlyArray<Item>) => {
75
65
  }
76
66
  }
77
67
 
78
- // An in-memory QueryStore (the reform-side double — stores the encoded value
79
- // directly, no envelope), so RemoteState persistence is exercised with no DOM.
80
68
  const fakeQueryStore = (seed: Record<string, unknown> = {}) => {
81
69
  const map = new Map<string, unknown>(Object.entries(seed))
82
70
  const layer = Layer.succeed(QueryStore, {
@@ -88,6 +76,85 @@ const fakeQueryStore = (seed: Record<string, unknown> = {}) => {
88
76
  return { map, layer }
89
77
  }
90
78
 
79
+ it.live('settled dispatches after Success is committed to truth', () => {
80
+ class Page extends State.make('settled-success-page', S.Number) {}
81
+ class Inputs extends StateGroup.make(Page) {}
82
+ class QuerySettled extends Event.make('SettledSuccessQuerySettled', S.Struct({})) {}
83
+ class Items extends RemoteState.make('SettledSuccessItems', {
84
+ inputs: [StateGroup.select(Inputs, 'settled-success-page')],
85
+ output: S.Array(Item),
86
+ intents: [],
87
+ alwaysOn: true,
88
+ }) {}
89
+ class Lane extends Channel.make('SettledSuccessLane', { policy: { _tag: 'merge' } }) {}
90
+ class Observe extends Procedure.make('SettledSuccessObserve', {
91
+ events: [QuerySettled],
92
+ channel: Lane,
93
+ }) {}
94
+
95
+ const observed: Array<string> = []
96
+ const ItemsLive = RemoteState.live(Items, {
97
+ query: () => Effect.as(tick(5), [{ id: 'a', name: 'alpha' }]),
98
+ send: () => Effect.void,
99
+ apply: (items) => items,
100
+ settled: { event: QuerySettled, payload: () => ({}) },
101
+ })
102
+ const ObserveLive = Procedure.live(Observe, function* () {
103
+ const truth = yield* Items.truth
104
+ observed.push(truth._tag)
105
+ })
106
+ const TestLayer = Layer.merge(ObserveLive, Channel.live(Lane)).pipe(
107
+ Layer.provideMerge(ItemsLive),
108
+ Layer.provideMerge(StateGroup.live(Inputs, { 'settled-success-page': 1 })),
109
+ Layer.provideMerge(Engine),
110
+ )
111
+
112
+ return Effect.gen(function* () {
113
+ yield* until(() => observed, (values) => values.length === 1)
114
+ expect(observed).toEqual(['Success'])
115
+ }).pipe(Effect.provide(TestLayer))
116
+ })
117
+
118
+ it.live('settled dispatches after Error is committed to truth', () => {
119
+ class Page extends State.make('settled-error-page', S.Number) {}
120
+ class Inputs extends StateGroup.make(Page) {}
121
+ class QuerySettled extends Event.make('SettledErrorQuerySettled', S.Struct({})) {}
122
+ class Items extends RemoteState.make('SettledErrorItems', {
123
+ inputs: [StateGroup.select(Inputs, 'settled-error-page')],
124
+ output: S.Array(Item),
125
+ error: S.String,
126
+ intents: [],
127
+ alwaysOn: true,
128
+ }) {}
129
+ class Lane extends Channel.make('SettledErrorLane', { policy: { _tag: 'merge' } }) {}
130
+ class Observe extends Procedure.make('SettledErrorObserve', {
131
+ events: [QuerySettled],
132
+ channel: Lane,
133
+ }) {}
134
+
135
+ const observed: Array<string> = []
136
+ const ItemsLive = RemoteState.live(Items, {
137
+ query: () => Effect.zipRight(tick(5), Effect.fail('offline')),
138
+ send: () => Effect.void,
139
+ apply: (items) => items,
140
+ settled: { event: QuerySettled, payload: () => ({}) },
141
+ })
142
+ const ObserveLive = Procedure.live(Observe, function* () {
143
+ const truth = yield* Items.truth
144
+ observed.push(truth._tag)
145
+ })
146
+ const TestLayer = Layer.merge(ObserveLive, Channel.live(Lane)).pipe(
147
+ Layer.provideMerge(ItemsLive),
148
+ Layer.provideMerge(StateGroup.live(Inputs, { 'settled-error-page': 1 })),
149
+ Layer.provideMerge(Engine),
150
+ )
151
+
152
+ return Effect.gen(function* () {
153
+ yield* until(() => observed, (values) => values.length === 1)
154
+ expect(observed).toEqual(['Error'])
155
+ }).pipe(Effect.provide(TestLayer))
156
+ })
157
+
91
158
  it.live('an intent overlays instantly — no fetch fired, truth untouched', () => {
92
159
  class Page extends State.make('page', S.Number) {}
93
160
  class Inputs extends StateGroup.make(Page) {}
@@ -100,7 +167,6 @@ it.live('an intent overlays instantly — no fetch fired, truth untouched', () =
100
167
  }) {}
101
168
  const ItemsLive = RemoteState.live(Items, {
102
169
  query: () => server.fetch(5),
103
- // Slow send: the pure-optimistic window stays open for the assertions.
104
170
  send: (intent) => server.receive(intent, 80),
105
171
  apply: applyIntent,
106
172
  })
@@ -116,7 +182,6 @@ it.live('an intent overlays instantly — no fetch fired, truth untouched', () =
116
182
  yield* until(() => store.get(), (v) => v._tag === 'Success')
117
183
  expect(server.state.fetches).toBe(1)
118
184
 
119
- // Dispatch the intent: the overlay shows it before the RPC or any refetch.
120
185
  yield* Event.dispatch(Added, { id: 'b', name: 'beta' })
121
186
  const optimistic = yield* until(
122
187
  () => store.get(),
@@ -126,7 +191,6 @@ it.live('an intent overlays instantly — no fetch fired, truth untouched', () =
126
191
  expect(optimistic.value[1]).toEqual({ id: 'b', name: 'beta' })
127
192
  }
128
193
  expect(queue.get()).toMatchObject([{ status: 'sending' }])
129
- // Nothing remote happened yet: one fetch ever, truth still one row.
130
194
  expect(server.state.fetches).toBe(1)
131
195
  expect(truth.get()).toMatchObject({ _tag: 'Success' })
132
196
  const t = truth.get()
@@ -159,17 +223,14 @@ it.live('convergence: the send acks, the refetch lands, the queue drains — and
159
223
  const queue = yield* Items.pending.store
160
224
  yield* until(() => store.get(), (v) => v._tag === 'Success')
161
225
 
162
- // FLICKER GUARD: log every Success frame from dispatch to convergence; the
163
- // row must be present in all of them (the old settle-on-ack idiom fails
164
- // this — the row vanished between the RPC response and the refetch landing).
226
+ // Flicker guard: once optimistic row appears, no later Success frame may lack it
165
227
  const frames: Array<number> = []
166
228
  const off = store.subscribe(() => {
167
229
  const v = store.get()
168
230
  if (v._tag === 'Success') frames.push(v.value.length)
169
231
  })
170
232
  yield* Event.dispatch(Added, { id: 'b', name: 'beta' })
171
- // Dispatch routes through the bus: wait for the entry to LAND before
172
- // waiting for it to leave (an empty read here would be vacuous).
233
+ // Wait for queue entry to land before waiting for settle (empty would be vacuous)
173
234
  yield* until(() => queue.get(), (q) => q.length === 1)
174
235
  yield* until(() => queue.get(), (q) => q.length === 0)
175
236
  off()
@@ -202,14 +263,11 @@ it.live('THE GENERATION RULE: a refetch that started before the ack can never se
202
263
  alwaysOn: true,
203
264
  }) {}
204
265
  const ItemsLive = RemoteState.live(Items, {
205
- // Slow query: its snapshot is taken at request arrival, so a run started
206
- // before the send acks carries truth WITHOUT the mutation.
207
266
  query: () => server.fetch(60),
208
267
  send: (intent) => server.receive(intent, 15),
209
268
  apply: applyIntent,
210
269
  invalidateOn: [Poke],
211
- // Trailing: the pre-ack run completes and LANDS (switch would cancel it),
212
- // which is exactly the stale-Success the rule must refuse to settle on.
270
+ // Trailing lets pre-ack run land stale Success must not settle
213
271
  coalesce: 'trailing',
214
272
  })
215
273
  const TestLayer = ItemsLive.pipe(
@@ -229,15 +287,11 @@ it.live('THE GENERATION RULE: a refetch that started before the ack can never se
229
287
  if (v._tag === 'Success') frames.push(v.value.length)
230
288
  })
231
289
 
232
- // A refetch starts (generation 2, snapshot WITHOUT the row)…
233
290
  yield* Event.dispatch(Poke, {})
234
291
  yield* tick(5)
235
- // …and the intent is dispatched + acked while it is still in flight. The
236
- // ack registers a waiter for generation 3 and queues the trailing refetch.
237
292
  yield* Event.dispatch(Added, { id: 'b', name: 'beta' })
238
293
 
239
- // The stale run (gen 2) lands its Success. The rule: it settles NOBODY —
240
- // the entry stays pending and the overlay keeps the row over stale truth.
294
+ // Gen-2 Success settles nobody (generation rule)
241
295
  yield* until(() => server.state.fetches, (n) => n === 2)
242
296
  yield* tick(25)
243
297
  expect(queue.get()).toHaveLength(1)
@@ -246,13 +300,9 @@ it.live('THE GENERATION RULE: a refetch that started before the ack can never se
246
300
  expect(midflight).toMatchObject({ _tag: 'Success' })
247
301
  if (midflight._tag === 'Success') expect(midflight.value).toHaveLength(2)
248
302
 
249
- // The post-ack run (gen 3) lands with the mutation: NOW the entry settles.
250
303
  yield* until(() => queue.get(), (q) => q.length === 0)
251
304
  off()
252
305
  expect(server.state.fetches).toBe(3)
253
- // The frames before the intent legitimately show one row (the Poke refetch
254
- // flips `refetching` and notifies). The flicker property is: once the
255
- // optimistic row APPEARS, no later frame may lack it.
256
306
  const firstOptimistic = frames.indexOf(2)
257
307
  expect(firstOptimistic).toBeGreaterThanOrEqual(0)
258
308
  expect(frames.slice(firstOptimistic).every((n) => n === 2)).toBe(true)
@@ -282,7 +332,6 @@ it.live('a failed send settles the intent, reverts the overlay, and Failed reach
282
332
  send: () => Effect.fail('rejected' as const).pipe(Effect.delay(Duration.millis(15))),
283
333
  apply: applyIntent,
284
334
  })
285
- // The toast/retry seam: an ordinary reducer on the public Failed event.
286
335
  const LogReducer = Reducer.make('failLog', { states: [FailLog], events: [Items.Failed] })
287
336
  const TestLayer = Layer.mergeAll(
288
337
  ItemsLive,
@@ -303,8 +352,6 @@ it.live('a failed send settles the intent, reverts the overlay, and Failed reach
303
352
  yield* until(() => store.get(), (v) => v._tag === 'Success' && v.value.length === 2)
304
353
  expect(queue.get()).toHaveLength(1)
305
354
 
306
- // The send fails: entry leaves the queue, the view reverts by construction,
307
- // and the Failed payload carried the intent + the error.
308
355
  yield* until(() => queue.get(), (q) => q.length === 0)
309
356
  const reverted = yield* until(
310
357
  () => store.get(),
@@ -315,7 +362,6 @@ it.live('a failed send settles the intent, reverts the overlay, and Failed reach
315
362
  }
316
363
  yield* until(() => log.get(), (l) => l.length === 1)
317
364
  expect(log.get()).toEqual(['Added|rejected'])
318
- // No invalidation on failure: the one boot fetch is still the only one.
319
365
  expect(server.state.fetches).toBe(1)
320
366
  }).pipe(Effect.provide(TestLayer))
321
367
  })
@@ -345,8 +391,7 @@ it.live('two intents apply in dispatch order and settle together on the post-ack
345
391
  const queue = yield* Items.pending.store
346
392
  yield* until(() => store.get(), (v) => v._tag === 'Success')
347
393
 
348
- // Order matters: the rename targets the row the add creates. Reversed
349
- // application would rename nothing and show 'beta'.
394
+ // Apply order = dispatch order
350
395
  yield* Event.dispatch(Added, { id: 'b', name: 'beta' })
351
396
  yield* Event.dispatch(Renamed, { id: 'b', name: 'BETA' })
352
397
  yield* until(() => queue.get(), (q) => q.length === 2)
@@ -382,8 +427,6 @@ it.live('one intent failing leaves the other pending and converging', () => {
382
427
  }) {}
383
428
  const ItemsLive = RemoteState.live(Items, {
384
429
  query: () => server.fetch(10),
385
- // The rename is rejected; the add is much slower and succeeds — so the
386
- // partial state (one failed, one in flight) is observable.
387
430
  send: (intent) =>
388
431
  intent._tag === 'Renamed'
389
432
  ? Effect.fail('rejected' as const).pipe(Effect.delay(Duration.millis(50)))
@@ -402,14 +445,12 @@ it.live('one intent failing leaves the other pending and converging', () => {
402
445
 
403
446
  yield* Event.dispatch(Added, { id: 'b', name: 'beta' })
404
447
  yield* Event.dispatch(Renamed, { id: 'a', name: 'ALPHA' })
405
- // Both folded: the overlay shows the rename optimistically.
406
448
  yield* until(
407
449
  () => store.get(),
408
450
  (v) => v._tag === 'Success' && v.value[0]?.name === 'ALPHA',
409
451
  )
410
452
  expect(queue.get()).toHaveLength(2)
411
453
 
412
- // The rename fails and leaves; the add stays optimistic and intact.
413
454
  yield* until(
414
455
  () => store.get(),
415
456
  (v) => v._tag === 'Success' && v.value[0]?.name === 'alpha',
@@ -424,7 +465,6 @@ it.live('one intent failing leaves the other pending and converging', () => {
424
465
  ])
425
466
  }
426
467
 
427
- // The add converges independently.
428
468
  yield* until(() => queue.get(), (q) => q.length === 0)
429
469
  const settled = yield* until(
430
470
  () => store.get(),
@@ -450,7 +490,6 @@ it.live("pending exposes the lifecycle: 'sending' -> 'confirmed' -> gone", () =>
450
490
  alwaysOn: true,
451
491
  }) {}
452
492
  const ItemsLive = RemoteState.live(Items, {
453
- // A slow refetch keeps the 'confirmed' window wide enough to observe.
454
493
  query: () => server.fetch(40),
455
494
  send: (intent) => server.receive(intent, 40),
456
495
  apply: applyIntent,
@@ -507,8 +546,7 @@ it.live('gated: intents queue and send while Idle; re-enabling converges and set
507
546
  yield* tick()
508
547
  expect(store.get()._tag).toBe('Idle')
509
548
 
510
- // The intent still sends while the query is gated off: the server takes
511
- // the mutation, but the view stays Idle and the entry waits for a run.
549
+ // Intents still send while gated Idle; settle waits for re-enable
512
550
  yield* Event.dispatch(Added, { id: 'b', name: 'beta' })
513
551
  yield* until(() => server.state.items, (items) => items.length === 2)
514
552
  yield* tick(20)
@@ -516,7 +554,6 @@ it.live('gated: intents queue and send while Idle; re-enabling converges and set
516
554
  expect(queue.get()).toHaveLength(1)
517
555
  expect(server.state.fetches).toBe(0)
518
556
 
519
- // Re-enable: the run lands with the mutation and settles the waiter.
520
557
  source.set(1)
521
558
  yield* until(() => queue.get(), (q) => q.length === 0)
522
559
  const settled = yield* until(
@@ -546,7 +583,6 @@ it.live('reuse: untouched element identities survive the whole optimistic round-
546
583
  alwaysOn: true,
547
584
  }) {}
548
585
  const ItemsLive = RemoteState.live(Items, {
549
- // Fresh objects every fetch — only `reuse` can preserve identities.
550
586
  query: () => Effect.map(server.fetch(10), (items) => items.map((item) => ({ ...item }))),
551
587
  send: (intent) => server.receive(intent, 10),
552
588
  apply: applyIntent,
@@ -572,8 +608,6 @@ it.live('reuse: untouched element identities survive the whole optimistic round-
572
608
  (v) => v._tag === 'Success' && v.refetching === false,
573
609
  )
574
610
  if (settled._tag !== 'Success') return
575
- // Row a was never touched: same identity through the overlay recompute AND
576
- // the convergence refetch. Row b moved: fresh.
577
611
  expect(settled.value[0]).toBe(untouched)
578
612
  expect(settled.value[1]).not.toBe(first.value[1])
579
613
  expect(settled.value[1]).toEqual({ id: 'b', name: 'BETA' })
@@ -609,11 +643,9 @@ it.live('invalidateOn + coalesce trailing compose with the mutation lifecycle',
609
643
  yield* until(() => store.get(), (v) => v._tag === 'Success')
610
644
  expect(server.state.fetches).toBe(1)
611
645
 
612
- // A realtime push refetches like any invalidateOn event.
613
646
  yield* Event.dispatch(BoardChanged, {})
614
647
  yield* until(() => server.state.fetches, (n) => n === 2)
615
648
 
616
- // The mutation lifecycle still converges under trailing conciliation.
617
649
  yield* Event.dispatch(Added, { id: 'b', name: 'beta' })
618
650
  yield* until(() => queue.get(), (q) => q.length === 1)
619
651
  yield* until(() => queue.get(), (q) => q.length === 0)
@@ -657,9 +689,7 @@ it.live('a query Error passes through by reference with intents still pending',
657
689
  const queue = yield* Items.pending.store
658
690
  yield* until(() => store.get(), (v) => v._tag === 'Error')
659
691
 
660
- // The send succeeds and invalidates, but every run lands Error: an Error
661
- // run settles NOBODY, so the entry stays pending (confirmed) — and the
662
- // overlay passes the Error arm through by reference, never applying.
692
+ // Error runs settle nobody; overlay passes Error through without apply
663
693
  yield* Event.dispatch(Added, { id: 'b', name: 'beta' })
664
694
  yield* until(() => queue.get(), (q) => q.length === 1 && q[0]?.status === 'confirmed')
665
695
  yield* until(() => store.get(), (v) => v._tag === 'Error' && v.refetching === false)
@@ -700,7 +730,6 @@ it.live('teardown: closing the scope unregisters the reducers, the procedure, an
700
730
  }),
701
731
  )
702
732
 
703
- // Scope closed: every registration is gone (a lazy feature unmounting).
704
733
  expect(reducers.entries.some((entry) => entry.name === 'Items/pending')).toBe(false)
705
734
  expect(reducers.entries.some((entry) => entry.name === 'Items/revision')).toBe(false)
706
735
  expect(procedures.entries.some((entry) => entry.name === 'Items/send')).toBe(false)
@@ -708,7 +737,6 @@ it.live('teardown: closing the scope unregisters the reducers, the procedure, an
708
737
  }).pipe(Effect.provide(Engine))
709
738
  })
710
739
 
711
- // ── persist (00b/00c): the converged truth round-trips a QueryStore. ─────────
712
740
 
713
741
  it.live('persist: hydrates the seeded truth (stale) then writes the fresh truth through', () => {
714
742
  class Page extends State.make('page', S.Number) {}
@@ -735,12 +763,10 @@ it.live('persist: hydrates the seeded truth (stale) then writes the fresh truth
735
763
 
736
764
  return Effect.gen(function* () {
737
765
  const view = yield* Items.store
738
- // The persisted truth shows instantly, marked refetching (stale-while-revalidate).
739
766
  const hydrated = view.get()
740
767
  expect(hydrated).toMatchObject({ _tag: 'Success', refetching: true })
741
768
  if (hydrated._tag === 'Success') expect(hydrated.value).toEqual([{ id: 'seed', name: 'seeded' }])
742
769
 
743
- // The fresh fetch lands and the converged truth is written through under the name key.
744
770
  const settled = yield* until(
745
771
  () => view.get(),
746
772
  (v) => v._tag === 'Success' && v.refetching === false,
@@ -767,7 +793,6 @@ it.live('persist: the optimistic overlay is NEVER written — only the converged
767
793
  }) {}
768
794
  const ItemsLive = RemoteState.live(Items, {
769
795
  query: () => server.fetch(10),
770
- // Slow send so the pure-optimistic window stays open for the store assertion.
771
796
  send: (intent) => server.receive(intent, 60),
772
797
  apply: applyIntent,
773
798
  persist: true,
@@ -782,17 +807,14 @@ it.live('persist: the optimistic overlay is NEVER written — only the converged
782
807
  const view = yield* Items.store
783
808
  const queue = yield* Items.pending.store
784
809
  yield* until(() => view.get(), (v) => v._tag === 'Success')
785
- // First truth is persisted.
786
810
  yield* until(() => store.map.get('Items'), (v) => Array.isArray(v) && v.length === 1)
787
811
 
788
- // Dispatch the optimistic intent: the overlay shows two rows...
789
812
  yield* Event.dispatch(Added, { id: 'b', name: 'beta' })
790
813
  yield* until(() => view.get(), (v) => v._tag === 'Success' && v.value.length === 2)
791
814
  yield* until(() => queue.get(), (q) => q.length === 1)
792
- // ...but the store still holds the PRE-mutation truth (no overlay write).
815
+ // Persist never captures overlay only settled server truth
793
816
  expect(store.map.get('Items')).toEqual([{ id: 'a', name: 'alpha' }])
794
817
 
795
- // Once the send acks and the refetch lands, the converged truth is written.
796
818
  yield* until(() => queue.get(), (q) => q.length === 0)
797
819
  yield* until(
798
820
  () => store.map.get('Items'),
@@ -837,7 +859,6 @@ it.live('persist: `true` keys by name; `{ key }` overrides — two definitions k
837
859
  yield* until(() => store.map.get('custom-B'), (v) => Array.isArray(v))
838
860
  expect(store.map.get('ItemsA')).toEqual([{ id: 'a', name: 'alpha' }])
839
861
  expect(store.map.get('custom-B')).toEqual([{ id: 'b', name: 'beta' }])
840
- // No collision: neither wrote the other's key, and the name key for B is empty.
841
862
  expect(store.map.has('ItemsB')).toBe(false)
842
863
  }).pipe(Effect.provide(TestLayer))
843
864
  })
@@ -847,8 +868,6 @@ it.live('persist (00c): a function key keys a family per-input — two input key
847
868
  class Inputs extends StateGroup.make(Page) {}
848
869
  const inputs = StateGroup.live(Inputs, { page: 1 })
849
870
  const store = fakeQueryStore()
850
- // The server returns a distinct row per requested page, so a collision (one
851
- // slot for both pages) would overwrite — the assertion catches it.
852
871
  const fetchPage = (page: number) =>
853
872
  Effect.as(tick(10), [{ id: `p${page}`, name: `page-${page}` }])
854
873
  class Fam extends RemoteState.make('Fam', {
@@ -871,12 +890,10 @@ it.live('persist (00c): a function key keys a family per-input — two input key
871
890
  yield* until(() => store.map.get('fam:1'), (v) => Array.isArray(v))
872
891
  expect(store.map.get('fam:1')).toEqual([{ id: 'p1', name: 'page-1' }])
873
892
 
874
- // Move the input to page 2: a new fetch writes to a DISTINCT slot.
875
893
  page.set(2)
876
894
  yield* until(() => view.get(), (v) => v._tag === 'Success' && v.value[0]?.id === 'p2')
877
895
  yield* until(() => store.map.get('fam:2'), (v) => Array.isArray(v))
878
896
  expect(store.map.get('fam:2')).toEqual([{ id: 'p2', name: 'page-2' }])
879
- // Page 1's slot is intact — no collision.
880
897
  expect(store.map.get('fam:1')).toEqual([{ id: 'p1', name: 'page-1' }])
881
898
  }).pipe(Effect.provide(TestLayer))
882
899
  })