@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.
Files changed (188) hide show
  1. package/README.md +15 -6
  2. package/dist/calc/asyncCalc.d.ts +3 -1
  3. package/dist/calc/asyncCalc.d.ts.map +1 -1
  4. package/dist/calc/asyncCalc.js +15 -5
  5. package/dist/calc/asyncCalc.js.map +1 -1
  6. package/dist/calc/asyncCalcTypes.d.ts +2 -0
  7. package/dist/calc/asyncCalcTypes.d.ts.map +1 -1
  8. package/dist/calc/calcFamily.d.ts.map +1 -1
  9. package/dist/calc/calcFamily.js +77 -34
  10. package/dist/calc/calcFamily.js.map +1 -1
  11. package/dist/calc/queryState.d.ts +3 -1
  12. package/dist/calc/queryState.d.ts.map +1 -1
  13. package/dist/calc/queryState.js +31 -9
  14. package/dist/calc/queryState.js.map +1 -1
  15. package/dist/channel/channel.d.ts.map +1 -1
  16. package/dist/channel/channel.js +6 -1
  17. package/dist/channel/channel.js.map +1 -1
  18. package/dist/compose/composition.d.ts.map +1 -1
  19. package/dist/compose/composition.js +7 -1
  20. package/dist/compose/composition.js.map +1 -1
  21. package/dist/compose/structure.d.ts.map +1 -1
  22. package/dist/compose/structure.js +33 -8
  23. package/dist/compose/structure.js.map +1 -1
  24. package/dist/compose/ui.d.ts.map +1 -1
  25. package/dist/compose/ui.js +16 -5
  26. package/dist/compose/ui.js.map +1 -1
  27. package/dist/event/event.d.ts +1 -1
  28. package/dist/event/event.d.ts.map +1 -1
  29. package/dist/event/event.js +26 -4
  30. package/dist/event/event.js.map +1 -1
  31. package/dist/internal/familyKeys.d.ts +16 -0
  32. package/dist/internal/familyKeys.d.ts.map +1 -0
  33. package/dist/internal/familyKeys.js +35 -0
  34. package/dist/internal/familyKeys.js.map +1 -0
  35. package/dist/internal/platform.d.ts +43 -0
  36. package/dist/internal/platform.d.ts.map +1 -0
  37. package/dist/internal/platform.js +68 -0
  38. package/dist/internal/platform.js.map +1 -0
  39. package/dist/internal/queryDriver.d.ts.map +1 -1
  40. package/dist/internal/queryDriver.js +88 -64
  41. package/dist/internal/queryDriver.js.map +1 -1
  42. package/dist/internal/queryDriverConsume.d.ts +9 -0
  43. package/dist/internal/queryDriverConsume.d.ts.map +1 -0
  44. package/dist/internal/queryDriverConsume.js +16 -0
  45. package/dist/internal/queryDriverConsume.js.map +1 -0
  46. package/dist/internal/queryDriverHydrate.d.ts.map +1 -1
  47. package/dist/internal/queryDriverHydrate.js +9 -3
  48. package/dist/internal/queryDriverHydrate.js.map +1 -1
  49. package/dist/internal/queryDriverPersist.d.ts +16 -0
  50. package/dist/internal/queryDriverPersist.d.ts.map +1 -0
  51. package/dist/internal/queryDriverPersist.js +15 -0
  52. package/dist/internal/queryDriverPersist.js.map +1 -0
  53. package/dist/internal/queryDriverPersistWarn.d.ts +12 -0
  54. package/dist/internal/queryDriverPersistWarn.d.ts.map +1 -0
  55. package/dist/internal/queryDriverPersistWarn.js +31 -0
  56. package/dist/internal/queryDriverPersistWarn.js.map +1 -0
  57. package/dist/internal/queryDriverSettle.d.ts +23 -0
  58. package/dist/internal/queryDriverSettle.d.ts.map +1 -0
  59. package/dist/internal/queryDriverSettle.js +52 -0
  60. package/dist/internal/queryDriverSettle.js.map +1 -0
  61. package/dist/internal/queryDriverStore.d.ts +2 -28
  62. package/dist/internal/queryDriverStore.d.ts.map +1 -1
  63. package/dist/internal/queryDriverStore.js +5 -29
  64. package/dist/internal/queryDriverStore.js.map +1 -1
  65. package/dist/internal/queryDriverTypes.d.ts +3 -0
  66. package/dist/internal/queryDriverTypes.d.ts.map +1 -1
  67. package/dist/internal/scheduler.d.ts +1 -0
  68. package/dist/internal/scheduler.d.ts.map +1 -1
  69. package/dist/internal/scheduler.js +69 -6
  70. package/dist/internal/scheduler.js.map +1 -1
  71. package/dist/internal.d.ts +2 -0
  72. package/dist/internal.d.ts.map +1 -1
  73. package/dist/internal.js +4 -0
  74. package/dist/internal.js.map +1 -1
  75. package/dist/reducer/reducer.js +3 -3
  76. package/dist/reducer/reducer.js.map +1 -1
  77. package/dist/remote/pendingQueue.d.ts +4 -2
  78. package/dist/remote/pendingQueue.d.ts.map +1 -1
  79. package/dist/remote/pendingQueue.js +20 -4
  80. package/dist/remote/pendingQueue.js.map +1 -1
  81. package/dist/remote/remoteState.d.ts.map +1 -1
  82. package/dist/remote/remoteState.js +27 -14
  83. package/dist/remote/remoteState.js.map +1 -1
  84. package/dist/remote/remoteStateSend.d.ts +3 -1
  85. package/dist/remote/remoteStateSend.d.ts.map +1 -1
  86. package/dist/remote/remoteStateSend.js +19 -3
  87. package/dist/remote/remoteStateSend.js.map +1 -1
  88. package/dist/remote/remoteStateTypes.d.ts +3 -0
  89. package/dist/remote/remoteStateTypes.d.ts.map +1 -1
  90. package/dist/runtime/bus.d.ts +5 -0
  91. package/dist/runtime/bus.d.ts.map +1 -1
  92. package/dist/runtime/bus.js +1 -1
  93. package/dist/runtime/bus.js.map +1 -1
  94. package/dist/runtime/capturedAppRuntime.d.ts.map +1 -1
  95. package/dist/runtime/capturedAppRuntime.js +37 -51
  96. package/dist/runtime/capturedAppRuntime.js.map +1 -1
  97. package/dist/runtime/eventBudget.d.ts.map +1 -1
  98. package/dist/runtime/eventBudget.js +2 -1
  99. package/dist/runtime/eventBudget.js.map +1 -1
  100. package/dist/runtime/featureMount.d.ts +34 -2
  101. package/dist/runtime/featureMount.d.ts.map +1 -1
  102. package/dist/runtime/featureMount.js +66 -2
  103. package/dist/runtime/featureMount.js.map +1 -1
  104. package/dist/runtime/loop.d.ts +12 -0
  105. package/dist/runtime/loop.d.ts.map +1 -1
  106. package/dist/runtime/loop.js +8 -5
  107. package/dist/runtime/loop.js.map +1 -1
  108. package/dist/state/state.d.ts +2 -2
  109. package/dist/state/state.d.ts.map +1 -1
  110. package/dist/state/state.js +2 -2
  111. package/dist/state/state.js.map +1 -1
  112. package/dist/state/stateFamily.d.ts +2 -2
  113. package/dist/state/stateFamily.d.ts.map +1 -1
  114. package/dist/state/stateFamily.js +80 -32
  115. package/dist/state/stateFamily.js.map +1 -1
  116. package/dist/state/stateGroup.d.ts +5 -5
  117. package/dist/state/stateGroup.d.ts.map +1 -1
  118. package/dist/state/stateGroup.js +1 -1
  119. package/dist/state/stateGroup.js.map +1 -1
  120. package/dist/testkit/flight.testkit.d.ts.map +1 -1
  121. package/dist/testkit/flight.testkit.js +2 -1
  122. package/dist/testkit/flight.testkit.js.map +1 -1
  123. package/dist/ui/trigger.d.ts +3 -0
  124. package/dist/ui/trigger.d.ts.map +1 -1
  125. package/dist/ui/trigger.js +14 -1
  126. package/dist/ui/trigger.js.map +1 -1
  127. package/dist/wire/tree.d.ts.map +1 -1
  128. package/dist/wire/tree.js +6 -1
  129. package/dist/wire/tree.js.map +1 -1
  130. package/package.json +1 -1
  131. package/src/calc/asyncCalc-key-attribution.test.ts +182 -0
  132. package/src/calc/asyncCalc-registry-name.test.ts +82 -0
  133. package/src/calc/asyncCalc.superseded-commit.test.ts +162 -0
  134. package/src/calc/asyncCalc.test.ts +35 -7
  135. package/src/calc/asyncCalc.ts +33 -10
  136. package/src/calc/asyncCalcTypes.ts +6 -0
  137. package/src/calc/calcFamily.ts +81 -34
  138. package/src/calc/family-identity.test.ts +231 -0
  139. package/src/calc/queryState.ts +36 -5
  140. package/src/channel/channel-throttle-frames.test.ts +163 -0
  141. package/src/channel/channel.ts +5 -0
  142. package/src/compose/compose-identity.test.ts +175 -0
  143. package/src/compose/composition.ts +8 -4
  144. package/src/compose/structure.ts +45 -13
  145. package/src/compose/ui.ts +22 -5
  146. package/src/event/event.ts +34 -5
  147. package/src/event/syncTrigger.test.ts +66 -0
  148. package/src/internal/familyKeys.ts +41 -0
  149. package/src/internal/platform.test.ts +55 -0
  150. package/src/internal/platform.ts +72 -0
  151. package/src/internal/queryDriver-defect-attribution.test.ts +107 -0
  152. package/src/internal/queryDriver-persist-key.test.ts +118 -0
  153. package/src/internal/queryDriver.persistence-staleness.test.ts +209 -0
  154. package/src/internal/queryDriver.ts +92 -80
  155. package/src/internal/queryDriverConsume.ts +42 -0
  156. package/src/internal/queryDriverHydrate.ts +9 -3
  157. package/src/internal/queryDriverPersist.ts +42 -0
  158. package/src/internal/queryDriverPersistWarn.ts +61 -0
  159. package/src/internal/queryDriverSettle.ts +85 -0
  160. package/src/internal/queryDriverStore.ts +9 -74
  161. package/src/internal/queryDriverTypes.ts +10 -0
  162. package/src/internal/queryState-and-scheduler-audit.test.ts +170 -0
  163. package/src/internal/scheduler-multi-source-wake.test.ts +111 -0
  164. package/src/internal/scheduler.ts +80 -6
  165. package/src/internal.ts +4 -0
  166. package/src/reducer/reducer.ts +3 -3
  167. package/src/remote/pendingQueue.ts +35 -5
  168. package/src/remote/remoteState.intent-key-stamp.test.ts +207 -0
  169. package/src/remote/remoteState.key-attribution.test.ts +174 -0
  170. package/src/remote/remoteState.ts +27 -15
  171. package/src/remote/remoteStateSend.ts +29 -8
  172. package/src/remote/remoteStateTypes.ts +7 -0
  173. package/src/runtime/bus.ts +6 -1
  174. package/src/runtime/capturedAppRuntime.ts +134 -110
  175. package/src/runtime/eventBudget.ts +2 -1
  176. package/src/runtime/feature-mount-ownership.test.ts +539 -0
  177. package/src/runtime/featureMount.ts +110 -3
  178. package/src/runtime/loop.ts +29 -6
  179. package/src/runtime/mount-inflight-ownership.test.ts +361 -0
  180. package/src/runtime/mount-scope-hardening.test.ts +440 -0
  181. package/src/runtime/runtime.schema-identity.test.ts +188 -0
  182. package/src/state/state.ts +8 -4
  183. package/src/state/stateFamily.ts +86 -36
  184. package/src/state/stateGroup.ts +24 -6
  185. package/src/testkit/flight.testkit.ts +2 -1
  186. package/src/ui/trigger.ts +18 -0
  187. package/src/wire/tree-key-presence.test.ts +46 -0
  188. package/src/wire/tree.ts +9 -1
