@playfast/reform 1.1.1 → 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/README.md +85 -55
  2. package/package.json +17 -17
  3. package/src/boundary/boundary.test.ts +82 -33
  4. package/src/boundary/boundary.ts +141 -32
  5. package/src/calc/asyncCalc.invalidate.test.ts +733 -32
  6. package/src/calc/asyncCalc.test.ts +397 -225
  7. package/src/calc/asyncCalc.ts +115 -100
  8. package/src/calc/asyncCalcTypes.ts +147 -0
  9. package/src/calc/calc.test.ts +13 -16
  10. package/src/calc/calc.ts +44 -18
  11. package/src/calc/calcFamily.test.ts +90 -32
  12. package/src/calc/calcFamily.ts +56 -25
  13. package/src/calc/compose.ts +1 -14
  14. package/src/channel/channel.ts +25 -6
  15. package/src/channel/{procedureRegistry.ts → procedures.ts} +41 -23
  16. package/src/compose/composition.test.ts +19 -0
  17. package/src/compose/composition.ts +168 -81
  18. package/src/compose/compositionTypes.ts +249 -0
  19. package/src/compose/props.ts +13 -6
  20. package/src/compose/provide.ts +187 -46
  21. package/src/compose/slot.ts +156 -19
  22. package/src/compose/structure.test.ts +6 -3
  23. package/src/compose/structure.ts +106 -13
  24. package/src/compose/ui.test.ts +19 -3
  25. package/src/compose/ui.ts +147 -54
  26. package/src/compose/ui.typecheck.ts +40 -4
  27. package/src/definition/definition.ts +22 -9
  28. package/src/event/event.fromSource.test.ts +172 -0
  29. package/src/event/event.test.ts +10 -0
  30. package/src/event/event.ts +102 -18
  31. package/src/feature/feature.mount.test.ts +123 -56
  32. package/src/feature/feature.test.ts +67 -63
  33. package/src/feature/feature.ts +222 -262
  34. package/src/feature/feature.typecheck.ts +224 -91
  35. package/src/feature/featureBinding.ts +130 -20
  36. package/src/feature/featureClass.ts +162 -0
  37. package/src/feature/featureConfig.ts +192 -0
  38. package/src/feature/featureFactory.ts +163 -0
  39. package/src/feature/featureModule.ts +136 -0
  40. package/src/feature/featureModule.typecheck.ts +63 -0
  41. package/src/feature/featureNativeTree.ts +152 -0
  42. package/src/feature/featureRequirement.ts +82 -0
  43. package/src/feature/featureVariants.ts +234 -0
  44. package/src/feature/mountFeature.ts +54 -0
  45. package/src/graph/closure.ts +222 -0
  46. package/src/graph/services.ts +95 -0
  47. package/src/graph/summary.ts +134 -0
  48. package/src/index.ts +171 -245
  49. package/src/internal/bucketCache.ts +39 -0
  50. package/src/internal/capture.ts +9 -4
  51. package/src/internal/env.ts +1 -3
  52. package/src/internal/errors.ts +21 -10
  53. package/src/internal/queryDriver.ts +61 -25
  54. package/src/internal/queryDriverHydrate.ts +45 -0
  55. package/src/internal/queryDriverStore.ts +8 -5
  56. package/src/internal/queryDriverTypes.ts +11 -11
  57. package/src/internal/reuse.test.ts +10 -3
  58. package/src/internal/reuse.ts +5 -2
  59. package/src/internal/scheduler.ts +4 -1
  60. package/src/internal/sources.ts +25 -11
  61. package/src/internal/store.test.ts +27 -6
  62. package/src/internal/track.ts +3 -2
  63. package/src/internal.ts +222 -0
  64. package/src/namespace/namespace.ts +46 -25
  65. package/src/procedure/procedure.ts +38 -11
  66. package/src/reducer/reducer.ts +78 -34
  67. package/src/remote/pendingQueue.ts +145 -0
  68. package/src/remote/remoteState.test.ts +536 -354
  69. package/src/remote/remoteState.ts +145 -106
  70. package/src/remote/remoteState.typecheck.ts +47 -23
  71. package/src/remote/remoteStateMake.ts +97 -0
  72. package/src/remote/remoteStateSend.ts +95 -37
  73. package/src/remote/remoteStateTypes.ts +155 -0
  74. package/src/runtime/appRuntime.activation.test.ts +104 -0
  75. package/src/runtime/appRuntime.test.ts +187 -111
  76. package/src/runtime/appRuntime.ts +36 -130
  77. package/src/runtime/capturedAppRuntime.ts +274 -0
  78. package/src/runtime/eventBudget.test.ts +39 -12
  79. package/src/runtime/eventBudget.ts +9 -8
  80. package/src/runtime/featureMount.ts +94 -0
  81. package/src/runtime/hardening.test.ts +22 -13
  82. package/src/runtime/instrumentation.test.ts +213 -194
  83. package/src/runtime/instrumentation.ts +6 -11
  84. package/src/runtime/loop.test.ts +84 -7
  85. package/src/runtime/loop.ts +69 -40
  86. package/src/runtime/runtimeHandle.ts +124 -0
  87. package/src/scene/featureScene.test.ts +4 -2
  88. package/src/scene/scene.ts +234 -64
  89. package/src/scene/seedScene.test.ts +69 -86
  90. package/src/state/state.nominal.typecheck.ts +68 -0
  91. package/src/state/state.test.ts +17 -0
  92. package/src/state/state.ts +79 -26
  93. package/src/state/stateFamily.test.ts +39 -8
  94. package/src/state/stateFamily.ts +55 -22
  95. package/src/state/stateGroup.ts +53 -17
  96. package/src/state/token.ts +40 -10
  97. package/src/synced/syncedStore.ts +46 -23
  98. package/src/testkit/flight.testkit.ts +84 -0
  99. package/src/wire/tree.test.ts +8 -4
  100. package/src/wire/triggers.test.ts +6 -2
  101. package/src/wire/triggers.ts +14 -10
  102. package/src/calc/asyncCalcDefinitions.ts +0 -48
  103. package/src/internal/ctx.ts +0 -9
  104. package/src/remote/remoteStateDefinition.ts +0 -135
  105. package/src/remote/remoteStateLayers.ts +0 -118
  106. package/src/remote/remoteStateLiveTypes.ts +0 -59
@@ -2,29 +2,18 @@ import { expect, it } from '@effect/vitest'
2
2
  import { Duration, Effect, Layer, Match, Option, Schema as S } from 'effect'
3
3
  import {
4
4
  Channel,
5
- Channels,
6
5
  Engine,
7
6
  Event,
8
- Procedures,
9
- QueryStore,
10
7
  Procedure,
11
8
  Reducer,
12
- Reducers,
13
9
  RemoteState,
14
10
  State,
15
11
  StateGroup,
16
12
  } from '../index'
