@playfast/reform 1.3.0 → 1.4.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 +15 -6
- package/dist/calc/asyncCalc.d.ts +3 -1
- package/dist/calc/asyncCalc.d.ts.map +1 -1
- package/dist/calc/asyncCalc.js +15 -5
- package/dist/calc/asyncCalc.js.map +1 -1
- package/dist/calc/asyncCalcTypes.d.ts +2 -0
- package/dist/calc/asyncCalcTypes.d.ts.map +1 -1
- package/dist/calc/calcFamily.d.ts.map +1 -1
- package/dist/calc/calcFamily.js +77 -34
- package/dist/calc/calcFamily.js.map +1 -1
- package/dist/calc/queryState.d.ts +3 -1
- package/dist/calc/queryState.d.ts.map +1 -1
- package/dist/calc/queryState.js +31 -9
- package/dist/calc/queryState.js.map +1 -1
- package/dist/channel/channel.d.ts.map +1 -1
- package/dist/channel/channel.js +6 -1
- package/dist/channel/channel.js.map +1 -1
- package/dist/compose/composition.d.ts.map +1 -1
- package/dist/compose/composition.js +7 -1
- package/dist/compose/composition.js.map +1 -1
- package/dist/compose/structure.d.ts.map +1 -1
- package/dist/compose/structure.js +33 -8
- package/dist/compose/structure.js.map +1 -1
- package/dist/compose/ui.d.ts.map +1 -1
- package/dist/compose/ui.js +16 -5
- package/dist/compose/ui.js.map +1 -1
- package/dist/event/event.d.ts.map +1 -1
- package/dist/event/event.js +23 -2
- package/dist/event/event.js.map +1 -1
- package/dist/internal/familyKeys.d.ts +16 -0
- package/dist/internal/familyKeys.d.ts.map +1 -0
- package/dist/internal/familyKeys.js +35 -0
- package/dist/internal/familyKeys.js.map +1 -0
- package/dist/internal/platform.d.ts +43 -0
- package/dist/internal/platform.d.ts.map +1 -0
- package/dist/internal/platform.js +68 -0
- package/dist/internal/platform.js.map +1 -0
- package/dist/internal/queryDriver.d.ts.map +1 -1
- package/dist/internal/queryDriver.js +88 -64
- package/dist/internal/queryDriver.js.map +1 -1
- package/dist/internal/queryDriverConsume.d.ts +9 -0
- package/dist/internal/queryDriverConsume.d.ts.map +1 -0
- package/dist/internal/queryDriverConsume.js +16 -0
- package/dist/internal/queryDriverConsume.js.map +1 -0
- package/dist/internal/queryDriverHydrate.d.ts.map +1 -1
- package/dist/internal/queryDriverHydrate.js +9 -3
- package/dist/internal/queryDriverHydrate.js.map +1 -1
- package/dist/internal/queryDriverPersist.d.ts +16 -0
- package/dist/internal/queryDriverPersist.d.ts.map +1 -0
- package/dist/internal/queryDriverPersist.js +15 -0
- package/dist/internal/queryDriverPersist.js.map +1 -0
- package/dist/internal/queryDriverPersistWarn.d.ts +12 -0
- package/dist/internal/queryDriverPersistWarn.d.ts.map +1 -0
- package/dist/internal/queryDriverPersistWarn.js +31 -0
- package/dist/internal/queryDriverPersistWarn.js.map +1 -0
- package/dist/internal/queryDriverSettle.d.ts +23 -0
- package/dist/internal/queryDriverSettle.d.ts.map +1 -0
- package/dist/internal/queryDriverSettle.js +52 -0
- package/dist/internal/queryDriverSettle.js.map +1 -0
- package/dist/internal/queryDriverStore.d.ts +2 -28
- package/dist/internal/queryDriverStore.d.ts.map +1 -1
- package/dist/internal/queryDriverStore.js +5 -29
- package/dist/internal/queryDriverStore.js.map +1 -1
- package/dist/internal/queryDriverTypes.d.ts +3 -0
- package/dist/internal/queryDriverTypes.d.ts.map +1 -1
- package/dist/internal/scheduler.d.ts +1 -0
- package/dist/internal/scheduler.d.ts.map +1 -1
- package/dist/internal/scheduler.js +69 -6
- package/dist/internal/scheduler.js.map +1 -1
- package/dist/internal.d.ts +1 -0
- package/dist/internal.d.ts.map +1 -1
- package/dist/internal.js +1 -0
- package/dist/internal.js.map +1 -1
- package/dist/reducer/reducer.js +3 -3
- package/dist/reducer/reducer.js.map +1 -1
- package/dist/remote/pendingQueue.d.ts +4 -2
- package/dist/remote/pendingQueue.d.ts.map +1 -1
- package/dist/remote/pendingQueue.js +20 -4
- package/dist/remote/pendingQueue.js.map +1 -1
- package/dist/remote/remoteState.d.ts.map +1 -1
- package/dist/remote/remoteState.js +27 -14
- package/dist/remote/remoteState.js.map +1 -1
- package/dist/remote/remoteStateSend.d.ts +3 -1
- package/dist/remote/remoteStateSend.d.ts.map +1 -1
- package/dist/remote/remoteStateSend.js +19 -3
- package/dist/remote/remoteStateSend.js.map +1 -1
- package/dist/remote/remoteStateTypes.d.ts +3 -0
- package/dist/remote/remoteStateTypes.d.ts.map +1 -1
- package/dist/runtime/bus.d.ts +5 -0
- package/dist/runtime/bus.d.ts.map +1 -1
- package/dist/runtime/bus.js +1 -1
- package/dist/runtime/bus.js.map +1 -1
- package/dist/runtime/capturedAppRuntime.d.ts.map +1 -1
- package/dist/runtime/capturedAppRuntime.js +37 -51
- package/dist/runtime/capturedAppRuntime.js.map +1 -1
- package/dist/runtime/eventBudget.d.ts.map +1 -1
- package/dist/runtime/eventBudget.js +2 -1
- package/dist/runtime/eventBudget.js.map +1 -1
- package/dist/runtime/featureMount.d.ts +34 -2
- package/dist/runtime/featureMount.d.ts.map +1 -1
- package/dist/runtime/featureMount.js +66 -2
- package/dist/runtime/featureMount.js.map +1 -1
- package/dist/runtime/loop.d.ts +12 -0
- package/dist/runtime/loop.d.ts.map +1 -1
- package/dist/runtime/loop.js +8 -5
- package/dist/runtime/loop.js.map +1 -1
- package/dist/state/state.d.ts +2 -2
- package/dist/state/state.d.ts.map +1 -1
- package/dist/state/state.js +2 -2
- package/dist/state/state.js.map +1 -1
- package/dist/state/stateFamily.d.ts +2 -2
- package/dist/state/stateFamily.d.ts.map +1 -1
- package/dist/state/stateFamily.js +80 -32
- package/dist/state/stateFamily.js.map +1 -1
- package/dist/state/stateGroup.d.ts +5 -5
- package/dist/state/stateGroup.d.ts.map +1 -1
- package/dist/state/stateGroup.js +1 -1
- package/dist/state/stateGroup.js.map +1 -1
- package/dist/testkit/flight.testkit.d.ts.map +1 -1
- package/dist/testkit/flight.testkit.js +2 -1
- package/dist/testkit/flight.testkit.js.map +1 -1
- package/dist/wire/tree.d.ts.map +1 -1
- package/dist/wire/tree.js +6 -1
- package/dist/wire/tree.js.map +1 -1
- package/package.json +1 -1
- package/src/calc/asyncCalc-key-attribution.test.ts +182 -0
- package/src/calc/asyncCalc-registry-name.test.ts +82 -0
- package/src/calc/asyncCalc.superseded-commit.test.ts +162 -0
- package/src/calc/asyncCalc.test.ts +35 -7
- package/src/calc/asyncCalc.ts +33 -10
- package/src/calc/asyncCalcTypes.ts +6 -0
- package/src/calc/calcFamily.ts +81 -34
- package/src/calc/family-identity.test.ts +231 -0
- package/src/calc/queryState.ts +36 -5
- package/src/channel/channel-throttle-frames.test.ts +163 -0
- package/src/channel/channel.ts +5 -0
- package/src/compose/compose-identity.test.ts +175 -0
- package/src/compose/composition.ts +8 -4
- package/src/compose/structure.ts +45 -13
- package/src/compose/ui.ts +22 -5
- package/src/event/event.ts +31 -2
- package/src/event/syncTrigger.test.ts +66 -0
- package/src/internal/familyKeys.ts +41 -0
- package/src/internal/platform.test.ts +55 -0
- package/src/internal/platform.ts +72 -0
- package/src/internal/queryDriver-defect-attribution.test.ts +107 -0
- package/src/internal/queryDriver-persist-key.test.ts +118 -0
- package/src/internal/queryDriver.persistence-staleness.test.ts +209 -0
- package/src/internal/queryDriver.ts +92 -80
- package/src/internal/queryDriverConsume.ts +42 -0
- package/src/internal/queryDriverHydrate.ts +9 -3
- package/src/internal/queryDriverPersist.ts +42 -0
- package/src/internal/queryDriverPersistWarn.ts +61 -0
- package/src/internal/queryDriverSettle.ts +85 -0
- package/src/internal/queryDriverStore.ts +9 -74
- package/src/internal/queryDriverTypes.ts +10 -0
- package/src/internal/queryState-and-scheduler-audit.test.ts +170 -0
- package/src/internal/scheduler-multi-source-wake.test.ts +111 -0
- package/src/internal/scheduler.ts +80 -6
- package/src/internal.ts +1 -0
- package/src/reducer/reducer.ts +3 -3
- package/src/remote/pendingQueue.ts +35 -5
- package/src/remote/remoteState.intent-key-stamp.test.ts +207 -0
- package/src/remote/remoteState.key-attribution.test.ts +174 -0
- package/src/remote/remoteState.ts +27 -15
- package/src/remote/remoteStateSend.ts +29 -8
- package/src/remote/remoteStateTypes.ts +7 -0
- package/src/runtime/bus.ts +6 -1
- package/src/runtime/capturedAppRuntime.ts +134 -110
- package/src/runtime/eventBudget.ts +2 -1
- package/src/runtime/feature-mount-ownership.test.ts +539 -0
- package/src/runtime/featureMount.ts +110 -3
- package/src/runtime/loop.ts +29 -6
- package/src/runtime/mount-inflight-ownership.test.ts +361 -0
- package/src/runtime/mount-scope-hardening.test.ts +440 -0
- package/src/runtime/runtime.schema-identity.test.ts +188 -0
- package/src/state/state.ts +8 -4
- package/src/state/stateFamily.ts +86 -36
- package/src/state/stateGroup.ts +24 -6
- package/src/testkit/flight.testkit.ts +2 -1
- package/src/wire/tree-key-presence.test.ts +46 -0
- package/src/wire/tree.ts +9 -1
|
@@ -109,6 +109,11 @@ export type AsyncCalcLive<
|
|
|
109
109
|
readonly coalesce?: 'switch' | 'trailing'
|
|
110
110
|
// oxlint-disable-next-line reform-rules/no-optional-fields -- caller-omitted option built at the asyncCalc/remoteState construction site; Option<T> would change that call shape
|
|
111
111
|
readonly reuse?: boolean
|
|
112
|
+
// Keep showing the previous key's result while the new key loads. Off by default:
|
|
113
|
+
// a retained payload is not an answer to the key being asked. Turn it on only when
|
|
114
|
+
// the payload is displayed, never when fields are read off it.
|
|
115
|
+
// oxlint-disable-next-line reform-rules/no-optional-fields -- caller-omitted option built at the asyncCalc/remoteState construction site; Option<T> would change that call shape
|
|
116
|
+
readonly keepPreviousData?: boolean
|
|
112
117
|
// oxlint-disable-next-line reform-rules/no-optional-fields -- caller-omitted option built at the asyncCalc/remoteState construction site; Option<T> would change that call shape
|
|
113
118
|
readonly persist?:
|
|
114
119
|
| boolean
|
|
@@ -133,6 +138,7 @@ export interface AsyncCalcLiveView<Inputs extends ReadonlyArray<AnySource>, A, E
|
|
|
133
138
|
readonly disabled?: (inputs: InputsObject<Inputs>) => boolean
|
|
134
139
|
readonly coalesce?: 'switch' | 'trailing'
|
|
135
140
|
readonly reuse?: boolean
|
|
141
|
+
readonly keepPreviousData?: boolean
|
|
136
142
|
readonly persist?:
|
|
137
143
|
| boolean
|
|
138
144
|
| { readonly key?: string | ((inputs: InputsObject<Inputs>) => string) }
|
package/src/calc/calcFamily.ts
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
sameKey,
|
|
8
8
|
wireSources,
|
|
9
9
|
} from '../internal/sources'
|
|
10
|
+
import { makeKeyCanonicalizer } from '../internal/familyKeys'
|
|
10
11
|
import { resolveScheduler } from '../internal/scheduler'
|
|
11
12
|
import { resolveInstrumentation, stateUpdateHook } from '../runtime/instrumentation'
|
|
12
13
|
import { reuse } from '../internal/reuse'
|
|
@@ -14,6 +15,7 @@ import { makeDerivedStore, type Store } from '../internal/store'
|
|
|
14
15
|
import { readTracked } from '../internal/track'
|
|
15
16
|
import { type FamilyOptions, type FamilyStore } from '../state/stateFamily'
|
|
16
17
|
import { type AnySource } from '../state/token'
|
|
18
|
+
import { microtask } from '../internal/platform'
|
|
17
19
|
|
|
18
20
|
export interface CalcFamilySchemaReflection {
|
|
19
21
|
readonly ast: Schema.Schema<unknown, unknown, unknown>['ast']
|
|
@@ -111,8 +113,39 @@ export const live = <N extends string, Inputs extends ReadonlyArray<AnySource>,
|
|
|
111
113
|
const onOutputChange = stateUpdateHook(instrumentation, family.manifest.name)
|
|
112
114
|
const sources = yield* wireSources(family.inputs, options.invalidateBy)
|
|
113
115
|
const entries = new Map<K, { readonly store: Store<Out>; readonly unsubscribe: () => void }>()
|
|
116
|
+
const keys = makeKeyCanonicalizer<K>()
|
|
114
117
|
const evictWhenUnused = options.evictWhenUnused === true
|
|
115
|
-
|
|
118
|
+
// Stamps identify a member *instance*, not a key: `forget` lets one key hold a
|
|
119
|
+
// succession of members, and a stale unsubscribe from an earlier one must not
|
|
120
|
+
// decrement — or evict — the one that replaced it.
|
|
121
|
+
const stamps = new Map<K, number>()
|
|
122
|
+
const nextStamp = MutableRef.make(0)
|
|
123
|
+
|
|
124
|
+
// A forgotten key's member is orphaned — `at(key)` mints a fresh one — so its
|
|
125
|
+
// subscribers must be woken to re-read, or they hold a store nothing recomputes
|
|
126
|
+
// again and never hear about the key another way.
|
|
127
|
+
const wakers = new Map<K, Set<() => void>>()
|
|
128
|
+
const wakersFor = (key: K): Set<() => void> => {
|
|
129
|
+
const existing = wakers.get(key)
|
|
130
|
+
if (existing !== undefined) {
|
|
131
|
+
return existing
|
|
132
|
+
}
|
|
133
|
+
const created = new Set<() => void>()
|
|
134
|
+
wakers.set(key, created)
|
|
135
|
+
return created
|
|
136
|
+
}
|
|
137
|
+
const wakeable = (key: K, store: Store<Out>): Store<Out> => ({
|
|
138
|
+
...store,
|
|
139
|
+
subscribe: (listener) => {
|
|
140
|
+
const registered = wakersFor(key)
|
|
141
|
+
registered.add(listener)
|
|
142
|
+
const off = store.subscribe(listener)
|
|
143
|
+
return () => {
|
|
144
|
+
registered.delete(listener)
|
|
145
|
+
off()
|
|
146
|
+
}
|
|
147
|
+
},
|
|
148
|
+
})
|
|
116
149
|
|
|
117
150
|
const dropEntry = (key: K): void => {
|
|
118
151
|
const entry = entries.get(key)
|
|
@@ -120,7 +153,13 @@ export const live = <N extends string, Inputs extends ReadonlyArray<AnySource>,
|
|
|
120
153
|
entry.unsubscribe()
|
|
121
154
|
}
|
|
122
155
|
entries.delete(key)
|
|
123
|
-
|
|
156
|
+
stamps.delete(key)
|
|
157
|
+
keys.release(key)
|
|
158
|
+
const woken = wakers.get(key)
|
|
159
|
+
wakers.delete(key)
|
|
160
|
+
if (woken !== undefined && woken.size > 0) {
|
|
161
|
+
scheduler.schedule(woken)
|
|
162
|
+
}
|
|
124
163
|
}
|
|
125
164
|
|
|
126
165
|
const createMember = (
|
|
@@ -148,51 +187,59 @@ export const live = <N extends string, Inputs extends ReadonlyArray<AnySource>,
|
|
|
148
187
|
return makeDerivedStore(recompute, sources.subscribe, scheduler, onOutputChange)
|
|
149
188
|
}
|
|
150
189
|
|
|
151
|
-
const refCounted = (key: K, store: Store<Out
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
released.done = true
|
|
163
|
-
off()
|
|
164
|
-
const remaining = (subscribers.get(key) ?? 1) - 1
|
|
165
|
-
if (remaining > 0) {
|
|
166
|
-
subscribers.set(key, remaining)
|
|
167
|
-
return
|
|
168
|
-
}
|
|
169
|
-
subscribers.delete(key)
|
|
170
|
-
queueMicrotask(() => {
|
|
171
|
-
const liveCount = subscribers.get(key)
|
|
172
|
-
if (liveCount === undefined || liveCount === 0) {
|
|
173
|
-
dropEntry(key)
|
|
190
|
+
const refCounted = (key: K, store: Store<Out>, stamp: number): Store<Out> => {
|
|
191
|
+
const count = MutableRef.make(0)
|
|
192
|
+
return {
|
|
193
|
+
...store,
|
|
194
|
+
subscribe: (listener) => {
|
|
195
|
+
MutableRef.update(count, (current) => current + 1)
|
|
196
|
+
const off = store.subscribe(listener)
|
|
197
|
+
const released = { done: false }
|
|
198
|
+
return () => {
|
|
199
|
+
if (released.done) {
|
|
200
|
+
return
|
|
174
201
|
}
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
202
|
+
released.done = true
|
|
203
|
+
off()
|
|
204
|
+
MutableRef.update(count, (current) => Math.max(0, current - 1))
|
|
205
|
+
if (MutableRef.get(count) > 0) {
|
|
206
|
+
return
|
|
207
|
+
}
|
|
208
|
+
microtask(() => {
|
|
209
|
+
// Still unused, and still the member this key holds.
|
|
210
|
+
if (MutableRef.get(count) === 0 && stamps.get(key) === stamp) {
|
|
211
|
+
dropEntry(key)
|
|
212
|
+
}
|
|
213
|
+
})
|
|
214
|
+
}
|
|
215
|
+
},
|
|
216
|
+
}
|
|
217
|
+
}
|
|
179
218
|
|
|
180
219
|
const familyStore: FamilyStore<K, Out> = {
|
|
181
|
-
at: (
|
|
220
|
+
at: (rawKey) => {
|
|
221
|
+
const key = keys.canonical(rawKey)
|
|
182
222
|
const existing = entries.get(key)
|
|
183
223
|
if (existing !== undefined) {
|
|
184
224
|
return existing.store
|
|
185
225
|
}
|
|
186
226
|
const member = createMember(key)
|
|
187
|
-
const
|
|
188
|
-
|
|
189
|
-
:
|
|
227
|
+
const stamp = MutableRef.updateAndGet(nextStamp, (current) => current + 1)
|
|
228
|
+
const created = {
|
|
229
|
+
store: wakeable(
|
|
230
|
+
key,
|
|
231
|
+
evictWhenUnused ? refCounted(key, member.store, stamp) : member.store,
|
|
232
|
+
),
|
|
233
|
+
unsubscribe: member.unsubscribe,
|
|
234
|
+
}
|
|
190
235
|
entries.set(key, created)
|
|
236
|
+
stamps.set(key, stamp)
|
|
191
237
|
return created.store
|
|
192
238
|
},
|
|
193
|
-
forget: dropEntry,
|
|
239
|
+
forget: (key) => dropEntry(keys.canonical(key)),
|
|
194
240
|
clear: () => {
|
|
195
241
|
;[...entries.keys()].forEach((key) => dropEntry(key))
|
|
242
|
+
keys.reset()
|
|
196
243
|
},
|
|
197
244
|
size: () => entries.size,
|
|
198
245
|
}
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bug hunt — `CalcFamily.live`'s per-member lifecycle (`calc/calcFamily.ts`).
|
|
3
|
+
*
|
|
4
|
+
* `StateFamily` treats a family key as holding a *succession* of store
|
|
5
|
+
* instances. `state/stateFamily.ts` stamps each instance so "a stale
|
|
6
|
+
* unsubscribe from an earlier one must not decrement — or evict — the one that
|
|
7
|
+
* replaced it", and wakes a forgotten key's subscribers so they do not "hold a
|
|
8
|
+
* store nothing writes to again and never hear about the key another way".
|
|
9
|
+
*
|
|
10
|
+
* `CalcFamily.live` re-implements that same lifecycle — its `subscribers:
|
|
11
|
+
* Map<K, number>` / `remaining` / `queueMicrotask` block is line-for-line what
|
|
12
|
+
* `stateFamily.ts` looked like before those two guards were added — and it got
|
|
13
|
+
* neither. The ref-count is credited to the KEY, not to the instance that
|
|
14
|
+
* earned it, and `dropEntry` severs a member's upstream subscription without
|
|
15
|
+
* telling the listeners still attached to it.
|
|
16
|
+
*
|
|
17
|
+
* Both tests run the *working* half of the same scenario first, in the same
|
|
18
|
+
* body, so a failure cannot be blamed on the harness. Every listener is
|
|
19
|
+
* attached before anything is awaited. Neither test waits a duration: `flush`
|
|
20
|
+
* drains microtask turns, which is the exact lane the scheduler and the
|
|
21
|
+
* eviction `queueMicrotask` both run on.
|
|
22
|
+
*/
|
|
23
|
+
import { expect, it } from '@effect/vitest'
|
|
24
|
+
import { Data, Effect, Equal, Layer, Schema as S } from 'effect'
|
|
25
|
+
import {
|
|
26
|
+
CalcFamily,
|
|
27
|
+
Engine,
|
|
28
|
+
Event,
|
|
29
|
+
publish,
|
|
30
|
+
Reducer,
|
|
31
|
+
State,
|
|
32
|
+
StateFamily,
|
|
33
|
+
StateGroup,
|
|
34
|
+
} from '../index'
|
|
35
|
+
import { flush, until } from '../testkit/flight.testkit'
|
|
36
|
+
|
|
37
|
+
const Board = S.Record({ key: S.String, value: S.Number })
|
|
38
|
+
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
// FINDING 1 — `calc/calcFamily.ts:115` + `:151-178`: the eviction ref-count is credited
|
|
41
|
+
// to the KEY, not to the store instance that earned it.
|
|
42
|
+
//
|
|
43
|
+
// `subscribers: Map<K, number>` is shared by every store a key ever holds, and
|
|
44
|
+
// `dropEntry` deletes the key's slot outright. So once `forget(key)` has minted
|
|
45
|
+
// a replacement, the *forgotten* member's release decrements — and then evicts —
|
|
46
|
+
// the live replacement, tearing down its upstream subscription. That is exactly
|
|
47
|
+
// the hazard `stateFamily.ts` closes with per-instance counts + `stamps`.
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
it.live('calcFamily: a forgotten member stale release evicts its live replacement', () => {
|
|
50
|
+
class Feed extends State.make('hunt-evict-feed', Board) {}
|
|
51
|
+
class Inputs extends StateGroup.make(Feed) {}
|
|
52
|
+
class Slice extends CalcFamily.make('HuntEvictSlice', {
|
|
53
|
+
key: S.String,
|
|
54
|
+
inputs: [StateGroup.select(Inputs, 'hunt-evict-feed')],
|
|
55
|
+
output: S.Number,
|
|
56
|
+
}) {}
|
|
57
|
+
const SliceLive = CalcFamily.live(
|
|
58
|
+
Slice,
|
|
59
|
+
(key) =>
|
|
60
|
+
({ 'hunt-evict-feed': feed }) =>
|
|
61
|
+
feed[key] ?? 0,
|
|
62
|
+
{ evictWhenUnused: true },
|
|
63
|
+
)
|
|
64
|
+
const TestLayer = SliceLive.pipe(
|
|
65
|
+
Layer.provideMerge(StateGroup.live(Inputs, { 'hunt-evict-feed': { control: 1, victim: 1 } })),
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
return Effect.gen(function* () {
|
|
69
|
+
const feed = yield* StateGroup.select(Inputs, 'hunt-evict-feed').store
|
|
70
|
+
const family = yield* Slice.store
|
|
71
|
+
const wakes = { control: 0, victim: 0 }
|
|
72
|
+
|
|
73
|
+
// CONTROL — key `control`: subscribe, forget, subscribe the replacement.
|
|
74
|
+
// The forgotten subscription's release is simply never called, so nothing
|
|
75
|
+
// stale is in play.
|
|
76
|
+
family.at('control').subscribe(() => {})
|
|
77
|
+
family.forget('control')
|
|
78
|
+
family.at('control').subscribe(() => {
|
|
79
|
+
wakes.control += 1
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
// DEFECT — key `victim`: the identical sequence, except the forgotten
|
|
83
|
+
// member's release runs after the replacement is already live.
|
|
84
|
+
const staleRelease = family.at('victim').subscribe(() => {})
|
|
85
|
+
family.forget('victim')
|
|
86
|
+
family.at('victim').subscribe(() => {
|
|
87
|
+
wakes.victim += 1
|
|
88
|
+
})
|
|
89
|
+
staleRelease()
|
|
90
|
+
|
|
91
|
+
yield* flush()
|
|
92
|
+
const membersAfterStaleRelease = family.size()
|
|
93
|
+
|
|
94
|
+
feed.set({ control: 2, victim: 2 })
|
|
95
|
+
yield* flush()
|
|
96
|
+
|
|
97
|
+
// The control proves the harness: forget + re-subscribe keeps a live member.
|
|
98
|
+
expect(wakes.control).toBe(1)
|
|
99
|
+
// Observed 1: only `control` survived — the stale release dropped `victim`.
|
|
100
|
+
expect(membersAfterStaleRelease).toBe(2)
|
|
101
|
+
// Observed 0: `victim`'s live subscriber is never woken again.
|
|
102
|
+
expect(wakes.victim).toBe(1)
|
|
103
|
+
}).pipe(Effect.provide(TestLayer))
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
107
|
+
// FINDING 2 — `calc/calcFamily.ts:117-124`: `dropEntry` orphans a member's
|
|
108
|
+
// subscribers silently.
|
|
109
|
+
//
|
|
110
|
+
// `forget(key)` severs the member's upstream subscription and drops it from
|
|
111
|
+
// `entries`, but never wakes the listeners still attached to that store. They
|
|
112
|
+
// keep a handle whose `getVersion()` can never move again — and that version is
|
|
113
|
+
// exactly what `@playfast/reform-react`'s `Compose` compares to decide whether
|
|
114
|
+
// to repaint — so the view shows a value that is no longer true, permanently.
|
|
115
|
+
// `stateFamily.ts` wakes a forgotten key's subscribers for precisely this reason.
|
|
116
|
+
// ---------------------------------------------------------------------------
|
|
117
|
+
it.live('calcFamily: forget silently orphans a member subscribers', () => {
|
|
118
|
+
class Feed extends State.make('hunt-orphan-feed', Board) {}
|
|
119
|
+
class Inputs extends StateGroup.make(Feed) {}
|
|
120
|
+
class Slice extends CalcFamily.make('HuntOrphanSlice', {
|
|
121
|
+
key: S.String,
|
|
122
|
+
inputs: [StateGroup.select(Inputs, 'hunt-orphan-feed')],
|
|
123
|
+
output: S.Number,
|
|
124
|
+
}) {}
|
|
125
|
+
const SliceLive = CalcFamily.live(
|
|
126
|
+
Slice,
|
|
127
|
+
(key) =>
|
|
128
|
+
({ 'hunt-orphan-feed': feed }) =>
|
|
129
|
+
feed[key] ?? 0,
|
|
130
|
+
)
|
|
131
|
+
const TestLayer = SliceLive.pipe(
|
|
132
|
+
Layer.provideMerge(StateGroup.live(Inputs, { 'hunt-orphan-feed': { control: 1, orphan: 1 } })),
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
return Effect.gen(function* () {
|
|
136
|
+
const feed = yield* StateGroup.select(Inputs, 'hunt-orphan-feed').store
|
|
137
|
+
const family = yield* Slice.store
|
|
138
|
+
const wakes = { control: 0, orphan: 0 }
|
|
139
|
+
|
|
140
|
+
family.at('control').subscribe(() => {
|
|
141
|
+
wakes.control += 1
|
|
142
|
+
})
|
|
143
|
+
family.at('orphan').subscribe(() => {
|
|
144
|
+
wakes.orphan += 1
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
// CONTROL — both members are live, and both wake on a shared-input move.
|
|
148
|
+
feed.set({ control: 2, orphan: 2 })
|
|
149
|
+
yield* flush()
|
|
150
|
+
expect(wakes.control).toBe(1)
|
|
151
|
+
expect(wakes.orphan).toBe(1)
|
|
152
|
+
|
|
153
|
+
// DEFECT — only `orphan` is forgotten. Its subscriber is neither woken by
|
|
154
|
+
// the forget itself nor reached by any later input move.
|
|
155
|
+
family.forget('orphan')
|
|
156
|
+
feed.set({ control: 3, orphan: 3 })
|
|
157
|
+
yield* flush()
|
|
158
|
+
|
|
159
|
+
// The control proves the harness: the untouched member still wakes.
|
|
160
|
+
expect(wakes.control).toBe(2)
|
|
161
|
+
// Observed 1: the forgotten member's subscriber went permanently silent.
|
|
162
|
+
expect(wakes.orphan).toBeGreaterThan(1)
|
|
163
|
+
}).pipe(Effect.provide(TestLayer))
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
// ---------------------------------------------------------------------------
|
|
167
|
+
// FINDING 3 — `state/stateFamily.ts:30` (and `calc/calcFamily.ts:113`): a family
|
|
168
|
+
// indexes its members in a plain `Map<K, …>`, i.e. by SameValueZero reference
|
|
169
|
+
// identity, while every other equality in reform is `Equal.equals`
|
|
170
|
+
// (`internal/store.ts:28`, `internal/sources.ts:45`).
|
|
171
|
+
//
|
|
172
|
+
// So a composite key that is `Equal.equals`-equal — the Effect-canonical way to
|
|
173
|
+
// have a value-typed key, `Schema.Data` / `Data.struct` — resolves to a DIFFERENT
|
|
174
|
+
// store on every lookup. Through the family reducer path that is unconditional,
|
|
175
|
+
// not a caller mistake: `reducer/reducer.ts:131-141` re-decodes the key with
|
|
176
|
+
// `family.validateKeySync(...)`, which mints a fresh object for a struct schema,
|
|
177
|
+
// so `family.at(key)` can never hit the entry the previous event wrote. The fold
|
|
178
|
+
// reads the seed forever, each event strands a new store, and nothing warns.
|
|
179
|
+
// ---------------------------------------------------------------------------
|
|
180
|
+
it.live('stateFamily: an Equal-equal composite key resolves to a different member', () => {
|
|
181
|
+
const RowKey = S.Data(S.Struct({ board: S.String, row: S.Number }))
|
|
182
|
+
class Flat extends StateFamily.make('hunt-flat-tally', S.String, S.Number) {}
|
|
183
|
+
class Keyed extends StateFamily.make('hunt-keyed-tally', RowKey, S.Number) {}
|
|
184
|
+
class Bumped extends Event.make('HuntBumped', S.Struct({ board: S.String, row: S.Number })) {}
|
|
185
|
+
class FlatReducer extends Reducer.make('HuntFlatReducer', {
|
|
186
|
+
family: Flat,
|
|
187
|
+
keyOf: (event) => `${event.board}:${event.row}`,
|
|
188
|
+
events: [Bumped],
|
|
189
|
+
}) {}
|
|
190
|
+
class KeyedReducer extends Reducer.make('HuntKeyedReducer', {
|
|
191
|
+
family: Keyed,
|
|
192
|
+
keyOf: (event) => Data.struct({ board: event.board, row: event.row }),
|
|
193
|
+
events: [Bumped],
|
|
194
|
+
}) {}
|
|
195
|
+
const TestLayer = Layer.mergeAll(
|
|
196
|
+
Reducer.live(FlatReducer, (count) => count + 1),
|
|
197
|
+
Reducer.live(KeyedReducer, (count) => count + 1),
|
|
198
|
+
).pipe(
|
|
199
|
+
Layer.provideMerge(
|
|
200
|
+
Layer.mergeAll(StateFamily.live(Flat, 0), StateFamily.live(Keyed, 0), Engine),
|
|
201
|
+
),
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
return Effect.gen(function* () {
|
|
205
|
+
const flat = yield* Flat.store
|
|
206
|
+
const keyed = yield* Keyed.store
|
|
207
|
+
const lookup = Data.struct({ board: 'b', row: 1 })
|
|
208
|
+
const twin = Data.struct({ board: 'b', row: 1 })
|
|
209
|
+
|
|
210
|
+
// Both keys are `Equal.equals`-equal — the premise the family violates.
|
|
211
|
+
expect(Equal.equals(lookup, twin)).toBe(true)
|
|
212
|
+
|
|
213
|
+
yield* publish('High', Event.construct(Bumped, { board: 'b', row: 1 }))
|
|
214
|
+
yield* publish('High', Event.construct(Bumped, { board: 'b', row: 1 }))
|
|
215
|
+
yield* publish('High', Event.construct(Bumped, { board: 'b', row: 1 }))
|
|
216
|
+
|
|
217
|
+
// CONTROL — the string-keyed twin of the same reducer, same events, same
|
|
218
|
+
// fold. Gating on it also proves all three events were folded.
|
|
219
|
+
yield* until(
|
|
220
|
+
() => flat.at('b:1').get(),
|
|
221
|
+
(count) => count === 3,
|
|
222
|
+
)
|
|
223
|
+
expect(flat.at('b:1').get()).toBe(3)
|
|
224
|
+
expect(flat.size()).toBe(1)
|
|
225
|
+
|
|
226
|
+
// Observed 0: every fold read the seed and wrote into a brand-new store.
|
|
227
|
+
expect(keyed.at(lookup).get()).toBe(3)
|
|
228
|
+
// Observed 4: three stranded members plus the one this lookup just minted.
|
|
229
|
+
expect(keyed.size()).toBe(1)
|
|
230
|
+
}).pipe(Effect.provide(TestLayer))
|
|
231
|
+
})
|
package/src/calc/queryState.ts
CHANGED
|
@@ -6,24 +6,55 @@ export interface QueryState<A, E> {
|
|
|
6
6
|
readonly error: Option.Option<E>
|
|
7
7
|
readonly isFetching: boolean
|
|
8
8
|
readonly isStale: boolean
|
|
9
|
+
// The settled result answers an input key the sources have since moved past.
|
|
10
|
+
// Without it a retained payload is indistinguishable from an answer to the key
|
|
11
|
+
// the consumer actually asked with.
|
|
12
|
+
readonly isPreviousKey: boolean
|
|
13
|
+
// Whether a previous key's payload is presentable. Carried on the state rather
|
|
14
|
+
// than passed per-reader so every reading of one query agrees about what is
|
|
15
|
+
// showing — `toAsyncData` and `isLoading` used to be able to contradict.
|
|
16
|
+
readonly keepPreviousData: boolean
|
|
9
17
|
}
|
|
10
18
|
|
|
11
|
-
export const empty = (fetching: boolean): QueryState<never, never> => ({
|
|
19
|
+
export const empty = (fetching: boolean, keepPreviousData = false): QueryState<never, never> => ({
|
|
12
20
|
data: Option.none(),
|
|
13
21
|
error: Option.none(),
|
|
14
22
|
isFetching: fetching,
|
|
15
23
|
isStale: false,
|
|
24
|
+
isPreviousKey: false,
|
|
25
|
+
keepPreviousData,
|
|
16
26
|
})
|
|
17
27
|
|
|
18
|
-
|
|
19
|
-
|
|
28
|
+
const unsettled = <A, E>(state: QueryState<A, E>, gated: boolean): AnyAsyncData<A, E> =>
|
|
29
|
+
state.isFetching || !gated ? AsyncData.loading : AsyncData.idle
|
|
30
|
+
|
|
31
|
+
// A result belonging to a superseded key is not an answer to the question being
|
|
32
|
+
// asked, so it reads as unsettled. `keepPreviousData` opts back into showing it —
|
|
33
|
+
// safe when the payload is only displayed, unsafe when fields are read off it.
|
|
34
|
+
export const toAsyncData = <A, E>(state: QueryState<A, E>, gated: boolean): AnyAsyncData<A, E> => {
|
|
35
|
+
// `keepPreviousData` keeps a *payload*, and only a payload. A previous key's error
|
|
36
|
+
// is a verdict on a question nobody is asking any more — republishing it gates the
|
|
37
|
+
// key now in flight behind a failure that is not its own — and a key that produced
|
|
38
|
+
// only an error has nothing to keep at all.
|
|
39
|
+
if (state.isPreviousKey) {
|
|
40
|
+
const kept = state.keepPreviousData ? state.data : Option.none()
|
|
41
|
+
return Option.match(kept, {
|
|
42
|
+
onNone: () => unsettled(state, gated),
|
|
43
|
+
onSome: (payload) => AsyncData.success(payload, state.isFetching),
|
|
44
|
+
})
|
|
45
|
+
}
|
|
46
|
+
return Option.match(state.error, {
|
|
20
47
|
onSome: (error) => AsyncData.error(error, state.isFetching),
|
|
21
48
|
onNone: () =>
|
|
22
49
|
Option.match(state.data, {
|
|
23
50
|
onSome: (payload) => AsyncData.success(payload, state.isFetching),
|
|
24
|
-
onNone: () => (state
|
|
51
|
+
onNone: () => unsettled(state, gated),
|
|
25
52
|
}),
|
|
26
53
|
})
|
|
54
|
+
}
|
|
27
55
|
|
|
56
|
+
// True while there is nothing to show for the key being asked. A previous key's
|
|
57
|
+
// payload counts as something to show exactly when the query keeps it.
|
|
28
58
|
export const isLoading = <A, E>(state: QueryState<A, E>): boolean =>
|
|
29
|
-
state.isFetching &&
|
|
59
|
+
state.isFetching &&
|
|
60
|
+
(Option.isNone(state.data) || (state.isPreviousKey && !state.keepPreviousData))
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { expect, it } from '@effect/vitest'
|
|
2
|
+
import { Clock, Duration, Effect, Layer, Option, Schema as S, TestClock } from 'effect'
|
|
3
|
+
import { Channel, Engine, Event, Procedure } from '../index'
|
|
4
|
+
import { Channels } from './channel'
|
|
5
|
+
|
|
6
|
+
// The drain loop notifies on the macrotask/microtask queue; draining turns is
|
|
7
|
+
// exact where a duration would be a guess.
|
|
8
|
+
const settle = Effect.yieldNow().pipe(Effect.repeatN(30))
|
|
9
|
+
|
|
10
|
+
// `runtime/loop.ts` hands a whole frame to a channel as N synchronous `offer`
|
|
11
|
+
// calls:
|
|
12
|
+
//
|
|
13
|
+
// ordered.forEach((envelope) => {
|
|
14
|
+
// procedures.channelsFor(envelope.event._tag).forEach((channelName) => {
|
|
15
|
+
// channels.get(channelName)?.offer(envelope.event)
|
|
16
|
+
// })
|
|
17
|
+
// })
|
|
18
|
+
//
|
|
19
|
+
// Reaching the registration is that exact code path — it only removes the
|
|
20
|
+
// scheduling luck of getting N publishes into one `Queue.takeAll`. Everything
|
|
21
|
+
// downstream (the queue, the policy stream, `runMatching`) is untouched.
|
|
22
|
+
const offerFrame = (
|
|
23
|
+
name: string,
|
|
24
|
+
events: ReadonlyArray<{ readonly _tag: string }>,
|
|
25
|
+
): Effect.Effect<void, never, Channels> =>
|
|
26
|
+
Effect.gen(function* () {
|
|
27
|
+
const channels = yield* Channels
|
|
28
|
+
yield* Option.match(Option.fromNullable(channels.byName.get(name)), {
|
|
29
|
+
onNone: () => Effect.dieMessage(`reform: no live channel '${name}'`),
|
|
30
|
+
onSome: (registration) =>
|
|
31
|
+
Effect.sync(() => {
|
|
32
|
+
events.forEach((event) => registration.offer(event))
|
|
33
|
+
}),
|
|
34
|
+
})
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
const throttlePolicy = (
|
|
38
|
+
units: number,
|
|
39
|
+
strategy: Option.Option<'enforce' | 'shape'>,
|
|
40
|
+
): Channel.ChannelPolicy => ({
|
|
41
|
+
_tag: 'throttle',
|
|
42
|
+
units,
|
|
43
|
+
duration: Duration.seconds(1),
|
|
44
|
+
cost: Option.none(),
|
|
45
|
+
burst: Option.none(),
|
|
46
|
+
strategy,
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
// BUG 1 — a throttled lane charges the whole frame as one indivisible chunk,
|
|
51
|
+
// so an `enforce` lane drops events it had the budget to admit.
|
|
52
|
+
//
|
|
53
|
+
// `channel.ts` throttles the raw event stream, which `Stream.fromQueue` emits
|
|
54
|
+
// one chunk per frame. `cost: (chunk) => Chunk.size(chunk) * cost` prices the
|
|
55
|
+
// frame, and `enforce` drops any chunk it cannot pay for *in full*. A lane
|
|
56
|
+
// rated `2 per second` therefore admits 2 of 3 events when they arrive in
|
|
57
|
+
// three frames, and 0 of 3 when the same three arrive in one frame.
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
it.effect('a throttle lane admits the events a frame has budget for', () => {
|
|
60
|
+
class Tick extends Event.make('HuntEnforceTick', S.Struct({ n: S.Number })) {}
|
|
61
|
+
class Spread extends Channel.make('HuntEnforceSpread', {
|
|
62
|
+
policy: throttlePolicy(2, Option.some('enforce' as const)),
|
|
63
|
+
}) {}
|
|
64
|
+
class Burst extends Channel.make('HuntEnforceBurst', {
|
|
65
|
+
policy: throttlePolicy(2, Option.some('enforce' as const)),
|
|
66
|
+
}) {}
|
|
67
|
+
class SpreadWork extends Procedure.make('HuntEnforceSpreadWork', {
|
|
68
|
+
events: [Tick],
|
|
69
|
+
channel: Spread,
|
|
70
|
+
}) {}
|
|
71
|
+
class BurstWork extends Procedure.make('HuntEnforceBurstWork', {
|
|
72
|
+
events: [Tick],
|
|
73
|
+
channel: Burst,
|
|
74
|
+
}) {}
|
|
75
|
+
|
|
76
|
+
const spread: Array<number> = []
|
|
77
|
+
const burst: Array<number> = []
|
|
78
|
+
const layer = Layer.mergeAll(
|
|
79
|
+
Channel.live(Spread),
|
|
80
|
+
Channel.live(Burst),
|
|
81
|
+
Procedure.live(SpreadWork, function* (event) {
|
|
82
|
+
spread.push(event.n)
|
|
83
|
+
}),
|
|
84
|
+
Procedure.live(BurstWork, function* (event) {
|
|
85
|
+
burst.push(event.n)
|
|
86
|
+
}),
|
|
87
|
+
).pipe(Layer.provideMerge(Engine))
|
|
88
|
+
|
|
89
|
+
const ticks = [
|
|
90
|
+
Event.construct(Tick, { n: 1 }),
|
|
91
|
+
Event.construct(Tick, { n: 2 }),
|
|
92
|
+
Event.construct(Tick, { n: 3 }),
|
|
93
|
+
]
|
|
94
|
+
|
|
95
|
+
return Effect.gen(function* () {
|
|
96
|
+
// Control: the same three events, one per frame, on the same policy.
|
|
97
|
+
yield* Effect.forEach(ticks, (tick) =>
|
|
98
|
+
offerFrame('HuntEnforceSpread', [tick]).pipe(Effect.zipRight(settle)),
|
|
99
|
+
)
|
|
100
|
+
// The bucket holds 2 units and the clock never moves, so 1 and 2 are
|
|
101
|
+
// admitted and 3 is dropped. This is what `enforce` is supposed to do.
|
|
102
|
+
expect(spread).toEqual([1, 2])
|
|
103
|
+
|
|
104
|
+
// Same policy, same three events, one frame.
|
|
105
|
+
yield* offerFrame('HuntEnforceBurst', ticks)
|
|
106
|
+
yield* settle
|
|
107
|
+
yield* TestClock.adjust(Duration.seconds(10))
|
|
108
|
+
yield* settle
|
|
109
|
+
|
|
110
|
+
// The lane has the same 2 units of budget, so 1 and 2 must run. Today the
|
|
111
|
+
// queue hands the parked stream `[1]` and then `[2, 3]`: the second chunk
|
|
112
|
+
// is priced at 2 against the 1 unit left, cannot be paid for in full, and
|
|
113
|
+
// is discarded whole — event 2 is dropped even though the lane had exactly
|
|
114
|
+
// the budget for it, and it never comes back.
|
|
115
|
+
expect(burst).toEqual([1, 2])
|
|
116
|
+
}).pipe(Effect.provide(layer))
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
// ---------------------------------------------------------------------------
|
|
120
|
+
// BUG 2 — the same whole-frame pricing makes the default `shape` strategy
|
|
121
|
+
// stall the head of a frame and then release the frame simultaneously.
|
|
122
|
+
//
|
|
123
|
+
// On a lane rated `1 per second`, three events in one frame produce: no run at
|
|
124
|
+
// t=0 (the frame waits until it can pay for all three) and then three runs
|
|
125
|
+
// starting together at t=2000. Both halves are wrong: the bucket starts full so
|
|
126
|
+
// the first event is owed an immediate run, and three concurrent starts inside
|
|
127
|
+
// one second is the opposite of the rate the policy declares.
|
|
128
|
+
// ---------------------------------------------------------------------------
|
|
129
|
+
it.effect('a throttle lane paces a frame instead of releasing it at once', () => {
|
|
130
|
+
class Tick extends Event.make('HuntShapeTick', S.Struct({ n: S.Number })) {}
|
|
131
|
+
class Lane extends Channel.make('HuntShapeLane', {
|
|
132
|
+
policy: throttlePolicy(1, Option.none()),
|
|
133
|
+
}) {}
|
|
134
|
+
class Work extends Procedure.make('HuntShapeWork', { events: [Tick], channel: Lane }) {}
|
|
135
|
+
|
|
136
|
+
const startedAt: Array<number> = []
|
|
137
|
+
const layer = Layer.mergeAll(
|
|
138
|
+
Channel.live(Lane),
|
|
139
|
+
Procedure.live(Work, function* () {
|
|
140
|
+
const now = yield* Clock.currentTimeMillis
|
|
141
|
+
startedAt.push(now)
|
|
142
|
+
}),
|
|
143
|
+
).pipe(Layer.provideMerge(Engine))
|
|
144
|
+
|
|
145
|
+
return Effect.gen(function* () {
|
|
146
|
+
yield* offerFrame('HuntShapeLane', [
|
|
147
|
+
Event.construct(Tick, { n: 1 }),
|
|
148
|
+
Event.construct(Tick, { n: 2 }),
|
|
149
|
+
Event.construct(Tick, { n: 3 }),
|
|
150
|
+
])
|
|
151
|
+
yield* settle
|
|
152
|
+
|
|
153
|
+
// The token bucket starts full, so the head of the frame owes no wait.
|
|
154
|
+
expect(startedAt).toEqual([0])
|
|
155
|
+
|
|
156
|
+
yield* TestClock.adjust(Duration.seconds(10))
|
|
157
|
+
yield* settle
|
|
158
|
+
|
|
159
|
+
expect(startedAt).toHaveLength(3)
|
|
160
|
+
// A lane rated `1 per second` must never start two runs in the same instant.
|
|
161
|
+
expect(new Set(startedAt).size).toBe(3)
|
|
162
|
+
}).pipe(Effect.provide(layer))
|
|
163
|
+
})
|
package/src/channel/channel.ts
CHANGED
|
@@ -186,6 +186,11 @@ export const live = (channel: ChannelClass): Layer.Layer<never, never, Channels
|
|
|
186
186
|
),
|
|
187
187
|
Match.tag('throttle', (policy) =>
|
|
188
188
|
events.pipe(
|
|
189
|
+
// One event per chunk. The drain hands a whole frame over as N offers,
|
|
190
|
+
// which arrives as one chunk; `Stream.throttle` prices a chunk whole and
|
|
191
|
+
// drops (or delays) any it cannot pay for in full, so a frame of three on
|
|
192
|
+
// a lane rated `2 per second` loses all three rather than admitting two.
|
|
193
|
+
Stream.rechunk(1),
|
|
189
194
|
Stream.throttle({
|
|
190
195
|
cost: (chunk) => Chunk.size(chunk) * Option.getOrElse(policy.cost, () => 1),
|
|
191
196
|
units: policy.units,
|