@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,29 +1,22 @@
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
- Channels,
4
+ Channel,
5
5
  Engine,
6
6
  Event,
7
- Procedures,
8
- QueryStore,
7
+ Procedure,
9
8
  Reducer,
10
- Reducers,
11
9
  RemoteState,
12
10
  State,
13
11
  StateGroup,
14
12
  } from '../index'
15
-
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.
13
+ import { Channels, Procedures } from '../channel/channel'
14
+ import { QueryStore } from '../internal/queryStore'
15
+ import { Reducers } from '../runtime/loop'
22
16
 
23
17
  const tick = (ms = 10) => Effect.sleep(Duration.millis(ms))
24
18
 
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.
19
+ // Bounded poll fixed tick budgets flake under parallel-suite host load
27
20
  const until = <A>(read: () => A, pred: (a: A) => boolean, rounds = 200): Effect.Effect<A> =>
28
21
  Effect.suspend(() => {
29
22
  const a = read()
@@ -32,8 +25,6 @@ const until = <A>(read: () => A, pred: (a: A) => boolean, rounds = 200): Effect.
32
25
  : Effect.flatMap(tick(10), () => until(read, pred, rounds - 1))
33
26
  })
34
27
 
35
- // ── Shared fixture: a tiny mutable item server. ─────────────────────────────
36
-
37
28
  const Item = S.Struct({ id: S.String, name: S.String })
38
29
  type Item = typeof Item.Type
39
30
 
