@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
@@ -0,0 +1,207 @@
1
+ import { expect, it } from '@effect/vitest'
2
+ import { Deferred, Effect, Layer, Match, Schema as S } from 'effect'
3
+ import { Channel, Engine, Event, Reducer, RemoteState, State } from '../index'
4
+ import { until } from '../testkit/flight.testkit'
5
+
6
+ // ---------------------------------------------------------------------------
7
+ // A pending intent is stamped with the input key it was issued against, so the
8
+ // overlay never applies it to another key's truth (pendingQueue.foldFor,
9
+ // SettleLink.key). The stamp is taken in the `Queued` reducer
10
+ // (remoteStateSend.ts:44-56) — "the same frame the intent was issued in, so it
11
+ // records the key the user was actually looking at".
12
+ //
13
+ // It is not. `Queued` is dispatched by the *send procedure*, which runs on the
14
+ // intent's Channel — an arbitrary time after the intent left the bus. Anything
15
+ // the channel's policy makes the send wait for (a single-flight `exclusive`
16
+ // lane, a `debounce` window, a busy `throttle` bucket) is a window in which the
17
+ // input key can move; the intent is then stamped with, and applied to, a key it
18
+ // was never issued against.
19
+ // ---------------------------------------------------------------------------
20
+
21
+ const Row = S.Struct({ id: S.String, name: S.String })
22
+ type Row = typeof Row.Type
23
+
24
+ class HuntAdded extends Event.make('HuntAdded', S.Struct({ id: S.String, name: S.String })) {}
25
+ class HuntRemoved extends Event.make('HuntRemoved', S.Struct({ id: S.String })) {}
26
+ type HuntIntent = Event.EventType<typeof HuntAdded> | Event.EventType<typeof HuntRemoved>
27
+
28
+ // the navigation the user performs *after* issuing the intent — an ordinary
29
+ // event, so it is ordered against the intent by the bus, not by the test.
30
+ class HuntGoTo extends Event.make('HuntGoTo', S.Struct({ page: S.Number })) {}
31
+
32
+ const applyIntent = (rows: ReadonlyArray<Row>, intent: HuntIntent): ReadonlyArray<Row> =>
33
+ Match.value(intent).pipe(
34
+ Match.tag('HuntAdded', ({ id, name }) =>
35
+ rows.some((row) => row.id === id) ? rows : [...rows, { id, name }],
36
+ ),
37
+ Match.tag('HuntRemoved', ({ id }) => rows.filter((row) => row.id !== id)),
38
+ Match.exhaustive,
39
+ )
40
+
41
+ const ids = (rows: ReadonlyArray<Row>): ReadonlyArray<string> => rows.map((row) => row.id)
42
+
43
+ it.live(
44
+ 'an intent held by its channel is stamped with the key it landed on, not the key it was issued on',
45
+ () => {
46
+ class Page extends State.make('huntPage', S.Number) {}
47
+ class Nav extends Reducer.make('huntNav', { states: [Page], events: [HuntGoTo] }) {}
48
+ class Items extends RemoteState.make('HuntItems', {
49
+ inputs: [Page],
50
+ output: S.Array(Row),
51
+ intents: [HuntAdded, HuntRemoved],
52
+ alwaysOn: true,
53
+ }) {}
54
+
55
+ const gateA = Effect.runSync(Deferred.make<void>())
56
+ const gateB = Effect.runSync(Deferred.make<void>())
57
+ // one send at a time: the framework, not the test, holds the second intent back
58
+ const sends = Channel.make('hunt/sends', { policy: { _tag: 'exclusive' } })
59
+
60
+ const ItemsLive = RemoteState.live(Items, {
61
+ query: ({ huntPage }) => Effect.succeed([{ id: `p${huntPage}`, name: `page-${huntPage}` }]),
62
+ send: (intent) => Deferred.await(intent.id === 'a' ? gateA : gateB),
63
+ apply: applyIntent,
64
+ channel: sends,
65
+ })
66
+ const TestLayer = Layer.mergeAll(
67
+ ItemsLive,
68
+ Reducer.live(Nav, (_, event) => event.page),
69
+ ).pipe(Layer.provideMerge(State.live(Page, 1)), Layer.provideMerge(Engine))
70
+
71
+ return Effect.gen(function* () {
72
+ const view = yield* Items.store
73
+ const truth = yield* Items.truth.store
74
+ const queue = yield* Items.pending.store
75
+
76
+ yield* until(
77
+ () => truth.get(),
78
+ (v) => v._tag === 'Success' && v.refetching === false && v.value[0]?.id === 'p1',
79
+ )
80
+
81
+ // 'a' is issued on page 1 and reaches the channel immediately; its send parks.
82
+ yield* Event.dispatch(HuntAdded, { id: 'a', name: 'a' })
83
+ yield* until(
84
+ () => queue.get(),
85
+ (q) => q.length === 1,
86
+ )
87
+ const onPageOne = view.get()
88
+ expect(onPageOne._tag).toBe('Success')
89
+ if (onPageOne._tag === 'Success') expect(ids(onPageOne.value)).toEqual(['p1', 'a'])
90
+
91
+ // 'b' is ALSO issued on page 1 — but the single-flight channel is busy with
92
+ // 'a', so its send (and its `Queued`) wait. THEN the user navigates.
93
+ yield* Event.dispatch(HuntAdded, { id: 'b', name: 'b' })
94
+ yield* Event.dispatch(HuntGoTo, { page: 2 })
95
+ yield* until(
96
+ () => truth.get(),
97
+ (v) => v._tag === 'Success' && v.refetching === false && v.value[0]?.id === 'p2',
98
+ )
99
+
100
+ // precondition: 'b' has still not been queued — the channel is holding it
101
+ expect(queue.get().map((entry) => entry.intent.id)).toEqual(['a'])
102
+
103
+ // CONTROL: 'a' was stamped on page 1, so page 2's truth is untouched by it.
104
+ // Same machinery, same assertion shape — it passes.
105
+ const control = view.get()
106
+ expect(control._tag).toBe('Success')
107
+ if (control._tag === 'Success') expect(ids(control.value)).toEqual(['p2'])
108
+
109
+ // release 'a'; the channel is now free and 'b' finally reaches the queue
110
+ yield* Deferred.succeed(gateA, undefined)
111
+ yield* until(
112
+ () => queue.get(),
113
+ (q) => q.some((entry) => entry.intent.id === 'b'),
114
+ )
115
+ yield* until(
116
+ () => truth.get(),
117
+ (v) => v._tag === 'Success' && v.refetching === false && v.value[0]?.id === 'p2',
118
+ )
119
+
120
+ const visible = view.get()
121
+ expect(visible._tag).toBe('Success')
122
+ if (visible._tag === 'Success') {
123
+ // 'b' was issued while the user was on page 1 — page 2 never carried it
124
+ expect(ids(visible.value)).toEqual(['p2'])
125
+ }
126
+
127
+ yield* Deferred.succeed(gateB, undefined)
128
+ }).pipe(Effect.provide(TestLayer))
129
+ },
130
+ )
131
+
132
+ it.live('a held optimistic delete erases a row from the input key it never targeted', () => {
133
+ class Page extends State.make('huntErasePage', S.Number) {}
134
+ class Nav extends Reducer.make('huntEraseNav', { states: [Page], events: [HuntGoTo] }) {}
135
+ class Items extends RemoteState.make('HuntEraseItems', {
136
+ inputs: [Page],
137
+ output: S.Array(Row),
138
+ intents: [HuntAdded, HuntRemoved],
139
+ alwaysOn: true,
140
+ }) {}
141
+
142
+ const gateHold = Effect.runSync(Deferred.make<void>())
143
+ const gateErase = Effect.runSync(Deferred.make<void>())
144
+ const sends = Channel.make('huntErase/sends', { policy: { _tag: 'exclusive' } })
145
+
146
+ const ItemsLive = RemoteState.live(Items, {
147
+ query: ({ huntErasePage }) => Effect.succeed([{ id: 'x', name: `x-on-${huntErasePage}` }]),
148
+ send: (intent) => Deferred.await(intent._tag === 'HuntAdded' ? gateHold : gateErase),
149
+ apply: applyIntent,
150
+ channel: sends,
151
+ })
152
+ const TestLayer = Layer.mergeAll(
153
+ ItemsLive,
154
+ Reducer.live(Nav, (_, event) => event.page),
155
+ ).pipe(Layer.provideMerge(State.live(Page, 1)), Layer.provideMerge(Engine))
156
+
157
+ return Effect.gen(function* () {
158
+ const view = yield* Items.store
159
+ const truth = yield* Items.truth.store
160
+ const queue = yield* Items.pending.store
161
+
162
+ yield* until(
163
+ () => truth.get(),
164
+ (v) => v._tag === 'Success' && v.refetching === false && v.value[0]?.name === 'x-on-1',
165
+ )
166
+
167
+ // occupy the single-flight channel with an unrelated intent issued on page 1
168
+ yield* Event.dispatch(HuntAdded, { id: 'hold', name: 'hold' })
169
+ yield* until(
170
+ () => queue.get(),
171
+ (q) => q.length === 1,
172
+ )
173
+
174
+ // the user deletes page 1's row 'x', then navigates away
175
+ yield* Event.dispatch(HuntRemoved, { id: 'x' })
176
+ yield* Event.dispatch(HuntGoTo, { page: 2 })
177
+ yield* until(
178
+ () => truth.get(),
179
+ (v) => v._tag === 'Success' && v.refetching === false && v.value[0]?.name === 'x-on-2',
180
+ )
181
+
182
+ // precondition + CONTROL: the *stamped* pending Added is correctly withheld
183
+ expect(queue.get().map((entry) => entry.intent.id)).toEqual(['hold'])
184
+ const control = view.get()
185
+ expect(control._tag).toBe('Success')
186
+ if (control._tag === 'Success') expect(ids(control.value)).toEqual(['x'])
187
+
188
+ yield* Deferred.succeed(gateHold, undefined)
189
+ yield* until(
190
+ () => queue.get(),
191
+ (q) => q.some((entry) => entry.intent._tag === 'HuntRemoved'),
192
+ )
193
+ yield* until(
194
+ () => truth.get(),
195
+ (v) => v._tag === 'Success' && v.refetching === false && v.value[0]?.name === 'x-on-2',
196
+ )
197
+
198
+ const visible = view.get()
199
+ expect(visible._tag).toBe('Success')
200
+ if (visible._tag === 'Success') {
201
+ // the delete targeted page 1's row; page 2's row must survive
202
+ expect(ids(visible.value)).toEqual(['x'])
203
+ }
204
+
205
+ yield* Deferred.succeed(gateErase, undefined)
206
+ }).pipe(Effect.provide(TestLayer))
207
+ })
@@ -0,0 +1,174 @@
1
+ import { expect, it } from '@effect/vitest'
2
+ import { Deferred, Effect, Layer, Match, Schema as S } from 'effect'
3
+ import { Engine, Event, RemoteState, State, StateGroup } from '../index'
4
+ import { until } from '../testkit/flight.testkit'
5
+
6
+ // ---------------------------------------------------------------------------
7
+ // RemoteState keeps ONE pending queue for the whole definition, but a
8
+ // definition is keyed by its `inputs`. A PendingIntent records `opId`,
9
+ // `intent` and `status` — nothing about the input key the intent was issued
10
+ // against (remoteStateTypes.ts:15). `recompute` therefore folds every pending
11
+ // intent onto whatever truth currently holds, even after the input key moved
12
+ // (remoteState.ts:203-217). Same identity/attribution class as issue #22:
13
+ // state that outlives the input it was derived from.
14
+ // ---------------------------------------------------------------------------
15
+
16
+ const Row = S.Struct({ id: S.String, name: S.String })
17
+ type Row = typeof Row.Type
18
+
19
+ class RegressAdded extends Event.make('RegressAdded', S.Struct({ id: S.String, name: S.String })) {}
20
+ class RegressRemoved extends Event.make('RegressRemoved', S.Struct({ id: S.String })) {}
21
+ type RegressIntent = Event.EventType<typeof RegressAdded> | Event.EventType<typeof RegressRemoved>
22
+
23
+ // idempotent against truth that already carries the intent, per the RemoteState contract
24
+ const applyIntent = (rows: ReadonlyArray<Row>, intent: RegressIntent): ReadonlyArray<Row> =>
25
+ Match.value(intent).pipe(
26
+ Match.tag('RegressAdded', ({ id, name }) =>
27
+ rows.some((row) => row.id === id) ? rows : [...rows, { id, name }],
28
+ ),
29
+ Match.tag('RegressRemoved', ({ id }) => rows.filter((row) => row.id !== id)),
30
+ Match.exhaustive,
31
+ )
32
+
33
+ const ids = (rows: ReadonlyArray<Row>): ReadonlyArray<string> => rows.map((row) => row.id)
34
+
35
+ // ---------------------------------------------------------------------------
36
+ // — additive leak across a plain input-key change.
37
+ // ---------------------------------------------------------------------------
38
+ it.live('a pending intent for one input key overlays another key truth', () => {
39
+ class RegressPage extends State.make('regressLeakPage', S.Number) {}
40
+ class RegressInputs extends StateGroup.make(RegressPage) {}
41
+ class Items extends RemoteState.make('RegressLeakItems', {
42
+ inputs: [StateGroup.select(RegressInputs, 'regressLeakPage')],
43
+ output: S.Array(Row),
44
+ intents: [RegressAdded, RegressRemoved],
45
+ alwaysOn: true,
46
+ }) {}
47
+
48
+ // the send never completes, so the intent stays genuinely in flight
49
+ const inFlight = Effect.runSync(Deferred.make<void>())
50
+ const ItemsLive = RemoteState.live(Items, {
51
+ query: ({ regressLeakPage }) =>
52
+ Effect.succeed([{ id: `p${regressLeakPage}`, name: `page-${regressLeakPage}` }]),
53
+ send: () => Deferred.await(inFlight),
54
+ apply: applyIntent,
55
+ })
56
+ const TestLayer = ItemsLive.pipe(
57
+ Layer.provideMerge(StateGroup.live(RegressInputs, { regressLeakPage: 1 })),
58
+ Layer.provideMerge(Engine),
59
+ )
60
+
61
+ return Effect.gen(function* () {
62
+ const view = yield* Items.store
63
+ const truth = yield* Items.truth.store
64
+ const queue = yield* Items.pending.store
65
+ const page = yield* RegressPage.store
66
+
67
+ yield* until(
68
+ () => truth.get(),
69
+ (v) => v._tag === 'Success' && v.refetching === false && v.value[0]?.id === 'p1',
70
+ )
71
+
72
+ yield* Event.dispatch(RegressAdded, { id: 'ghost', name: 'ghost' })
73
+ yield* until(
74
+ () => queue.get(),
75
+ (q) => q.length === 1,
76
+ )
77
+ const optimistic = view.get()
78
+ expect(optimistic._tag).toBe('Success')
79
+ if (optimistic._tag === 'Success') expect(ids(optimistic.value)).toEqual(['p1', 'ghost'])
80
+
81
+ // move to a different input key and let its truth fully settle
82
+ page.set(2)
83
+ yield* until(
84
+ () => truth.get(),
85
+ (v) => v._tag === 'Success' && v.refetching === false && v.value[0]?.id === 'p2',
86
+ )
87
+ // the send is still parked, so the intent is still pending — for page 1
88
+ expect(queue.get()).toHaveLength(1)
89
+
90
+ const visible = view.get()
91
+ expect(visible._tag).toBe('Success')
92
+ if (visible._tag === 'Success') {
93
+ // page 2's truth is [p2]; 'ghost' was only ever optimistic for page 1
94
+ expect(ids(visible.value)).toEqual(['p2'])
95
+ }
96
+
97
+ yield* Deferred.succeed(inFlight, undefined)
98
+ }).pipe(Effect.provide(TestLayer))
99
+ })
100
+
101
+ // ---------------------------------------------------------------------------
102
+ // — destructive variant, and it survives a full gated Idle round trip:
103
+ // truth is cleared to Idle and re-fetched for a *different* key, yet the stale
104
+ // intent still erases a row it never targeted. Not "we hold the overlay while
105
+ // the same query revalidates".
106
+ // ---------------------------------------------------------------------------
107
+ it.live('an optimistic delete for one input key erases a row from another key', () => {
108
+ class RegressPage extends State.make('regressErasePage', S.Number) {}
109
+ class RegressInputs extends StateGroup.make(RegressPage) {}
110
+ class Items extends RemoteState.make('RegressEraseItems', {
111
+ inputs: [StateGroup.select(RegressInputs, 'regressErasePage')],
112
+ output: S.Array(Row),
113
+ intents: [RegressAdded, RegressRemoved],
114
+ }) {}
115
+
116
+ const inFlight = Effect.runSync(Deferred.make<void>())
117
+ const ItemsLive = RemoteState.live(Items, {
118
+ query: ({ regressErasePage }) =>
119
+ Effect.succeed([{ id: 'x', name: `x-on-${regressErasePage}` }]),
120
+ send: () => Deferred.await(inFlight),
121
+ apply: applyIntent,
122
+ disabled: ({ regressErasePage }) => regressErasePage < 0,
123
+ })
124
+ const TestLayer = ItemsLive.pipe(
125
+ Layer.provideMerge(StateGroup.live(RegressInputs, { regressErasePage: 1 })),
126
+ Layer.provideMerge(Engine),
127
+ )
128
+
129
+ return Effect.gen(function* () {
130
+ const view = yield* Items.store
131
+ const truth = yield* Items.truth.store
132
+ const queue = yield* Items.pending.store
133
+ const page = yield* RegressPage.store
134
+
135
+ yield* until(
136
+ () => truth.get(),
137
+ (v) => v._tag === 'Success' && v.value[0]?.name === 'x-on-1',
138
+ )
139
+
140
+ // delete row 'x' *of page 1*, optimistically
141
+ yield* Event.dispatch(RegressRemoved, { id: 'x' })
142
+ yield* until(
143
+ () => queue.get(),
144
+ (q) => q.length === 1,
145
+ )
146
+ const optimistic = view.get()
147
+ expect(optimistic._tag).toBe('Success')
148
+ if (optimistic._tag === 'Success') expect(ids(optimistic.value)).toEqual([])
149
+
150
+ // gate off: truth drops to Idle and its data is discarded
151
+ page.set(-1)
152
+ yield* until(
153
+ () => view.get(),
154
+ (v) => v._tag === 'Idle',
155
+ )
156
+
157
+ // come back on a different key — a fresh query, a different row
158
+ page.set(7)
159
+ yield* until(
160
+ () => truth.get(),
161
+ (v) => v._tag === 'Success' && v.value[0]?.name === 'x-on-7',
162
+ )
163
+ expect(queue.get()).toHaveLength(1)
164
+
165
+ const visible = view.get()
166
+ expect(visible._tag).toBe('Success')
167
+ if (visible._tag === 'Success') {
168
+ // page 7's row was never targeted by the pending delete
169
+ expect(ids(visible.value)).toEqual(['x'])
170
+ }
171
+
172
+ yield* Deferred.succeed(inFlight, undefined)
173
+ }).pipe(Effect.provide(TestLayer))
174
+ })
@@ -10,6 +10,7 @@ import {
10
10
  } from '../internal/queryDriver'