@@ -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
- persistOption === true || persistOption === undefined || persistOption === false
159
- ? calc.manifest.name
160
- : (persistOption.key ?? calc.manifest.name)
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
- export const invalidate = (calc: NamedCalc): Effect.Effect<void, never, Queries> =>
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.sync(() => queries.byName.get(calc.name)?.invalidate()),
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
- Effect.flatMap(Queries, (queries) => Effect.sync(() => queries.byName.get(calc.name)?.refetch()))
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) }
@@ -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
- const subscribers = new Map<K, number>()
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
- subscribers.delete(key)
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>): Store<Out> => ({
152
- ...store,
153
- subscribe: (listener) => {
154
- const priorCount = subscribers.get(key)
155
- subscribers.set(key, (priorCount === undefined ? 0 : priorCount) + 1)
156
- const off = store.subscribe(listener)
157
- const released = { done: false }
158
- return () => {
159
- if (released.done) {
160
- return
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: (key) => {
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 created = evictWhenUnused
188
- ? { store: refCounted(key, member.store), unsubscribe: member.unsubscribe }
189
- : member
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
+ })
@@ -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
- export const toAsyncData = <A, E>(state: QueryState<A, E>, gated: boolean): AnyAsyncData<A, E> =>
19
- Option.match(state.error, {
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.isFetching || !gated ? AsyncData.loading : AsyncData.idle),
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 && Option.isNone(state.data)
59
+ state.isFetching &&
60
+ (Option.isNone(state.data) || (state.isPreviousKey && !state.keepPreviousData))