@playfast/reform 1.3.0 → 1.4.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 +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 +1 -1
- package/dist/event/event.d.ts.map +1 -1
- package/dist/event/event.js +26 -4
- 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 +2 -0
- package/dist/internal.d.ts.map +1 -1
- package/dist/internal.js +4 -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/ui/trigger.d.ts +3 -0
- package/dist/ui/trigger.d.ts.map +1 -1
- package/dist/ui/trigger.js +14 -1
- package/dist/ui/trigger.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 +34 -5
- 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 +4 -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/ui/trigger.ts +18 -0
- package/src/wire/tree-key-presence.test.ts +46 -0
- package/src/wire/tree.ts +9 -1
package/src/calc/asyncCalc.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Context, Effect, Layer, Match, Option, Schema } from 'effect'
|
|
2
2
|
import { yieldableClass } from '../definition/definition'
|
|
3
3
|
import { type AnyEvent } from '../event/event'
|
|
4
|
+
import { resolvePersistSlot } from '../internal/queryDriverPersistWarn'
|
|
4
5
|
import { type InputStores } from '../internal/sources'
|
|
5
6
|
import {
|
|
6
7
|
bumpRevision,
|
|
@@ -15,7 +16,7 @@ import { type Store } from '../internal/store'
|
|
|
15
16
|
import { readTracked } from '../internal/track'
|
|
16
17
|
import * as Reducer from '../reducer/reducer'
|
|
17
18
|
import { Reducers } from '../runtime/loop'
|
|
18
|
-
import { Queries } from '../runtime/queries'
|
|
19
|
+
import { Queries, type QueryHandle } from '../runtime/queries'
|
|
19
20
|
import * as State from '../state/state'
|
|
20
21
|
import { type AnySource, type SourceCapture } from '../state/token'
|
|
21
22
|
import { type AsyncData, narrowStore } from './asyncData'
|
|
@@ -44,7 +45,6 @@ export type {
|
|
|
44
45
|
DefinedAsyncCalcClass,
|
|
45
46
|
} from './asyncCalcTypes'
|
|
46
47
|
|
|
47
|
-
|
|
48
48
|
export function make<
|
|
49
49
|
const N extends string,
|
|
50
50
|
const Inputs extends ReadonlyArray<AnySource>,
|
|
@@ -154,10 +154,12 @@ export function live<
|
|
|
154
154
|
const cfg: AsyncCalcLiveView<Inputs, A, E, R> = config
|
|
155
155
|
|
|
156
156
|
const persistOption = cfg.persist
|
|
157
|
-
const persistKey =
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
157
|
+
const persistKey = yield* resolvePersistSlot({
|
|
158
|
+
label: 'AsyncCalc',
|
|
159
|
+
name: calc.manifest.name,
|
|
160
|
+
inputs: calc.inputs.length,
|
|
161
|
+
option: persistOption,
|
|
162
|
+
})
|
|
161
163
|
const persistCodec: QueryCodec<A> = {
|
|
162
164
|
encode: Schema.encode(calc.manifest.output),
|
|
163
165
|
decode: Schema.decodeUnknown(calc.manifest.output),
|
|
@@ -188,6 +190,7 @@ export function live<
|
|
|
188
190
|
disabled: cfg.disabled,
|
|
189
191
|
coalesce: cfg.coalesce,
|
|
190
192
|
reuse: cfg.reuse,
|
|
193
|
+
keepPreviousData: cfg.keepPreviousData,
|
|
191
194
|
persist,
|
|
192
195
|
extraKey,
|
|
193
196
|
})
|
|
@@ -208,14 +211,34 @@ export function live<
|
|
|
208
211
|
)
|
|
209
212
|
}
|
|
210
213
|
|
|
214
|
+
// Resolved through `manifest`, never through `calc.name`: a `class X extends
|
|
215
|
+
// AsyncCalc.make('some/name')` declaration defines its own `name` own-property,
|
|
216
|
+
// which shadows the registered one — so a name-keyed lookup silently misses for
|
|
217
|
+
// exactly the namespaced names the framework asks authors to use.
|
|
211
218
|
export interface NamedCalc {
|
|
212
|
-
readonly name: string
|
|
219
|
+
readonly manifest: { readonly name: string }
|
|
213
220
|
}
|
|
214
221
|
|
|
215
|
-
|
|
222
|
+
const onHandle = (
|
|
223
|
+
calc: NamedCalc,
|
|
224
|
+
verb: string,
|
|
225
|
+
act: (handle: QueryHandle) => void,
|
|
226
|
+
): Effect.Effect<void, never, Queries> =>
|
|
216
227
|
Effect.flatMap(Queries, (queries) =>
|
|
217
|
-
Effect.
|
|
228
|
+
Effect.suspend(() =>
|
|
229
|
+
Option.match(Option.fromNullable(queries.byName.get(calc.manifest.name)), {
|
|
230
|
+
// Swallowing this is how a dead invalidation goes unnoticed for a release.
|
|
231
|
+
onNone: () =>
|
|
232
|
+
Effect.logWarning(
|
|
233
|
+
`reform: ${verb} skipped — no live query named '${calc.manifest.name}'`,
|
|
234
|
+
),
|
|
235
|
+
onSome: (handle) => Effect.sync(() => act(handle)),
|
|
236
|
+
}),
|
|
237
|
+
),
|
|
218
238
|
)
|
|
219
239
|
|
|
240
|
+
export const invalidate = (calc: NamedCalc): Effect.Effect<void, never, Queries> =>
|
|
241
|
+
onHandle(calc, 'invalidate', (handle) => handle.invalidate())
|
|
242
|
+
|
|
220
243
|
export const refetch = (calc: NamedCalc): Effect.Effect<void, never, Queries> =>
|
|
221
|
-
|
|
244
|
+
onHandle(calc, 'refetch', (handle) => handle.refetch())
|
|
@@ -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))
|