11
11
  import { reuse as shareStructure } from '../internal/reuse'
12
12
  import { resolveScheduler } from '../internal/scheduler'
13
+ import { resolvePersistSlot } from '../internal/queryDriverPersistWarn'
13
14
  import { type InputStores, sameKey } from '../internal/sources'
14
15
  import { makeDerivedStore } from '../internal/store'
15
16
  import * as Reducer from '../reducer/reducer'
@@ -128,13 +129,14 @@ export const live = <
128
129
  }
129
130
  }
130
131
 
131
- const persistOption = config.persist
132
- const persistKey =
133
- persistOption === true || persistOption === undefined || persistOption === false
134
- ? name
135
- : (persistOption.key ?? name)
132
+ const persistKey = yield* resolvePersistSlot({
133
+ label: 'RemoteState',
134
+ name,
135
+ inputs: remote.inputs.length,
136
+ option: config.persist,
137
+ })
136
138
  const persist =
137
- persistOption === undefined || persistOption === false
139
+ config.persist === undefined || config.persist === false
138
140
  ? undefined
139
141
  : { key: persistKey, codec: remote.codec }
140
142
  const revisionKey = (source: NonNullable<typeof revision>) => ({
@@ -152,6 +154,7 @@ export const live = <
152
154
  disabled: config.disabled,
153
155
  coalesce: config.coalesce,
154
156
  reuse: config.reuse,
157
+ keepPreviousData: config.keepPreviousData,
155
158
  persist,
156
159
  extraKey,
157
160
  onSettled,
@@ -184,6 +187,8 @@ export const live = <
184
187
  const link: SettleLink = {
185
188
  requested: driver.requested,
186
189
  register: (opId, waitFor) => registerWaiter({ opId, waitFor }),
190
+ key: driver.key,
191
+ subscribeKey: driver.subscribeKey,
187
192
  }
188
193
  return Context.make(truthTag, narrowStore<A, E, Gated>(driver.store)).pipe(
189
194
  Context.add(linkTag, link),
@@ -197,19 +202,24 @@ export const live = <
197
202
  const scheduler = yield* resolveScheduler
198
203
  const feedStore = widenStore<A, E, Gated>(yield* truthTag)
199
204
  const queue = yield* pendingQueueTag
205
+ const link = yield* linkTag
200
206
  const memo = MutableRef.make<
201
207
  { readonly key: ReadonlyArray<unknown>; readonly output: AnyAsyncData<A, E> } | undefined
202
208
  >(undefined)
203
209
  const recompute = (): AnyAsyncData<A, E> => {
204
210
  const feed = feedStore.getSnapshot()
205
- const key = [feed, queue.revision()]
211
+ const inputKey = link.key()
212
+ // The input key joins the memo key: which intents apply depends on it.
213
+ const key = [feed, queue.revision(), ...inputKey]
206
214
  const prev = MutableRef.get(memo)
207
215
  if (prev !== undefined && sameKey(key, prev.key)) {
208
216
  return prev.output
209
217
  }
210
218
  // Full replay preserves observable value identity when apply allocates and reuse is off.
219
+ // Only this key's intents: an optimistic write issued on another key is not
220
+ // about this payload, and applying it injects or deletes rows it never targeted.
211
221
  const applyPending = (base: A): A =>
212
- queue.fold(base, (applied, entry) => config.apply(applied, entry.intent))
222
+ queue.foldFor(inputKey, base, (applied, entry) => config.apply(applied, entry.intent))
213
223
  const overlaid: AnyAsyncData<A, E> =
214
224
  feed._tag === 'Success'
215
225
  ? AsyncData.success(applyPending(feed.value), feed.refetching)
@@ -228,12 +238,16 @@ export const live = <
228
238
  MutableRef.set(memo, { key, output })
229
239
  return output
230
240
  }
241
+ // The key is a third input: it decides which queued intents apply, and it can
242
+ // move without the feed or the queue writing anything.
231
243
  const subscribe = (listener: () => void): (() => void) => {
232
244
  const offFeed = feedStore.subscribe(listener)
233
245
  const offQueue = queue.store.subscribe(listener)
246
+ const offKey = link.subscribeKey(listener)
234
247
  return () => {
235
248
  offFeed()
236
249
  offQueue()
250
+ offKey()
237
251
  }
238
252
  }
239
253
  const derived = makeDerivedStore(recompute, subscribe, scheduler)
@@ -243,21 +257,19 @@ export const live = <
243
257
  )
244
258
 
245
259
  const channel = config.channel ?? Channel.make(`${name}/sends`, { policy: { _tag: 'merge' } })
246
- const intentEvents = {
247
- queued: Queued,
248
- acked: Acked,
249
- settled: Settled,
250
- invalidated: Invalidated,
251
- }
260
+ const intentEvents = { queued: Queued, acked: Acked, settled: Settled, invalidated: Invalidated }
261
+ const intentTags = new Set(remote.intents.map((event) => event.tag))
252
262
  const pendingReducer = queueReducerLayer({
263
+ linkTag,
253
264
  name: `${name}/pending`,
254
265
  queueTag: pendingQueueTag,
266
+ handles: intentTags,
255
267
  events: intentEvents,
256
268
  })
257
269
  const sendProcedure = sendProcedureLayer({
258
270
  name: `${name}/send`,
259
271
  channel,
260
- handles: new Set(remote.intents.map((event) => event.tag)),
272
+ handles: intentTags,
261
273
  linkTag,
262
274
  failed: remote.Failed,
263
275
  send: config.send,
@@ -1,10 +1,11 @@
1
- import { Context, Effect, Either, Layer } from 'effect'
1
+ import { Context, Effect, Either, Layer, Option, Predicate } from 'effect'
2
2
  import * as Channel from '../channel/channel'
3
3
  import * as Event from '../event/event'
4
4
  import { Bus, narrowHandled } from '../runtime/bus'
5
5
  import { type ReducerEntry, Reducers } from '../runtime/loop'
6
6
  import type { PendingQueue } from './pendingQueue'
7
7
  import type { QueueFoldEventExternalApi, SettleLink } from './remoteStateTypes'
8
+ import { operationId } from '../internal/platform'
8
9
 
9
10
  export interface IntentEvents<I> {
10
11
  readonly queued: Event.EventClass<string, { readonly opId: string; readonly intent: I }>
@@ -16,13 +17,15 @@ export interface IntentEvents<I> {
16
17
  interface QueueReducerOptions<I> {
17
18
  readonly name: string
18
19
  readonly queueTag: Context.Tag<PendingQueue<I>, PendingQueue<I>>
20
+ readonly linkTag: Context.Tag<SettleLink, SettleLink>
21
+ readonly handles: ReadonlySet<string>
19
22
  readonly events: IntentEvents<I>
20
23
  }
21
24
 
22
25
  // Direct registry assembly avoids deferred conditional parameters while Intents is generic.
23
26
  export const queueReducerLayer = <I>(
24
27
  options: QueueReducerOptions<I>,
25
- ): Layer.Layer<never, never, Reducers | PendingQueue<I>> =>
28
+ ): Layer.Layer<never, never, Reducers | PendingQueue<I> | SettleLink> =>
26
29
  Layer.scopedDiscard(
27
30
  Effect.gen(function* () {
28
31
  const reducers = yield* Reducers
@@ -30,22 +33,40 @@ export const queueReducerLayer = <I>(
30
33
  yield* Effect.logWarning(`reform: duplicate reducer name '${options.name}' registered`)
31
34
  }
32
35
  const queue = yield* options.queueTag
36
+ const link = yield* options.linkTag
37
+ // `Queued` is dispatched from the send procedure, which runs on the intent's
38
+ // channel — a `debounce`/`throttle`/`exclusive` policy can hold an intent across
39
+ // a key change, so the key at that moment is not the key the intent targets.
40
+ // Reducers run before channels are offered in the same drain frame, so reading
41
+ // the key here records the one the user was actually looking at.
42
+ const issuedKeys = new WeakMap<object, ReadonlyArray<unknown>>()
33
43
  const entry: ReducerEntry = {
34
44
  name: options.name,
35
45
  handles: new Set([
46
+ ...options.handles,
36
47
  options.events.queued.tag,
37
48
  options.events.acked.tag,
38
49
  options.events.settled.tag,
39
50
  ]),
40
51
  apply: (event) => {
52
+ if (options.handles.has(event._tag)) {
53
+ issuedKeys.set(event, link.key())
54
+ return
55
+ }
41
56
  const handled = narrowHandled<QueueFoldEventExternalApi<I>>(event)
42
57
  if (handled._tag === options.events.queued.tag) {
43
58
  if (handled.intent !== undefined) {
44
- queue.append({
45
- opId: handled.opId,
46
- intent: handled.intent,
47
- status: 'sending',
48
- })
59
+ const issued = Predicate.isObject(handled.intent)
60
+ ? Option.fromNullable(issuedKeys.get(handled.intent))
61
+ : Option.none<ReadonlyArray<unknown>>()
62
+ queue.append(
63
+ {
64
+ opId: handled.opId,
65
+ intent: handled.intent,
66
+ status: 'sending',
67
+ },
68
+ Option.some(Option.getOrElse(issued, () => link.key())),
69
+ )
49
70
  }
50
71
  return
51
72
  }
@@ -87,7 +108,7 @@ export const sendProcedureLayer = <I, R2>(
87
108
  const deliver = Effect.fn('deliver')(function* (
88
109
  intent: I,
89
110
  ): Effect.fn.Return<void, never, Bus | R2> {
90
- const opId = yield* Effect.sync(() => crypto.randomUUID())
111
+ const opId = yield* Effect.sync(() => operationId())
91
112
  yield* Event.dispatch(options.events.queued, { opId, intent })
92
113
  const outcome = yield* options.send(intent).pipe(
93
114
  Effect.catchAllDefect((defect) => Effect.fail(defect)),
@@ -132,6 +132,9 @@ export type RemoteStateLiveExternalApi<
132
132
  readonly invalidateOn?: ReadonlyArray<Event.AnyEvent>
133
133
  readonly coalesce?: 'switch' | 'trailing'
134
134
  readonly reuse?: boolean
135
+ // Keep showing the previous key's truth while the new key loads. Off by default.
136
+ // oxlint-disable-next-line reform-rules/no-optional-fields -- caller-omitted option built at the remoteState construction site; Option<T> would change that call shape
137
+ readonly keepPreviousData?: boolean
135
138
  readonly settled?: {
136
139
  readonly event: SettledEvent
137
140
  readonly payload: (result: RemoteStateSettled<A, E>) => EventPayload<SettledEvent>
@@ -147,6 +150,10 @@ export type RemoteStateLiveExternalApi<
147
150
  export interface SettleLink {
148
151
  readonly requested: () => number
149
152
  readonly register: (opId: string, waitFor: number) => void
153
+ // The input key an intent is issued against, so the overlay never applies it to
154
+ // another key's truth.
155
+ readonly key: () => ReadonlyArray<unknown>
156
+ readonly subscribeKey: (listener: () => void) => () => void
150
157
  }
151
158
 
152
159
  export interface WaiterRegistration {
@@ -14,6 +14,11 @@ export type Priority = 'High' | 'Normal'
14
14
  export interface Envelope {
15
15
  readonly priority: Priority
16
16
  readonly event: Tagged
17
+ /**
18
+ * Set when the synchronous trigger path already ran this event's reducers, so the
19
+ * drain contributes only the channel/procedure half and never applies them twice.
20
+ */
21
+ readonly reduced: boolean
17
22
  }
18
23
 
19
24
  const BusBase: Context.TagClass<Bus, 'reform/Bus', PubSub.PubSub<Envelope>> = Context.Tag(
@@ -26,4 +31,4 @@ export class Bus extends BusBase {}
26
31
  export const busLayer: Layer.Layer<Bus> = Layer.scoped(Bus, PubSub.unbounded<Envelope>())
27
32
 
28
33
  export const publish = (priority: Priority, event: Tagged): Effect.Effect<void, never, Bus> =>
29
- Effect.flatMap(Bus, (bus) => PubSub.publish(bus, { priority, event }))
34
+ Effect.flatMap(Bus, (bus) => PubSub.publish(bus, { priority, event, reduced: false }))