@playfast/reform 1.0.1 → 1.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +18 -1
- package/package.json +1 -1
- package/src/boundary/boundary.test.ts +1 -33
- package/src/boundary/boundary.ts +4 -82
- package/src/calc/asyncCalc.invalidate.test.ts +2 -14
- package/src/calc/asyncCalc.test.ts +0 -38
- package/src/calc/asyncCalc.ts +11 -163
- package/src/calc/asyncCalcDefinitions.ts +48 -0
- package/src/calc/asyncData.ts +0 -37
- package/src/calc/calc.test.ts +4 -28
- package/src/calc/calc.ts +6 -46
- package/src/calc/calcFamily.test.ts +0 -12
- package/src/calc/calcFamily.ts +6 -48
- package/src/calc/compose.test.ts +1 -12
- package/src/calc/compose.ts +0 -22
- package/src/calc/queryState.ts +0 -23
- package/src/channel/channel.ts +72 -174
- package/src/channel/procedureRegistry.ts +90 -0
- package/src/compose/composition.ts +7 -67
- package/src/compose/host.ts +0 -6
- package/src/compose/props.ts +3 -9
- package/src/compose/provide.ts +19 -33
- package/src/compose/slot.ts +2 -30
- package/src/compose/structure.test.ts +0 -6
- package/src/compose/structure.ts +3 -67
- package/src/compose/ui.test.ts +0 -4
- package/src/compose/ui.ts +17 -111
- package/src/compose/ui.typecheck.ts +0 -14
- package/src/definition/definition.ts +0 -32
- package/src/event/event.test.ts +0 -3
- package/src/event/event.ts +5 -14
- package/src/event/eventGroup.ts +0 -1
- package/src/feature/feature.mount.test.ts +66 -54
- package/src/feature/feature.test.ts +67 -46
- package/src/feature/feature.ts +185 -214
- package/src/feature/feature.typecheck.ts +29 -16
- package/src/feature/featureBinding.ts +48 -0
- package/src/index.ts +245 -229
- package/src/internal/capture.ts +0 -20
- package/src/internal/ctx.ts +2 -9
- package/src/internal/env.ts +9 -0
- package/src/internal/errors.test.ts +0 -6
- package/src/internal/errors.ts +14 -48
- package/src/internal/inspect.test.ts +0 -6
- package/src/internal/inspect.ts +0 -12
- package/src/internal/queryDriver.ts +42 -243
- package/src/internal/queryDriverStore.ts +153 -0
- package/src/internal/queryDriverTypes.ts +57 -0
- package/src/internal/queryEvents.ts +0 -12
- package/src/internal/queryStore.ts +0 -14
- package/src/internal/reuse.test.ts +0 -11
- package/src/internal/reuse.ts +0 -28
- package/src/internal/scheduler.ts +0 -43
- package/src/internal/seeds.ts +0 -14
- package/src/internal/sources.ts +4 -41
- 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 +0 -18
- package/src/internal/variance.ts +5 -0
- package/src/namespace/namespace.test.ts +46 -0
- package/src/namespace/namespace.ts +85 -0
- package/src/procedure/procedure.ts +2 -24
- package/src/reducer/reducer.ts +3 -21
- package/src/remote/remoteState.test.ts +92 -75
- package/src/remote/remoteState.ts +99 -486
- package/src/remote/remoteState.typecheck.ts +0 -33
- package/src/remote/remoteStateDefinition.ts +135 -0
- package/src/remote/remoteStateLayers.ts +118 -0
- package/src/remote/remoteStateLiveTypes.ts +59 -0
- package/src/remote/remoteStateSend.ts +64 -0
- package/src/runtime/appRuntime.test.ts +197 -0
- package/src/runtime/appRuntime.ts +40 -83
- package/src/runtime/bus.ts +2 -15
- package/src/runtime/eventBudget.test.ts +155 -0
- package/src/runtime/eventBudget.ts +119 -0
- package/src/runtime/hardening.test.ts +74 -9
- package/src/runtime/instrumentation.test.ts +213 -192
- package/src/runtime/instrumentation.ts +0 -49
- package/src/runtime/loop.test.ts +0 -17
- package/src/runtime/loop.ts +25 -55
- package/src/runtime/queries.ts +0 -17
- package/src/scene/featureScene.test.ts +59 -0
- package/src/scene/scene.ts +32 -59
- package/src/scene/seedScene.test.ts +96 -116
- package/src/state/state.ts +7 -26
- package/src/state/stateFamily.test.ts +2 -11
- package/src/state/stateFamily.ts +9 -52
- package/src/state/stateGroup.ts +2 -49
- package/src/state/token.ts +4 -17
- package/src/synced/syncedStore.ts +7 -42
- package/src/ui/node.ts +0 -6
- package/src/ui/trigger.ts +0 -5
- package/src/wire/tree.test.ts +0 -3
- package/src/wire/tree.ts +0 -39
- package/src/wire/triggers.test.ts +0 -4
- package/src/wire/triggers.ts +5 -27
|
@@ -1,11 +1,13 @@
|
|
|
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
|
+
Channel,
|
|
4
5
|
Channels,
|
|
5
6
|
Engine,
|
|
6
7
|
Event,
|
|
7
8
|
Procedures,
|
|
8
9
|
QueryStore,
|
|
10
|
+
Procedure,
|
|
9
11
|
Reducer,
|
|
10
12
|
Reducers,
|
|
11
13
|
RemoteState,
|
|
@@ -13,17 +15,9 @@ import {
|
|
|
13
15
|
StateGroup,
|
|
14
16
|
} from '../index'
|
|
15
17
|
|
|
16
|
-
// RemoteState is server-owned state with optimistic mutations as one primitive:
|
|
17
|
-
// visible = pending.reduce(apply, serverTruth). Dispatching an intent event is
|
|
18
|
-
// the only write surface; a settled or failed intent leaves the queue and the
|
|
19
|
-
// derivation converges. The headline guarantee is the GENERATION RULE: an
|
|
20
|
-
// intent acked at query-run generation g settles only on a Success run with a
|
|
21
|
-
// later generation — the optimistic change never flickers out mid-convergence.
|
|
22
|
-
|
|
23
18
|
const tick = (ms = 10) => Effect.sleep(Duration.millis(ms))
|
|
24
19
|
|
|
25
|
-
//
|
|
26
|
-
// timers under parallel-suite host load, where a fixed `tick` budget flakes.
|
|
20
|
+
// Bounded poll — fixed tick budgets flake under parallel-suite host load
|
|
27
21
|
const until = <A>(read: () => A, pred: (a: A) => boolean, rounds = 200): Effect.Effect<A> =>
|
|
28
22
|
Effect.suspend(() => {
|
|
29
23
|
const a = read()
|
|
@@ -32,8 +26,6 @@ const until = <A>(read: () => A, pred: (a: A) => boolean, rounds = 200): Effect.
|
|
|
32
26
|
: Effect.flatMap(tick(10), () => until(read, pred, rounds - 1))
|
|
33
27
|
})
|
|
34
28
|
|
|
35
|
-
// ── Shared fixture: a tiny mutable item server. ─────────────────────────────
|
|
36
|
-
|
|
37
29
|
const Item = S.Struct({ id: S.String, name: S.String })
|
|
38
30
|
type Item = typeof Item.Type
|
|
39
31
|
|
|
@@ -41,8 +33,7 @@ class Added extends Event.make('Added', S.Struct({ id: S.String, name: S.String
|
|
|
41
33
|
class Renamed extends Event.make('Renamed', S.Struct({ id: S.String, name: S.String })) {}
|
|
42
34
|
type Intent = Event.EventType<typeof Added> | Event.EventType<typeof Renamed>
|
|
43
35
|
|
|
44
|
-
//
|
|
45
|
-
// intent (an add whose id landed is skipped; a rename converges naturally).
|
|
36
|
+
// apply must be idempotent against truth that already carries the intent
|
|
46
37
|
const applyIntent = (items: ReadonlyArray<Item>, intent: Intent): ReadonlyArray<Item> =>
|
|
47
38
|
Match.value(intent).pipe(
|
|
48
39
|
Match.tag('Added', ({ id, name }) =>
|
|
@@ -54,8 +45,7 @@ const applyIntent = (items: ReadonlyArray<Item>, intent: Intent): ReadonlyArray<
|
|
|
54
45
|
Match.exhaustive,
|
|
55
46
|
)
|
|
56
47
|
|
|
57
|
-
//
|
|
58
|
-
// DB read) and whose send applies the mutation right before acking.
|
|
48
|
+
// Query snapshots at request arrival; send mutates then acks
|
|
59
49
|
const makeServer = (initial: ReadonlyArray<Item>) => {
|
|
60
50
|
const state = { items: initial, fetches: 0 }
|
|
61
51
|
return {
|
|
@@ -75,8 +65,6 @@ const makeServer = (initial: ReadonlyArray<Item>) => {
|
|
|
75
65
|
}
|
|
76
66
|
}
|
|
77
67
|
|
|
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
68
|
const fakeQueryStore = (seed: Record<string, unknown> = {}) => {
|
|
81
69
|
const map = new Map<string, unknown>(Object.entries(seed))
|
|
82
70
|
const layer = Layer.succeed(QueryStore, {
|
|
@@ -88,6 +76,85 @@ const fakeQueryStore = (seed: Record<string, unknown> = {}) => {
|
|
|
88
76
|
return { map, layer }
|
|
89
77
|
}
|
|
90
78
|
|
|
79
|
+
it.live('settled dispatches after Success is committed to truth', () => {
|
|
80
|
+
class Page extends State.make('settled-success-page', S.Number) {}
|
|
81
|
+
class Inputs extends StateGroup.make(Page) {}
|
|
82
|
+
class QuerySettled extends Event.make('SettledSuccessQuerySettled', S.Struct({})) {}
|
|
83
|
+
class Items extends RemoteState.make('SettledSuccessItems', {
|
|
84
|
+
inputs: [StateGroup.select(Inputs, 'settled-success-page')],
|
|
85
|
+
output: S.Array(Item),
|
|
86
|
+
intents: [],
|
|
87
|
+
alwaysOn: true,
|
|
88
|
+
}) {}
|
|
89
|
+
class Lane extends Channel.make('SettledSuccessLane', { policy: { _tag: 'merge' } }) {}
|
|
90
|
+
class Observe extends Procedure.make('SettledSuccessObserve', {
|
|
91
|
+
events: [QuerySettled],
|
|
92
|
+
channel: Lane,
|
|
93
|
+
}) {}
|
|
94
|
+
|
|
95
|
+
const observed: Array<string> = []
|
|
96
|
+
const ItemsLive = RemoteState.live(Items, {
|
|
97
|
+
query: () => Effect.as(tick(5), [{ id: 'a', name: 'alpha' }]),
|
|
98
|
+
send: () => Effect.void,
|
|
99
|
+
apply: (items) => items,
|
|
100
|
+
settled: { event: QuerySettled, payload: () => ({}) },
|
|
101
|
+
})
|
|
102
|
+
const ObserveLive = Procedure.live(Observe, function* () {
|
|
103
|
+
const truth = yield* Items.truth
|
|
104
|
+
observed.push(truth._tag)
|
|
105
|
+
})
|
|
106
|
+
const TestLayer = Layer.merge(ObserveLive, Channel.live(Lane)).pipe(
|
|
107
|
+
Layer.provideMerge(ItemsLive),
|
|
108
|
+
Layer.provideMerge(StateGroup.live(Inputs, { 'settled-success-page': 1 })),
|
|
109
|
+
Layer.provideMerge(Engine),
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
return Effect.gen(function* () {
|
|
113
|
+
yield* until(() => observed, (values) => values.length === 1)
|
|
114
|
+
expect(observed).toEqual(['Success'])
|
|
115
|
+
}).pipe(Effect.provide(TestLayer))
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it.live('settled dispatches after Error is committed to truth', () => {
|
|
119
|
+
class Page extends State.make('settled-error-page', S.Number) {}
|
|
120
|
+
class Inputs extends StateGroup.make(Page) {}
|
|
121
|
+
class QuerySettled extends Event.make('SettledErrorQuerySettled', S.Struct({})) {}
|
|
122
|
+
class Items extends RemoteState.make('SettledErrorItems', {
|
|
123
|
+
inputs: [StateGroup.select(Inputs, 'settled-error-page')],
|
|
124
|
+
output: S.Array(Item),
|
|
125
|
+
error: S.String,
|
|
126
|
+
intents: [],
|
|
127
|
+
alwaysOn: true,
|
|
128
|
+
}) {}
|
|
129
|
+
class Lane extends Channel.make('SettledErrorLane', { policy: { _tag: 'merge' } }) {}
|
|
130
|
+
class Observe extends Procedure.make('SettledErrorObserve', {
|
|
131
|
+
events: [QuerySettled],
|
|
132
|
+
channel: Lane,
|
|
133
|
+
}) {}
|
|
134
|
+
|
|
135
|
+
const observed: Array<string> = []
|
|
136
|
+
const ItemsLive = RemoteState.live(Items, {
|
|
137
|
+
query: () => Effect.zipRight(tick(5), Effect.fail('offline')),
|
|
138
|
+
send: () => Effect.void,
|
|
139
|
+
apply: (items) => items,
|
|
140
|
+
settled: { event: QuerySettled, payload: () => ({}) },
|
|
141
|
+
})
|
|
142
|
+
const ObserveLive = Procedure.live(Observe, function* () {
|
|
143
|
+
const truth = yield* Items.truth
|
|
144
|
+
observed.push(truth._tag)
|
|
145
|
+
})
|
|
146
|
+
const TestLayer = Layer.merge(ObserveLive, Channel.live(Lane)).pipe(
|
|
147
|
+
Layer.provideMerge(ItemsLive),
|
|
148
|
+
Layer.provideMerge(StateGroup.live(Inputs, { 'settled-error-page': 1 })),
|
|
149
|
+
Layer.provideMerge(Engine),
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
return Effect.gen(function* () {
|
|
153
|
+
yield* until(() => observed, (values) => values.length === 1)
|
|
154
|
+
expect(observed).toEqual(['Error'])
|
|
155
|
+
}).pipe(Effect.provide(TestLayer))
|
|
156
|
+
})
|
|
157
|
+
|
|
91
158
|
it.live('an intent overlays instantly — no fetch fired, truth untouched', () => {
|
|
92
159
|
class Page extends State.make('page', S.Number) {}
|
|
93
160
|
class Inputs extends StateGroup.make(Page) {}
|
|
@@ -100,7 +167,6 @@ it.live('an intent overlays instantly — no fetch fired, truth untouched', () =
|
|
|
100
167
|
}) {}
|
|
101
168
|
const ItemsLive = RemoteState.live(Items, {
|
|
102
169
|
query: () => server.fetch(5),
|
|
103
|
-
// Slow send: the pure-optimistic window stays open for the assertions.
|
|
104
170
|
send: (intent) => server.receive(intent, 80),
|
|
105
171
|
apply: applyIntent,
|
|
106
172
|
})
|
|
@@ -116,7 +182,6 @@ it.live('an intent overlays instantly — no fetch fired, truth untouched', () =
|
|
|
116
182
|
yield* until(() => store.get(), (v) => v._tag === 'Success')
|
|
117
183
|
expect(server.state.fetches).toBe(1)
|
|
118
184
|
|
|
119
|
-
// Dispatch the intent: the overlay shows it before the RPC or any refetch.
|
|
120
185
|
yield* Event.dispatch(Added, { id: 'b', name: 'beta' })
|
|
121
186
|
const optimistic = yield* until(
|
|
122
187
|
() => store.get(),
|
|
@@ -126,7 +191,6 @@ it.live('an intent overlays instantly — no fetch fired, truth untouched', () =
|
|
|
126
191
|
expect(optimistic.value[1]).toEqual({ id: 'b', name: 'beta' })
|
|
127
192
|
}
|
|
128
193
|
expect(queue.get()).toMatchObject([{ status: 'sending' }])
|
|
129
|
-
// Nothing remote happened yet: one fetch ever, truth still one row.
|
|
130
194
|
expect(server.state.fetches).toBe(1)
|
|
131
195
|
expect(truth.get()).toMatchObject({ _tag: 'Success' })
|
|
132
196
|
const t = truth.get()
|
|
@@ -159,17 +223,14 @@ it.live('convergence: the send acks, the refetch lands, the queue drains — and
|
|
|
159
223
|
const queue = yield* Items.pending.store
|
|
160
224
|
yield* until(() => store.get(), (v) => v._tag === 'Success')
|
|
161
225
|
|
|
162
|
-
//
|
|
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).
|
|
226
|
+
// Flicker guard: once optimistic row appears, no later Success frame may lack it
|
|
165
227
|
const frames: Array<number> = []
|
|
166
228
|
const off = store.subscribe(() => {
|
|
167
229
|
const v = store.get()
|
|
168
230
|
if (v._tag === 'Success') frames.push(v.value.length)
|
|
169
231
|
})
|
|
170
232
|
yield* Event.dispatch(Added, { id: 'b', name: 'beta' })
|
|
171
|
-
//
|
|
172
|
-
// waiting for it to leave (an empty read here would be vacuous).
|
|
233
|
+
// Wait for queue entry to land before waiting for settle (empty would be vacuous)
|
|
173
234
|
yield* until(() => queue.get(), (q) => q.length === 1)
|
|
174
235
|
yield* until(() => queue.get(), (q) => q.length === 0)
|
|
175
236
|
off()
|
|
@@ -202,14 +263,11 @@ it.live('THE GENERATION RULE: a refetch that started before the ack can never se
|
|
|
202
263
|
alwaysOn: true,
|
|
203
264
|
}) {}
|
|
204
265
|
const ItemsLive = RemoteState.live(Items, {
|
|
205
|
-
// Slow query: its snapshot is taken at request arrival, so a run started
|
|
206
|
-
// before the send acks carries truth WITHOUT the mutation.
|
|
207
266
|
query: () => server.fetch(60),
|
|
208
267
|
send: (intent) => server.receive(intent, 15),
|
|
209
268
|
apply: applyIntent,
|
|
210
269
|
invalidateOn: [Poke],
|
|
211
|
-
// Trailing
|
|
212
|
-
// which is exactly the stale-Success the rule must refuse to settle on.
|
|
270
|
+
// Trailing lets pre-ack run land — stale Success must not settle
|
|
213
271
|
coalesce: 'trailing',
|
|
214
272
|
})
|
|
215
273
|
const TestLayer = ItemsLive.pipe(
|
|
@@ -229,15 +287,11 @@ it.live('THE GENERATION RULE: a refetch that started before the ack can never se
|
|
|
229
287
|
if (v._tag === 'Success') frames.push(v.value.length)
|
|
230
288
|
})
|
|
231
289
|
|
|
232
|
-
// A refetch starts (generation 2, snapshot WITHOUT the row)…
|
|
233
290
|
yield* Event.dispatch(Poke, {})
|
|
234
291
|
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
292
|
yield* Event.dispatch(Added, { id: 'b', name: 'beta' })
|
|
238
293
|
|
|
239
|
-
//
|
|
240
|
-
// the entry stays pending and the overlay keeps the row over stale truth.
|
|
294
|
+
// Gen-2 Success settles nobody (generation rule)
|
|
241
295
|
yield* until(() => server.state.fetches, (n) => n === 2)
|
|
242
296
|
yield* tick(25)
|
|
243
297
|
expect(queue.get()).toHaveLength(1)
|
|
@@ -246,13 +300,9 @@ it.live('THE GENERATION RULE: a refetch that started before the ack can never se
|
|
|
246
300
|
expect(midflight).toMatchObject({ _tag: 'Success' })
|
|
247
301
|
if (midflight._tag === 'Success') expect(midflight.value).toHaveLength(2)
|
|
248
302
|
|
|
249
|
-
// The post-ack run (gen 3) lands with the mutation: NOW the entry settles.
|
|
250
303
|
yield* until(() => queue.get(), (q) => q.length === 0)
|
|
251
304
|
off()
|
|
252
305
|
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
306
|
const firstOptimistic = frames.indexOf(2)
|
|
257
307
|
expect(firstOptimistic).toBeGreaterThanOrEqual(0)
|
|
258
308
|
expect(frames.slice(firstOptimistic).every((n) => n === 2)).toBe(true)
|
|
@@ -282,7 +332,6 @@ it.live('a failed send settles the intent, reverts the overlay, and Failed reach
|
|
|
282
332
|
send: () => Effect.fail('rejected' as const).pipe(Effect.delay(Duration.millis(15))),
|
|
283
333
|
apply: applyIntent,
|
|
284
334
|
})
|
|
285
|
-
// The toast/retry seam: an ordinary reducer on the public Failed event.
|
|
286
335
|
const LogReducer = Reducer.make('failLog', { states: [FailLog], events: [Items.Failed] })
|
|
287
336
|
const TestLayer = Layer.mergeAll(
|
|
288
337
|
ItemsLive,
|
|
@@ -303,8 +352,6 @@ it.live('a failed send settles the intent, reverts the overlay, and Failed reach
|
|
|
303
352
|
yield* until(() => store.get(), (v) => v._tag === 'Success' && v.value.length === 2)
|
|
304
353
|
expect(queue.get()).toHaveLength(1)
|
|
305
354
|
|
|
306
|
-
// The send fails: entry leaves the queue, the view reverts by construction,
|
|
307
|
-
// and the Failed payload carried the intent + the error.
|
|
308
355
|
yield* until(() => queue.get(), (q) => q.length === 0)
|
|
309
356
|
const reverted = yield* until(
|
|
310
357
|
() => store.get(),
|
|
@@ -315,7 +362,6 @@ it.live('a failed send settles the intent, reverts the overlay, and Failed reach
|
|
|
315
362
|
}
|
|
316
363
|
yield* until(() => log.get(), (l) => l.length === 1)
|
|
317
364
|
expect(log.get()).toEqual(['Added|rejected'])
|
|
318
|
-
// No invalidation on failure: the one boot fetch is still the only one.
|
|
319
365
|
expect(server.state.fetches).toBe(1)
|
|
320
366
|
}).pipe(Effect.provide(TestLayer))
|
|
321
367
|
})
|
|
@@ -345,8 +391,7 @@ it.live('two intents apply in dispatch order and settle together on the post-ack
|
|
|
345
391
|
const queue = yield* Items.pending.store
|
|
346
392
|
yield* until(() => store.get(), (v) => v._tag === 'Success')
|
|
347
393
|
|
|
348
|
-
//
|
|
349
|
-
// application would rename nothing and show 'beta'.
|
|
394
|
+
// Apply order = dispatch order
|
|
350
395
|
yield* Event.dispatch(Added, { id: 'b', name: 'beta' })
|
|
351
396
|
yield* Event.dispatch(Renamed, { id: 'b', name: 'BETA' })
|
|
352
397
|
yield* until(() => queue.get(), (q) => q.length === 2)
|
|
@@ -382,8 +427,6 @@ it.live('one intent failing leaves the other pending and converging', () => {
|
|
|
382
427
|
}) {}
|
|
383
428
|
const ItemsLive = RemoteState.live(Items, {
|
|
384
429
|
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
430
|
send: (intent) =>
|
|
388
431
|
intent._tag === 'Renamed'
|
|
389
432
|
? Effect.fail('rejected' as const).pipe(Effect.delay(Duration.millis(50)))
|
|
@@ -402,14 +445,12 @@ it.live('one intent failing leaves the other pending and converging', () => {
|
|
|
402
445
|
|
|
403
446
|
yield* Event.dispatch(Added, { id: 'b', name: 'beta' })
|
|
404
447
|
yield* Event.dispatch(Renamed, { id: 'a', name: 'ALPHA' })
|
|
405
|
-
// Both folded: the overlay shows the rename optimistically.
|
|
406
448
|
yield* until(
|
|
407
449
|
() => store.get(),
|
|
408
450
|
(v) => v._tag === 'Success' && v.value[0]?.name === 'ALPHA',
|
|
409
451
|
)
|
|
410
452
|
expect(queue.get()).toHaveLength(2)
|
|
411
453
|
|
|
412
|
-
// The rename fails and leaves; the add stays optimistic and intact.
|
|
413
454
|
yield* until(
|
|
414
455
|
() => store.get(),
|
|
415
456
|
(v) => v._tag === 'Success' && v.value[0]?.name === 'alpha',
|
|
@@ -424,7 +465,6 @@ it.live('one intent failing leaves the other pending and converging', () => {
|
|
|
424
465
|
])
|
|
425
466
|
}
|
|
426
467
|
|
|
427
|
-
// The add converges independently.
|
|
428
468
|
yield* until(() => queue.get(), (q) => q.length === 0)
|
|
429
469
|
const settled = yield* until(
|
|
430
470
|
() => store.get(),
|
|
@@ -450,7 +490,6 @@ it.live("pending exposes the lifecycle: 'sending' -> 'confirmed' -> gone", () =>
|
|
|
450
490
|
alwaysOn: true,
|
|
451
491
|
}) {}
|
|
452
492
|
const ItemsLive = RemoteState.live(Items, {
|
|
453
|
-
// A slow refetch keeps the 'confirmed' window wide enough to observe.
|
|
454
493
|
query: () => server.fetch(40),
|
|
455
494
|
send: (intent) => server.receive(intent, 40),
|
|
456
495
|
apply: applyIntent,
|
|
@@ -507,8 +546,7 @@ it.live('gated: intents queue and send while Idle; re-enabling converges and set
|
|
|
507
546
|
yield* tick()
|
|
508
547
|
expect(store.get()._tag).toBe('Idle')
|
|
509
548
|
|
|
510
|
-
//
|
|
511
|
-
// the mutation, but the view stays Idle and the entry waits for a run.
|
|
549
|
+
// Intents still send while gated Idle; settle waits for re-enable
|
|
512
550
|
yield* Event.dispatch(Added, { id: 'b', name: 'beta' })
|
|
513
551
|
yield* until(() => server.state.items, (items) => items.length === 2)
|
|
514
552
|
yield* tick(20)
|
|
@@ -516,7 +554,6 @@ it.live('gated: intents queue and send while Idle; re-enabling converges and set
|
|
|
516
554
|
expect(queue.get()).toHaveLength(1)
|
|
517
555
|
expect(server.state.fetches).toBe(0)
|
|
518
556
|
|
|
519
|
-
// Re-enable: the run lands with the mutation and settles the waiter.
|
|
520
557
|
source.set(1)
|
|
521
558
|
yield* until(() => queue.get(), (q) => q.length === 0)
|
|
522
559
|
const settled = yield* until(
|
|
@@ -546,7 +583,6 @@ it.live('reuse: untouched element identities survive the whole optimistic round-
|
|
|
546
583
|
alwaysOn: true,
|
|
547
584
|
}) {}
|
|
548
585
|
const ItemsLive = RemoteState.live(Items, {
|
|
549
|
-
// Fresh objects every fetch — only `reuse` can preserve identities.
|
|
550
586
|
query: () => Effect.map(server.fetch(10), (items) => items.map((item) => ({ ...item }))),
|
|
551
587
|
send: (intent) => server.receive(intent, 10),
|
|
552
588
|
apply: applyIntent,
|
|
@@ -572,8 +608,6 @@ it.live('reuse: untouched element identities survive the whole optimistic round-
|
|
|
572
608
|
(v) => v._tag === 'Success' && v.refetching === false,
|
|
573
609
|
)
|
|
574
610
|
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
611
|
expect(settled.value[0]).toBe(untouched)
|
|
578
612
|
expect(settled.value[1]).not.toBe(first.value[1])
|
|
579
613
|
expect(settled.value[1]).toEqual({ id: 'b', name: 'BETA' })
|
|
@@ -609,11 +643,9 @@ it.live('invalidateOn + coalesce trailing compose with the mutation lifecycle',
|
|
|
609
643
|
yield* until(() => store.get(), (v) => v._tag === 'Success')
|
|
610
644
|
expect(server.state.fetches).toBe(1)
|
|
611
645
|
|
|
612
|
-
// A realtime push refetches like any invalidateOn event.
|
|
613
646
|
yield* Event.dispatch(BoardChanged, {})
|
|
614
647
|
yield* until(() => server.state.fetches, (n) => n === 2)
|
|
615
648
|
|
|
616
|
-
// The mutation lifecycle still converges under trailing conciliation.
|
|
617
649
|
yield* Event.dispatch(Added, { id: 'b', name: 'beta' })
|
|
618
650
|
yield* until(() => queue.get(), (q) => q.length === 1)
|
|
619
651
|
yield* until(() => queue.get(), (q) => q.length === 0)
|
|
@@ -657,9 +689,7 @@ it.live('a query Error passes through by reference with intents still pending',
|
|
|
657
689
|
const queue = yield* Items.pending.store
|
|
658
690
|
yield* until(() => store.get(), (v) => v._tag === 'Error')
|
|
659
691
|
|
|
660
|
-
//
|
|
661
|
-
// run settles NOBODY, so the entry stays pending (confirmed) — and the
|
|
662
|
-
// overlay passes the Error arm through by reference, never applying.
|
|
692
|
+
// Error runs settle nobody; overlay passes Error through without apply
|
|
663
693
|
yield* Event.dispatch(Added, { id: 'b', name: 'beta' })
|
|
664
694
|
yield* until(() => queue.get(), (q) => q.length === 1 && q[0]?.status === 'confirmed')
|
|
665
695
|
yield* until(() => store.get(), (v) => v._tag === 'Error' && v.refetching === false)
|
|
@@ -700,7 +730,6 @@ it.live('teardown: closing the scope unregisters the reducers, the procedure, an
|
|
|
700
730
|
}),
|
|
701
731
|
)
|
|
702
732
|
|
|
703
|
-
// Scope closed: every registration is gone (a lazy feature unmounting).
|
|
704
733
|
expect(reducers.entries.some((entry) => entry.name === 'Items/pending')).toBe(false)
|
|
705
734
|
expect(reducers.entries.some((entry) => entry.name === 'Items/revision')).toBe(false)
|
|
706
735
|
expect(procedures.entries.some((entry) => entry.name === 'Items/send')).toBe(false)
|
|
@@ -708,7 +737,6 @@ it.live('teardown: closing the scope unregisters the reducers, the procedure, an
|
|
|
708
737
|
}).pipe(Effect.provide(Engine))
|
|
709
738
|
})
|
|
710
739
|
|
|
711
|
-
// ── persist (00b/00c): the converged truth round-trips a QueryStore. ─────────
|
|
712
740
|
|
|
713
741
|
it.live('persist: hydrates the seeded truth (stale) then writes the fresh truth through', () => {
|
|
714
742
|
class Page extends State.make('page', S.Number) {}
|
|
@@ -735,12 +763,10 @@ it.live('persist: hydrates the seeded truth (stale) then writes the fresh truth
|
|
|
735
763
|
|
|
736
764
|
return Effect.gen(function* () {
|
|
737
765
|
const view = yield* Items.store
|
|
738
|
-
// The persisted truth shows instantly, marked refetching (stale-while-revalidate).
|
|
739
766
|
const hydrated = view.get()
|
|
740
767
|
expect(hydrated).toMatchObject({ _tag: 'Success', refetching: true })
|
|
741
768
|
if (hydrated._tag === 'Success') expect(hydrated.value).toEqual([{ id: 'seed', name: 'seeded' }])
|
|
742
769
|
|
|
743
|
-
// The fresh fetch lands and the converged truth is written through under the name key.
|
|
744
770
|
const settled = yield* until(
|
|
745
771
|
() => view.get(),
|
|
746
772
|
(v) => v._tag === 'Success' && v.refetching === false,
|
|
@@ -767,7 +793,6 @@ it.live('persist: the optimistic overlay is NEVER written — only the converged
|
|
|
767
793
|
}) {}
|
|
768
794
|
const ItemsLive = RemoteState.live(Items, {
|
|
769
795
|
query: () => server.fetch(10),
|
|
770
|
-
// Slow send so the pure-optimistic window stays open for the store assertion.
|
|
771
796
|
send: (intent) => server.receive(intent, 60),
|
|
772
797
|
apply: applyIntent,
|
|
773
798
|
persist: true,
|
|
@@ -782,17 +807,14 @@ it.live('persist: the optimistic overlay is NEVER written — only the converged
|
|
|
782
807
|
const view = yield* Items.store
|
|
783
808
|
const queue = yield* Items.pending.store
|
|
784
809
|
yield* until(() => view.get(), (v) => v._tag === 'Success')
|
|
785
|
-
// First truth is persisted.
|
|
786
810
|
yield* until(() => store.map.get('Items'), (v) => Array.isArray(v) && v.length === 1)
|
|
787
811
|
|
|
788
|
-
// Dispatch the optimistic intent: the overlay shows two rows...
|
|
789
812
|
yield* Event.dispatch(Added, { id: 'b', name: 'beta' })
|
|
790
813
|
yield* until(() => view.get(), (v) => v._tag === 'Success' && v.value.length === 2)
|
|
791
814
|
yield* until(() => queue.get(), (q) => q.length === 1)
|
|
792
|
-
//
|
|
815
|
+
// Persist never captures overlay — only settled server truth
|
|
793
816
|
expect(store.map.get('Items')).toEqual([{ id: 'a', name: 'alpha' }])
|
|
794
817
|
|
|
795
|
-
// Once the send acks and the refetch lands, the converged truth is written.
|
|
796
818
|
yield* until(() => queue.get(), (q) => q.length === 0)
|
|
797
819
|
yield* until(
|
|
798
820
|
() => store.map.get('Items'),
|
|
@@ -837,7 +859,6 @@ it.live('persist: `true` keys by name; `{ key }` overrides — two definitions k
|
|
|
837
859
|
yield* until(() => store.map.get('custom-B'), (v) => Array.isArray(v))
|
|
838
860
|
expect(store.map.get('ItemsA')).toEqual([{ id: 'a', name: 'alpha' }])
|
|
839
861
|
expect(store.map.get('custom-B')).toEqual([{ id: 'b', name: 'beta' }])
|
|
840
|
-
// No collision: neither wrote the other's key, and the name key for B is empty.
|
|
841
862
|
expect(store.map.has('ItemsB')).toBe(false)
|
|
842
863
|
}).pipe(Effect.provide(TestLayer))
|
|
843
864
|
})
|
|
@@ -847,8 +868,6 @@ it.live('persist (00c): a function key keys a family per-input — two input key
|
|
|
847
868
|
class Inputs extends StateGroup.make(Page) {}
|
|
848
869
|
const inputs = StateGroup.live(Inputs, { page: 1 })
|
|
849
870
|
const store = fakeQueryStore()
|
|
850
|
-
// The server returns a distinct row per requested page, so a collision (one
|
|
851
|
-
// slot for both pages) would overwrite — the assertion catches it.
|
|
852
871
|
const fetchPage = (page: number) =>
|
|
853
872
|
Effect.as(tick(10), [{ id: `p${page}`, name: `page-${page}` }])
|
|
854
873
|
class Fam extends RemoteState.make('Fam', {
|
|
@@ -871,12 +890,10 @@ it.live('persist (00c): a function key keys a family per-input — two input key
|
|
|
871
890
|
yield* until(() => store.map.get('fam:1'), (v) => Array.isArray(v))
|
|
872
891
|
expect(store.map.get('fam:1')).toEqual([{ id: 'p1', name: 'page-1' }])
|
|
873
892
|
|
|
874
|
-
// Move the input to page 2: a new fetch writes to a DISTINCT slot.
|
|
875
893
|
page.set(2)
|
|
876
894
|
yield* until(() => view.get(), (v) => v._tag === 'Success' && v.value[0]?.id === 'p2')
|
|
877
895
|
yield* until(() => store.map.get('fam:2'), (v) => Array.isArray(v))
|
|
878
896
|
expect(store.map.get('fam:2')).toEqual([{ id: 'p2', name: 'page-2' }])
|
|
879
|
-
// Page 1's slot is intact — no collision.
|
|
880
897
|
expect(store.map.get('fam:1')).toEqual([{ id: 'p1', name: 'page-1' }])
|
|
881
898
|
}).pipe(Effect.provide(TestLayer))
|
|
882
899
|
})
|