17
-
18
- const tick = (ms = 10) => Effect.sleep(Duration.millis(ms))
19
-
20
- // Bounded poll — fixed tick budgets flake under parallel-suite host load
21
- const until = <A>(read: () => A, pred: (a: A) => boolean, rounds = 200): Effect.Effect<A> =>
22
- Effect.suspend(() => {
23
- const a = read()
24
- return pred(a) || rounds <= 0
25
- ? Effect.succeed(a)
26
- : Effect.flatMap(tick(10), () => until(read, pred, rounds - 1))
27
- })
13
+ import { Channels, Procedures } from '../channel/channel'
14
+ import { QueryStore } from '../internal/queryStore'
15
+ import { Reducers } from '../runtime/loop'
16
+ import { makeFlightGate, tick, until } from '../testkit/flight.testkit'
28
17
 
29
18
  const Item = S.Struct({ id: S.String, name: S.String })
30
19
  type Item = typeof Item.Type
@@ -110,7 +99,10 @@ it.live('settled dispatches after Success is committed to truth', () => {
110
99
  )
111
100
 
112
101
  return Effect.gen(function* () {
113
- yield* until(() => observed, (values) => values.length === 1)
102
+ yield* until(
103
+ () => observed,
104
+ (values) => values.length === 1,
105
+ )
114
106
  expect(observed).toEqual(['Success'])
115
107
  }).pipe(Effect.provide(TestLayer))
116
108
  })
@@ -150,7 +142,10 @@ it.live('settled dispatches after Error is committed to truth', () => {
150
142
  )
151
143
 
152
144
  return Effect.gen(function* () {
153
- yield* until(() => observed, (values) => values.length === 1)
145
+ yield* until(
146
+ () => observed,
147
+ (values) => values.length === 1,
148
+ )
154
149
  expect(observed).toEqual(['Error'])
155
150
  }).pipe(Effect.provide(TestLayer))
156
151
  })
@@ -179,7 +174,10 @@ it.live('an intent overlays instantly — no fetch fired, truth untouched', () =
179
174
  const store = yield* Items.store
180
175
  const truth = yield* Items.truth.store
181
176
  const queue = yield* Items.pending.store
182
- yield* until(() => store.get(), (v) => v._tag === 'Success')
177
+ yield* until(
178
+ () => store.get(),
179
+ (v) => v._tag === 'Success',
180
+ )
183
181
  expect(server.state.fetches).toBe(1)
184
182
 
185
183
  yield* Event.dispatch(Added, { id: 'b', name: 'beta' })
@@ -198,173 +196,219 @@ it.live('an intent overlays instantly — no fetch fired, truth untouched', () =
198
196
  }).pipe(Effect.provide(TestLayer))
199
197
  })
200
198
 
