@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.
Files changed (182) 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.map +1 -1
  28. package/dist/event/event.js +23 -2
  29. package/dist/event/event.js.map +1 -1
  30. package/dist/internal/familyKeys.d.ts +16 -0
  31. package/dist/internal/familyKeys.d.ts.map +1 -0
  32. package/dist/internal/familyKeys.js +35 -0
  33. package/dist/internal/familyKeys.js.map +1 -0
  34. package/dist/internal/platform.d.ts +43 -0
  35. package/dist/internal/platform.d.ts.map +1 -0
  36. package/dist/internal/platform.js +68 -0
  37. package/dist/internal/platform.js.map +1 -0
  38. package/dist/internal/queryDriver.d.ts.map +1 -1
  39. package/dist/internal/queryDriver.js +88 -64
  40. package/dist/internal/queryDriver.js.map +1 -1
  41. package/dist/internal/queryDriverConsume.d.ts +9 -0
  42. package/dist/internal/queryDriverConsume.d.ts.map +1 -0
  43. package/dist/internal/queryDriverConsume.js +16 -0
  44. package/dist/internal/queryDriverConsume.js.map +1 -0
  45. package/dist/internal/queryDriverHydrate.d.ts.map +1 -1
  46. package/dist/internal/queryDriverHydrate.js +9 -3
  47. package/dist/internal/queryDriverHydrate.js.map +1 -1
  48. package/dist/internal/queryDriverPersist.d.ts +16 -0
  49. package/dist/internal/queryDriverPersist.d.ts.map +1 -0
  50. package/dist/internal/queryDriverPersist.js +15 -0
  51. package/dist/internal/queryDriverPersist.js.map +1 -0
  52. package/dist/internal/queryDriverPersistWarn.d.ts +12 -0
  53. package/dist/internal/queryDriverPersistWarn.d.ts.map +1 -0
  54. package/dist/internal/queryDriverPersistWarn.js +31 -0
  55. package/dist/internal/queryDriverPersistWarn.js.map +1 -0
  56. package/dist/internal/queryDriverSettle.d.ts +23 -0
  57. package/dist/internal/queryDriverSettle.d.ts.map +1 -0
  58. package/dist/internal/queryDriverSettle.js +52 -0
  59. package/dist/internal/queryDriverSettle.js.map +1 -0
  60. package/dist/internal/queryDriverStore.d.ts +2 -28
  61. package/dist/internal/queryDriverStore.d.ts.map +1 -1
  62. package/dist/internal/queryDriverStore.js +5 -29
  63. package/dist/internal/queryDriverStore.js.map +1 -1
  64. package/dist/internal/queryDriverTypes.d.ts +3 -0
  65. package/dist/internal/queryDriverTypes.d.ts.map +1 -1
  66. package/dist/internal/scheduler.d.ts +1 -0
  67. package/dist/internal/scheduler.d.ts.map +1 -1
  68. package/dist/internal/scheduler.js +69 -6
  69. package/dist/internal/scheduler.js.map +1 -1
  70. package/dist/internal.d.ts +1 -0
  71. package/dist/internal.d.ts.map +1 -1
  72. package/dist/internal.js +1 -0
  73. package/dist/internal.js.map +1 -1
  74. package/dist/reducer/reducer.js +3 -3
  75. package/dist/reducer/reducer.js.map +1 -1
  76. package/dist/remote/pendingQueue.d.ts +4 -2
  77. package/dist/remote/pendingQueue.d.ts.map +1 -1
  78. package/dist/remote/pendingQueue.js +20 -4
  79. package/dist/remote/pendingQueue.js.map +1 -1
  80. package/dist/remote/remoteState.d.ts.map +1 -1
  81. package/dist/remote/remoteState.js +27 -14
  82. package/dist/remote/remoteState.js.map +1 -1
  83. package/dist/remote/remoteStateSend.d.ts +3 -1
  84. package/dist/remote/remoteStateSend.d.ts.map +1 -1
  85. package/dist/remote/remoteStateSend.js +19 -3
  86. package/dist/remote/remoteStateSend.js.map +1 -1
  87. package/dist/remote/remoteStateTypes.d.ts +3 -0
  88. package/dist/remote/remoteStateTypes.d.ts.map +1 -1
  89. package/dist/runtime/bus.d.ts +5 -0
  90. package/dist/runtime/bus.d.ts.map +1 -1
  91. package/dist/runtime/bus.js +1 -1
  92. package/dist/runtime/bus.js.map +1 -1
  93. package/dist/runtime/capturedAppRuntime.d.ts.map +1 -1
  94. package/dist/runtime/capturedAppRuntime.js +37 -51
  95. package/dist/runtime/capturedAppRuntime.js.map +1 -1
  96. package/dist/runtime/eventBudget.d.ts.map +1 -1
  97. package/dist/runtime/eventBudget.js +2 -1
  98. package/dist/runtime/eventBudget.js.map +1 -1
  99. package/dist/runtime/featureMount.d.ts +34 -2
  100. package/dist/runtime/featureMount.d.ts.map +1 -1
  101. package/dist/runtime/featureMount.js +66 -2
  102. package/dist/runtime/featureMount.js.map +1 -1
  103. package/dist/runtime/loop.d.ts +12 -0
  104. package/dist/runtime/loop.d.ts.map +1 -1
  105. package/dist/runtime/loop.js +8 -5
  106. package/dist/runtime/loop.js.map +1 -1
  107. package/dist/state/state.d.ts +2 -2
  108. package/dist/state/state.d.ts.map +1 -1
  109. package/dist/state/state.js +2 -2
  110. package/dist/state/state.js.map +1 -1
  111. package/dist/state/stateFamily.d.ts +2 -2
  112. package/dist/state/stateFamily.d.ts.map +1 -1
  113. package/dist/state/stateFamily.js +80 -32
  114. package/dist/state/stateFamily.js.map +1 -1
  115. package/dist/state/stateGroup.d.ts +5 -5
  116. package/dist/state/stateGroup.d.ts.map +1 -1
  117. package/dist/state/stateGroup.js +1 -1
  118. package/dist/state/stateGroup.js.map +1 -1
  119. package/dist/testkit/flight.testkit.d.ts.map +1 -1
  120. package/dist/testkit/flight.testkit.js +2 -1
  121. package/dist/testkit/flight.testkit.js.map +1 -1
  122. package/dist/wire/tree.d.ts.map +1 -1
  123. package/dist/wire/tree.js +6 -1
  124. package/dist/wire/tree.js.map +1 -1
  125. package/package.json +1 -1
  126. package/src/calc/asyncCalc-key-attribution.test.ts +182 -0
  127. package/src/calc/asyncCalc-registry-name.test.ts +82 -0
  128. package/src/calc/asyncCalc.superseded-commit.test.ts +162 -0
  129. package/src/calc/asyncCalc.test.ts +35 -7
  130. package/src/calc/asyncCalc.ts +33 -10
  131. package/src/calc/asyncCalcTypes.ts +6 -0
  132. package/src/calc/calcFamily.ts +81 -34
  133. package/src/calc/family-identity.test.ts +231 -0
  134. package/src/calc/queryState.ts +36 -5
  135. package/src/channel/channel-throttle-frames.test.ts +163 -0
  136. package/src/channel/channel.ts +5 -0
  137. package/src/compose/compose-identity.test.ts +175 -0
  138. package/src/compose/composition.ts +8 -4
  139. package/src/compose/structure.ts +45 -13
  140. package/src/compose/ui.ts +22 -5
  141. package/src/event/event.ts +31 -2
  142. package/src/event/syncTrigger.test.ts +66 -0
  143. package/src/internal/familyKeys.ts +41 -0
  144. package/src/internal/platform.test.ts +55 -0
  145. package/src/internal/platform.ts +72 -0
  146. package/src/internal/queryDriver-defect-attribution.test.ts +107 -0
  147. package/src/internal/queryDriver-persist-key.test.ts +118 -0
  148. package/src/internal/queryDriver.persistence-staleness.test.ts +209 -0
  149. package/src/internal/queryDriver.ts +92 -80
  150. package/src/internal/queryDriverConsume.ts +42 -0
  151. package/src/internal/queryDriverHydrate.ts +9 -3
  152. package/src/internal/queryDriverPersist.ts +42 -0
  153. package/src/internal/queryDriverPersistWarn.ts +61 -0
  154. package/src/internal/queryDriverSettle.ts +85 -0
  155. package/src/internal/queryDriverStore.ts +9 -74
  156. package/src/internal/queryDriverTypes.ts +10 -0
  157. package/src/internal/queryState-and-scheduler-audit.test.ts +170 -0
  158. package/src/internal/scheduler-multi-source-wake.test.ts +111 -0
  159. package/src/internal/scheduler.ts +80 -6
  160. package/src/internal.ts +1 -0
  161. package/src/reducer/reducer.ts +3 -3
  162. package/src/remote/pendingQueue.ts +35 -5
  163. package/src/remote/remoteState.intent-key-stamp.test.ts +207 -0
  164. package/src/remote/remoteState.key-attribution.test.ts +174 -0
  165. package/src/remote/remoteState.ts +27 -15
  166. package/src/remote/remoteStateSend.ts +29 -8
  167. package/src/remote/remoteStateTypes.ts +7 -0
  168. package/src/runtime/bus.ts +6 -1
  169. package/src/runtime/capturedAppRuntime.ts +134 -110
  170. package/src/runtime/eventBudget.ts +2 -1
  171. package/src/runtime/feature-mount-ownership.test.ts +539 -0
  172. package/src/runtime/featureMount.ts +110 -3
  173. package/src/runtime/loop.ts +29 -6
  174. package/src/runtime/mount-inflight-ownership.test.ts +361 -0
  175. package/src/runtime/mount-scope-hardening.test.ts +440 -0
  176. package/src/runtime/runtime.schema-identity.test.ts +188 -0
  177. package/src/state/state.ts +8 -4
  178. package/src/state/stateFamily.ts +86 -36
  179. package/src/state/stateGroup.ts +24 -6
  180. package/src/testkit/flight.testkit.ts +2 -1
  181. package/src/wire/tree-key-presence.test.ts +46 -0
  182. 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) }
@@ -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))
@@ -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
+ })
@@ -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,