@@ -41,8 +32,7 @@ class Added extends Event.make('Added', S.Struct({ id: S.String, name: S.String
41
32
  class Renamed extends Event.make('Renamed', S.Struct({ id: S.String, name: S.String })) {}
42
33
  type Intent = Event.EventType<typeof Added> | Event.EventType<typeof Renamed>
43
34
 
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).
35
+ // apply must be idempotent against truth that already carries the intent
46
36
  const applyIntent = (items: ReadonlyArray<Item>, intent: Intent): ReadonlyArray<Item> =>
47
37
  Match.value(intent).pipe(
48
38
  Match.tag('Added', ({ id, name }) =>
@@ -54,8 +44,7 @@ const applyIntent = (items: ReadonlyArray<Item>, intent: Intent): ReadonlyArray<
54
44
  Match.exhaustive,
55
45
  )
56
46
 
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.
47
+ // Query snapshots at request arrival; send mutates then acks
59
48
  const makeServer = (initial: ReadonlyArray<Item>) => {
60
49
  const state = { items: initial, fetches: 0 }
61
50
  return {
@@ -75,8 +64,6 @@ const makeServer = (initial: ReadonlyArray<Item>) => {
75
64
  }
76
65
  }
77
66
 
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
67
  const fakeQueryStore = (seed: Record<string, unknown> = {}) => {
81
68
  const map = new Map<string, unknown>(Object.entries(seed))
82
69
  const layer = Layer.succeed(QueryStore, {
@@ -88,112 +75,94 @@ const fakeQueryStore = (seed: Record<string, unknown> = {}) => {
88
75
  return { map, layer }
89
76
  }
90
77
 
91
- it.live('an intent overlays instantly no fetch fired, truth untouched', () => {
92
- class Page extends State.make('page', S.Number) {}
78
+ it.live('settled dispatches after Success is committed to truth', () => {
79
+ class Page extends State.make('settled-success-page', S.Number) {}
93
80
  class Inputs extends StateGroup.make(Page) {}
94
- const server = makeServer([{ id: 'a', name: 'alpha' }])
95
- class Items extends RemoteState.make('Items', {
96
- inputs: [StateGroup.select(Inputs, 'page')],
81
+ class QuerySettled extends Event.make('SettledSuccessQuerySettled', S.Struct({})) {}
82
+ class Items extends RemoteState.make('SettledSuccessItems', {
83
+ inputs: [StateGroup.select(Inputs, 'settled-success-page')],
97
84
  output: S.Array(Item),
98
- intents: [Added, Renamed],
85
+ intents: [],
99
86
  alwaysOn: true,
100
87
  }) {}
88
+ class Lane extends Channel.make('SettledSuccessLane', { policy: { _tag: 'merge' } }) {}
89
+ class Observe extends Procedure.make('SettledSuccessObserve', {
90
+ events: [QuerySettled],
91
+ channel: Lane,
92
+ }) {}
93
+
94
+ const observed: Array<string> = []
101
95
  const ItemsLive = RemoteState.live(Items, {
102
- query: () => server.fetch(5),
103
- // Slow send: the pure-optimistic window stays open for the assertions.
104
- send: (intent) => server.receive(intent, 80),
105
- apply: applyIntent,
96
+ query: () => Effect.as(tick(5), [{ id: 'a', name: 'alpha' }]),
97
+ send: () => Effect.void,
98
+ apply: (items) => items,
99
+ settled: { event: QuerySettled, payload: () => ({}) },
106
100
  })
107
- const TestLayer = ItemsLive.pipe(
108
- Layer.provideMerge(StateGroup.live(Inputs, { page: 1 })),
101
+ const ObserveLive = Procedure.live(Observe, function* () {
102
+ const truth = yield* Items.truth
103
+ observed.push(truth._tag)
104
+ })
105
+ const TestLayer = Layer.merge(ObserveLive, Channel.live(Lane)).pipe(
106
+ Layer.provideMerge(ItemsLive),
107
+ Layer.provideMerge(StateGroup.live(Inputs, { 'settled-success-page': 1 })),
109
108
  Layer.provideMerge(Engine),
110
109
  )
111
110
 
112
111
  return Effect.gen(function* () {
113
- const store = yield* Items.store
114
- const truth = yield* Items.truth.store
115
- const queue = yield* Items.pending.store
116
- yield* until(() => store.get(), (v) => v._tag === 'Success')
117
- expect(server.state.fetches).toBe(1)
118
-
119
- // Dispatch the intent: the overlay shows it before the RPC or any refetch.
120
- yield* Event.dispatch(Added, { id: 'b', name: 'beta' })
121
- const optimistic = yield* until(
122
- () => store.get(),
123
- (v) => v._tag === 'Success' && v.value.length === 2,
112
+ yield* until(
113
+ () => observed,
114
+ (values) => values.length === 1,
124
115
  )
125
- if (optimistic._tag === 'Success') {
126
- expect(optimistic.value[1]).toEqual({ id: 'b', name: 'beta' })
127
- }
128
- expect(queue.get()).toMatchObject([{ status: 'sending' }])
129
- // Nothing remote happened yet: one fetch ever, truth still one row.
130
- expect(server.state.fetches).toBe(1)
131
- expect(truth.get()).toMatchObject({ _tag: 'Success' })
132
- const t = truth.get()
133
- if (t._tag === 'Success') expect(t.value).toHaveLength(1)
116
+ expect(observed).toEqual(['Success'])
134
117
  }).pipe(Effect.provide(TestLayer))
135
118
  })
136
119
 
137
- it.live('convergence: the send acks, the refetch lands, the queue drains — and no frame ever lacked the row', () => {
138
- class Page extends State.make('page', S.Number) {}
120
+ it.live('settled dispatches after Error is committed to truth', () => {
121
+ class Page extends State.make('settled-error-page', S.Number) {}
139
122
  class Inputs extends StateGroup.make(Page) {}
140
- const server = makeServer([{ id: 'a', name: 'alpha' }])
141
- class Items extends RemoteState.make('Items', {
142
- inputs: [StateGroup.select(Inputs, 'page')],
123
+ class QuerySettled extends Event.make('SettledErrorQuerySettled', S.Struct({})) {}
124
+ class Items extends RemoteState.make('SettledErrorItems', {
125
+ inputs: [StateGroup.select(Inputs, 'settled-error-page')],
143
126
  output: S.Array(Item),
144
- intents: [Added, Renamed],
127
+ error: S.String,
128
+ intents: [],
145
129
  alwaysOn: true,
146
130
  }) {}
131
+ class Lane extends Channel.make('SettledErrorLane', { policy: { _tag: 'merge' } }) {}
132
+ class Observe extends Procedure.make('SettledErrorObserve', {
133
+ events: [QuerySettled],
134
+ channel: Lane,
135
+ }) {}
136
+
137
+ const observed: Array<string> = []
147
138
  const ItemsLive = RemoteState.live(Items, {
148
- query: () => server.fetch(15),
149
- send: (intent) => server.receive(intent, 15),
150
- apply: applyIntent,
139
+ query: () => Effect.zipRight(tick(5), Effect.fail('offline')),
140
+ send: () => Effect.void,
141
+ apply: (items) => items,
142
+ settled: { event: QuerySettled, payload: () => ({}) },
151
143
  })
152
- const TestLayer = ItemsLive.pipe(
153
- Layer.provideMerge(StateGroup.live(Inputs, { page: 1 })),
144
+ const ObserveLive = Procedure.live(Observe, function* () {
145
+ const truth = yield* Items.truth
146
+ observed.push(truth._tag)
147
+ })
148
+ const TestLayer = Layer.merge(ObserveLive, Channel.live(Lane)).pipe(
149
+ Layer.provideMerge(ItemsLive),
150
+ Layer.provideMerge(StateGroup.live(Inputs, { 'settled-error-page': 1 })),
154
151
  Layer.provideMerge(Engine),
155
152
  )
156
153
 
157
154
  return Effect.gen(function* () {
158
- const store = yield* Items.store
159
- const queue = yield* Items.pending.store
160
- yield* until(() => store.get(), (v) => v._tag === 'Success')
161
-
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).
165
- const frames: Array<number> = []
166
- const off = store.subscribe(() => {
167
- const v = store.get()
168
- if (v._tag === 'Success') frames.push(v.value.length)
169
- })
170
- 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).
173
- yield* until(() => queue.get(), (q) => q.length === 1)
174
- yield* until(() => queue.get(), (q) => q.length === 0)
175
- off()
176
- expect(frames.length).toBeGreaterThan(0)
177
- expect(frames.every((n) => n === 2)).toBe(true)
178
-
179
- const settled = yield* until(
180
- () => store.get(),
181
- (v) => v._tag === 'Success' && v.refetching === false,
155
+ yield* until(
156
+ () => observed,
157
+ (values) => values.length === 1,
182
158
  )
183
- if (settled._tag === 'Success') {
184
- expect(settled.value).toEqual([
185
- { id: 'a', name: 'alpha' },
186
- { id: 'b', name: 'beta' },
187
- ])
188
- }
189
- expect(server.state.fetches).toBe(2)
159
+ expect(observed).toEqual(['Error'])
190
160
  }).pipe(Effect.provide(TestLayer))
191
161
  })
192
162
 
193
- it.live('THE GENERATION RULE: a refetch that started before the ack can never settle the intent', () => {
163
+ it.live('an intent overlays instantly no fetch fired, truth untouched', () => {
194
164
  class Page extends State.make('page', S.Number) {}
195
165
  class Inputs extends StateGroup.make(Page) {}
196
- class Poke extends Event.make('Poke', S.Struct({})) {}
197
166
  const server = makeServer([{ id: 'a', name: 'alpha' }])
198
167
  class Items extends RemoteState.make('Items', {
199
168
  inputs: [StateGroup.select(Inputs, 'page')],
@@ -202,15 +171,9 @@ it.live('THE GENERATION RULE: a refetch that started before the ack can never se
202
171
  alwaysOn: true,
203
172
  }) {}
204
173
  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
- query: () => server.fetch(60),
208
- send: (intent) => server.receive(intent, 15),
174
+ query: () => server.fetch(5),
175
+ send: (intent) => server.receive(intent, 80),
209
176
  apply: applyIntent,
210
- 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.
213
- coalesce: 'trailing',
214
177
  })
215
178
  const TestLayer = ItemsLive.pipe(
216
179
  Layer.provideMerge(StateGroup.live(Inputs, { page: 1 })),
@@ -219,106 +182,239 @@ it.live('THE GENERATION RULE: a refetch that started before the ack can never se
219
182
 
220
183
  return Effect.gen(function* () {
221
184
  const store = yield* Items.store
185
+ const truth = yield* Items.truth.store
222
186
  const queue = yield* Items.pending.store
223
- yield* until(() => store.get(), (v) => v._tag === 'Success')
187
+ yield* until(
188
+ () => store.get(),
189
+ (v) => v._tag === 'Success',
190
+ )
224
191
  expect(server.state.fetches).toBe(1)
225
192
 
226
- const frames: Array<number> = []
227
- const off = store.subscribe(() => {
228
- const v = store.get()
229
- if (v._tag === 'Success') frames.push(v.value.length)
230
- })
231
-
232
- // A refetch starts (generation 2, snapshot WITHOUT the row)…
233
- yield* Event.dispatch(Poke, {})
234
- 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
193
  yield* Event.dispatch(Added, { id: 'b', name: 'beta' })
238
-
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.
241
- yield* until(() => server.state.fetches, (n) => n === 2)
242
- yield* tick(25)
243
- expect(queue.get()).toHaveLength(1)
244
- expect(queue.get()[0]).toMatchObject({ status: 'confirmed' })
245
- const midflight = store.get()
246
- expect(midflight).toMatchObject({ _tag: 'Success' })
247
- if (midflight._tag === 'Success') expect(midflight.value).toHaveLength(2)
248
-
249
- // The post-ack run (gen 3) lands with the mutation: NOW the entry settles.
250
- yield* until(() => queue.get(), (q) => q.length === 0)
251
- off()
252
- 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
- const firstOptimistic = frames.indexOf(2)
257
- expect(firstOptimistic).toBeGreaterThanOrEqual(0)
258
- expect(frames.slice(firstOptimistic).every((n) => n === 2)).toBe(true)
259
- const settled = store.get()
260
- if (settled._tag === 'Success') {
261
- expect(settled.value).toEqual([
262
- { id: 'a', name: 'alpha' },
263
- { id: 'b', name: 'beta' },
264
- ])
194
+ const optimistic = yield* until(
195
+ () => store.get(),
196
+ (v) => v._tag === 'Success' && v.value.length === 2,
197
+ )
198
+ if (optimistic._tag === 'Success') {
199
+ expect(optimistic.value[1]).toEqual({ id: 'b', name: 'beta' })
265
200
  }
201
+ expect(queue.get()).toMatchObject([{ status: 'sending' }])
202
+ expect(server.state.fetches).toBe(1)
203
+ expect(truth.get()).toMatchObject({ _tag: 'Success' })
204
+ const t = truth.get()
205
+ if (t._tag === 'Success') expect(t.value).toHaveLength(1)
266
206
  }).pipe(Effect.provide(TestLayer))
267
207
  })
268
208
 
269
- it.live('a failed send settles the intent, reverts the overlay, and Failed reaches a user reducer', () => {
270
- class Page extends State.make('page', S.Number) {}
271
- class Inputs extends StateGroup.make(Page) {}
272
- class FailLog extends State.make('failLog', S.Array(S.String)) {}
273
- const server = makeServer([{ id: 'a', name: 'alpha' }])
274
- class Items extends RemoteState.make('Items', {
275
- inputs: [StateGroup.select(Inputs, 'page')],
276
- output: S.Array(Item),
277
- intents: [Added, Renamed],
278
- alwaysOn: true,
279
- }) {}
280
- const ItemsLive = RemoteState.live(Items, {
281
- query: () => server.fetch(5),
282
- send: () => Effect.fail('rejected' as const).pipe(Effect.delay(Duration.millis(15))),
283
- apply: applyIntent,
284
- })
285
- // The toast/retry seam: an ordinary reducer on the public Failed event.
286
- const LogReducer = Reducer.make('failLog', { states: [FailLog], events: [Items.Failed] })
287
- const TestLayer = Layer.mergeAll(
288
- ItemsLive,
289
- Reducer.live(LogReducer, (log, event) => [...log, `${event.intent._tag}|${String(event.error)}`]),
290
- ).pipe(
291
- Layer.provideMerge(State.live(FailLog, [])),
292
- Layer.provideMerge(StateGroup.live(Inputs, { page: 1 })),
293
- Layer.provideMerge(Engine),
294
- )
295
-
296
- return Effect.gen(function* () {
297
- const store = yield* Items.store
298
- const queue = yield* Items.pending.store
299
- const log = yield* FailLog.store
300
- yield* until(() => store.get(), (v) => v._tag === 'Success')
209
+ it.live(
210
+ 'convergence: the send acks, the refetch lands, the queue drains — and no frame ever lacked the row',
211
+ () => {
212
+ class Page extends State.make('page', S.Number) {}
213
+ class Inputs extends StateGroup.make(Page) {}
214
+ const server = makeServer([{ id: 'a', name: 'alpha' }])
215
+ class Items extends RemoteState.make('Items', {
216
+ inputs: [StateGroup.select(Inputs, 'page')],
217
+ output: S.Array(Item),
218
+ intents: [Added, Renamed],
219
+ alwaysOn: true,
220
+ }) {}
221
+ const ItemsLive = RemoteState.live(Items, {
222
+ query: () => server.fetch(15),
223
+ send: (intent) => server.receive(intent, 15),
224
+ apply: applyIntent,
225
+ })
226
+ const TestLayer = ItemsLive.pipe(
227
+ Layer.provideMerge(StateGroup.live(Inputs, { page: 1 })),
228
+ Layer.provideMerge(Engine),
229
+ )
301
230
 
302
- yield* Event.dispatch(Added, { id: 'b', name: 'beta' })
303
- yield* until(() => store.get(), (v) => v._tag === 'Success' && v.value.length === 2)
304
- expect(queue.get()).toHaveLength(1)
231
+ return Effect.gen(function* () {
232
+ const store = yield* Items.store
233
+ const queue = yield* Items.pending.store
234
+ yield* until(
235
+ () => store.get(),
236
+ (v) => v._tag === 'Success',
237
+ )
238
+
239
+ // Flicker guard: once optimistic row appears, no later Success frame may lack it
240
+ const frames: Array<number> = []
241
+ const off = store.subscribe(() => {
242
+ const v = store.get()
243
+ if (v._tag === 'Success') frames.push(v.value.length)
244
+ })
245
+ yield* Event.dispatch(Added, { id: 'b', name: 'beta' })
246
+ // Wait for queue entry to land before waiting for settle (empty would be vacuous)
247
+ yield* until(
248
+ () => queue.get(),
249
+ (q) => q.length === 1,
250
+ )
251
+ yield* until(
252
+ () => queue.get(),
253
+ (q) => q.length === 0,
254
+ )
255
+ off()
256
+ expect(frames.length).toBeGreaterThan(0)
257
+ expect(frames.every((n) => n === 2)).toBe(true)
258
+
259
+ const settled = yield* until(
260
+ () => store.get(),
261
+ (v) => v._tag === 'Success' && v.refetching === false,
262
+ )
263
+ if (settled._tag === 'Success') {
264
+ expect(settled.value).toEqual([
265
+ { id: 'a', name: 'alpha' },
266
+ { id: 'b', name: 'beta' },
267
+ ])
268
+ }
269
+ expect(server.state.fetches).toBe(2)
270
+ }).pipe(Effect.provide(TestLayer))
271
+ },
272
+ )
273
+
274
+ it.live(
275
+ 'THE GENERATION RULE: a refetch that started before the ack can never settle the intent',
276
+ () => {
277
+ class Page extends State.make('page', S.Number) {}
278
+ class Inputs extends StateGroup.make(Page) {}
279
+ class Poke extends Event.make('Poke', S.Struct({})) {}
280
+ const server = makeServer([{ id: 'a', name: 'alpha' }])
281
+ class Items extends RemoteState.make('Items', {
282
+ inputs: [StateGroup.select(Inputs, 'page')],
283
+ output: S.Array(Item),
284
+ intents: [Added, Renamed],
285
+ alwaysOn: true,
286
+ }) {}
287
+ const ItemsLive = RemoteState.live(Items, {
288
+ query: () => server.fetch(60),
289
+ send: (intent) => server.receive(intent, 15),
290
+ apply: applyIntent,
291
+ invalidateOn: [Poke],
292
+ // Trailing lets pre-ack run land — stale Success must not settle
293
+ coalesce: 'trailing',
294
+ })
295
+ const TestLayer = ItemsLive.pipe(
296
+ Layer.provideMerge(StateGroup.live(Inputs, { page: 1 })),
297
+ Layer.provideMerge(Engine),
298
+ )
305
299
 
306
- // The send fails: entry leaves the queue, the view reverts by construction,
307
- // and the Failed payload carried the intent + the error.
308
- yield* until(() => queue.get(), (q) => q.length === 0)
309
- const reverted = yield* until(
310
- () => store.get(),
311
- (v) => v._tag === 'Success' && v.value.length === 1,
300
+ return Effect.gen(function* () {
301
+ const store = yield* Items.store
302
+ const queue = yield* Items.pending.store
303
+ yield* until(
304
+ () => store.get(),
305
+ (v) => v._tag === 'Success',
306
+ )
307
+ expect(server.state.fetches).toBe(1)
308
+
309
+ const frames: Array<number> = []
310
+ const off = store.subscribe(() => {
311
+ const v = store.get()
312
+ if (v._tag === 'Success') frames.push(v.value.length)
313
+ })
314
+
315
+ yield* Event.dispatch(Poke, {})
316
+ yield* tick(5)
317
+ yield* Event.dispatch(Added, { id: 'b', name: 'beta' })
318
+
319
+ // Gen-2 Success settles nobody (generation rule)
320
+ yield* until(
321
+ () => server.state.fetches,
322
+ (n) => n === 2,
323
+ )
324
+ yield* tick(25)
325
+ expect(queue.get()).toHaveLength(1)
326
+ expect(queue.get()[0]).toMatchObject({ status: 'confirmed' })
327
+ const midflight = store.get()
328
+ expect(midflight).toMatchObject({ _tag: 'Success' })
329
+ if (midflight._tag === 'Success') expect(midflight.value).toHaveLength(2)
330
+
331
+ yield* until(
332
+ () => queue.get(),
333
+ (q) => q.length === 0,
334
+ )
335
+ off()
336
+ expect(server.state.fetches).toBe(3)
337
+ const firstOptimistic = frames.indexOf(2)
338
+ expect(firstOptimistic).toBeGreaterThanOrEqual(0)
339
+ expect(frames.slice(firstOptimistic).every((n) => n === 2)).toBe(true)
340
+ const settled = store.get()
341
+ if (settled._tag === 'Success') {
342
+ expect(settled.value).toEqual([
343
+ { id: 'a', name: 'alpha' },
344
+ { id: 'b', name: 'beta' },
345
+ ])
346
+ }
347
+ }).pipe(Effect.provide(TestLayer))
348
+ },
349
+ )
350
+
351
+ it.live(
352
+ 'a failed send settles the intent, reverts the overlay, and Failed reaches a user reducer',
353
+ () => {
354
+ class Page extends State.make('page', S.Number) {}
355
+ class Inputs extends StateGroup.make(Page) {}
356
+ class FailLog extends State.make('failLog', S.Array(S.String)) {}
357
+ const server = makeServer([{ id: 'a', name: 'alpha' }])
358
+ class Items extends RemoteState.make('Items', {
359
+ inputs: [StateGroup.select(Inputs, 'page')],
360
+ output: S.Array(Item),
361
+ intents: [Added, Renamed],
362
+ alwaysOn: true,
363
+ }) {}
364
+ const ItemsLive = RemoteState.live(Items, {
365
+ query: () => server.fetch(5),
366
+ send: () => Effect.fail('rejected' as const).pipe(Effect.delay(Duration.millis(15))),
367
+ apply: applyIntent,
368
+ })
369
+ const LogReducer = Reducer.make('failLog', { states: [FailLog], events: [Items.Failed] })
370
+ const TestLayer = Layer.mergeAll(
371
+ ItemsLive,
372
+ Reducer.live(LogReducer, (log, event) => [
373
+ ...log,
374
+ `${event.intent._tag}|${String(event.error)}`,
375
+ ]),
376
+ ).pipe(
377
+ Layer.provideMerge(State.live(FailLog, [])),
378
+ Layer.provideMerge(StateGroup.live(Inputs, { page: 1 })),
379
+ Layer.provideMerge(Engine),
312
380
  )
313
- if (reverted._tag === 'Success') {
314
- expect(reverted.value).toEqual([{ id: 'a', name: 'alpha' }])
315
- }
316
- yield* until(() => log.get(), (l) => l.length === 1)
317
- expect(log.get()).toEqual(['Added|rejected'])
318
- // No invalidation on failure: the one boot fetch is still the only one.
319
- expect(server.state.fetches).toBe(1)
320
- }).pipe(Effect.provide(TestLayer))
321
- })
381
+
382
+ return Effect.gen(function* () {
383
+ const store = yield* Items.store
384
+ const queue = yield* Items.pending.store
385
+ const log = yield* FailLog.store
386
+ yield* until(
387
+ () => store.get(),
388
+ (v) => v._tag === 'Success',
389
+ )
390
+
391
+ yield* Event.dispatch(Added, { id: 'b', name: 'beta' })
392
+ yield* until(
393
+ () => store.get(),
394
+ (v) => v._tag === 'Success' && v.value.length === 2,
395
+ )
396
+ expect(queue.get()).toHaveLength(1)
397
+
398
+ yield* until(
399
+ () => queue.get(),
400
+ (q) => q.length === 0,
401
+ )
402
+ const reverted = yield* until(
403
+ () => store.get(),
404
+ (v) => v._tag === 'Success' && v.value.length === 1,
405
+ )
406
+ if (reverted._tag === 'Success') {
407
+ expect(reverted.value).toEqual([{ id: 'a', name: 'alpha' }])
408
+ }
409
+ yield* until(
410
+ () => log.get(),
411
+ (l) => l.length === 1,
412
+ )
413
+ expect(log.get()).toEqual(['Added|rejected'])
414
+ expect(server.state.fetches).toBe(1)
415
+ }).pipe(Effect.provide(TestLayer))
416
+ },
417
+ )
322
418
 
323
419
  it.live('two intents apply in dispatch order and settle together on the post-ack run', () => {
324
420
  class Page extends State.make('page', S.Number) {}
@@ -343,20 +439,28 @@ it.live('two intents apply in dispatch order and settle together on the post-ack
343
439
  return Effect.gen(function* () {
344
440
  const store = yield* Items.store
345
441
  const queue = yield* Items.pending.store
346
- yield* until(() => store.get(), (v) => v._tag === 'Success')
442
+ yield* until(
443
+ () => store.get(),
444
+ (v) => v._tag === 'Success',
445
+ )
347
446
 
348
- // Order matters: the rename targets the row the add creates. Reversed
349
- // application would rename nothing and show 'beta'.
447
+ // Apply order = dispatch order
350
448
  yield* Event.dispatch(Added, { id: 'b', name: 'beta' })
351
449
  yield* Event.dispatch(Renamed, { id: 'b', name: 'BETA' })
352
- yield* until(() => queue.get(), (q) => q.length === 2)
450
+ yield* until(
451
+ () => queue.get(),
452
+ (q) => q.length === 2,
453
+ )
353
454
  const optimistic = store.get()
354
455
  expect(optimistic).toMatchObject({ _tag: 'Success' })
355
456
  if (optimistic._tag === 'Success') {
356
457
  expect(optimistic.value[1]).toEqual({ id: 'b', name: 'BETA' })
357
458
  }
358
459
 
359
- yield* until(() => queue.get(), (q) => q.length === 0)
460
+ yield* until(
461
+ () => queue.get(),
462
+ (q) => q.length === 0,
463
+ )
360
464
  const settled = yield* until(
361
465
  () => store.get(),
362
466
  (v) => v._tag === 'Success' && v.refetching === false,
@@ -382,8 +486,6 @@ it.live('one intent failing leaves the other pending and converging', () => {
382
486
  }) {}
383
487
  const ItemsLive = RemoteState.live(Items, {
384
488
  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
489
  send: (intent) =>
388
490
  intent._tag === 'Renamed'
389
491
  ? Effect.fail('rejected' as const).pipe(Effect.delay(Duration.millis(50)))
@@ -398,18 +500,19 @@ it.live('one intent failing leaves the other pending and converging', () => {
398
500
  return Effect.gen(function* () {
399
501
  const store = yield* Items.store
400
502
  const queue = yield* Items.pending.store
401
- yield* until(() => store.get(), (v) => v._tag === 'Success')
503
+ yield* until(
504
+ () => store.get(),
505
+ (v) => v._tag === 'Success',
506
+ )
402
507
 
403
508
  yield* Event.dispatch(Added, { id: 'b', name: 'beta' })
404
509
  yield* Event.dispatch(Renamed, { id: 'a', name: 'ALPHA' })
405
- // Both folded: the overlay shows the rename optimistically.
406
510
  yield* until(
407
511
  () => store.get(),
408
512
  (v) => v._tag === 'Success' && v.value[0]?.name === 'ALPHA',
409
513
  )
410
514
  expect(queue.get()).toHaveLength(2)
411
515
 
412
- // The rename fails and leaves; the add stays optimistic and intact.
413
516
  yield* until(
414
517
  () => store.get(),
415
518
  (v) => v._tag === 'Success' && v.value[0]?.name === 'alpha',
@@ -424,8 +527,10 @@ it.live('one intent failing leaves the other pending and converging', () => {
424
527
  ])
425
528
  }
426
529
 
427
- // The add converges independently.
428
- yield* until(() => queue.get(), (q) => q.length === 0)
530
+ yield* until(
531
+ () => queue.get(),
532
+ (q) => q.length === 0,
533
+ )
429
534
  const settled = yield* until(
430
535
  () => store.get(),
431
536
  (v) => v._tag === 'Success' && v.refetching === false,
@@ -450,7 +555,6 @@ it.live("pending exposes the lifecycle: 'sending' -> 'confirmed' -> gone", () =>
450
555
  alwaysOn: true,
451
556
  }) {}
452
557
  const ItemsLive = RemoteState.live(Items, {
453
- // A slow refetch keeps the 'confirmed' window wide enough to observe.
454
558
  query: () => server.fetch(40),
455
559
  send: (intent) => server.receive(intent, 40),
456
560
  apply: applyIntent,
@@ -463,10 +567,16 @@ it.live("pending exposes the lifecycle: 'sending' -> 'confirmed' -> gone", () =>
463
567
  return Effect.gen(function* () {
464
568
  const store = yield* Items.store
465
569
  const queue = yield* Items.pending.store
466
- yield* until(() => store.get(), (v) => v._tag === 'Success')
570
+ yield* until(
571
+ () => store.get(),
572
+ (v) => v._tag === 'Success',
573
+ )
467
574
 
468
575
  yield* Event.dispatch(Added, { id: 'b', name: 'beta' })
469
- const sending = yield* until(() => queue.get(), (q) => q.length === 1)
576
+ const sending = yield* until(
577
+ () => queue.get(),
578
+ (q) => q.length === 1,
579
+ )
470
580
  expect(sending[0]).toMatchObject({ status: 'sending', intent: { _tag: 'Added', id: 'b' } })
471
581
 
472
582
  const confirmed = yield* until(
@@ -475,7 +585,10 @@ it.live("pending exposes the lifecycle: 'sending' -> 'confirmed' -> gone", () =>
475
585
  )
476
586
  expect(confirmed[0]).toMatchObject({ status: 'confirmed' })
477
587
 
478
- yield* until(() => queue.get(), (q) => q.length === 0)
588
+ yield* until(
589
+ () => queue.get(),
590
+ (q) => q.length === 0,
591
+ )
479
592
  expect(queue.get()).toEqual([])
480
593
  }).pipe(Effect.provide(TestLayer))
481
594
  })
@@ -507,18 +620,22 @@ it.live('gated: intents queue and send while Idle; re-enabling converges and set
507
620
  yield* tick()
508
621
  expect(store.get()._tag).toBe('Idle')
509
622
 
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.
623
+ // Intents still send while gated Idle; settle waits for re-enable
512
624
  yield* Event.dispatch(Added, { id: 'b', name: 'beta' })
513
- yield* until(() => server.state.items, (items) => items.length === 2)
625
+ yield* until(
626
+ () => server.state.items,
627
+ (items) => items.length === 2,
628
+ )
514
629
  yield* tick(20)
515
630
  expect(store.get()._tag).toBe('Idle')
516
631
  expect(queue.get()).toHaveLength(1)
517
632
  expect(server.state.fetches).toBe(0)
518
633
 
519
- // Re-enable: the run lands with the mutation and settles the waiter.
520
634
  source.set(1)
521
- yield* until(() => queue.get(), (q) => q.length === 0)
635
+ yield* until(
636
+ () => queue.get(),
637
+ (q) => q.length === 0,
638
+ )
522
639
  const settled = yield* until(
523
640
  () => store.get(),
524
641
  (v) => v._tag === 'Success' && v.refetching === false,
@@ -546,7 +663,6 @@ it.live('reuse: untouched element identities survive the whole optimistic round-
546
663
  alwaysOn: true,
547
664
  }) {}
548
665
  const ItemsLive = RemoteState.live(Items, {
549
- // Fresh objects every fetch — only `reuse` can preserve identities.
550
666
  query: () => Effect.map(server.fetch(10), (items) => items.map((item) => ({ ...item }))),
551
667
  send: (intent) => server.receive(intent, 10),
552
668
  apply: applyIntent,
@@ -560,20 +676,27 @@ it.live('reuse: untouched element identities survive the whole optimistic round-
560
676
  return Effect.gen(function* () {
561
677
  const store = yield* Items.store
562
678
  const queue = yield* Items.pending.store
563
- const first = yield* until(() => store.get(), (v) => v._tag === 'Success')
679
+ const first = yield* until(
680
+ () => store.get(),
681
+ (v) => v._tag === 'Success',
682
+ )
564
683
  if (first._tag !== 'Success') return
565
684
  const untouched = first.value[0]
566
685
 
567
686
  yield* Event.dispatch(Renamed, { id: 'b', name: 'BETA' })
568
- yield* until(() => queue.get(), (q) => q.length === 1)
569
- yield* until(() => queue.get(), (q) => q.length === 0)
687
+ yield* until(
688
+ () => queue.get(),
689
+ (q) => q.length === 1,
690
+ )
691
+ yield* until(
692
+ () => queue.get(),
693
+ (q) => q.length === 0,
694
+ )
570
695
  const settled = yield* until(
571
696
  () => store.get(),
572
697
  (v) => v._tag === 'Success' && v.refetching === false,
573
698
  )
574
699
  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
700
  expect(settled.value[0]).toBe(untouched)
578
701
  expect(settled.value[1]).not.toBe(first.value[1])
579
702
  expect(settled.value[1]).toEqual({ id: 'b', name: 'BETA' })
@@ -606,17 +729,27 @@ it.live('invalidateOn + coalesce trailing compose with the mutation lifecycle',
606
729
  return Effect.gen(function* () {
607
730
  const store = yield* Items.store
608
731
  const queue = yield* Items.pending.store
609
- yield* until(() => store.get(), (v) => v._tag === 'Success')
732
+ yield* until(
733
+ () => store.get(),
734
+ (v) => v._tag === 'Success',
735
+ )
610
736
  expect(server.state.fetches).toBe(1)
611
737
 
612
- // A realtime push refetches like any invalidateOn event.
613
738
  yield* Event.dispatch(BoardChanged, {})
614
- yield* until(() => server.state.fetches, (n) => n === 2)
739
+ yield* until(
740
+ () => server.state.fetches,
741
+ (n) => n === 2,
742
+ )
615
743
 
616
- // The mutation lifecycle still converges under trailing conciliation.
617
744
  yield* Event.dispatch(Added, { id: 'b', name: 'beta' })
618
- yield* until(() => queue.get(), (q) => q.length === 1)
619
- yield* until(() => queue.get(), (q) => q.length === 0)
745
+ yield* until(
746
+ () => queue.get(),
747
+ (q) => q.length === 1,
748
+ )
749
+ yield* until(
750
+ () => queue.get(),
751
+ (q) => q.length === 0,
752
+ )
620
753
  const settled = yield* until(
621
754
  () => store.get(),
622
755
  (v) => v._tag === 'Success' && v.refetching === false,
@@ -655,60 +788,67 @@ it.live('a query Error passes through by reference with intents still pending',
655
788
  const store = yield* Items.store
656
789
  const truth = yield* Items.truth.store
657
790
  const queue = yield* Items.pending.store
658
- yield* until(() => store.get(), (v) => v._tag === 'Error')
791
+ yield* until(
792
+ () => store.get(),
793
+ (v) => v._tag === 'Error',
794
+ )
659
795
 
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.
796
+ // Error runs settle nobody; overlay passes Error through without apply
663
797
  yield* Event.dispatch(Added, { id: 'b', name: 'beta' })
664
- yield* until(() => queue.get(), (q) => q.length === 1 && q[0]?.status === 'confirmed')
665
- yield* until(() => store.get(), (v) => v._tag === 'Error' && v.refetching === false)
798
+ yield* until(
799
+ () => queue.get(),
800
+ (q) => q.length === 1 && q[0]?.status === 'confirmed',
801
+ )
802
+ yield* until(
803
+ () => store.get(),
804
+ (v) => v._tag === 'Error' && v.refetching === false,
805
+ )
666
806
  yield* tick(20)
667
807
  expect(queue.get()).toHaveLength(1)
668
808
  expect(store.get()).toBe(truth.get())
669
809
  }).pipe(Effect.provide(TestLayer))
670
810
  })
671
811
 
672
- it.live('teardown: closing the scope unregisters the reducers, the procedure, and the channel', () => {
673
- class Page extends State.make('page', S.Number) {}
674
- class Inputs extends StateGroup.make(Page) {}
675
- const server = makeServer([{ id: 'a', name: 'alpha' }])
676
- class Items extends RemoteState.make('Items', {
677
- inputs: [StateGroup.select(Inputs, 'page')],
678
- output: S.Array(Item),
679
- intents: [Added, Renamed],
680
- alwaysOn: true,
681
- }) {}
682
- const ItemsLive = RemoteState.live(Items, {
683
- query: () => server.fetch(5),
684
- send: (intent) => server.receive(intent, 5),
685
- apply: applyIntent,
686
- }).pipe(Layer.provideMerge(StateGroup.live(Inputs, { page: 1 })))
687
-
688
- return Effect.gen(function* () {
689
- const reducers = yield* Reducers
690
- const procedures = yield* Procedures
691
- const channels = yield* Channels
692
-
693
- yield* Effect.scoped(
694
- Effect.gen(function* () {
695
- yield* Layer.build(ItemsLive)
696
- expect(reducers.entries.some((entry) => entry.name === 'Items/pending')).toBe(true)
697
- expect(reducers.entries.some((entry) => entry.name === 'Items/revision')).toBe(true)
698
- expect(procedures.entries.some((entry) => entry.name === 'Items/send')).toBe(true)
699
- expect(channels.byName.has('Items/sends')).toBe(true)
700
- }),
701
- )
702
-
703
- // Scope closed: every registration is gone (a lazy feature unmounting).
704
- expect(reducers.entries.some((entry) => entry.name === 'Items/pending')).toBe(false)
705
- expect(reducers.entries.some((entry) => entry.name === 'Items/revision')).toBe(false)
706
- expect(procedures.entries.some((entry) => entry.name === 'Items/send')).toBe(false)
707
- expect(channels.byName.has('Items/sends')).toBe(false)
708
- }).pipe(Effect.provide(Engine))
709
- })
812
+ it.live(
813
+ 'teardown: closing the scope unregisters the reducers, the procedure, and the channel',
814
+ () => {
815
+ class Page extends State.make('page', S.Number) {}
816
+ class Inputs extends StateGroup.make(Page) {}
817
+ const server = makeServer([{ id: 'a', name: 'alpha' }])
818
+ class Items extends RemoteState.make('Items', {
819
+ inputs: [StateGroup.select(Inputs, 'page')],
820
+ output: S.Array(Item),
821
+ intents: [Added, Renamed],
822
+ alwaysOn: true,
823
+ }) {}
824
+ const ItemsLive = RemoteState.live(Items, {
825
+ query: () => server.fetch(5),
826
+ send: (intent) => server.receive(intent, 5),
827
+ apply: applyIntent,
828
+ }).pipe(Layer.provideMerge(StateGroup.live(Inputs, { page: 1 })))
829
+
830
+ return Effect.gen(function* () {
831
+ const reducers = yield* Reducers
832
+ const procedures = yield* Procedures
833
+ const channels = yield* Channels
834
+
835
+ yield* Effect.scoped(
836
+ Effect.gen(function* () {
837
+ yield* Layer.build(ItemsLive)
838
+ expect(reducers.entries.some((entry) => entry.name === 'Items/pending')).toBe(true)
839
+ expect(reducers.entries.some((entry) => entry.name === 'Items/revision')).toBe(true)
840
+ expect(procedures.entries.some((entry) => entry.name === 'Items/send')).toBe(true)
841
+ expect(channels.byName.has('Items/sends')).toBe(true)
842
+ }),
843
+ )
710
844
 
711
- // ── persist (00b/00c): the converged truth round-trips a QueryStore. ─────────
845
+ expect(reducers.entries.some((entry) => entry.name === 'Items/pending')).toBe(false)
846
+ expect(reducers.entries.some((entry) => entry.name === 'Items/revision')).toBe(false)
847
+ expect(procedures.entries.some((entry) => entry.name === 'Items/send')).toBe(false)
848
+ expect(channels.byName.has('Items/sends')).toBe(false)
849
+ }).pipe(Effect.provide(Engine))
850
+ },
851
+ )
712
852
 
713
853
  it.live('persist: hydrates the seeded truth (stale) then writes the fresh truth through', () => {
714
854
  class Page extends State.make('page', S.Number) {}
@@ -735,12 +875,11 @@ it.live('persist: hydrates the seeded truth (stale) then writes the fresh truth
735
875
 
736
876
  return Effect.gen(function* () {
737
877
  const view = yield* Items.store
738
- // The persisted truth shows instantly, marked refetching (stale-while-revalidate).
739
878
  const hydrated = view.get()
740
879
  expect(hydrated).toMatchObject({ _tag: 'Success', refetching: true })
741
- if (hydrated._tag === 'Success') expect(hydrated.value).toEqual([{ id: 'seed', name: 'seeded' }])
880
+ if (hydrated._tag === 'Success')
881
+ expect(hydrated.value).toEqual([{ id: 'seed', name: 'seeded' }])
742
882
 
743
- // The fresh fetch lands and the converged truth is written through under the name key.
744
883
  const settled = yield* until(
745
884
  () => view.get(),
746
885
  (v) => v._tag === 'Success' && v.refetching === false,
@@ -754,129 +893,183 @@ it.live('persist: hydrates the seeded truth (stale) then writes the fresh truth
754
893
  }).pipe(Effect.provide(TestLayer))
755
894
  })
756
895
 
757
- it.live('persist: the optimistic overlay is NEVER written — only the converged server truth is', () => {
758
- class Page extends State.make('page', S.Number) {}
759
- class Inputs extends StateGroup.make(Page) {}
760
- const server = makeServer([{ id: 'a', name: 'alpha' }])
761
- const store = fakeQueryStore()
762
- class Items extends RemoteState.make('Items', {
763
- inputs: [StateGroup.select(Inputs, 'page')],
764
- output: S.Array(Item),
765
- intents: [Added, Renamed],
766
- alwaysOn: true,
767
- }) {}
768
- const ItemsLive = RemoteState.live(Items, {
769
- query: () => server.fetch(10),
770
- // Slow send so the pure-optimistic window stays open for the store assertion.
771
- send: (intent) => server.receive(intent, 60),
772
- apply: applyIntent,
773
- persist: true,
774
- })
775
- const TestLayer = ItemsLive.pipe(
776
- Layer.provideMerge(StateGroup.live(Inputs, { page: 1 })),
777
- Layer.provideMerge(store.layer),
778
- Layer.provideMerge(Engine),
779
- )
780
-
781
- return Effect.gen(function* () {
782
- const view = yield* Items.store
783
- const queue = yield* Items.pending.store
784
- yield* until(() => view.get(), (v) => v._tag === 'Success')
785
- // First truth is persisted.
786
- yield* until(() => store.map.get('Items'), (v) => Array.isArray(v) && v.length === 1)
787
-
788
- // Dispatch the optimistic intent: the overlay shows two rows...
789
- yield* Event.dispatch(Added, { id: 'b', name: 'beta' })
790
- yield* until(() => view.get(), (v) => v._tag === 'Success' && v.value.length === 2)
791
- yield* until(() => queue.get(), (q) => q.length === 1)
792
- // ...but the store still holds the PRE-mutation truth (no overlay write).
793
- expect(store.map.get('Items')).toEqual([{ id: 'a', name: 'alpha' }])
794
-
795
- // Once the send acks and the refetch lands, the converged truth is written.
796
- yield* until(() => queue.get(), (q) => q.length === 0)
797
- yield* until(
798
- () => store.map.get('Items'),
799
- (v) => Array.isArray(v) && v.length === 2,
896
+ it.live(
897
+ 'persist: the optimistic overlay is NEVER written — only the converged server truth is',
898
+ () => {
899
+ class Page extends State.make('page', S.Number) {}
900
+ class Inputs extends StateGroup.make(Page) {}
901
+ const server = makeServer([{ id: 'a', name: 'alpha' }])
902
+ const store = fakeQueryStore()
903
+ class Items extends RemoteState.make('Items', {
904
+ inputs: [StateGroup.select(Inputs, 'page')],
905
+ output: S.Array(Item),
906
+ intents: [Added, Renamed],
907
+ alwaysOn: true,
908
+ }) {}
909
+ const ItemsLive = RemoteState.live(Items, {
910
+ query: () => server.fetch(10),
911
+ send: (intent) => server.receive(intent, 60),
912
+ apply: applyIntent,
913
+ persist: true,
914
+ })
915
+ const TestLayer = ItemsLive.pipe(
916
+ Layer.provideMerge(StateGroup.live(Inputs, { page: 1 })),
917
+ Layer.provideMerge(store.layer),
918
+ Layer.provideMerge(Engine),
800
919
  )
801
- expect(store.map.get('Items')).toEqual([
802
- { id: 'a', name: 'alpha' },
803
- { id: 'b', name: 'beta' },
804
- ])
805
- }).pipe(Effect.provide(TestLayer))
806
- })
807
920
 
808
- it.live('persist: `true` keys by name; `{ key }` overrides — two definitions keep separate slots', () => {
809
- class Page extends State.make('page', S.Number) {}
810
- class Inputs extends StateGroup.make(Page) {}
811
- const serverA = makeServer([{ id: 'a', name: 'alpha' }])
812
- const serverB = makeServer([{ id: 'b', name: 'beta' }])
813
- const store = fakeQueryStore()
814
- class ItemsA extends RemoteState.make('ItemsA', {
815
- inputs: [StateGroup.select(Inputs, 'page')],
816
- output: S.Array(Item),
817
- intents: [Added, Renamed],
818
- alwaysOn: true,
819
- }) {}
820
- class ItemsB extends RemoteState.make('ItemsB', {
821
- inputs: [StateGroup.select(Inputs, 'page')],
822
- output: S.Array(Item),
823
- intents: [Added, Renamed],
824
- alwaysOn: true,
825
- }) {}
826
- const TestLayer = Layer.mergeAll(
827
- RemoteState.live(ItemsA, { query: () => serverA.fetch(10), send: (i) => serverA.receive(i, 10), apply: applyIntent, persist: true }),
828
- RemoteState.live(ItemsB, { query: () => serverB.fetch(10), send: (i) => serverB.receive(i, 10), apply: applyIntent, persist: { key: 'custom-B' } }),
829
- ).pipe(Layer.provideMerge(StateGroup.live(Inputs, { page: 1 })), Layer.provideMerge(store.layer), Layer.provideMerge(Engine))
830
-
831
- return Effect.gen(function* () {
832
- const a = yield* ItemsA.store
833
- const b = yield* ItemsB.store
834
- yield* until(() => a.get(), (v) => v._tag === 'Success')
835
- yield* until(() => b.get(), (v) => v._tag === 'Success')
836
- yield* until(() => store.map.get('ItemsA'), (v) => Array.isArray(v))
837
- yield* until(() => store.map.get('custom-B'), (v) => Array.isArray(v))
838
- expect(store.map.get('ItemsA')).toEqual([{ id: 'a', name: 'alpha' }])
839
- 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
- expect(store.map.has('ItemsB')).toBe(false)
842
- }).pipe(Effect.provide(TestLayer))
843
- })
921
+ return Effect.gen(function* () {
922
+ const view = yield* Items.store
923
+ const queue = yield* Items.pending.store
924
+ yield* until(
925
+ () => view.get(),
926
+ (v) => v._tag === 'Success',
927
+ )
928
+ yield* until(
929
+ () => store.map.get('Items'),
930
+ (v) => Array.isArray(v) && v.length === 1,
931
+ )
932
+
933
+ yield* Event.dispatch(Added, { id: 'b', name: 'beta' })
934
+ yield* until(
935
+ () => view.get(),
936
+ (v) => v._tag === 'Success' && v.value.length === 2,
937
+ )
938
+ yield* until(
939
+ () => queue.get(),
940
+ (q) => q.length === 1,
941
+ )
942
+ // Persist never captures overlay only settled server truth
943
+ expect(store.map.get('Items')).toEqual([{ id: 'a', name: 'alpha' }])
944
+
945
+ yield* until(
946
+ () => queue.get(),
947
+ (q) => q.length === 0,
948
+ )
949
+ yield* until(
950
+ () => store.map.get('Items'),
951
+ (v) => Array.isArray(v) && v.length === 2,
952
+ )
953
+ expect(store.map.get('Items')).toEqual([
954
+ { id: 'a', name: 'alpha' },
955
+ { id: 'b', name: 'beta' },
956
+ ])
957
+ }).pipe(Effect.provide(TestLayer))
958
+ },
959
+ )
960
+
961
+ it.live(
962
+ 'persist: `true` keys by name; `{ key }` overrides — two definitions keep separate slots',
963
+ () => {
964
+ class Page extends State.make('page', S.Number) {}
965
+ class Inputs extends StateGroup.make(Page) {}
966
+ const serverA = makeServer([{ id: 'a', name: 'alpha' }])
967
+ const serverB = makeServer([{ id: 'b', name: 'beta' }])
968
+ const store = fakeQueryStore()
969
+ class ItemsA extends RemoteState.make('ItemsA', {
970
+ inputs: [StateGroup.select(Inputs, 'page')],
971
+ output: S.Array(Item),
972
+ intents: [Added, Renamed],
973
+ alwaysOn: true,
974
+ }) {}
975
+ class ItemsB extends RemoteState.make('ItemsB', {
976
+ inputs: [StateGroup.select(Inputs, 'page')],
977
+ output: S.Array(Item),
978
+ intents: [Added, Renamed],
979
+ alwaysOn: true,
980
+ }) {}
981
+ const TestLayer = Layer.mergeAll(
982
+ RemoteState.live(ItemsA, {
983
+ query: () => serverA.fetch(10),
984
+ send: (i) => serverA.receive(i, 10),
985
+ apply: applyIntent,
986
+ persist: true,
987
+ }),
988
+ RemoteState.live(ItemsB, {
989
+ query: () => serverB.fetch(10),
990
+ send: (i) => serverB.receive(i, 10),
991
+ apply: applyIntent,
992
+ persist: { key: 'custom-B' },
993
+ }),
994
+ ).pipe(
995
+ Layer.provideMerge(StateGroup.live(Inputs, { page: 1 })),
996
+ Layer.provideMerge(store.layer),
997
+ Layer.provideMerge(Engine),
998
+ )
844
999
 
845
- it.live('persist (00c): a function key keys a family per-input — two input keys, two slots, no collision', () => {
846
- class Page extends State.make('page', S.Number) {}
847
- class Inputs extends StateGroup.make(Page) {}
848
- const inputs = StateGroup.live(Inputs, { page: 1 })
849
- 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
- const fetchPage = (page: number) =>
853
- Effect.as(tick(10), [{ id: `p${page}`, name: `page-${page}` }])
854
- class Fam extends RemoteState.make('Fam', {
855
- inputs: [StateGroup.select(Inputs, 'page')],
856
- output: S.Array(Item),
857
- intents: [Added, Renamed],
858
- alwaysOn: true,
859
- }) {}
860
- const FamLive = RemoteState.live(Fam, {
861
- query: ({ page }) => fetchPage(page),
862
- send: (i) => Effect.sync(() => void i),
863
- apply: applyIntent,
864
- persist: { key: ({ page }) => `fam:${page}` },
865
- })
866
- const TestLayer = FamLive.pipe(Layer.provideMerge(inputs), Layer.provideMerge(store.layer), Layer.provideMerge(Engine))
1000
+ return Effect.gen(function* () {
1001
+ const a = yield* ItemsA.store
1002
+ const b = yield* ItemsB.store
1003
+ yield* until(
1004
+ () => a.get(),
1005
+ (v) => v._tag === 'Success',
1006
+ )
1007
+ yield* until(
1008
+ () => b.get(),
1009
+ (v) => v._tag === 'Success',
1010
+ )
1011
+ yield* until(
1012
+ () => store.map.get('ItemsA'),
1013
+ (v) => Array.isArray(v),
1014
+ )
1015
+ yield* until(
1016
+ () => store.map.get('custom-B'),
1017
+ (v) => Array.isArray(v),
1018
+ )
1019
+ expect(store.map.get('ItemsA')).toEqual([{ id: 'a', name: 'alpha' }])
1020
+ expect(store.map.get('custom-B')).toEqual([{ id: 'b', name: 'beta' }])
1021
+ expect(store.map.has('ItemsB')).toBe(false)
1022
+ }).pipe(Effect.provide(TestLayer))
1023
+ },
1024
+ )
1025
+
1026
+ it.live(
1027
+ 'persist (00c): a function key keys a family per-input — two input keys, two slots, no collision',
1028
+ () => {
1029
+ class Page extends State.make('page', S.Number) {}
1030
+ class Inputs extends StateGroup.make(Page) {}
1031
+ const inputs = StateGroup.live(Inputs, { page: 1 })
1032
+ const store = fakeQueryStore()
1033
+ const fetchPage = (page: number) =>
1034
+ Effect.as(tick(10), [{ id: `p${page}`, name: `page-${page}` }])
1035
+ class Fam extends RemoteState.make('Fam', {
1036
+ inputs: [StateGroup.select(Inputs, 'page')],
1037
+ output: S.Array(Item),
1038
+ intents: [Added, Renamed],
1039
+ alwaysOn: true,
1040
+ }) {}
1041
+ const FamLive = RemoteState.live(Fam, {
1042
+ query: ({ page }) => fetchPage(page),
1043
+ send: (i) => Effect.sync(() => void i),
1044
+ apply: applyIntent,
1045
+ persist: { key: ({ page }) => `fam:${page}` },
1046
+ })
1047
+ const TestLayer = FamLive.pipe(
1048
+ Layer.provideMerge(inputs),
1049
+ Layer.provideMerge(store.layer),
1050
+ Layer.provideMerge(Engine),
1051
+ )
867
1052
 
868
- return Effect.gen(function* () {
869
- const view = yield* Fam.store
870
- const page = yield* Page.store
871
- yield* until(() => store.map.get('fam:1'), (v) => Array.isArray(v))
872
- expect(store.map.get('fam:1')).toEqual([{ id: 'p1', name: 'page-1' }])
873
-
874
- // Move the input to page 2: a new fetch writes to a DISTINCT slot.
875
- page.set(2)
876
- yield* until(() => view.get(), (v) => v._tag === 'Success' && v.value[0]?.id === 'p2')
877
- yield* until(() => store.map.get('fam:2'), (v) => Array.isArray(v))
878
- expect(store.map.get('fam:2')).toEqual([{ id: 'p2', name: 'page-2' }])
879
- // Page 1's slot is intact — no collision.
880
- expect(store.map.get('fam:1')).toEqual([{ id: 'p1', name: 'page-1' }])
881
- }).pipe(Effect.provide(TestLayer))
882
- })
1053
+ return Effect.gen(function* () {
1054
+ const view = yield* Fam.store
1055
+ const page = yield* Page.store
1056
+ yield* until(
1057
+ () => store.map.get('fam:1'),
1058
+ (v) => Array.isArray(v),
1059
+ )
1060
+ expect(store.map.get('fam:1')).toEqual([{ id: 'p1', name: 'page-1' }])
1061
+
1062
+ page.set(2)
1063
+ yield* until(
1064
+ () => view.get(),
1065
+ (v) => v._tag === 'Success' && v.value[0]?.id === 'p2',
1066
+ )
1067
+ yield* until(
1068
+ () => store.map.get('fam:2'),
1069
+ (v) => Array.isArray(v),
1070
+ )
1071
+ expect(store.map.get('fam:2')).toEqual([{ id: 'p2', name: 'page-2' }])
1072
+ expect(store.map.get('fam:1')).toEqual([{ id: 'p1', name: 'page-1' }])
1073
+ }).pipe(Effect.provide(TestLayer))
1074
+ },
1075
+ )