201
- it.live('convergence: the send acks, the refetch lands, the queue drains — and no frame ever lacked the row', () => {
202
- class Page extends State.make('page', S.Number) {}
203
- class Inputs extends StateGroup.make(Page) {}
204
- const server = makeServer([{ id: 'a', name: 'alpha' }])
205
- class Items extends RemoteState.make('Items', {
206
- inputs: [StateGroup.select(Inputs, 'page')],
207
- output: S.Array(Item),
208
- intents: [Added, Renamed],
209
- alwaysOn: true,
210
- }) {}
211
- const ItemsLive = RemoteState.live(Items, {
212
- query: () => server.fetch(15),
213
- send: (intent) => server.receive(intent, 15),
214
- apply: applyIntent,
215
- })
216
- const TestLayer = ItemsLive.pipe(
217
- Layer.provideMerge(StateGroup.live(Inputs, { page: 1 })),
218
- Layer.provideMerge(Engine),
219
- )
220
-
221
- return Effect.gen(function* () {
222
- const store = yield* Items.store
223
- const queue = yield* Items.pending.store
224
- yield* until(() => store.get(), (v) => v._tag === 'Success')
225
-
226
- // Flicker guard: once optimistic row appears, no later Success frame may lack it
227
- const frames: Array<number> = []
228
- const off = store.subscribe(() => {
229
- const v = store.get()
230
- if (v._tag === 'Success') frames.push(v.value.length)
199
+ it.live(
200
+ 'convergence: the send acks, the refetch lands, the queue drains — and no frame ever lacked the row',
201
+ () => {
202
+ class Page extends State.make('page', S.Number) {}
203
+ class Inputs extends StateGroup.make(Page) {}
204
+ const server = makeServer([{ id: 'a', name: 'alpha' }])
205
+ class Items extends RemoteState.make('Items', {
206
+ inputs: [StateGroup.select(Inputs, 'page')],
207
+ output: S.Array(Item),
208
+ intents: [Added, Renamed],
209
+ alwaysOn: true,
210
+ }) {}
211
+ const ItemsLive = RemoteState.live(Items, {
212
+ query: () => server.fetch(15),
213
+ send: (intent) => server.receive(intent, 15),
214
+ apply: applyIntent,
231
215
  })
232
- yield* Event.dispatch(Added, { id: 'b', name: 'beta' })
233
- // Wait for queue entry to land before waiting for settle (empty would be vacuous)
234
- yield* until(() => queue.get(), (q) => q.length === 1)
235
- yield* until(() => queue.get(), (q) => q.length === 0)
236
- off()
237
- expect(frames.length).toBeGreaterThan(0)
238
- expect(frames.every((n) => n === 2)).toBe(true)
239
-
240
- const settled = yield* until(
241
- () => store.get(),
242
- (v) => v._tag === 'Success' && v.refetching === false,
216
+ const TestLayer = ItemsLive.pipe(
217
+ Layer.provideMerge(StateGroup.live(Inputs, { page: 1 })),
218
+ Layer.provideMerge(Engine),
243
219
  )
244
- if (settled._tag === 'Success') {
245
- expect(settled.value).toEqual([
246
- { id: 'a', name: 'alpha' },
247
- { id: 'b', name: 'beta' },
248
- ])
249
- }
250
- expect(server.state.fetches).toBe(2)
251
- }).pipe(Effect.provide(TestLayer))
252
- })
253
220
 
254
- it.live('THE GENERATION RULE: a refetch that started before the ack can never settle the intent', () => {
255
- class Page extends State.make('page', S.Number) {}
256
- class Inputs extends StateGroup.make(Page) {}
257
- class Poke extends Event.make('Poke', S.Struct({})) {}
258
- const server = makeServer([{ id: 'a', name: 'alpha' }])
259
- class Items extends RemoteState.make('Items', {
260
- inputs: [StateGroup.select(Inputs, 'page')],
261
- output: S.Array(Item),
262
- intents: [Added, Renamed],
263
- alwaysOn: true,
264
- }) {}
265
- const ItemsLive = RemoteState.live(Items, {
266
- query: () => server.fetch(60),
267
- send: (intent) => server.receive(intent, 15),
268
- apply: applyIntent,
269
- invalidateOn: [Poke],
270
- // Trailing lets pre-ack run land — stale Success must not settle
271
- coalesce: 'trailing',
272
- })
273
- const TestLayer = ItemsLive.pipe(
274
- Layer.provideMerge(StateGroup.live(Inputs, { page: 1 })),
275
- Layer.provideMerge(Engine),
276
- )
277
-
278
- return Effect.gen(function* () {
279
- const store = yield* Items.store
280
- const queue = yield* Items.pending.store
281
- yield* until(() => store.get(), (v) => v._tag === 'Success')
282
- expect(server.state.fetches).toBe(1)
283
-
284
- const frames: Array<number> = []
285
- const off = store.subscribe(() => {
286
- const v = store.get()
287
- if (v._tag === 'Success') frames.push(v.value.length)
221
+ return Effect.gen(function* () {
222
+ const store = yield* Items.store
223
+ const queue = yield* Items.pending.store
224
+ yield* until(
225
+ () => store.get(),
226
+ (v) => v._tag === 'Success',
227
+ )
228
+
229
+ // Flicker guard: once optimistic row appears, no later Success frame may lack it
230
+ const frames: Array<number> = []
231
+ const off = store.subscribe(() => {
232
+ const v = store.get()
233
+ if (v._tag === 'Success') frames.push(v.value.length)
234
+ })
235
+ yield* Event.dispatch(Added, { id: 'b', name: 'beta' })
236
+ // Wait for queue entry to land before waiting for settle (empty would be vacuous)
237
+ yield* until(
238
+ () => queue.get(),
239
+ (q) => q.length === 1,
240
+ )
241
+ yield* until(
242
+ () => queue.get(),
243
+ (q) => q.length === 0,
244
+ )
245
+ off()
246
+ expect(frames.length).toBeGreaterThan(0)
247
+ expect(frames.every((n) => n === 2)).toBe(true)
248
+
249
+ const settled = yield* until(
250
+ () => store.get(),
251
+ (v) => v._tag === 'Success' && v.refetching === false,
252
+ )
253
+ if (settled._tag === 'Success') {
254
+ expect(settled.value).toEqual([
255
+ { id: 'a', name: 'alpha' },
256
+ { id: 'b', name: 'beta' },
257
+ ])
258
+ }
259
+ expect(server.state.fetches).toBe(2)
260
+ }).pipe(Effect.provide(TestLayer))
261
+ },
262
+ )
263
+
264
+ it.live(
265
+ 'THE GENERATION RULE: a refetch that started before the ack can never settle the intent',
266
+ () => {
267
+ class Page extends State.make('page', S.Number) {}
268
+ class Inputs extends StateGroup.make(Page) {}
269
+ class Poke extends Event.make('Poke', S.Struct({})) {}
270
+ const server = makeServer([{ id: 'a', name: 'alpha' }])
271
+ class Items extends RemoteState.make('Items', {
272
+ inputs: [StateGroup.select(Inputs, 'page')],
273
+ output: S.Array(Item),
274
+ intents: [Added, Renamed],
275
+ alwaysOn: true,
276
+ }) {}
277
+ const gate = makeFlightGate()
278
+ const ItemsLive = RemoteState.live(Items, {
279
+ query: () => gate.through(server.fetch(0)),
280
+ send: (intent) => server.receive(intent, 0),
281
+ apply: applyIntent,
282
+ invalidateOn: [Poke],
283
+ // Trailing lets pre-ack run land — stale Success must not settle
284
+ coalesce: 'trailing',
288
285
  })
286
+ const TestLayer = ItemsLive.pipe(
287
+ Layer.provideMerge(StateGroup.live(Inputs, { page: 1 })),
288
+ Layer.provideMerge(Engine),
289
+ )
289
290
 
290
- yield* Event.dispatch(Poke, {})
291
- yield* tick(5)
292
- yield* Event.dispatch(Added, { id: 'b', name: 'beta' })
293
-
294
- // Gen-2 Success settles nobody (generation rule)
295
- yield* until(() => server.state.fetches, (n) => n === 2)
296
- yield* tick(25)
297
- expect(queue.get()).toHaveLength(1)
298
- expect(queue.get()[0]).toMatchObject({ status: 'confirmed' })
299
- const midflight = store.get()
300
- expect(midflight).toMatchObject({ _tag: 'Success' })
301
- if (midflight._tag === 'Success') expect(midflight.value).toHaveLength(2)
302
-
303
- yield* until(() => queue.get(), (q) => q.length === 0)
304
- off()
305
- expect(server.state.fetches).toBe(3)
306
- const firstOptimistic = frames.indexOf(2)
307
- expect(firstOptimistic).toBeGreaterThanOrEqual(0)
308
- expect(frames.slice(firstOptimistic).every((n) => n === 2)).toBe(true)
309
- const settled = store.get()
310
- if (settled._tag === 'Success') {
311
- expect(settled.value).toEqual([
312
- { id: 'a', name: 'alpha' },
313
- { id: 'b', name: 'beta' },
314
- ])
315
- }
316
- }).pipe(Effect.provide(TestLayer))
317
- })
318
-
319
- it.live('a failed send settles the intent, reverts the overlay, and Failed reaches a user reducer', () => {
320
- class Page extends State.make('page', S.Number) {}
321
- class Inputs extends StateGroup.make(Page) {}
322
- class FailLog extends State.make('failLog', S.Array(S.String)) {}
323
- const server = makeServer([{ id: 'a', name: 'alpha' }])
324
- class Items extends RemoteState.make('Items', {
325
- inputs: [StateGroup.select(Inputs, 'page')],
326
- output: S.Array(Item),
327
- intents: [Added, Renamed],
328
- alwaysOn: true,
329
- }) {}
330
- const ItemsLive = RemoteState.live(Items, {
331
- query: () => server.fetch(5),
332
- send: () => Effect.fail('rejected' as const).pipe(Effect.delay(Duration.millis(15))),
333
- apply: applyIntent,
334
- })
335
- const LogReducer = Reducer.make('failLog', { states: [FailLog], events: [Items.Failed] })
336
- const TestLayer = Layer.mergeAll(
337
- ItemsLive,
338
- Reducer.live(LogReducer, (log, event) => [...log, `${event.intent._tag}|${String(event.error)}`]),
339
- ).pipe(
340
- Layer.provideMerge(State.live(FailLog, [])),
341
- Layer.provideMerge(StateGroup.live(Inputs, { page: 1 })),
342
- Layer.provideMerge(Engine),
343
- )
344
-
345
- return Effect.gen(function* () {
346
- const store = yield* Items.store
347
- const queue = yield* Items.pending.store
348
- const log = yield* FailLog.store
349
- yield* until(() => store.get(), (v) => v._tag === 'Success')
350
-
351
- yield* Event.dispatch(Added, { id: 'b', name: 'beta' })
352
- yield* until(() => store.get(), (v) => v._tag === 'Success' && v.value.length === 2)
353
- expect(queue.get()).toHaveLength(1)
354
-
355
- yield* until(() => queue.get(), (q) => q.length === 0)
356
- const reverted = yield* until(
357
- () => store.get(),
358
- (v) => v._tag === 'Success' && v.value.length === 1,
291
+ return Effect.gen(function* () {
292
+ const store = yield* Items.store
293
+ const queue = yield* Items.pending.store
294
+ yield* until(
295
+ () => store.get(),
296
+ (v) => v._tag === 'Success',
297
+ )
298
+ expect(server.state.fetches).toBe(1)
299
+
300
+ const frames: Array<number> = []
301
+ const off = store.subscribe(() => {
302
+ const v = store.get()
303
+ if (v._tag === 'Success') frames.push(v.value.length)
304
+ })
305
+
306
+ // The gen-2 fetch is pinned open, so the intent dispatched after it can only be
307
+ // settled by a later run — the stale snapshot is structurally denied the chance.
308
+ yield* gate.hold
309
+ yield* Event.dispatch(Poke, {})
310
+ yield* gate.awaitEntry(2)
311
+ expect(server.state.fetches).toBe(2)
312
+ yield* Event.dispatch(Added, { id: 'b', name: 'beta' })
313
+
314
+ yield* until(
315
+ () => queue.get(),
316
+ (q) => q[0]?.status === 'confirmed',
317
+ )
318
+ expect(queue.get()).toHaveLength(1)
319
+ expect(queue.get()[0]).toMatchObject({ status: 'confirmed' })
320
+ const midflight = store.get()
321
+ expect(midflight).toMatchObject({ _tag: 'Success' })
322
+ if (midflight._tag === 'Success') expect(midflight.value).toHaveLength(2)
323
+
324
+ yield* gate.release
325
+ yield* until(
326
+ () => queue.get(),
327
+ (q) => q.length === 0,
328
+ )
329
+ off()
330
+ expect(server.state.fetches).toBe(3)
331
+ const firstOptimistic = frames.indexOf(2)
332
+ expect(firstOptimistic).toBeGreaterThanOrEqual(0)
333
+ expect(frames.slice(firstOptimistic).every((n) => n === 2)).toBe(true)
334
+ const settled = store.get()
335
+ if (settled._tag === 'Success') {
336
+ expect(settled.value).toEqual([
337
+ { id: 'a', name: 'alpha' },
338
+ { id: 'b', name: 'beta' },
339
+ ])
340
+ }
341
+ }).pipe(Effect.provide(TestLayer))
342
+ },
343
+ )
344
+
345
+ it.live(
346
+ 'a failed send settles the intent, reverts the overlay, and Failed reaches a user reducer',
347
+ () => {
348
+ class Page extends State.make('page', S.Number) {}
349
+ class Inputs extends StateGroup.make(Page) {}
350
+ class FailLog extends State.make('failLog', S.Array(S.String)) {}
351
+ const server = makeServer([{ id: 'a', name: 'alpha' }])
352
+ class Items extends RemoteState.make('Items', {
353
+ inputs: [StateGroup.select(Inputs, 'page')],
354
+ output: S.Array(Item),
355
+ intents: [Added, Renamed],
356
+ alwaysOn: true,
357
+ }) {}
358
+ const ItemsLive = RemoteState.live(Items, {
359
+ query: () => server.fetch(5),
360
+ send: () => Effect.fail('rejected' as const).pipe(Effect.delay(Duration.millis(15))),
361
+ apply: applyIntent,
362
+ })
363
+ const LogReducer = Reducer.make('failLog', { states: [FailLog], events: [Items.Failed] })
364
+ const TestLayer = Layer.mergeAll(
365
+ ItemsLive,
366
+ Reducer.live(LogReducer, (log, event) => [
367
+ ...log,
368
+ `${event.intent._tag}|${String(event.error)}`,
369
+ ]),
370
+ ).pipe(
371
+ Layer.provideMerge(State.live(FailLog, [])),
372
+ Layer.provideMerge(StateGroup.live(Inputs, { page: 1 })),
373
+ Layer.provideMerge(Engine),
359
374
  )
360
- if (reverted._tag === 'Success') {
361
- expect(reverted.value).toEqual([{ id: 'a', name: 'alpha' }])
362
- }
363
- yield* until(() => log.get(), (l) => l.length === 1)
364
- expect(log.get()).toEqual(['Added|rejected'])
365
- expect(server.state.fetches).toBe(1)
366
- }).pipe(Effect.provide(TestLayer))
367
- })
375
+
376
+ return Effect.gen(function* () {
377
+ const store = yield* Items.store
378
+ const queue = yield* Items.pending.store
379
+ const log = yield* FailLog.store
380
+ yield* until(
381
+ () => store.get(),
382
+ (v) => v._tag === 'Success',
383
+ )
384
+
385
+ yield* Event.dispatch(Added, { id: 'b', name: 'beta' })
386
+ yield* until(
387
+ () => store.get(),
388
+ (v) => v._tag === 'Success' && v.value.length === 2,
389
+ )
390
+ expect(queue.get()).toHaveLength(1)
391
+
392
+ yield* until(
393
+ () => queue.get(),
394
+ (q) => q.length === 0,
395
+ )
396
+ const reverted = yield* until(
397
+ () => store.get(),
398
+ (v) => v._tag === 'Success' && v.value.length === 1,
399
+ )
400
+ if (reverted._tag === 'Success') {
401
+ expect(reverted.value).toEqual([{ id: 'a', name: 'alpha' }])
402
+ }
403
+ yield* until(
404
+ () => log.get(),
405
+ (l) => l.length === 1,
406
+ )
407
+ expect(log.get()).toEqual(['Added|rejected'])
408
+ expect(server.state.fetches).toBe(1)
409
+ }).pipe(Effect.provide(TestLayer))
410
+ },
411
+ )
368
412
 
369
413
  it.live('two intents apply in dispatch order and settle together on the post-ack run', () => {
370
414
  class Page extends State.make('page', S.Number) {}
@@ -389,19 +433,28 @@ it.live('two intents apply in dispatch order and settle together on the post-ack
389
433
  return Effect.gen(function* () {
390
434
  const store = yield* Items.store
391
435
  const queue = yield* Items.pending.store
392
- yield* until(() => store.get(), (v) => v._tag === 'Success')
436
+ yield* until(
437
+ () => store.get(),
438
+ (v) => v._tag === 'Success',
439
+ )
393
440
 
394
441
  // Apply order = dispatch order
395
442
  yield* Event.dispatch(Added, { id: 'b', name: 'beta' })
396
443
  yield* Event.dispatch(Renamed, { id: 'b', name: 'BETA' })
397
- yield* until(() => queue.get(), (q) => q.length === 2)
444
+ yield* until(
445
+ () => queue.get(),
446
+ (q) => q.length === 2,
447
+ )
398
448
  const optimistic = store.get()
399
449
  expect(optimistic).toMatchObject({ _tag: 'Success' })
400
450
  if (optimistic._tag === 'Success') {
401
451
  expect(optimistic.value[1]).toEqual({ id: 'b', name: 'BETA' })
402
452
  }
403
453
 
404
- yield* until(() => queue.get(), (q) => q.length === 0)
454
+ yield* until(
455
+ () => queue.get(),
456
+ (q) => q.length === 0,
457
+ )
405
458
  const settled = yield* until(
406
459
  () => store.get(),
407
460
  (v) => v._tag === 'Success' && v.refetching === false,
@@ -441,7 +494,10 @@ it.live('one intent failing leaves the other pending and converging', () => {
441
494
  return Effect.gen(function* () {
442
495
  const store = yield* Items.store
443
496
  const queue = yield* Items.pending.store
444
- yield* until(() => store.get(), (v) => v._tag === 'Success')
497
+ yield* until(
498
+ () => store.get(),
499
+ (v) => v._tag === 'Success',
500
+ )
445
501
 
446
502
  yield* Event.dispatch(Added, { id: 'b', name: 'beta' })
447
503
  yield* Event.dispatch(Renamed, { id: 'a', name: 'ALPHA' })
@@ -465,7 +521,10 @@ it.live('one intent failing leaves the other pending and converging', () => {
465
521
  ])
466
522
  }
467
523
 
468
- yield* until(() => queue.get(), (q) => q.length === 0)
524
+ yield* until(
525
+ () => queue.get(),
526
+ (q) => q.length === 0,
527
+ )
469
528
  const settled = yield* until(
470
529
  () => store.get(),
471
530
  (v) => v._tag === 'Success' && v.refetching === false,
@@ -502,10 +561,16 @@ it.live("pending exposes the lifecycle: 'sending' -> 'confirmed' -> gone", () =>
502
561
  return Effect.gen(function* () {
503
562
  const store = yield* Items.store
504
563
  const queue = yield* Items.pending.store
505
- yield* until(() => store.get(), (v) => v._tag === 'Success')
564
+ yield* until(
565
+ () => store.get(),
566
+ (v) => v._tag === 'Success',
567
+ )
506
568
 
507
569
  yield* Event.dispatch(Added, { id: 'b', name: 'beta' })
508
- const sending = yield* until(() => queue.get(), (q) => q.length === 1)
570
+ const sending = yield* until(
571
+ () => queue.get(),
572
+ (q) => q.length === 1,
573
+ )
509
574
  expect(sending[0]).toMatchObject({ status: 'sending', intent: { _tag: 'Added', id: 'b' } })
510
575
 
511
576
  const confirmed = yield* until(
@@ -514,7 +579,10 @@ it.live("pending exposes the lifecycle: 'sending' -> 'confirmed' -> gone", () =>
514
579
  )
515
580
  expect(confirmed[0]).toMatchObject({ status: 'confirmed' })
516
581
 
517
- yield* until(() => queue.get(), (q) => q.length === 0)
582
+ yield* until(
583
+ () => queue.get(),
584
+ (q) => q.length === 0,
585
+ )
518
586
  expect(queue.get()).toEqual([])
519
587
  }).pipe(Effect.provide(TestLayer))
520
588
  })
@@ -543,19 +611,28 @@ it.live('gated: intents queue and send while Idle; re-enabling converges and set
543
611
  const source = yield* StateGroup.select(Inputs, 'page').store
544
612
  const store = yield* Items.store
545
613
  const queue = yield* Items.pending.store
546
- yield* tick()
547
614
  expect(store.get()._tag).toBe('Idle')
548
615
 
549
616
  // Intents still send while gated Idle; settle waits for re-enable
550
617
  yield* Event.dispatch(Added, { id: 'b', name: 'beta' })
551
- yield* until(() => server.state.items, (items) => items.length === 2)
552
- yield* tick(20)
618
+ yield* until(
619
+ () => server.state.items,
620
+ (items) => items.length === 2,
621
+ )
622
+ // The ack is the only thing that could settle the intent — assert the instant it lands.
623
+ yield* until(
624
+ () => queue.get(),
625
+ (q) => q[0]?.status === 'confirmed',
626
+ )
553
627
  expect(store.get()._tag).toBe('Idle')
554
628
  expect(queue.get()).toHaveLength(1)
555
629
  expect(server.state.fetches).toBe(0)
556
630
 
557
631
  source.set(1)
558
- yield* until(() => queue.get(), (q) => q.length === 0)
632
+ yield* until(
633
+ () => queue.get(),
634
+ (q) => q.length === 0,
635
+ )
559
636
  const settled = yield* until(
560
637
  () => store.get(),
561
638
  (v) => v._tag === 'Success' && v.refetching === false,
@@ -596,13 +673,22 @@ it.live('reuse: untouched element identities survive the whole optimistic round-
596
673
  return Effect.gen(function* () {
597
674
  const store = yield* Items.store
598
675
  const queue = yield* Items.pending.store
599
- const first = yield* until(() => store.get(), (v) => v._tag === 'Success')
676
+ const first = yield* until(
677
+ () => store.get(),
678
+ (v) => v._tag === 'Success',
679
+ )
600
680
  if (first._tag !== 'Success') return
601
681
  const untouched = first.value[0]
602
682
 
603
683
  yield* Event.dispatch(Renamed, { id: 'b', name: 'BETA' })
604
- yield* until(() => queue.get(), (q) => q.length === 1)
605
- yield* until(() => queue.get(), (q) => q.length === 0)
684
+ yield* until(
685
+ () => queue.get(),
686
+ (q) => q.length === 1,
687
+ )
688
+ yield* until(
689
+ () => queue.get(),
690
+ (q) => q.length === 0,
691
+ )
606
692
  const settled = yield* until(
607
693
  () => store.get(),
608
694
  (v) => v._tag === 'Success' && v.refetching === false,
@@ -640,15 +726,27 @@ it.live('invalidateOn + coalesce trailing compose with the mutation lifecycle',
640
726
  return Effect.gen(function* () {
641
727
  const store = yield* Items.store
642
728
  const queue = yield* Items.pending.store
643
- yield* until(() => store.get(), (v) => v._tag === 'Success')
729
+ yield* until(
730
+ () => store.get(),
731
+ (v) => v._tag === 'Success',
732
+ )
644
733
  expect(server.state.fetches).toBe(1)
645
734
 
646
735
  yield* Event.dispatch(BoardChanged, {})
647
- yield* until(() => server.state.fetches, (n) => n === 2)
736
+ yield* until(
737
+ () => server.state.fetches,
738
+ (n) => n === 2,
739
+ )
648
740
 
649
741
  yield* Event.dispatch(Added, { id: 'b', name: 'beta' })
650
- yield* until(() => queue.get(), (q) => q.length === 1)
651
- yield* until(() => queue.get(), (q) => q.length === 0)
742
+ yield* until(
743
+ () => queue.get(),
744
+ (q) => q.length === 1,
745
+ )
746
+ yield* until(
747
+ () => queue.get(),
748
+ (q) => q.length === 0,
749
+ )
652
750
  const settled = yield* until(
653
751
  () => store.get(),
654
752
  (v) => v._tag === 'Success' && v.refetching === false,
@@ -673,8 +771,13 @@ it.live('a query Error passes through by reference with intents still pending',
673
771
  intents: [Added, Renamed],
674
772
  alwaysOn: true,
675
773
  }) {}
774
+ const attempts = { n: 0 }
676
775
  const ItemsLive = RemoteState.live(Items, {
677
- query: () => Effect.fail('boom').pipe(Effect.delay(Duration.millis(5))),
776
+ query: () =>
777
+ Effect.suspend(() => {
778
+ attempts.n += 1
779
+ return Effect.fail('boom')
780
+ }),
678
781
  send: (intent) => server.receive(intent, 10),
679
782
  apply: applyIntent,
680
783
  })
@@ -687,56 +790,71 @@ it.live('a query Error passes through by reference with intents still pending',
687
790
  const store = yield* Items.store
688
791
  const truth = yield* Items.truth.store
689
792
  const queue = yield* Items.pending.store
690
- yield* until(() => store.get(), (v) => v._tag === 'Error')
793
+ yield* until(
794
+ () => store.get(),
795
+ (v) => v._tag === 'Error',
796
+ )
691
797
 
692
798
  // Error runs settle nobody; overlay passes Error through without apply
693
799
  yield* Event.dispatch(Added, { id: 'b', name: 'beta' })
694
- yield* until(() => queue.get(), (q) => q.length === 1 && q[0]?.status === 'confirmed')
695
- yield* until(() => store.get(), (v) => v._tag === 'Error' && v.refetching === false)
696
- yield* tick(20)
800
+ yield* until(
801
+ () => queue.get(),
802
+ (q) => q.length === 1 && q[0]?.status === 'confirmed',
803
+ )
804
+ // Only a completed run could settle the intent — wait for the post-ack one to land.
805
+ yield* until(
806
+ () => attempts.n,
807
+ (n) => n >= 2,
808
+ )
809
+ yield* until(
810
+ () => store.get(),
811
+ (v) => v._tag === 'Error' && v.refetching === false,
812
+ )
697
813
  expect(queue.get()).toHaveLength(1)
698
814
  expect(store.get()).toBe(truth.get())
699
815
  }).pipe(Effect.provide(TestLayer))
700
816
  })
701
817
 
702
- it.live('teardown: closing the scope unregisters the reducers, the procedure, and the channel', () => {
703
- class Page extends State.make('page', S.Number) {}
704
- class Inputs extends StateGroup.make(Page) {}
705
- const server = makeServer([{ id: 'a', name: 'alpha' }])
706
- class Items extends RemoteState.make('Items', {
707
- inputs: [StateGroup.select(Inputs, 'page')],
708
- output: S.Array(Item),
709
- intents: [Added, Renamed],
710
- alwaysOn: true,
711
- }) {}
712
- const ItemsLive = RemoteState.live(Items, {
713
- query: () => server.fetch(5),
714
- send: (intent) => server.receive(intent, 5),
715
- apply: applyIntent,
716
- }).pipe(Layer.provideMerge(StateGroup.live(Inputs, { page: 1 })))
717
-
718
- return Effect.gen(function* () {
719
- const reducers = yield* Reducers
720
- const procedures = yield* Procedures
721
- const channels = yield* Channels
722
-
723
- yield* Effect.scoped(
724
- Effect.gen(function* () {
725
- yield* Layer.build(ItemsLive)
726
- expect(reducers.entries.some((entry) => entry.name === 'Items/pending')).toBe(true)
727
- expect(reducers.entries.some((entry) => entry.name === 'Items/revision')).toBe(true)
728
- expect(procedures.entries.some((entry) => entry.name === 'Items/send')).toBe(true)
729
- expect(channels.byName.has('Items/sends')).toBe(true)
730
- }),
731
- )
732
-
733
- expect(reducers.entries.some((entry) => entry.name === 'Items/pending')).toBe(false)
734
- expect(reducers.entries.some((entry) => entry.name === 'Items/revision')).toBe(false)
735
- expect(procedures.entries.some((entry) => entry.name === 'Items/send')).toBe(false)
736
- expect(channels.byName.has('Items/sends')).toBe(false)
737
- }).pipe(Effect.provide(Engine))
738
- })
818
+ it.live(
819
+ 'teardown: closing the scope unregisters the reducers, the procedure, and the channel',
820
+ () => {
821
+ class Page extends State.make('page', S.Number) {}
822
+ class Inputs extends StateGroup.make(Page) {}
823
+ const server = makeServer([{ id: 'a', name: 'alpha' }])
824
+ class Items extends RemoteState.make('Items', {
825
+ inputs: [StateGroup.select(Inputs, 'page')],
826
+ output: S.Array(Item),
827
+ intents: [Added, Renamed],
828
+ alwaysOn: true,
829
+ }) {}
830
+ const ItemsLive = RemoteState.live(Items, {
831
+ query: () => server.fetch(5),
832
+ send: (intent) => server.receive(intent, 5),
833
+ apply: applyIntent,
834
+ }).pipe(Layer.provideMerge(StateGroup.live(Inputs, { page: 1 })))
835
+
836
+ return Effect.gen(function* () {
837
+ const reducers = yield* Reducers
838
+ const procedures = yield* Procedures
839
+ const channels = yield* Channels
840
+
841
+ yield* Effect.scoped(
842
+ Effect.gen(function* () {
843
+ yield* Layer.build(ItemsLive)
844
+ expect(reducers.entries.some((entry) => entry.name === 'Items/pending')).toBe(true)
845
+ expect(reducers.entries.some((entry) => entry.name === 'Items/revision')).toBe(true)
846
+ expect(procedures.entries.some((entry) => entry.name === 'Items/send')).toBe(true)
847
+ expect(channels.byName.has('Items/sends')).toBe(true)
848
+ }),
849
+ )
739
850
 
851
+ expect(reducers.entries.some((entry) => entry.name === 'Items/pending')).toBe(false)
852
+ expect(reducers.entries.some((entry) => entry.name === 'Items/revision')).toBe(false)
853
+ expect(procedures.entries.some((entry) => entry.name === 'Items/send')).toBe(false)
854
+ expect(channels.byName.has('Items/sends')).toBe(false)
855
+ }).pipe(Effect.provide(Engine))
856
+ },
857
+ )
740
858
 
741
859
  it.live('persist: hydrates the seeded truth (stale) then writes the fresh truth through', () => {
742
860
  class Page extends State.make('page', S.Number) {}
@@ -765,7 +883,8 @@ it.live('persist: hydrates the seeded truth (stale) then writes the fresh truth
765
883
  const view = yield* Items.store
766
884
  const hydrated = view.get()
767
885
  expect(hydrated).toMatchObject({ _tag: 'Success', refetching: true })
768
- if (hydrated._tag === 'Success') expect(hydrated.value).toEqual([{ id: 'seed', name: 'seeded' }])
886
+ if (hydrated._tag === 'Success')
887
+ expect(hydrated.value).toEqual([{ id: 'seed', name: 'seeded' }])
769
888
 
770
889
  const settled = yield* until(
771
890
  () => view.get(),
@@ -780,120 +899,183 @@ it.live('persist: hydrates the seeded truth (stale) then writes the fresh truth
780
899
  }).pipe(Effect.provide(TestLayer))
781
900
  })
782
901
 
783
- it.live('persist: the optimistic overlay is NEVER written — only the converged server truth is', () => {
784
- class Page extends State.make('page', S.Number) {}
785
- class Inputs extends StateGroup.make(Page) {}
786
- const server = makeServer([{ id: 'a', name: 'alpha' }])
787
- const store = fakeQueryStore()
788
- class Items extends RemoteState.make('Items', {
789
- inputs: [StateGroup.select(Inputs, 'page')],
790
- output: S.Array(Item),
791
- intents: [Added, Renamed],
792
- alwaysOn: true,
793
- }) {}
794
- const ItemsLive = RemoteState.live(Items, {
795
- query: () => server.fetch(10),
796
- send: (intent) => server.receive(intent, 60),
797
- apply: applyIntent,
798
- persist: true,
799
- })
800
- const TestLayer = ItemsLive.pipe(
801
- Layer.provideMerge(StateGroup.live(Inputs, { page: 1 })),
802
- Layer.provideMerge(store.layer),
803
- Layer.provideMerge(Engine),
804
- )
805
-
806
- return Effect.gen(function* () {
807
- const view = yield* Items.store
808
- const queue = yield* Items.pending.store
809
- yield* until(() => view.get(), (v) => v._tag === 'Success')
810
- yield* until(() => store.map.get('Items'), (v) => Array.isArray(v) && v.length === 1)
811
-
812
- yield* Event.dispatch(Added, { id: 'b', name: 'beta' })
813
- yield* until(() => view.get(), (v) => v._tag === 'Success' && v.value.length === 2)
814
- yield* until(() => queue.get(), (q) => q.length === 1)
815
- // Persist never captures overlay — only settled server truth
816
- expect(store.map.get('Items')).toEqual([{ id: 'a', name: 'alpha' }])
817
-
818
- yield* until(() => queue.get(), (q) => q.length === 0)
819
- yield* until(
820
- () => store.map.get('Items'),
821
- (v) => Array.isArray(v) && v.length === 2,
902
+ it.live(
903
+ 'persist: the optimistic overlay is NEVER written — only the converged server truth is',
904
+ () => {
905
+ class Page extends State.make('page', S.Number) {}
906
+ class Inputs extends StateGroup.make(Page) {}
907
+ const server = makeServer([{ id: 'a', name: 'alpha' }])
908
+ const store = fakeQueryStore()
909
+ class Items extends RemoteState.make('Items', {
910
+ inputs: [StateGroup.select(Inputs, 'page')],
911
+ output: S.Array(Item),
912
+ intents: [Added, Renamed],
913
+ alwaysOn: true,
914
+ }) {}
915
+ const ItemsLive = RemoteState.live(Items, {
916
+ query: () => server.fetch(10),
917
+ send: (intent) => server.receive(intent, 60),
918
+ apply: applyIntent,
919
+ persist: true,
920
+ })
921
+ const TestLayer = ItemsLive.pipe(
922
+ Layer.provideMerge(StateGroup.live(Inputs, { page: 1 })),
923
+ Layer.provideMerge(store.layer),
924
+ Layer.provideMerge(Engine),
822
925
  )
823
- expect(store.map.get('Items')).toEqual([
824
- { id: 'a', name: 'alpha' },
825
- { id: 'b', name: 'beta' },
826
- ])
827
- }).pipe(Effect.provide(TestLayer))
828
- })
829
-
830
- it.live('persist: `true` keys by name; `{ key }` overrides — two definitions keep separate slots', () => {
831
- class Page extends State.make('page', S.Number) {}
832
- class Inputs extends StateGroup.make(Page) {}
833
- const serverA = makeServer([{ id: 'a', name: 'alpha' }])
834
- const serverB = makeServer([{ id: 'b', name: 'beta' }])
835
- const store = fakeQueryStore()
836
- class ItemsA extends RemoteState.make('ItemsA', {
837
- inputs: [StateGroup.select(Inputs, 'page')],
838
- output: S.Array(Item),
839
- intents: [Added, Renamed],
840
- alwaysOn: true,
841
- }) {}
842
- class ItemsB extends RemoteState.make('ItemsB', {
843
- inputs: [StateGroup.select(Inputs, 'page')],
844
- output: S.Array(Item),
845
- intents: [Added, Renamed],
846
- alwaysOn: true,
847
- }) {}
848
- const TestLayer = Layer.mergeAll(
849
- RemoteState.live(ItemsA, { query: () => serverA.fetch(10), send: (i) => serverA.receive(i, 10), apply: applyIntent, persist: true }),
850
- RemoteState.live(ItemsB, { query: () => serverB.fetch(10), send: (i) => serverB.receive(i, 10), apply: applyIntent, persist: { key: 'custom-B' } }),
851
- ).pipe(Layer.provideMerge(StateGroup.live(Inputs, { page: 1 })), Layer.provideMerge(store.layer), Layer.provideMerge(Engine))
852
926
 
853
- return Effect.gen(function* () {
854
- const a = yield* ItemsA.store
855
- const b = yield* ItemsB.store
856
- yield* until(() => a.get(), (v) => v._tag === 'Success')
857
- yield* until(() => b.get(), (v) => v._tag === 'Success')
858
- yield* until(() => store.map.get('ItemsA'), (v) => Array.isArray(v))
859
- yield* until(() => store.map.get('custom-B'), (v) => Array.isArray(v))
860
- expect(store.map.get('ItemsA')).toEqual([{ id: 'a', name: 'alpha' }])
861
- expect(store.map.get('custom-B')).toEqual([{ id: 'b', name: 'beta' }])
862
- expect(store.map.has('ItemsB')).toBe(false)
863
- }).pipe(Effect.provide(TestLayer))
864
- })
927
+ return Effect.gen(function* () {
928
+ const view = yield* Items.store
929
+ const queue = yield* Items.pending.store
930
+ yield* until(
931
+ () => view.get(),
932
+ (v) => v._tag === 'Success',
933
+ )
934
+ yield* until(
935
+ () => store.map.get('Items'),
936
+ (v) => Array.isArray(v) && v.length === 1,
937
+ )
938
+
939
+ yield* Event.dispatch(Added, { id: 'b', name: 'beta' })
940
+ yield* until(
941
+ () => view.get(),
942
+ (v) => v._tag === 'Success' && v.value.length === 2,
943
+ )
944
+ yield* until(
945
+ () => queue.get(),
946
+ (q) => q.length === 1,
947
+ )
948
+ // Persist never captures overlay — only settled server truth
949
+ expect(store.map.get('Items')).toEqual([{ id: 'a', name: 'alpha' }])
950
+
951
+ yield* until(
952
+ () => queue.get(),
953
+ (q) => q.length === 0,
954
+ )
955
+ yield* until(
956
+ () => store.map.get('Items'),
957
+ (v) => Array.isArray(v) && v.length === 2,
958
+ )
959
+ expect(store.map.get('Items')).toEqual([
960
+ { id: 'a', name: 'alpha' },
961
+ { id: 'b', name: 'beta' },
962
+ ])
963
+ }).pipe(Effect.provide(TestLayer))
964
+ },
965
+ )
966
+
967
+ it.live(
968
+ 'persist: `true` keys by name; `{ key }` overrides — two definitions keep separate slots',
969
+ () => {
970
+ class Page extends State.make('page', S.Number) {}
971
+ class Inputs extends StateGroup.make(Page) {}
972
+ const serverA = makeServer([{ id: 'a', name: 'alpha' }])
973
+ const serverB = makeServer([{ id: 'b', name: 'beta' }])
974
+ const store = fakeQueryStore()
975
+ class ItemsA extends RemoteState.make('ItemsA', {
976
+ inputs: [StateGroup.select(Inputs, 'page')],
977
+ output: S.Array(Item),
978
+ intents: [Added, Renamed],
979
+ alwaysOn: true,
980
+ }) {}
981
+ class ItemsB extends RemoteState.make('ItemsB', {
982
+ inputs: [StateGroup.select(Inputs, 'page')],
983
+ output: S.Array(Item),
984
+ intents: [Added, Renamed],
985
+ alwaysOn: true,
986
+ }) {}
987
+ const TestLayer = Layer.mergeAll(
988
+ RemoteState.live(ItemsA, {
989
+ query: () => serverA.fetch(10),
990
+ send: (i) => serverA.receive(i, 10),
991
+ apply: applyIntent,
992
+ persist: true,
993
+ }),
994
+ RemoteState.live(ItemsB, {
995
+ query: () => serverB.fetch(10),
996
+ send: (i) => serverB.receive(i, 10),
997
+ apply: applyIntent,
998
+ persist: { key: 'custom-B' },
999
+ }),
1000
+ ).pipe(
1001
+ Layer.provideMerge(StateGroup.live(Inputs, { page: 1 })),
1002
+ Layer.provideMerge(store.layer),
1003
+ Layer.provideMerge(Engine),
1004
+ )
865
1005
 
866
- it.live('persist (00c): a function key keys a family per-input — two input keys, two slots, no collision', () => {
867
- class Page extends State.make('page', S.Number) {}
868
- class Inputs extends StateGroup.make(Page) {}
869
- const inputs = StateGroup.live(Inputs, { page: 1 })
870
- const store = fakeQueryStore()
871
- const fetchPage = (page: number) =>
872
- Effect.as(tick(10), [{ id: `p${page}`, name: `page-${page}` }])
873
- class Fam extends RemoteState.make('Fam', {
874
- inputs: [StateGroup.select(Inputs, 'page')],
875
- output: S.Array(Item),
876
- intents: [Added, Renamed],
877
- alwaysOn: true,
878
- }) {}
879
- const FamLive = RemoteState.live(Fam, {
880
- query: ({ page }) => fetchPage(page),
881
- send: (i) => Effect.sync(() => void i),
882
- apply: applyIntent,
883
- persist: { key: ({ page }) => `fam:${page}` },
884
- })
885
- const TestLayer = FamLive.pipe(Layer.provideMerge(inputs), Layer.provideMerge(store.layer), Layer.provideMerge(Engine))
1006
+ return Effect.gen(function* () {
1007
+ const a = yield* ItemsA.store
1008
+ const b = yield* ItemsB.store
1009
+ yield* until(
1010
+ () => a.get(),
1011
+ (v) => v._tag === 'Success',
1012
+ )
1013
+ yield* until(
1014
+ () => b.get(),
1015
+ (v) => v._tag === 'Success',
1016
+ )
1017
+ yield* until(
1018
+ () => store.map.get('ItemsA'),
1019
+ (v) => Array.isArray(v),
1020
+ )
1021
+ yield* until(
1022
+ () => store.map.get('custom-B'),
1023
+ (v) => Array.isArray(v),
1024
+ )
1025
+ expect(store.map.get('ItemsA')).toEqual([{ id: 'a', name: 'alpha' }])
1026
+ expect(store.map.get('custom-B')).toEqual([{ id: 'b', name: 'beta' }])
1027
+ expect(store.map.has('ItemsB')).toBe(false)
1028
+ }).pipe(Effect.provide(TestLayer))
1029
+ },
1030
+ )
1031
+
1032
+ it.live(
1033
+ 'persist (00c): a function key keys a family per-input — two input keys, two slots, no collision',
1034
+ () => {
1035
+ class Page extends State.make('page', S.Number) {}
1036
+ class Inputs extends StateGroup.make(Page) {}
1037
+ const inputs = StateGroup.live(Inputs, { page: 1 })
1038
+ const store = fakeQueryStore()
1039
+ const fetchPage = (page: number) =>
1040
+ Effect.as(tick(10), [{ id: `p${page}`, name: `page-${page}` }])
1041
+ class Fam extends RemoteState.make('Fam', {
1042
+ inputs: [StateGroup.select(Inputs, 'page')],
1043
+ output: S.Array(Item),
1044
+ intents: [Added, Renamed],
1045
+ alwaysOn: true,
1046
+ }) {}
1047
+ const FamLive = RemoteState.live(Fam, {
1048
+ query: ({ page }) => fetchPage(page),
1049
+ send: (i) => Effect.sync(() => void i),
1050
+ apply: applyIntent,
1051
+ persist: { key: ({ page }) => `fam:${page}` },
1052
+ })
1053
+ const TestLayer = FamLive.pipe(
1054
+ Layer.provideMerge(inputs),
1055
+ Layer.provideMerge(store.layer),
1056
+ Layer.provideMerge(Engine),
1057
+ )
886
1058
 
887
- return Effect.gen(function* () {
888
- const view = yield* Fam.store
889
- const page = yield* Page.store
890
- yield* until(() => store.map.get('fam:1'), (v) => Array.isArray(v))
891
- expect(store.map.get('fam:1')).toEqual([{ id: 'p1', name: 'page-1' }])
892
-
893
- page.set(2)
894
- yield* until(() => view.get(), (v) => v._tag === 'Success' && v.value[0]?.id === 'p2')
895
- yield* until(() => store.map.get('fam:2'), (v) => Array.isArray(v))
896
- expect(store.map.get('fam:2')).toEqual([{ id: 'p2', name: 'page-2' }])
897
- expect(store.map.get('fam:1')).toEqual([{ id: 'p1', name: 'page-1' }])
898
- }).pipe(Effect.provide(TestLayer))
899
- })
1059
+ return Effect.gen(function* () {
1060
+ const view = yield* Fam.store
1061
+ const page = yield* Page.store
1062
+ yield* until(
1063
+ () => store.map.get('fam:1'),
1064
+ (v) => Array.isArray(v),
1065
+ )
1066
+ expect(store.map.get('fam:1')).toEqual([{ id: 'p1', name: 'page-1' }])
1067
+
1068
+ page.set(2)
1069
+ yield* until(
1070
+ () => view.get(),
1071
+ (v) => v._tag === 'Success' && v.value[0]?.id === 'p2',
1072
+ )
1073
+ yield* until(
1074
+ () => store.map.get('fam:2'),
1075
+ (v) => Array.isArray(v),
1076
+ )
1077
+ expect(store.map.get('fam:2')).toEqual([{ id: 'p2', name: 'page-2' }])
1078
+ expect(store.map.get('fam:1')).toEqual([{ id: 'p1', name: 'page-1' }])
1079
+ }).pipe(Effect.provide(TestLayer))
1080
+ },
1081
+ )