@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.
- package/README.md +103 -56
- package/package.json +17 -17
- package/src/boundary/boundary.test.ts +63 -49
- package/src/boundary/boundary.ts +144 -113
- package/src/calc/asyncCalc.invalidate.test.ts +518 -32
- package/src/calc/asyncCalc.test.ts +207 -213
- package/src/calc/asyncCalc.ts +131 -154
- package/src/calc/asyncData.ts +0 -37
- package/src/calc/calc.test.ts +7 -33
- package/src/calc/calc.ts +44 -58
- package/src/calc/calcFamily.test.ts +80 -34
- package/src/calc/calcFamily.ts +54 -65
- package/src/calc/compose.test.ts +1 -12
- package/src/calc/compose.ts +1 -36
- package/src/calc/queryState.ts +0 -23
- package/src/channel/channel.ts +126 -111
- package/src/compose/composition.test.ts +19 -0
- package/src/compose/composition.ts +351 -127
- package/src/compose/host.ts +0 -6
- package/src/compose/props.ts +13 -12
- package/src/compose/provide.ts +189 -62
- package/src/compose/slot.ts +158 -49
- package/src/compose/structure.test.ts +6 -9
- package/src/compose/structure.ts +108 -79
- package/src/compose/ui.test.ts +19 -7
- package/src/compose/ui.ts +155 -156
- package/src/compose/ui.typecheck.ts +40 -18
- package/src/definition/definition.ts +22 -41
- package/src/event/event.fromSource.test.ts +172 -0
- package/src/event/event.test.ts +10 -3
- package/src/event/event.ts +102 -27
- package/src/event/eventGroup.ts +0 -1
- package/src/feature/feature.mount.test.ts +122 -43
- package/src/feature/feature.test.ts +46 -21
- package/src/feature/feature.ts +1347 -256
- package/src/feature/feature.typecheck.ts +273 -68
- package/src/graph/closure.ts +403 -0
- package/src/index.ts +68 -126
- package/src/internal/bucketCache.ts +39 -0
- package/src/internal/capture.ts +9 -24
- package/src/internal/env.ts +7 -0
- package/src/internal/errors.test.ts +0 -6
- package/src/internal/errors.ts +37 -60
- package/src/internal/inspect.test.ts +0 -6
- package/src/internal/inspect.ts +0 -12
- package/src/internal/queryDriver.ts +105 -201
- package/src/internal/queryDriverStore.ts +156 -0
- package/src/internal/queryDriverTypes.ts +59 -0
- package/src/internal/queryEvents.ts +0 -12
- package/src/internal/queryStore.ts +0 -14
- package/src/internal/reuse.test.ts +10 -14
- package/src/internal/reuse.ts +5 -30
- package/src/internal/scheduler.ts +4 -44
- package/src/internal/seeds.ts +0 -14
- package/src/internal/sources.ts +26 -49
- package/src/internal/stateRegistry.ts +0 -14
- package/src/internal/store.test.ts +1 -3
- package/src/internal/store.ts +0 -37
- package/src/internal/track.ts +3 -20
- package/src/internal/variance.ts +5 -0
- package/src/internal.ts +222 -0
- package/src/namespace/namespace.test.ts +46 -0
- package/src/namespace/namespace.ts +106 -0
- package/src/procedure/procedure.ts +40 -35
- package/src/reducer/reducer.ts +78 -52
- package/src/remote/remoteState.test.ts +590 -397
- package/src/remote/remoteState.ts +425 -347
- package/src/remote/remoteState.typecheck.ts +47 -56
- package/src/runtime/appRuntime.activation.test.ts +100 -0
- package/src/runtime/appRuntime.test.ts +249 -0
- package/src/runtime/appRuntime.ts +415 -97
- package/src/runtime/bus.ts +2 -15
- package/src/runtime/eventBudget.test.ts +152 -0
- package/src/runtime/eventBudget.ts +120 -0
- package/src/runtime/hardening.test.ts +73 -13
- package/src/runtime/instrumentation.test.ts +55 -52
- package/src/runtime/instrumentation.ts +6 -60
- package/src/runtime/loop.test.ts +36 -17
- package/src/runtime/loop.ts +87 -88
- package/src/runtime/queries.ts +0 -17
- package/src/scene/featureScene.test.ts +61 -0
- package/src/scene/scene.ts +247 -104
- package/src/scene/seedScene.test.ts +42 -79
- package/src/state/state.nominal.typecheck.ts +68 -0
- package/src/state/state.test.ts +17 -0
- package/src/state/state.ts +80 -46
- package/src/state/stateFamily.test.ts +16 -11
- package/src/state/stateFamily.ts +55 -65
- package/src/state/stateGroup.ts +53 -64
- package/src/state/token.ts +40 -23
- package/src/synced/syncedStore.ts +46 -58
- package/src/testkit/flight.testkit.ts +75 -0
- package/src/ui/node.ts +0 -6
- package/src/ui/trigger.ts +0 -5
- package/src/wire/tree.test.ts +8 -7
- package/src/wire/tree.ts +0 -39
- package/src/wire/triggers.test.ts +6 -6
- package/src/wire/triggers.ts +14 -32
- 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
|
-
|
|
4
|
+
Channel,
|
|
5
5
|
Engine,
|
|
6
6
|
Event,
|
|
7
|
-
|
|
8
|
-
QueryStore,
|
|
7
|
+
Procedure,
|
|
9
8
|
Reducer,
|
|
10
|
-
Reducers,
|
|
11
9
|
RemoteState,
|
|
12
10
|
State,
|
|
13
11
|
StateGroup,
|
|
14
12
|
} from '../index'
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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('
|
|
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
|
-
|
|
95
|
-
class Items extends RemoteState.make('
|
|
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: [
|
|
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: () =>
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
|
108
|
-
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
|
|
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('
|
|
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
|
-
|
|
141
|
-
class Items extends RemoteState.make('
|
|
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
|
-
|
|
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: () =>
|
|
149
|
-
send: (
|
|
150
|
-
apply:
|
|
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
|
|
153
|
-
|
|
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
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
-
|
|
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('
|
|
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
|
-
|
|
206
|
-
|
|
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(
|
|
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
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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(
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
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
|
-
|
|
303
|
-
|
|
304
|
-
|
|
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
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
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
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
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(
|
|
442
|
+
yield* until(
|
|
443
|
+
() => store.get(),
|
|
444
|
+
(v) => v._tag === 'Success',
|
|
445
|
+
)
|
|
347
446
|
|
|
348
|
-
//
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
428
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
//
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
569
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
619
|
-
|
|
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(
|
|
791
|
+
yield* until(
|
|
792
|
+
() => store.get(),
|
|
793
|
+
(v) => v._tag === 'Error',
|
|
794
|
+
)
|
|
659
795
|
|
|
660
|
-
//
|
|
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(
|
|
665
|
-
|
|
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(
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
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
|
-
|
|
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')
|
|
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(
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
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
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
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
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
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
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
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
|
+
)
|