@playfast/reform 0.0.7 → 0.0.8
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/package.json +2 -2
- package/src/boundary/boundary.test.ts +301 -0
- package/src/boundary/boundary.ts +192 -0
- package/src/calc/asyncCalc.test.ts +556 -0
- package/src/calc/asyncCalc.ts +267 -0
- package/src/calc/asyncData.ts +96 -0
- package/src/calc/calc.test.ts +287 -0
- package/src/calc/calc.ts +120 -0
- package/src/calc/calcFamily.test.ts +206 -0
- package/src/calc/calcFamily.ts +194 -0
- package/src/calc/compose.test.ts +68 -0
- package/src/calc/compose.ts +69 -0
- package/src/channel/channel.ts +269 -0
- package/src/compose/composition.ts +112 -0
- package/{dist/compose/host.d.ts → src/compose/host.ts} +9 -8
- package/{dist/compose/props.js → src/compose/props.ts} +7 -5
- package/src/compose/provide.ts +70 -0
- package/{dist/compose/slot.d.ts → src/compose/slot.ts} +36 -19
- package/src/compose/ui.test.ts +62 -0
- package/src/compose/ui.ts +226 -0
- package/src/compose/ui.typecheck.ts +56 -0
- package/src/definition/definition.ts +76 -0
- package/src/event/event.test.ts +23 -0
- package/src/event/event.ts +65 -0
- package/src/event/eventGroup.ts +14 -0
- package/src/feature/feature.mount.test.ts +82 -0
- package/src/feature/feature.test.ts +60 -0
- package/{dist/feature/feature.d.ts → src/feature/feature.ts} +178 -92
- package/src/feature/feature.typecheck.ts +108 -0
- package/src/index.ts +176 -0
- package/{dist/internal/capture.d.ts → src/internal/capture.ts} +13 -11
- package/{dist/internal/ctx.d.ts → src/internal/ctx.ts} +8 -4
- package/src/internal/errors.test.ts +33 -0
- package/src/internal/errors.ts +145 -0
- package/src/internal/inspect.test.ts +28 -0
- package/src/internal/inspect.ts +34 -0
- package/src/internal/queryDriver.ts +247 -0
- package/src/internal/reuse.test.ts +116 -0
- package/src/internal/reuse.ts +73 -0
- package/src/internal/scheduler.ts +93 -0
- package/{dist/internal/seeds.d.ts → src/internal/seeds.ts} +6 -3
- package/src/internal/sources.ts +104 -0
- package/{dist/internal/stateRegistry.js → src/internal/stateRegistry.ts} +18 -14
- package/src/internal/store.test.ts +80 -0
- package/src/internal/store.ts +105 -0
- package/{dist/internal/track.d.ts → src/internal/track.ts} +17 -11
- package/src/procedure/procedure.ts +89 -0
- package/src/reducer/reducer.ts +137 -0
- package/src/remote/remoteState.test.ts +695 -0
- package/src/remote/remoteState.ts +571 -0
- package/src/remote/remoteState.typecheck.ts +195 -0
- package/src/runtime/bus.ts +40 -0
- package/src/runtime/hardening.test.ts +69 -0
- package/src/runtime/loop.test.ts +178 -0
- package/src/runtime/loop.ts +171 -0
- package/src/scene/scene.ts +76 -0
- package/src/scene/seedScene.test.ts +169 -0
- package/src/state/state.ts +94 -0
- package/src/state/stateFamily.test.ts +138 -0
- package/src/state/stateFamily.ts +174 -0
- package/src/state/stateGroup.ts +105 -0
- package/{dist/state/token.d.ts → src/state/token.ts} +22 -13
- package/src/synced/syncedStore.ts +99 -0
- package/{dist/ui/node.d.ts → src/ui/node.ts} +3 -3
- package/{dist/ui/trigger.d.ts → src/ui/trigger.ts} +1 -2
- package/src/wire/tree.test.ts +81 -0
- package/src/wire/tree.ts +129 -0
- package/src/wire/triggers.test.ts +76 -0
- package/src/wire/triggers.ts +98 -0
- package/dist/boundary/boundary.d.ts +0 -72
- package/dist/boundary/boundary.d.ts.map +0 -1
- package/dist/boundary/boundary.js +0 -83
- package/dist/boundary/boundary.js.map +0 -1
- package/dist/calc/asyncCalc.d.ts +0 -91
- package/dist/calc/asyncCalc.d.ts.map +0 -1
- package/dist/calc/asyncCalc.js +0 -95
- package/dist/calc/asyncCalc.js.map +0 -1
- package/dist/calc/asyncData.d.ts +0 -57
- package/dist/calc/asyncData.d.ts.map +0 -1
- package/dist/calc/asyncData.js +0 -34
- package/dist/calc/asyncData.js.map +0 -1
- package/dist/calc/calc.d.ts +0 -57
- package/dist/calc/calc.d.ts.map +0 -1
- package/dist/calc/calc.js +0 -58
- package/dist/calc/calc.js.map +0 -1
- package/dist/calc/calcFamily.d.ts +0 -57
- package/dist/calc/calcFamily.d.ts.map +0 -1
- package/dist/calc/calcFamily.js +0 -124
- package/dist/calc/calcFamily.js.map +0 -1
- package/dist/calc/compose.d.ts +0 -16
- package/dist/calc/compose.d.ts.map +0 -1
- package/dist/calc/compose.js +0 -7
- package/dist/calc/compose.js.map +0 -1
- package/dist/channel/channel.d.ts +0 -115
- package/dist/channel/channel.d.ts.map +0 -1
- package/dist/channel/channel.js +0 -138
- package/dist/channel/channel.js.map +0 -1
- package/dist/compose/composition.d.ts +0 -72
- package/dist/compose/composition.d.ts.map +0 -1
- package/dist/compose/composition.js +0 -46
- package/dist/compose/composition.js.map +0 -1
- package/dist/compose/host.d.ts.map +0 -1
- package/dist/compose/host.js +0 -6
- package/dist/compose/host.js.map +0 -1
- package/dist/compose/props.d.ts +0 -13
- package/dist/compose/props.d.ts.map +0 -1
- package/dist/compose/props.js.map +0 -1
- package/dist/compose/provide.d.ts +0 -22
- package/dist/compose/provide.d.ts.map +0 -1
- package/dist/compose/provide.js +0 -28
- package/dist/compose/provide.js.map +0 -1
- package/dist/compose/slot.d.ts.map +0 -1
- package/dist/compose/slot.js +0 -23
- package/dist/compose/slot.js.map +0 -1
- package/dist/compose/ui.d.ts +0 -135
- package/dist/compose/ui.d.ts.map +0 -1
- package/dist/compose/ui.js +0 -63
- package/dist/compose/ui.js.map +0 -1
- package/dist/definition/definition.d.ts +0 -33
- package/dist/definition/definition.d.ts.map +0 -1
- package/dist/definition/definition.js +0 -42
- package/dist/definition/definition.js.map +0 -1
- package/dist/event/event.d.ts +0 -33
- package/dist/event/event.d.ts.map +0 -1
- package/dist/event/event.js +0 -30
- package/dist/event/event.js.map +0 -1
- package/dist/event/eventGroup.d.ts +0 -9
- package/dist/event/eventGroup.d.ts.map +0 -1
- package/dist/event/eventGroup.js +0 -4
- package/dist/event/eventGroup.js.map +0 -1
- package/dist/feature/feature.d.ts.map +0 -1
- package/dist/feature/feature.js +0 -98
- package/dist/feature/feature.js.map +0 -1
- package/dist/index.d.ts +0 -53
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js +0 -62
- package/dist/index.js.map +0 -1
- package/dist/internal/capture.d.ts.map +0 -1
- package/dist/internal/capture.js +0 -12
- package/dist/internal/capture.js.map +0 -1
- package/dist/internal/ctx.d.ts.map +0 -1
- package/dist/internal/ctx.js +0 -2
- package/dist/internal/ctx.js.map +0 -1
- package/dist/internal/errors.d.ts +0 -89
- package/dist/internal/errors.d.ts.map +0 -1
- package/dist/internal/errors.js +0 -102
- package/dist/internal/errors.js.map +0 -1
- package/dist/internal/inspect.d.ts +0 -17
- package/dist/internal/inspect.d.ts.map +0 -1
- package/dist/internal/inspect.js +0 -32
- package/dist/internal/inspect.js.map +0 -1
- package/dist/internal/queryDriver.d.ts +0 -65
- package/dist/internal/queryDriver.d.ts.map +0 -1
- package/dist/internal/queryDriver.js +0 -134
- package/dist/internal/queryDriver.js.map +0 -1
- package/dist/internal/reuse.d.ts +0 -10
- package/dist/internal/reuse.d.ts.map +0 -1
- package/dist/internal/reuse.js +0 -68
- package/dist/internal/reuse.js.map +0 -1
- package/dist/internal/scheduler.d.ts +0 -47
- package/dist/internal/scheduler.d.ts.map +0 -1
- package/dist/internal/scheduler.js +0 -70
- package/dist/internal/scheduler.js.map +0 -1
- package/dist/internal/seeds.d.ts.map +0 -1
- package/dist/internal/seeds.js +0 -17
- package/dist/internal/seeds.js.map +0 -1
- package/dist/internal/sources.d.ts +0 -39
- package/dist/internal/sources.d.ts.map +0 -1
- package/dist/internal/sources.js +0 -59
- package/dist/internal/sources.js.map +0 -1
- package/dist/internal/stateRegistry.d.ts +0 -2
- package/dist/internal/stateRegistry.d.ts.map +0 -1
- package/dist/internal/stateRegistry.js.map +0 -1
- package/dist/internal/store.d.ts +0 -47
- package/dist/internal/store.d.ts.map +0 -1
- package/dist/internal/store.js +0 -73
- package/dist/internal/store.js.map +0 -1
- package/dist/internal/track.d.ts.map +0 -1
- package/dist/internal/track.js +0 -19
- package/dist/internal/track.js.map +0 -1
- package/dist/procedure/procedure.d.ts +0 -40
- package/dist/procedure/procedure.d.ts.map +0 -1
- package/dist/procedure/procedure.js +0 -50
- package/dist/procedure/procedure.js.map +0 -1
- package/dist/reducer/reducer.d.ts +0 -44
- package/dist/reducer/reducer.d.ts.map +0 -1
- package/dist/reducer/reducer.js +0 -63
- package/dist/reducer/reducer.js.map +0 -1
- package/dist/remote/remoteState.d.ts +0 -119
- package/dist/remote/remoteState.d.ts.map +0 -1
- package/dist/remote/remoteState.js +0 -270
- package/dist/remote/remoteState.js.map +0 -1
- package/dist/runtime/bus.d.ts +0 -27
- package/dist/runtime/bus.d.ts.map +0 -1
- package/dist/runtime/bus.js +0 -21
- package/dist/runtime/bus.js.map +0 -1
- package/dist/runtime/loop.d.ts +0 -45
- package/dist/runtime/loop.d.ts.map +0 -1
- package/dist/runtime/loop.js +0 -117
- package/dist/runtime/loop.js.map +0 -1
- package/dist/scene/scene.d.ts +0 -44
- package/dist/scene/scene.d.ts.map +0 -1
- package/dist/scene/scene.js +0 -31
- package/dist/scene/scene.js.map +0 -1
- package/dist/state/state.d.ts +0 -37
- package/dist/state/state.d.ts.map +0 -1
- package/dist/state/state.js +0 -46
- package/dist/state/state.js.map +0 -1
- package/dist/state/stateFamily.d.ts +0 -79
- package/dist/state/stateFamily.d.ts.map +0 -1
- package/dist/state/stateFamily.js +0 -99
- package/dist/state/stateFamily.js.map +0 -1
- package/dist/state/stateGroup.d.ts +0 -50
- package/dist/state/stateGroup.d.ts.map +0 -1
- package/dist/state/stateGroup.js +0 -54
- package/dist/state/stateGroup.js.map +0 -1
- package/dist/state/token.d.ts.map +0 -1
- package/dist/state/token.js +0 -20
- package/dist/state/token.js.map +0 -1
- package/dist/synced/syncedStore.d.ts +0 -51
- package/dist/synced/syncedStore.d.ts.map +0 -1
- package/dist/synced/syncedStore.js +0 -43
- package/dist/synced/syncedStore.js.map +0 -1
- package/dist/ui/node.d.ts.map +0 -1
- package/dist/ui/node.js +0 -2
- package/dist/ui/node.js.map +0 -1
- package/dist/ui/trigger.d.ts.map +0 -1
- package/dist/ui/trigger.js +0 -2
- package/dist/ui/trigger.js.map +0 -1
- package/dist/wire/tree.d.ts +0 -66
- package/dist/wire/tree.d.ts.map +0 -1
- package/dist/wire/tree.js +0 -60
- package/dist/wire/tree.js.map +0 -1
- package/dist/wire/triggers.d.ts +0 -44
- package/dist/wire/triggers.d.ts.map +0 -1
- package/dist/wire/triggers.js +0 -31
- package/dist/wire/triggers.js.map +0 -1
|
@@ -0,0 +1,695 @@
|
|
|
1
|
+
import { expect, it } from '@effect/vitest'
|
|
2
|
+
import { Duration, Effect, Layer, Match, Schema as S } from 'effect'
|
|
3
|
+
import {
|
|
4
|
+
Channels,
|
|
5
|
+
Engine,
|
|
6
|
+
Event,
|
|
7
|
+
Procedures,
|
|
8
|
+
Reducer,
|
|
9
|
+
Reducers,
|
|
10
|
+
RemoteState,
|
|
11
|
+
State,
|
|
12
|
+
StateGroup,
|
|
13
|
+
} from '../index'
|
|
14
|
+
|
|
15
|
+
// RemoteState is server-owned state with optimistic mutations as one primitive:
|
|
16
|
+
// visible = pending.reduce(apply, serverTruth). Dispatching an intent event is
|
|
17
|
+
// the only write surface; a settled or failed intent leaves the queue and the
|
|
18
|
+
// derivation converges. The headline guarantee is the GENERATION RULE: an
|
|
19
|
+
// intent acked at query-run generation g settles only on a Success run with a
|
|
20
|
+
// later generation — the optimistic change never flickers out mid-convergence.
|
|
21
|
+
|
|
22
|
+
const tick = (ms = 10) => Effect.sleep(Duration.millis(ms))
|
|
23
|
+
|
|
24
|
+
// Poll until the predicate holds (bounded) — for assertions that race real
|
|
25
|
+
// timers under parallel-suite host load, where a fixed `tick` budget flakes.
|
|
26
|
+
const until = <A>(read: () => A, pred: (a: A) => boolean, rounds = 200): Effect.Effect<A> =>
|
|
27
|
+
Effect.suspend(() => {
|
|
28
|
+
const a = read()
|
|
29
|
+
return pred(a) || rounds <= 0
|
|
30
|
+
? Effect.succeed(a)
|
|
31
|
+
: Effect.flatMap(tick(10), () => until(read, pred, rounds - 1))
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
// ── Shared fixture: a tiny mutable item server. ─────────────────────────────
|
|
35
|
+
|
|
36
|
+
const Item = S.Struct({ id: S.String, name: S.String })
|
|
37
|
+
type Item = typeof Item.Type
|
|
38
|
+
|
|
39
|
+
class Added extends Event.make('Added', S.Struct({ id: S.String, name: S.String })) {}
|
|
40
|
+
class Renamed extends Event.make('Renamed', S.Struct({ id: S.String, name: S.String })) {}
|
|
41
|
+
type Intent = Event.EventType<typeof Added> | Event.EventType<typeof Renamed>
|
|
42
|
+
|
|
43
|
+
// The per-intent fold — idempotent against truth that already carries the
|
|
44
|
+
// intent (an add whose id landed is skipped; a rename converges naturally).
|
|
45
|
+
const applyIntent = (items: ReadonlyArray<Item>, intent: Intent): ReadonlyArray<Item> =>
|
|
46
|
+
Match.value(intent).pipe(
|
|
47
|
+
Match.tag('Added', ({ id, name }) =>
|
|
48
|
+
items.some((item) => item.id === id) ? items : [...items, { id, name }],
|
|
49
|
+
),
|
|
50
|
+
Match.tag('Renamed', ({ id, name }) =>
|
|
51
|
+
items.map((item) => (item.id === id ? { ...item, name } : item)),
|
|
52
|
+
),
|
|
53
|
+
Match.exhaustive,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
// A server double whose query SNAPSHOTS state at request arrival (like a real
|
|
57
|
+
// DB read) and whose send applies the mutation right before acking.
|
|
58
|
+
const makeServer = (initial: ReadonlyArray<Item>) => {
|
|
59
|
+
const state = { items: initial, fetches: 0 }
|
|
60
|
+
return {
|
|
61
|
+
state,
|
|
62
|
+
fetch: (delayMs: number) =>
|
|
63
|
+
Effect.suspend(() => {
|
|
64
|
+
state.fetches += 1
|
|
65
|
+
const snapshot = state.items
|
|
66
|
+
return Effect.as(tick(delayMs), snapshot)
|
|
67
|
+
}),
|
|
68
|
+
receive: (intent: Intent, delayMs: number) =>
|
|
69
|
+
Effect.flatMap(tick(delayMs), () =>
|
|
70
|
+
Effect.sync(() => {
|
|
71
|
+
state.items = applyIntent(state.items, intent)
|
|
72
|
+
}),
|
|
73
|
+
),
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
it.live('an intent overlays instantly — no fetch fired, truth untouched', () => {
|
|
78
|
+
class Page extends State.make('page', S.Number) {}
|
|
79
|
+
class Inputs extends StateGroup.make(Page) {}
|
|
80
|
+
const server = makeServer([{ id: 'a', name: 'alpha' }])
|
|
81
|
+
class Items extends RemoteState.make('Items', {
|
|
82
|
+
inputs: [StateGroup.select(Inputs, 'page')],
|
|
83
|
+
output: S.Array(Item),
|
|
84
|
+
intents: [Added, Renamed],
|
|
85
|
+
alwaysOn: true,
|
|
86
|
+
}) {}
|
|
87
|
+
const ItemsLive = RemoteState.live(Items, {
|
|
88
|
+
query: () => server.fetch(5),
|
|
89
|
+
// Slow send: the pure-optimistic window stays open for the assertions.
|
|
90
|
+
send: (intent) => server.receive(intent, 80),
|
|
91
|
+
apply: applyIntent,
|
|
92
|
+
})
|
|
93
|
+
const TestLayer = ItemsLive.pipe(
|
|
94
|
+
Layer.provideMerge(StateGroup.live(Inputs, { page: 1 })),
|
|
95
|
+
Layer.provideMerge(Engine),
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
return Effect.gen(function* () {
|
|
99
|
+
const store = yield* Items.store
|
|
100
|
+
const truth = yield* Items.truth.store
|
|
101
|
+
const queue = yield* Items.pending.store
|
|
102
|
+
yield* until(() => store.get(), (v) => v._tag === 'Success')
|
|
103
|
+
expect(server.state.fetches).toBe(1)
|
|
104
|
+
|
|
105
|
+
// Dispatch the intent: the overlay shows it before the RPC or any refetch.
|
|
106
|
+
yield* Event.dispatch(Added, { id: 'b', name: 'beta' })
|
|
107
|
+
const optimistic = yield* until(
|
|
108
|
+
() => store.get(),
|
|
109
|
+
(v) => v._tag === 'Success' && v.value.length === 2,
|
|
110
|
+
)
|
|
111
|
+
if (optimistic._tag === 'Success') {
|
|
112
|
+
expect(optimistic.value[1]).toEqual({ id: 'b', name: 'beta' })
|
|
113
|
+
}
|
|
114
|
+
expect(queue.get()).toMatchObject([{ status: 'sending' }])
|
|
115
|
+
// Nothing remote happened yet: one fetch ever, truth still one row.
|
|
116
|
+
expect(server.state.fetches).toBe(1)
|
|
117
|
+
expect(truth.get()).toMatchObject({ _tag: 'Success' })
|
|
118
|
+
const t = truth.get()
|
|
119
|
+
if (t._tag === 'Success') expect(t.value).toHaveLength(1)
|
|
120
|
+
}).pipe(Effect.provide(TestLayer))
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it.live('convergence: the send acks, the refetch lands, the queue drains — and no frame ever lacked the row', () => {
|
|
124
|
+
class Page extends State.make('page', S.Number) {}
|
|
125
|
+
class Inputs extends StateGroup.make(Page) {}
|
|
126
|
+
const server = makeServer([{ id: 'a', name: 'alpha' }])
|
|
127
|
+
class Items extends RemoteState.make('Items', {
|
|
128
|
+
inputs: [StateGroup.select(Inputs, 'page')],
|
|
129
|
+
output: S.Array(Item),
|
|
130
|
+
intents: [Added, Renamed],
|
|
131
|
+
alwaysOn: true,
|
|
132
|
+
}) {}
|
|
133
|
+
const ItemsLive = RemoteState.live(Items, {
|
|
134
|
+
query: () => server.fetch(15),
|
|
135
|
+
send: (intent) => server.receive(intent, 15),
|
|
136
|
+
apply: applyIntent,
|
|
137
|
+
})
|
|
138
|
+
const TestLayer = ItemsLive.pipe(
|
|
139
|
+
Layer.provideMerge(StateGroup.live(Inputs, { page: 1 })),
|
|
140
|
+
Layer.provideMerge(Engine),
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
return Effect.gen(function* () {
|
|
144
|
+
const store = yield* Items.store
|
|
145
|
+
const queue = yield* Items.pending.store
|
|
146
|
+
yield* until(() => store.get(), (v) => v._tag === 'Success')
|
|
147
|
+
|
|
148
|
+
// FLICKER GUARD: log every Success frame from dispatch to convergence; the
|
|
149
|
+
// row must be present in all of them (the old settle-on-ack idiom fails
|
|
150
|
+
// this — the row vanished between the RPC response and the refetch landing).
|
|
151
|
+
const frames: Array<number> = []
|
|
152
|
+
const off = store.subscribe(() => {
|
|
153
|
+
const v = store.get()
|
|
154
|
+
if (v._tag === 'Success') frames.push(v.value.length)
|
|
155
|
+
})
|
|
156
|
+
yield* Event.dispatch(Added, { id: 'b', name: 'beta' })
|
|
157
|
+
// Dispatch routes through the bus: wait for the entry to LAND before
|
|
158
|
+
// waiting for it to leave (an empty read here would be vacuous).
|
|
159
|
+
yield* until(() => queue.get(), (q) => q.length === 1)
|
|
160
|
+
yield* until(() => queue.get(), (q) => q.length === 0)
|
|
161
|
+
off()
|
|
162
|
+
expect(frames.length).toBeGreaterThan(0)
|
|
163
|
+
expect(frames.every((n) => n === 2)).toBe(true)
|
|
164
|
+
|
|
165
|
+
const settled = yield* until(
|
|
166
|
+
() => store.get(),
|
|
167
|
+
(v) => v._tag === 'Success' && v.refetching === false,
|
|
168
|
+
)
|
|
169
|
+
if (settled._tag === 'Success') {
|
|
170
|
+
expect(settled.value).toEqual([
|
|
171
|
+
{ id: 'a', name: 'alpha' },
|
|
172
|
+
{ id: 'b', name: 'beta' },
|
|
173
|
+
])
|
|
174
|
+
}
|
|
175
|
+
expect(server.state.fetches).toBe(2)
|
|
176
|
+
}).pipe(Effect.provide(TestLayer))
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
it.live('THE GENERATION RULE: a refetch that started before the ack can never settle the intent', () => {
|
|
180
|
+
class Page extends State.make('page', S.Number) {}
|
|
181
|
+
class Inputs extends StateGroup.make(Page) {}
|
|
182
|
+
class Poke extends Event.make('Poke', S.Struct({})) {}
|
|
183
|
+
const server = makeServer([{ id: 'a', name: 'alpha' }])
|
|
184
|
+
class Items extends RemoteState.make('Items', {
|
|
185
|
+
inputs: [StateGroup.select(Inputs, 'page')],
|
|
186
|
+
output: S.Array(Item),
|
|
187
|
+
intents: [Added, Renamed],
|
|
188
|
+
alwaysOn: true,
|
|
189
|
+
}) {}
|
|
190
|
+
const ItemsLive = RemoteState.live(Items, {
|
|
191
|
+
// Slow query: its snapshot is taken at request arrival, so a run started
|
|
192
|
+
// before the send acks carries truth WITHOUT the mutation.
|
|
193
|
+
query: () => server.fetch(60),
|
|
194
|
+
send: (intent) => server.receive(intent, 15),
|
|
195
|
+
apply: applyIntent,
|
|
196
|
+
invalidateOn: [Poke],
|
|
197
|
+
// Trailing: the pre-ack run completes and LANDS (switch would cancel it),
|
|
198
|
+
// which is exactly the stale-Success the rule must refuse to settle on.
|
|
199
|
+
coalesce: 'trailing',
|
|
200
|
+
})
|
|
201
|
+
const TestLayer = ItemsLive.pipe(
|
|
202
|
+
Layer.provideMerge(StateGroup.live(Inputs, { page: 1 })),
|
|
203
|
+
Layer.provideMerge(Engine),
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
return Effect.gen(function* () {
|
|
207
|
+
const store = yield* Items.store
|
|
208
|
+
const queue = yield* Items.pending.store
|
|
209
|
+
yield* until(() => store.get(), (v) => v._tag === 'Success')
|
|
210
|
+
expect(server.state.fetches).toBe(1)
|
|
211
|
+
|
|
212
|
+
const frames: Array<number> = []
|
|
213
|
+
const off = store.subscribe(() => {
|
|
214
|
+
const v = store.get()
|
|
215
|
+
if (v._tag === 'Success') frames.push(v.value.length)
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
// A refetch starts (generation 2, snapshot WITHOUT the row)…
|
|
219
|
+
yield* Event.dispatch(Poke, {})
|
|
220
|
+
yield* tick(5)
|
|
221
|
+
// …and the intent is dispatched + acked while it is still in flight. The
|
|
222
|
+
// ack registers a waiter for generation 3 and queues the trailing refetch.
|
|
223
|
+
yield* Event.dispatch(Added, { id: 'b', name: 'beta' })
|
|
224
|
+
|
|
225
|
+
// The stale run (gen 2) lands its Success. The rule: it settles NOBODY —
|
|
226
|
+
// the entry stays pending and the overlay keeps the row over stale truth.
|
|
227
|
+
yield* until(() => server.state.fetches, (n) => n === 2)
|
|
228
|
+
yield* tick(25)
|
|
229
|
+
expect(queue.get()).toHaveLength(1)
|
|
230
|
+
expect(queue.get()[0]).toMatchObject({ status: 'confirmed' })
|
|
231
|
+
const midflight = store.get()
|
|
232
|
+
expect(midflight).toMatchObject({ _tag: 'Success' })
|
|
233
|
+
if (midflight._tag === 'Success') expect(midflight.value).toHaveLength(2)
|
|
234
|
+
|
|
235
|
+
// The post-ack run (gen 3) lands with the mutation: NOW the entry settles.
|
|
236
|
+
yield* until(() => queue.get(), (q) => q.length === 0)
|
|
237
|
+
off()
|
|
238
|
+
expect(server.state.fetches).toBe(3)
|
|
239
|
+
// The frames before the intent legitimately show one row (the Poke refetch
|
|
240
|
+
// flips `refetching` and notifies). The flicker property is: once the
|
|
241
|
+
// optimistic row APPEARS, no later frame may lack it.
|
|
242
|
+
const firstOptimistic = frames.indexOf(2)
|
|
243
|
+
expect(firstOptimistic).toBeGreaterThanOrEqual(0)
|
|
244
|
+
expect(frames.slice(firstOptimistic).every((n) => n === 2)).toBe(true)
|
|
245
|
+
const settled = store.get()
|
|
246
|
+
if (settled._tag === 'Success') {
|
|
247
|
+
expect(settled.value).toEqual([
|
|
248
|
+
{ id: 'a', name: 'alpha' },
|
|
249
|
+
{ id: 'b', name: 'beta' },
|
|
250
|
+
])
|
|
251
|
+
}
|
|
252
|
+
}).pipe(Effect.provide(TestLayer))
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
it.live('a failed send settles the intent, reverts the overlay, and Failed reaches a user reducer', () => {
|
|
256
|
+
class Page extends State.make('page', S.Number) {}
|
|
257
|
+
class Inputs extends StateGroup.make(Page) {}
|
|
258
|
+
class FailLog extends State.make('failLog', S.Array(S.String)) {}
|
|
259
|
+
const server = makeServer([{ id: 'a', name: 'alpha' }])
|
|
260
|
+
class Items extends RemoteState.make('Items', {
|
|
261
|
+
inputs: [StateGroup.select(Inputs, 'page')],
|
|
262
|
+
output: S.Array(Item),
|
|
263
|
+
intents: [Added, Renamed],
|
|
264
|
+
alwaysOn: true,
|
|
265
|
+
}) {}
|
|
266
|
+
const ItemsLive = RemoteState.live(Items, {
|
|
267
|
+
query: () => server.fetch(5),
|
|
268
|
+
send: () => Effect.fail('rejected' as const).pipe(Effect.delay(Duration.millis(15))),
|
|
269
|
+
apply: applyIntent,
|
|
270
|
+
})
|
|
271
|
+
// The toast/retry seam: an ordinary reducer on the public Failed event.
|
|
272
|
+
const LogReducer = Reducer.make('failLog', { states: [FailLog], events: [Items.Failed] })
|
|
273
|
+
const TestLayer = Layer.mergeAll(
|
|
274
|
+
ItemsLive,
|
|
275
|
+
Reducer.live(LogReducer, (log, event) => [...log, `${event.intent._tag}|${String(event.error)}`]),
|
|
276
|
+
).pipe(
|
|
277
|
+
Layer.provideMerge(State.live(FailLog, [])),
|
|
278
|
+
Layer.provideMerge(StateGroup.live(Inputs, { page: 1 })),
|
|
279
|
+
Layer.provideMerge(Engine),
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
return Effect.gen(function* () {
|
|
283
|
+
const store = yield* Items.store
|
|
284
|
+
const queue = yield* Items.pending.store
|
|
285
|
+
const log = yield* FailLog.store
|
|
286
|
+
yield* until(() => store.get(), (v) => v._tag === 'Success')
|
|
287
|
+
|
|
288
|
+
yield* Event.dispatch(Added, { id: 'b', name: 'beta' })
|
|
289
|
+
yield* until(() => store.get(), (v) => v._tag === 'Success' && v.value.length === 2)
|
|
290
|
+
expect(queue.get()).toHaveLength(1)
|
|
291
|
+
|
|
292
|
+
// The send fails: entry leaves the queue, the view reverts by construction,
|
|
293
|
+
// and the Failed payload carried the intent + the error.
|
|
294
|
+
yield* until(() => queue.get(), (q) => q.length === 0)
|
|
295
|
+
const reverted = yield* until(
|
|
296
|
+
() => store.get(),
|
|
297
|
+
(v) => v._tag === 'Success' && v.value.length === 1,
|
|
298
|
+
)
|
|
299
|
+
if (reverted._tag === 'Success') {
|
|
300
|
+
expect(reverted.value).toEqual([{ id: 'a', name: 'alpha' }])
|
|
301
|
+
}
|
|
302
|
+
yield* until(() => log.get(), (l) => l.length === 1)
|
|
303
|
+
expect(log.get()).toEqual(['Added|rejected'])
|
|
304
|
+
// No invalidation on failure: the one boot fetch is still the only one.
|
|
305
|
+
expect(server.state.fetches).toBe(1)
|
|
306
|
+
}).pipe(Effect.provide(TestLayer))
|
|
307
|
+
})
|
|
308
|
+
|
|
309
|
+
it.live('two intents apply in dispatch order and settle together on the post-ack run', () => {
|
|
310
|
+
class Page extends State.make('page', S.Number) {}
|
|
311
|
+
class Inputs extends StateGroup.make(Page) {}
|
|
312
|
+
const server = makeServer([{ id: 'a', name: 'alpha' }])
|
|
313
|
+
class Items extends RemoteState.make('Items', {
|
|
314
|
+
inputs: [StateGroup.select(Inputs, 'page')],
|
|
315
|
+
output: S.Array(Item),
|
|
316
|
+
intents: [Added, Renamed],
|
|
317
|
+
alwaysOn: true,
|
|
318
|
+
}) {}
|
|
319
|
+
const ItemsLive = RemoteState.live(Items, {
|
|
320
|
+
query: () => server.fetch(15),
|
|
321
|
+
send: (intent) => server.receive(intent, 15),
|
|
322
|
+
apply: applyIntent,
|
|
323
|
+
})
|
|
324
|
+
const TestLayer = ItemsLive.pipe(
|
|
325
|
+
Layer.provideMerge(StateGroup.live(Inputs, { page: 1 })),
|
|
326
|
+
Layer.provideMerge(Engine),
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
return Effect.gen(function* () {
|
|
330
|
+
const store = yield* Items.store
|
|
331
|
+
const queue = yield* Items.pending.store
|
|
332
|
+
yield* until(() => store.get(), (v) => v._tag === 'Success')
|
|
333
|
+
|
|
334
|
+
// Order matters: the rename targets the row the add creates. Reversed
|
|
335
|
+
// application would rename nothing and show 'beta'.
|
|
336
|
+
yield* Event.dispatch(Added, { id: 'b', name: 'beta' })
|
|
337
|
+
yield* Event.dispatch(Renamed, { id: 'b', name: 'BETA' })
|
|
338
|
+
yield* until(() => queue.get(), (q) => q.length === 2)
|
|
339
|
+
const optimistic = store.get()
|
|
340
|
+
expect(optimistic).toMatchObject({ _tag: 'Success' })
|
|
341
|
+
if (optimistic._tag === 'Success') {
|
|
342
|
+
expect(optimistic.value[1]).toEqual({ id: 'b', name: 'BETA' })
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
yield* until(() => queue.get(), (q) => q.length === 0)
|
|
346
|
+
const settled = yield* until(
|
|
347
|
+
() => store.get(),
|
|
348
|
+
(v) => v._tag === 'Success' && v.refetching === false,
|
|
349
|
+
)
|
|
350
|
+
if (settled._tag === 'Success') {
|
|
351
|
+
expect(settled.value).toEqual([
|
|
352
|
+
{ id: 'a', name: 'alpha' },
|
|
353
|
+
{ id: 'b', name: 'BETA' },
|
|
354
|
+
])
|
|
355
|
+
}
|
|
356
|
+
}).pipe(Effect.provide(TestLayer))
|
|
357
|
+
})
|
|
358
|
+
|
|
359
|
+
it.live('one intent failing leaves the other pending and converging', () => {
|
|
360
|
+
class Page extends State.make('page', S.Number) {}
|
|
361
|
+
class Inputs extends StateGroup.make(Page) {}
|
|
362
|
+
const server = makeServer([{ id: 'a', name: 'alpha' }])
|
|
363
|
+
class Items extends RemoteState.make('Items', {
|
|
364
|
+
inputs: [StateGroup.select(Inputs, 'page')],
|
|
365
|
+
output: S.Array(Item),
|
|
366
|
+
intents: [Added, Renamed],
|
|
367
|
+
alwaysOn: true,
|
|
368
|
+
}) {}
|
|
369
|
+
const ItemsLive = RemoteState.live(Items, {
|
|
370
|
+
query: () => server.fetch(10),
|
|
371
|
+
// The rename is rejected; the add is much slower and succeeds — so the
|
|
372
|
+
// partial state (one failed, one in flight) is observable.
|
|
373
|
+
send: (intent) =>
|
|
374
|
+
intent._tag === 'Renamed'
|
|
375
|
+
? Effect.fail('rejected' as const).pipe(Effect.delay(Duration.millis(50)))
|
|
376
|
+
: server.receive(intent, 150),
|
|
377
|
+
apply: applyIntent,
|
|
378
|
+
})
|
|
379
|
+
const TestLayer = ItemsLive.pipe(
|
|
380
|
+
Layer.provideMerge(StateGroup.live(Inputs, { page: 1 })),
|
|
381
|
+
Layer.provideMerge(Engine),
|
|
382
|
+
)
|
|
383
|
+
|
|
384
|
+
return Effect.gen(function* () {
|
|
385
|
+
const store = yield* Items.store
|
|
386
|
+
const queue = yield* Items.pending.store
|
|
387
|
+
yield* until(() => store.get(), (v) => v._tag === 'Success')
|
|
388
|
+
|
|
389
|
+
yield* Event.dispatch(Added, { id: 'b', name: 'beta' })
|
|
390
|
+
yield* Event.dispatch(Renamed, { id: 'a', name: 'ALPHA' })
|
|
391
|
+
// Both folded: the overlay shows the rename optimistically.
|
|
392
|
+
yield* until(
|
|
393
|
+
() => store.get(),
|
|
394
|
+
(v) => v._tag === 'Success' && v.value[0]?.name === 'ALPHA',
|
|
395
|
+
)
|
|
396
|
+
expect(queue.get()).toHaveLength(2)
|
|
397
|
+
|
|
398
|
+
// The rename fails and leaves; the add stays optimistic and intact.
|
|
399
|
+
yield* until(
|
|
400
|
+
() => store.get(),
|
|
401
|
+
(v) => v._tag === 'Success' && v.value[0]?.name === 'alpha',
|
|
402
|
+
)
|
|
403
|
+
expect(queue.get()).toHaveLength(1)
|
|
404
|
+
expect(queue.get()[0]).toMatchObject({ status: 'sending', intent: { _tag: 'Added' } })
|
|
405
|
+
const partial = store.get()
|
|
406
|
+
if (partial._tag === 'Success') {
|
|
407
|
+
expect(partial.value).toEqual([
|
|
408
|
+
{ id: 'a', name: 'alpha' },
|
|
409
|
+
{ id: 'b', name: 'beta' },
|
|
410
|
+
])
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// The add converges independently.
|
|
414
|
+
yield* until(() => queue.get(), (q) => q.length === 0)
|
|
415
|
+
const settled = yield* until(
|
|
416
|
+
() => store.get(),
|
|
417
|
+
(v) => v._tag === 'Success' && v.refetching === false,
|
|
418
|
+
)
|
|
419
|
+
if (settled._tag === 'Success') {
|
|
420
|
+
expect(settled.value).toEqual([
|
|
421
|
+
{ id: 'a', name: 'alpha' },
|
|
422
|
+
{ id: 'b', name: 'beta' },
|
|
423
|
+
])
|
|
424
|
+
}
|
|
425
|
+
}).pipe(Effect.provide(TestLayer))
|
|
426
|
+
})
|
|
427
|
+
|
|
428
|
+
it.live("pending exposes the lifecycle: 'sending' -> 'confirmed' -> gone", () => {
|
|
429
|
+
class Page extends State.make('page', S.Number) {}
|
|
430
|
+
class Inputs extends StateGroup.make(Page) {}
|
|
431
|
+
const server = makeServer([{ id: 'a', name: 'alpha' }])
|
|
432
|
+
class Items extends RemoteState.make('Items', {
|
|
433
|
+
inputs: [StateGroup.select(Inputs, 'page')],
|
|
434
|
+
output: S.Array(Item),
|
|
435
|
+
intents: [Added, Renamed],
|
|
436
|
+
alwaysOn: true,
|
|
437
|
+
}) {}
|
|
438
|
+
const ItemsLive = RemoteState.live(Items, {
|
|
439
|
+
// A slow refetch keeps the 'confirmed' window wide enough to observe.
|
|
440
|
+
query: () => server.fetch(40),
|
|
441
|
+
send: (intent) => server.receive(intent, 40),
|
|
442
|
+
apply: applyIntent,
|
|
443
|
+
})
|
|
444
|
+
const TestLayer = ItemsLive.pipe(
|
|
445
|
+
Layer.provideMerge(StateGroup.live(Inputs, { page: 1 })),
|
|
446
|
+
Layer.provideMerge(Engine),
|
|
447
|
+
)
|
|
448
|
+
|
|
449
|
+
return Effect.gen(function* () {
|
|
450
|
+
const store = yield* Items.store
|
|
451
|
+
const queue = yield* Items.pending.store
|
|
452
|
+
yield* until(() => store.get(), (v) => v._tag === 'Success')
|
|
453
|
+
|
|
454
|
+
yield* Event.dispatch(Added, { id: 'b', name: 'beta' })
|
|
455
|
+
const sending = yield* until(() => queue.get(), (q) => q.length === 1)
|
|
456
|
+
expect(sending[0]).toMatchObject({ status: 'sending', intent: { _tag: 'Added', id: 'b' } })
|
|
457
|
+
|
|
458
|
+
const confirmed = yield* until(
|
|
459
|
+
() => queue.get(),
|
|
460
|
+
(q) => q.length === 1 && q[0]?.status === 'confirmed',
|
|
461
|
+
)
|
|
462
|
+
expect(confirmed[0]).toMatchObject({ status: 'confirmed' })
|
|
463
|
+
|
|
464
|
+
yield* until(() => queue.get(), (q) => q.length === 0)
|
|
465
|
+
expect(queue.get()).toEqual([])
|
|
466
|
+
}).pipe(Effect.provide(TestLayer))
|
|
467
|
+
})
|
|
468
|
+
|
|
469
|
+
it.live('gated: intents queue and send while Idle; re-enabling converges and settles', () => {
|
|
470
|
+
class Page extends State.make('page', S.Number) {}
|
|
471
|
+
class Inputs extends StateGroup.make(Page) {}
|
|
472
|
+
const server = makeServer([{ id: 'a', name: 'alpha' }])
|
|
473
|
+
class Items extends RemoteState.make('Items', {
|
|
474
|
+
inputs: [StateGroup.select(Inputs, 'page')],
|
|
475
|
+
output: S.Array(Item),
|
|
476
|
+
intents: [Added, Renamed],
|
|
477
|
+
}) {}
|
|
478
|
+
const ItemsLive = RemoteState.live(Items, {
|
|
479
|
+
query: () => server.fetch(5),
|
|
480
|
+
send: (intent) => server.receive(intent, 10),
|
|
481
|
+
apply: applyIntent,
|
|
482
|
+
disabled: ({ page }) => page < 0,
|
|
483
|
+
})
|
|
484
|
+
const TestLayer = ItemsLive.pipe(
|
|
485
|
+
Layer.provideMerge(StateGroup.live(Inputs, { page: -1 })),
|
|
486
|
+
Layer.provideMerge(Engine),
|
|
487
|
+
)
|
|
488
|
+
|
|
489
|
+
return Effect.gen(function* () {
|
|
490
|
+
const source = yield* StateGroup.select(Inputs, 'page').store
|
|
491
|
+
const store = yield* Items.store
|
|
492
|
+
const queue = yield* Items.pending.store
|
|
493
|
+
yield* tick()
|
|
494
|
+
expect(store.get()._tag).toBe('Idle')
|
|
495
|
+
|
|
496
|
+
// The intent still sends while the query is gated off: the server takes
|
|
497
|
+
// the mutation, but the view stays Idle and the entry waits for a run.
|
|
498
|
+
yield* Event.dispatch(Added, { id: 'b', name: 'beta' })
|
|
499
|
+
yield* until(() => server.state.items, (items) => items.length === 2)
|
|
500
|
+
yield* tick(20)
|
|
501
|
+
expect(store.get()._tag).toBe('Idle')
|
|
502
|
+
expect(queue.get()).toHaveLength(1)
|
|
503
|
+
expect(server.state.fetches).toBe(0)
|
|
504
|
+
|
|
505
|
+
// Re-enable: the run lands with the mutation and settles the waiter.
|
|
506
|
+
source.set(1)
|
|
507
|
+
yield* until(() => queue.get(), (q) => q.length === 0)
|
|
508
|
+
const settled = yield* until(
|
|
509
|
+
() => store.get(),
|
|
510
|
+
(v) => v._tag === 'Success' && v.refetching === false,
|
|
511
|
+
)
|
|
512
|
+
if (settled._tag === 'Success') {
|
|
513
|
+
expect(settled.value).toEqual([
|
|
514
|
+
{ id: 'a', name: 'alpha' },
|
|
515
|
+
{ id: 'b', name: 'beta' },
|
|
516
|
+
])
|
|
517
|
+
}
|
|
518
|
+
}).pipe(Effect.provide(TestLayer))
|
|
519
|
+
})
|
|
520
|
+
|
|
521
|
+
it.live('reuse: untouched element identities survive the whole optimistic round-trip', () => {
|
|
522
|
+
class Page extends State.make('page', S.Number) {}
|
|
523
|
+
class Inputs extends StateGroup.make(Page) {}
|
|
524
|
+
const server = makeServer([
|
|
525
|
+
{ id: 'a', name: 'alpha' },
|
|
526
|
+
{ id: 'b', name: 'beta' },
|
|
527
|
+
])
|
|
528
|
+
class Items extends RemoteState.make('Items', {
|
|
529
|
+
inputs: [StateGroup.select(Inputs, 'page')],
|
|
530
|
+
output: S.Array(Item),
|
|
531
|
+
intents: [Added, Renamed],
|
|
532
|
+
alwaysOn: true,
|
|
533
|
+
}) {}
|
|
534
|
+
const ItemsLive = RemoteState.live(Items, {
|
|
535
|
+
// Fresh objects every fetch — only `reuse` can preserve identities.
|
|
536
|
+
query: () => Effect.map(server.fetch(10), (items) => items.map((item) => ({ ...item }))),
|
|
537
|
+
send: (intent) => server.receive(intent, 10),
|
|
538
|
+
apply: applyIntent,
|
|
539
|
+
reuse: true,
|
|
540
|
+
})
|
|
541
|
+
const TestLayer = ItemsLive.pipe(
|
|
542
|
+
Layer.provideMerge(StateGroup.live(Inputs, { page: 1 })),
|
|
543
|
+
Layer.provideMerge(Engine),
|
|
544
|
+
)
|
|
545
|
+
|
|
546
|
+
return Effect.gen(function* () {
|
|
547
|
+
const store = yield* Items.store
|
|
548
|
+
const queue = yield* Items.pending.store
|
|
549
|
+
const first = yield* until(() => store.get(), (v) => v._tag === 'Success')
|
|
550
|
+
if (first._tag !== 'Success') return
|
|
551
|
+
const untouched = first.value[0]
|
|
552
|
+
|
|
553
|
+
yield* Event.dispatch(Renamed, { id: 'b', name: 'BETA' })
|
|
554
|
+
yield* until(() => queue.get(), (q) => q.length === 1)
|
|
555
|
+
yield* until(() => queue.get(), (q) => q.length === 0)
|
|
556
|
+
const settled = yield* until(
|
|
557
|
+
() => store.get(),
|
|
558
|
+
(v) => v._tag === 'Success' && v.refetching === false,
|
|
559
|
+
)
|
|
560
|
+
if (settled._tag !== 'Success') return
|
|
561
|
+
// Row a was never touched: same identity through the overlay recompute AND
|
|
562
|
+
// the convergence refetch. Row b moved: fresh.
|
|
563
|
+
expect(settled.value[0]).toBe(untouched)
|
|
564
|
+
expect(settled.value[1]).not.toBe(first.value[1])
|
|
565
|
+
expect(settled.value[1]).toEqual({ id: 'b', name: 'BETA' })
|
|
566
|
+
}).pipe(Effect.provide(TestLayer))
|
|
567
|
+
})
|
|
568
|
+
|
|
569
|
+
it.live('invalidateOn + coalesce trailing compose with the mutation lifecycle', () => {
|
|
570
|
+
class Page extends State.make('page', S.Number) {}
|
|
571
|
+
class Inputs extends StateGroup.make(Page) {}
|
|
572
|
+
class BoardChanged extends Event.make('BoardChanged', S.Struct({})) {}
|
|
573
|
+
const server = makeServer([{ id: 'a', name: 'alpha' }])
|
|
574
|
+
class Items extends RemoteState.make('Items', {
|
|
575
|
+
inputs: [StateGroup.select(Inputs, 'page')],
|
|
576
|
+
output: S.Array(Item),
|
|
577
|
+
intents: [Added, Renamed],
|
|
578
|
+
alwaysOn: true,
|
|
579
|
+
}) {}
|
|
580
|
+
const ItemsLive = RemoteState.live(Items, {
|
|
581
|
+
query: () => server.fetch(10),
|
|
582
|
+
send: (intent) => server.receive(intent, 10),
|
|
583
|
+
apply: applyIntent,
|
|
584
|
+
invalidateOn: [BoardChanged],
|
|
585
|
+
coalesce: 'trailing',
|
|
586
|
+
})
|
|
587
|
+
const TestLayer = ItemsLive.pipe(
|
|
588
|
+
Layer.provideMerge(StateGroup.live(Inputs, { page: 1 })),
|
|
589
|
+
Layer.provideMerge(Engine),
|
|
590
|
+
)
|
|
591
|
+
|
|
592
|
+
return Effect.gen(function* () {
|
|
593
|
+
const store = yield* Items.store
|
|
594
|
+
const queue = yield* Items.pending.store
|
|
595
|
+
yield* until(() => store.get(), (v) => v._tag === 'Success')
|
|
596
|
+
expect(server.state.fetches).toBe(1)
|
|
597
|
+
|
|
598
|
+
// A realtime push refetches like any invalidateOn event.
|
|
599
|
+
yield* Event.dispatch(BoardChanged, {})
|
|
600
|
+
yield* until(() => server.state.fetches, (n) => n === 2)
|
|
601
|
+
|
|
602
|
+
// The mutation lifecycle still converges under trailing conciliation.
|
|
603
|
+
yield* Event.dispatch(Added, { id: 'b', name: 'beta' })
|
|
604
|
+
yield* until(() => queue.get(), (q) => q.length === 1)
|
|
605
|
+
yield* until(() => queue.get(), (q) => q.length === 0)
|
|
606
|
+
const settled = yield* until(
|
|
607
|
+
() => store.get(),
|
|
608
|
+
(v) => v._tag === 'Success' && v.refetching === false,
|
|
609
|
+
)
|
|
610
|
+
if (settled._tag === 'Success') {
|
|
611
|
+
expect(settled.value).toEqual([
|
|
612
|
+
{ id: 'a', name: 'alpha' },
|
|
613
|
+
{ id: 'b', name: 'beta' },
|
|
614
|
+
])
|
|
615
|
+
}
|
|
616
|
+
}).pipe(Effect.provide(TestLayer))
|
|
617
|
+
})
|
|
618
|
+
|
|
619
|
+
it.live('a query Error passes through by reference with intents still pending', () => {
|
|
620
|
+
class Page extends State.make('page', S.Number) {}
|
|
621
|
+
class Inputs extends StateGroup.make(Page) {}
|
|
622
|
+
const server = makeServer([{ id: 'a', name: 'alpha' }])
|
|
623
|
+
class Items extends RemoteState.make('Items', {
|
|
624
|
+
inputs: [StateGroup.select(Inputs, 'page')],
|
|
625
|
+
output: S.Array(Item),
|
|
626
|
+
error: S.String,
|
|
627
|
+
intents: [Added, Renamed],
|
|
628
|
+
alwaysOn: true,
|
|
629
|
+
}) {}
|
|
630
|
+
const ItemsLive = RemoteState.live(Items, {
|
|
631
|
+
query: () => Effect.fail('boom').pipe(Effect.delay(Duration.millis(5))),
|
|
632
|
+
send: (intent) => server.receive(intent, 10),
|
|
633
|
+
apply: applyIntent,
|
|
634
|
+
})
|
|
635
|
+
const TestLayer = ItemsLive.pipe(
|
|
636
|
+
Layer.provideMerge(StateGroup.live(Inputs, { page: 1 })),
|
|
637
|
+
Layer.provideMerge(Engine),
|
|
638
|
+
)
|
|
639
|
+
|
|
640
|
+
return Effect.gen(function* () {
|
|
641
|
+
const store = yield* Items.store
|
|
642
|
+
const truth = yield* Items.truth.store
|
|
643
|
+
const queue = yield* Items.pending.store
|
|
644
|
+
yield* until(() => store.get(), (v) => v._tag === 'Error')
|
|
645
|
+
|
|
646
|
+
// The send succeeds and invalidates, but every run lands Error: an Error
|
|
647
|
+
// run settles NOBODY, so the entry stays pending (confirmed) — and the
|
|
648
|
+
// overlay passes the Error arm through by reference, never applying.
|
|
649
|
+
yield* Event.dispatch(Added, { id: 'b', name: 'beta' })
|
|
650
|
+
yield* until(() => queue.get(), (q) => q.length === 1 && q[0]?.status === 'confirmed')
|
|
651
|
+
yield* until(() => store.get(), (v) => v._tag === 'Error' && v.refetching === false)
|
|
652
|
+
yield* tick(20)
|
|
653
|
+
expect(queue.get()).toHaveLength(1)
|
|
654
|
+
expect(store.get()).toBe(truth.get())
|
|
655
|
+
}).pipe(Effect.provide(TestLayer))
|
|
656
|
+
})
|
|
657
|
+
|
|
658
|
+
it.live('teardown: closing the scope unregisters the reducers, the procedure, and the channel', () => {
|
|
659
|
+
class Page extends State.make('page', S.Number) {}
|
|
660
|
+
class Inputs extends StateGroup.make(Page) {}
|
|
661
|
+
const server = makeServer([{ id: 'a', name: 'alpha' }])
|
|
662
|
+
class Items extends RemoteState.make('Items', {
|
|
663
|
+
inputs: [StateGroup.select(Inputs, 'page')],
|
|
664
|
+
output: S.Array(Item),
|
|
665
|
+
intents: [Added, Renamed],
|
|
666
|
+
alwaysOn: true,
|
|
667
|
+
}) {}
|
|
668
|
+
const ItemsLive = RemoteState.live(Items, {
|
|
669
|
+
query: () => server.fetch(5),
|
|
670
|
+
send: (intent) => server.receive(intent, 5),
|
|
671
|
+
apply: applyIntent,
|
|
672
|
+
}).pipe(Layer.provideMerge(StateGroup.live(Inputs, { page: 1 })))
|
|
673
|
+
|
|
674
|
+
return Effect.gen(function* () {
|
|
675
|
+
const reducers = yield* Reducers
|
|
676
|
+
const procedures = yield* Procedures
|
|
677
|
+
const channels = yield* Channels
|
|
678
|
+
|
|
679
|
+
yield* Effect.scoped(
|
|
680
|
+
Effect.gen(function* () {
|
|
681
|
+
yield* Layer.build(ItemsLive)
|
|
682
|
+
expect(reducers.entries.some((entry) => entry.name === 'Items/pending')).toBe(true)
|
|
683
|
+
expect(reducers.entries.some((entry) => entry.name === 'Items/revision')).toBe(true)
|
|
684
|
+
expect(procedures.entries.some((entry) => entry.name === 'Items/send')).toBe(true)
|
|
685
|
+
expect(channels.byName.has('Items/sends')).toBe(true)
|
|
686
|
+
}),
|
|
687
|
+
)
|
|
688
|
+
|
|
689
|
+
// Scope closed: every registration is gone (a lazy feature unmounting).
|
|
690
|
+
expect(reducers.entries.some((entry) => entry.name === 'Items/pending')).toBe(false)
|
|
691
|
+
expect(reducers.entries.some((entry) => entry.name === 'Items/revision')).toBe(false)
|
|
692
|
+
expect(procedures.entries.some((entry) => entry.name === 'Items/send')).toBe(false)
|
|
693
|
+
expect(channels.byName.has('Items/sends')).toBe(false)
|
|
694
|
+
}).pipe(Effect.provide(Engine))
|
|
695
|
+
})
|