@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.
- package/README.md +85 -55
- package/package.json +17 -17
- package/src/boundary/boundary.test.ts +82 -33
- package/src/boundary/boundary.ts +141 -32
- package/src/calc/asyncCalc.invalidate.test.ts +733 -32
- package/src/calc/asyncCalc.test.ts +397 -225
- package/src/calc/asyncCalc.ts +115 -100
- package/src/calc/asyncCalcTypes.ts +147 -0
- package/src/calc/calc.test.ts +13 -16
- package/src/calc/calc.ts +44 -18
- package/src/calc/calcFamily.test.ts +90 -32
- package/src/calc/calcFamily.ts +56 -25
- package/src/calc/compose.ts +1 -14
- package/src/channel/channel.ts +25 -6
- package/src/channel/{procedureRegistry.ts → procedures.ts} +41 -23
- package/src/compose/composition.test.ts +19 -0
- package/src/compose/composition.ts +168 -81
- package/src/compose/compositionTypes.ts +249 -0
- package/src/compose/props.ts +13 -6
- package/src/compose/provide.ts +187 -46
- package/src/compose/slot.ts +156 -19
- package/src/compose/structure.test.ts +6 -3
- package/src/compose/structure.ts +106 -13
- package/src/compose/ui.test.ts +19 -3
- package/src/compose/ui.ts +147 -54
- package/src/compose/ui.typecheck.ts +40 -4
- package/src/definition/definition.ts +22 -9
- package/src/event/event.fromSource.test.ts +172 -0
- package/src/event/event.test.ts +10 -0
- package/src/event/event.ts +102 -18
- package/src/feature/feature.mount.test.ts +123 -56
- package/src/feature/feature.test.ts +67 -63
- package/src/feature/feature.ts +222 -262
- package/src/feature/feature.typecheck.ts +224 -91
- package/src/feature/featureBinding.ts +130 -20
- package/src/feature/featureClass.ts +162 -0
- package/src/feature/featureConfig.ts +192 -0
- package/src/feature/featureFactory.ts +163 -0
- package/src/feature/featureModule.ts +136 -0
- package/src/feature/featureModule.typecheck.ts +63 -0
- package/src/feature/featureNativeTree.ts +152 -0
- package/src/feature/featureRequirement.ts +82 -0
- package/src/feature/featureVariants.ts +234 -0
- package/src/feature/mountFeature.ts +54 -0
- package/src/graph/closure.ts +222 -0
- package/src/graph/services.ts +95 -0
- package/src/graph/summary.ts +134 -0
- package/src/index.ts +171 -245
- package/src/internal/bucketCache.ts +39 -0
- package/src/internal/capture.ts +9 -4
- package/src/internal/env.ts +1 -3
- package/src/internal/errors.ts +21 -10
- package/src/internal/queryDriver.ts +61 -25
- package/src/internal/queryDriverHydrate.ts +45 -0
- package/src/internal/queryDriverStore.ts +8 -5
- package/src/internal/queryDriverTypes.ts +11 -11
- package/src/internal/reuse.test.ts +10 -3
- package/src/internal/reuse.ts +5 -2
- package/src/internal/scheduler.ts +4 -1
- package/src/internal/sources.ts +25 -11
- package/src/internal/store.test.ts +27 -6
- package/src/internal/track.ts +3 -2
- package/src/internal.ts +222 -0
- package/src/namespace/namespace.ts +46 -25
- package/src/procedure/procedure.ts +38 -11
- package/src/reducer/reducer.ts +78 -34
- package/src/remote/pendingQueue.ts +145 -0
- package/src/remote/remoteState.test.ts +536 -354
- package/src/remote/remoteState.ts +145 -106
- package/src/remote/remoteState.typecheck.ts +47 -23
- package/src/remote/remoteStateMake.ts +97 -0
- package/src/remote/remoteStateSend.ts +95 -37
- package/src/remote/remoteStateTypes.ts +155 -0
- package/src/runtime/appRuntime.activation.test.ts +104 -0
- package/src/runtime/appRuntime.test.ts +187 -111
- package/src/runtime/appRuntime.ts +36 -130
- package/src/runtime/capturedAppRuntime.ts +274 -0
- package/src/runtime/eventBudget.test.ts +39 -12
- package/src/runtime/eventBudget.ts +9 -8
- package/src/runtime/featureMount.ts +94 -0
- package/src/runtime/hardening.test.ts +22 -13
- package/src/runtime/instrumentation.test.ts +213 -194
- package/src/runtime/instrumentation.ts +6 -11
- package/src/runtime/loop.test.ts +84 -7
- package/src/runtime/loop.ts +69 -40
- package/src/runtime/runtimeHandle.ts +124 -0
- package/src/scene/featureScene.test.ts +4 -2
- package/src/scene/scene.ts +234 -64
- package/src/scene/seedScene.test.ts +69 -86
- package/src/state/state.nominal.typecheck.ts +68 -0
- package/src/state/state.test.ts +17 -0
- package/src/state/state.ts +79 -26
- package/src/state/stateFamily.test.ts +39 -8
- package/src/state/stateFamily.ts +55 -22
- package/src/state/stateGroup.ts +53 -17
- package/src/state/token.ts +40 -10
- package/src/synced/syncedStore.ts +46 -23
- package/src/testkit/flight.testkit.ts +84 -0
- package/src/wire/tree.test.ts +8 -4
- package/src/wire/triggers.test.ts +6 -2
- package/src/wire/triggers.ts +14 -10
- package/src/calc/asyncCalcDefinitions.ts +0 -48
- package/src/internal/ctx.ts +0 -9
- package/src/remote/remoteStateDefinition.ts +0 -135
- package/src/remote/remoteStateLayers.ts +0 -118
- 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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
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
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
(
|
|
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
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
552
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
605
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
651
|
-
|
|
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: () =>
|
|
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(
|
|
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(
|
|
695
|
-
|
|
696
|
-
|
|
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(
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
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')
|
|
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(
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
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
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
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
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
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
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
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
|
+
)
|