@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,440 @@
1
+ import { Context, Deferred, Effect, Layer, Option } from 'effect'
2
+ import { expect, it } from '@effect/vitest'
3
+ import * as Composition from '../compose/composition'
4
+ import { provide } from '../compose/provide'
5
+ import { mount } from '../compose/structure'
6
+ import * as Ui from '../compose/ui'
7
+ import { ui } from '../compose/ui'
8
+ import * as Feature from '../feature/feature'
9
+ import { featureModule } from '../feature/feature'
10
+ import { scene } from '../scene/scene'
11
+ import { makeScheduler } from '../internal/scheduler'
12
+ import { flush, until } from '../testkit/flight.testkit'
13
+ import { Engine } from './loop'
14
+ import { makeAppRuntime } from './appRuntime'
15
+
16
+ /**
17
+ * Round-4 adversarial audit — the owner-scope / instrumentation / scheduler fix set.
18
+ *
19
+ * Only findings that survived an attempt to refute them are filed here. Every
20
+ * assertion states the behaviour the FIXED code is supposed to have; each test
21
+ * fails on the current working tree and carries a PASSING control in the same
22
+ * body that proves the harness works and isolates the defect.
23
+ *
24
+ * Targets:
25
+ * 1 + 2 — `runtime/capturedAppRuntime.ts` / `runtime/featureMount.ts` (owner scopes)
26
+ * 3 — `internal/scheduler.ts` (`isLive` over a non-Set iterable)
27
+ */
28
+
29
+ // `until`'s poll budget. 40 rounds x 10ms is the same budget the round-3 hunt
30
+ // used: far above the microtask cost of a real release, short enough that a
31
+ // failing arm reports quickly.
32
+ const ROUNDS = 40
33
+
34
+ // ---------------------------------------------------------------------------
35
+ // Shared scene / feature definitions
36
+ // ---------------------------------------------------------------------------
37
+
38
+ interface LifecycleProbeService {
39
+ readonly acquired: Array<string>
40
+ readonly released: Array<string>
41
+ }
42
+
43
+ const LifecycleProbeBase: Context.TagClass<
44
+ LifecycleProbe,
45
+ 'audit4.LifecycleProbe',
46
+ LifecycleProbeService
47
+ > = Context.Tag('audit4.LifecycleProbe')<LifecycleProbe, LifecycleProbeService>()
48
+ class LifecycleProbe extends LifecycleProbeBase {}
49
+
50
+ const makeProbe = (): LifecycleProbeService => ({ acquired: [], released: [] })
51
+
52
+ class AuditUi extends ui('audit4.AuditUi')<{ props: {} }>() {}
53
+ class AuditComp extends Composition.make('audit4.AuditComp', {
54
+ title: 'Audit',
55
+ ui: AuditUi,
56
+ })<AuditComp>() {}
57
+ const AuditLive = Composition.live(AuditComp, function* () {
58
+ return mount({ props: {}, slots: {} })
59
+ })
60
+
61
+ class AuditRootUi extends ui('audit4.AuditRootUi')<{ props: {} }>() {}
62
+ class AuditRootComp extends Composition.make('audit4.AuditRootComp', {
63
+ title: 'AuditRoot',
64
+ ui: AuditRootUi,
65
+ })<AuditRootComp>() {}
66
+ const AuditRootLive = Composition.live(AuditRootComp, function* () {
67
+ return mount({ props: {}, slots: {} })
68
+ })
69
+
70
+ const auditRootViews = AuditRootLive.pipe(
71
+ Layer.provideMerge(
72
+ provide(
73
+ AuditRootUi,
74
+ Ui.make(AuditRootUi, () => null),
75
+ ),
76
+ ),
77
+ )
78
+
79
+ const auditScene = (probe: LifecycleProbeService) =>
80
+ scene(AuditRootComp, {
81
+ provide: [
82
+ auditRootViews.pipe(
83
+ Layer.provideMerge(Layer.succeed(LifecycleProbe, probe)),
84
+ Layer.provideMerge(Engine),
85
+ ),
86
+ ],
87
+ })
88
+
89
+ // A scoped marker. `acquired` records that the layer really built; `released`
90
+ // records that its scope really closed.
91
+ const scopedMark = (label: string): Layer.Layer<never, never, LifecycleProbe> =>
92
+ Layer.scopedDiscard(
93
+ Effect.gen(function* () {
94
+ const probe = yield* LifecycleProbe
95
+ yield* Effect.acquireRelease(
96
+ Effect.sync(() => probe.acquired.push(label)),
97
+ () => Effect.sync(() => probe.released.push(label)),
98
+ )
99
+ }),
100
+ )
101
+
102
+ // Same marker, but its release parks on a latch nobody has opened yet: a flush
103
+ // that has not finished, a socket that has not answered, a queue still draining.
104
+ const parkedMark = (
105
+ label: string,
106
+ latch: Deferred.Deferred<void>,
107
+ ): Layer.Layer<never, never, LifecycleProbe> =>
108
+ Layer.scopedDiscard(
109
+ Effect.gen(function* () {
110
+ const probe = yield* LifecycleProbe
111
+ yield* Effect.acquireRelease(
112
+ Effect.sync(() => probe.acquired.push(label)),
113
+ () =>
114
+ Deferred.await(latch).pipe(
115
+ Effect.zipRight(Effect.sync(() => probe.released.push(label))),
116
+ ),
117
+ )
118
+ }),
119
+ )
120
+
121
+ // ---------------------------------------------------------------------------
122
+ // FINDING 1 — runtime/capturedAppRuntime.ts + runtime/featureMount.ts
123
+ //
124
+ // `forkMountScope` is `Scope.fork(owner, sequential)`. Effect's `Scope.fork` on a
125
+ // CLOSED owner does not fail and does not give back a usable scope: it hands back
126
+ // a scope that is *already closed* (`ScopeImpl.fork`: `if (this.state._tag ===
127
+ // "Closed") { newScope.state = this.state; return newScope }`). `addFinalizer` on
128
+ // a closed scope runs the finalizer THERE AND THEN, so `Layer.build` against it
129
+ // SUCCEEDS while releasing every resource at the moment it is acquired.
130
+ //
131
+ // Nothing on the mount paths checks for that. A `RuntimeHandle` handed to a
132
+ // feature's `onLive` mounts into the FEATURE's scope (that is the point of the
133
+ // fix), so a host that still holds the handle after the feature is disposed —
134
+ // proof/drive-style headless hosts, any custom host, and the window React's
135
+ // `CapturedFeatureHost` opens when a child's effect re-runs in the same commit
136
+ // that closed its parent's mount — now gets a mount that reports `onLive`,
137
+ // hands over services whose finalizers have already run, and never calls
138
+ // `onFailed`. The runtime already knows how to refuse a mount it cannot own:
139
+ // `if (status.disposed) { return () => {} }` is the same situation one level down.
140
+ //
141
+ // Before the fix this call produced a working (leaking) mount, so the fix traded
142
+ // a leak for a silent dead mount reported as live.
143
+ // ---------------------------------------------------------------------------
144
+
145
+ class OwnerFeature extends Feature.make('audit4.owner', {
146
+ composition: AuditComp,
147
+ module: featureModule([LifecycleProbe], Layer.merge(AuditLive, scopedMark('owner'))),
148
+ }) {}
149
+
150
+ // Two identical children: one mounted on a live owner (the control), one on the
151
+ // dead one. Same module shape, same requirements, same mount path.
152
+ class ControlChildFeature extends Feature.make('audit4.controlChild', {
153
+ composition: AuditComp,
154
+ module: featureModule([LifecycleProbe], Layer.merge(AuditLive, scopedMark('control-child'))),
155
+ }) {}
156
+ class OrphanChildFeature extends Feature.make('audit4.orphanChild', {
157
+ composition: AuditComp,
158
+ module: featureModule([LifecycleProbe], Layer.merge(AuditLive, scopedMark('orphan-child'))),
159
+ }) {}
160
+
161
+ const OwnerNative = Feature.tree<typeof OwnerFeature>(OwnerFeature.token)
162
+ const ControlChildNative = Feature.tree<typeof ControlChildFeature>(ControlChildFeature.token)
163
+ const OrphanChildNative = Feature.tree<typeof OrphanChildFeature>(OrphanChildFeature.token)
164
+
165
+ it.scopedLive(
166
+ 'a feature mounted through a handle whose owner scope has closed is not reported live',
167
+ () =>
168
+ Effect.gen(function* () {
169
+ const probe = makeProbe()
170
+ const app = makeAppRuntime(auditScene(probe))
171
+ yield* Effect.addFinalizer(() => Effect.sync(() => app.dispose()))
172
+
173
+ // Every listener/handler is attached before anything is awaited.
174
+ const owner = { live: 0, failed: 0 }
175
+ const control = { live: 0, failed: 0 }
176
+ const orphan = { live: 0, failed: 0 }
177
+ // The owner's handle, captured as a closure so its exact `RuntimeHandle<...>`
178
+ // type is kept without restating it.
179
+ const mountOrphan = { run: Option.none<() => void>() }
180
+
181
+ const disposeOwner = app.mountFeature(OwnerNative.binding, {
182
+ onLive: (ownerRuntime) => {
183
+ owner.live += 1
184
+ mountOrphan.run = Option.some(() => {
185
+ ownerRuntime.mountFeature(OrphanChildNative.binding, {
186
+ onLive: () => {
187
+ orphan.live += 1
188
+ },
189
+ onFailed: () => {
190
+ orphan.failed += 1
191
+ },
192
+ })
193
+ })
194
+ },
195
+ onFailed: () => {
196
+ owner.failed += 1
197
+ },
198
+ })
199
+
200
+ // CONTROL — the same child module, same mount call, mounted on an owner scope
201
+ // that is open (the runtime's own root). It goes live and STAYS live: its
202
+ // resource is acquired and not released.
203
+ app.mountFeature(ControlChildNative.binding, {
204
+ onLive: () => {
205
+ control.live += 1
206
+ },
207
+ onFailed: () => {
208
+ control.failed += 1
209
+ },
210
+ })
211
+
212
+ yield* until(
213
+ () => owner.live + control.live + owner.failed + control.failed,
214
+ (n) => n >= 2,
215
+ ROUNDS,
216
+ )
217
+ expect(owner).toEqual({ live: 1, failed: 0 })
218
+ expect(control).toEqual({ live: 1, failed: 0 })
219
+ expect(probe.acquired.slice().sort()).toEqual(['control-child', 'owner'])
220
+ expect(probe.released).toEqual([])
221
+
222
+ // CONTROL — the owner's own disposer closes the owner's scope, and only it.
223
+ disposeOwner()
224
+ yield* until(
225
+ () => probe.released.length,
226
+ (n) => n >= 1,
227
+ ROUNDS,
228
+ )
229
+ yield* flush()
230
+ expect(probe.released).toEqual(['owner'])
231
+
232
+ // THE DEFECT — the host still holds the owner's handle and mounts through it.
233
+ // The handle's owner scope is closed, so this mount can own nothing.
234
+ Option.match(mountOrphan.run, {
235
+ onNone: () => undefined,
236
+ onSome: (run) => run(),
237
+ })
238
+ yield* until(
239
+ () => orphan.live + orphan.failed,
240
+ (n) => n >= 1,
241
+ ROUNDS,
242
+ )
243
+ yield* flush()
244
+
245
+ // A mount the runtime cannot own must not acquire resources it will release
246
+ // in the same breath...
247
+ expect(probe.acquired).not.toContain('orphan-child')
248
+ // ...and must not be reported live. `onFailed` (or the same silent no-op the
249
+ // `status.disposed` guard gives) is the honest answer; `onLive` is not.
250
+ expect(orphan.live).toBe(0)
251
+ }),
252
+ )
253
+
254
+ // ---------------------------------------------------------------------------
255
+ // FINDING 2 — runtime/capturedAppRuntime.ts, `dispose()`
256
+ //
257
+ // Every feature mount is now forked from ONE `rootScope`, and `dispose()` closes
258
+ // it with a single `Effect.runFork(Scope.close(rootScope, ...))`. `rootScope` is
259
+ // `Scope.make()`, i.e. `ExecutionStrategy.sequential`, so its finalizers — one
260
+ // `childScope.close(exit)` per mount — run one after another, each awaiting the
261
+ // previous. Sibling mounts are independent, and a finalizer that has not finished
262
+ // is not a defect: a flush, a socket close, a queue drain all take a turn. One
263
+ // such finalizer now withholds the teardown of every OTHER mount for as long as
264
+ // it runs, and forever if it never completes.
265
+ //
266
+ // The mounts that carry their own disposer (`mountScoped`, through
267
+ // `activationDisposers`) each get their own `Effect.runFork` and are unaffected;
268
+ // features have no disposer of their own on this path, so they are exactly the
269
+ // ones that serialize. `Scope.fork`'s strategy argument governs how a mount's OWN
270
+ // finalizers run (sequential/LIFO is right there); what serializes siblings is
271
+ // the strategy of the scope they are forked FROM.
272
+ // ---------------------------------------------------------------------------
273
+
274
+ const parkedFeature = (latch: Deferred.Deferred<void>) =>
275
+ class ParkedFeature extends Feature.make('audit4.parked', {
276
+ composition: AuditComp,
277
+ module: featureModule([LifecycleProbe], Layer.merge(AuditLive, parkedMark('parked', latch))),
278
+ }) {}
279
+
280
+ class TidyFeature extends Feature.make('audit4.tidy', {
281
+ composition: AuditComp,
282
+ module: featureModule([LifecycleProbe], Layer.merge(AuditLive, scopedMark('tidy'))),
283
+ }) {}
284
+ const TidyNative = Feature.tree<typeof TidyFeature>(TidyFeature.token)
285
+
286
+ it.scopedLive(
287
+ 'dispose(): one mount whose finalizer has not finished does not withhold the others',
288
+ () =>
289
+ Effect.gen(function* () {
290
+ // ---- CONTROL: `tidy` mounted SECOND, so the root closes it FIRST ---------
291
+ // Same scene, same two features, same `dispose()` — only the order the two
292
+ // mounts were registered in differs. `tidy` releases promptly, which proves
293
+ // the harness, the probe and `dispose()`'s reach are all working, and pins the
294
+ // difference below to teardown order alone.
295
+ const controlLatch = yield* Deferred.make<void>()
296
+ const ControlParked = parkedFeature(controlLatch)
297
+ const ControlParkedNative = Feature.tree<typeof ControlParked>(ControlParked.token)
298
+ const controlProbe = makeProbe()
299
+ const controlApp = makeAppRuntime(auditScene(controlProbe))
300
+ yield* Effect.addFinalizer(() =>
301
+ Effect.sync(() => controlApp.dispose()).pipe(
302
+ Effect.zipRight(Effect.asVoid(Deferred.succeed(controlLatch, undefined))),
303
+ ),
304
+ )
305
+ const controlFailures: Array<string> = []
306
+ controlApp.mountFeature(ControlParkedNative.binding, {
307
+ onLive: () => undefined,
308
+ onFailed: () => controlFailures.push('parked'),
309
+ })
310
+ controlApp.mountFeature(TidyNative.binding, {
311
+ onLive: () => undefined,
312
+ onFailed: () => controlFailures.push('tidy'),
313
+ })
314
+ yield* until(
315
+ () => controlProbe.acquired.length,
316
+ (n) => n >= 2,
317
+ ROUNDS,
318
+ )
319
+ expect(controlFailures).toEqual([])
320
+ expect(controlProbe.acquired.slice().sort()).toEqual(['parked', 'tidy'])
321
+
322
+ controlApp.dispose()
323
+ yield* until(
324
+ () => controlProbe.released.length,
325
+ (n) => n >= 1,
326
+ ROUNDS,
327
+ )
328
+ yield* flush()
329
+ expect(controlProbe.released).toEqual(['tidy'])
330
+
331
+ // ---- THE DEFECT: `tidy` mounted FIRST, so the root closes it LAST --------
332
+ const latch = yield* Deferred.make<void>()
333
+ const Parked = parkedFeature(latch)
334
+ const ParkedNative = Feature.tree<typeof Parked>(Parked.token)
335
+ const probe = makeProbe()
336
+ const app = makeAppRuntime(auditScene(probe))
337
+ yield* Effect.addFinalizer(() =>
338
+ Effect.sync(() => app.dispose()).pipe(
339
+ Effect.zipRight(Effect.asVoid(Deferred.succeed(latch, undefined))),
340
+ ),
341
+ )
342
+ const failures: Array<string> = []
343
+ app.mountFeature(TidyNative.binding, {
344
+ onLive: () => undefined,
345
+ onFailed: () => failures.push('tidy'),
346
+ })
347
+ app.mountFeature(ParkedNative.binding, {
348
+ onLive: () => undefined,
349
+ onFailed: () => failures.push('parked'),
350
+ })
351
+ yield* until(
352
+ () => probe.acquired.length,
353
+ (n) => n >= 2,
354
+ ROUNDS,
355
+ )
356
+ expect(failures).toEqual([])
357
+ expect(probe.acquired.slice().sort()).toEqual(['parked', 'tidy'])
358
+
359
+ app.dispose()
360
+ yield* until(
361
+ () => probe.released.length,
362
+ (n) => n >= 1,
363
+ ROUNDS,
364
+ )
365
+ yield* flush()
366
+ // `tidy`'s finalizer is instant and owes nothing to `parked`. It is withheld
367
+ // for as long as `parked`'s finalizer runs.
368
+ expect(probe.released).toContain('tidy')
369
+
370
+ // CONTROL — opening the latch settles BOTH, which proves neither finalizer was
371
+ // broken or skipped: the only thing under test above is when they ran.
372
+ yield* Deferred.succeed(latch, undefined)
373
+ yield* until(
374
+ () => probe.released.length,
375
+ (n) => n >= 2,
376
+ ROUNDS,
377
+ )
378
+ expect(probe.released.slice().sort()).toEqual(['parked', 'tidy'])
379
+ }),
380
+ )
381
+
382
+ // ---------------------------------------------------------------------------
383
+ // FINDING 3 — internal/scheduler.ts
384
+ //
385
+ // `schedule` takes `Iterable<() => void>` and is exported from
386
+ // `@playfast/reform/internal` as part of the "primitive-authoring toolkit … for
387
+ // first-party companion packages that build their own first-class Sources".
388
+ //
389
+ // `schedule` now snapshots with `Array.from(listeners)` AND keeps `listeners`
390
+ // itself to re-read at flush; `isLive` then answers a non-Set source with
391
+ // `Arr.fromIterable(source).includes(listener)`. For any one-shot iterable — the
392
+ // `MapIterator` from `map.values()`, a `SetIterator`, a generator — the snapshot
393
+ // drains the iterator, so the re-read at flush sees an EMPTY sequence and EVERY
394
+ // listener of that write is judged dead and dropped. Not one wake is delivered
395
+ // and nothing reports an error.
396
+ //
397
+ // The previous `[...listeners].forEach(...)` consumed the iterable exactly once,
398
+ // so this is a regression, and it is silent: the type says `Iterable`.
399
+ // ---------------------------------------------------------------------------
400
+
401
+ const drainMicrotasks = async (turns = 5): Promise<void> => {
402
+ await Array.from({ length: turns }).reduce<Promise<void>>(
403
+ (chain) => chain.then(() => new Promise<void>((resolve) => queueMicrotask(resolve))),
404
+ Promise.resolve(),
405
+ )
406
+ }
407
+
408
+ it('scheduler: a write whose source is a one-shot iterable still wakes its listeners', async () => {
409
+ const woken: Array<string> = []
410
+ const first = (): void => void woken.push('first')
411
+ const second = (): void => void woken.push('second')
412
+
413
+ // CONTROL — the identical listeners, published through a Set (the hot path a
414
+ // store uses) and through an array literal (the shape `boundary`/`event` use).
415
+ // Both are delivered, which proves the scheduler, the drain and this harness.
416
+ const setScheduler = makeScheduler()
417
+ const setWoken: Array<string> = []
418
+ const setFirst = (): void => void setWoken.push('first')
419
+ setScheduler.schedule(new Set([setFirst]))
420
+ const arrayScheduler = makeScheduler()
421
+ const arrayWoken: Array<string> = []
422
+ const arrayFirst = (): void => void arrayWoken.push('first')
423
+ arrayScheduler.schedule([arrayFirst])
424
+
425
+ // THE DEFECT — the same two listeners handed over as a one-shot iterable. A
426
+ // registry that keeps its subscribers in a Map and publishes `map.values()` is
427
+ // the plainest way to write this, and it typechecks.
428
+ const registry = new Map<string, () => void>([
429
+ ['first', first],
430
+ ['second', second],
431
+ ])
432
+ const scheduler = makeScheduler()
433
+ scheduler.schedule(registry.values())
434
+
435
+ await drainMicrotasks()
436
+
437
+ expect(setWoken).toEqual(['first'])
438
+ expect(arrayWoken).toEqual(['first'])
439
+ expect(woken).toEqual(['first', 'second'])
440
+ })
@@ -0,0 +1,188 @@
1
+ import { expect, it } from '@effect/vitest'
2
+ import { Effect, Exit, Layer, LogLevel, Logger, Option, Schema as S } from 'effect'
3
+ import { Engine, Event, publish, Reducer, State, StateFamily, StateGroup } from '../index'
4
+ import { makeStore } from '../internal/store'
5
+ import { flush, until } from '../testkit/flight.testkit'
6
+
7
+ const silent = Logger.withMinimumLogLevel(LogLevel.None)
8
+
9
+ // ---------------------------------------------------------------------------
10
+ // — reducer/reducer.ts:160 re-decodes an ALREADY-DECODED fold result.
11
+ //
12
+ // `event.ts:51` documents the exact hazard and dodges it with `validateSync`
13
+ // ("decodeUnknownSync would reject transforming schemas — e.g. a real Option,
14
+ // whose _tag lives on the prototype"). The reducer write path never got the
15
+ // same treatment, so any State whose schema transforms (S.Option, S.Date,
16
+ // S.BigInt, S.Set, …) makes every fold throw a ParseError. `isolateApply`
17
+ // swallows it, so the write is lost and only a log line records it.
18
+ // ---------------------------------------------------------------------------
19
+ it.live('a reducer over an Option-valued State silently drops every write', () => {
20
+ class Selected extends State.make('identity/selected', S.Option(S.String)) {}
21
+ class Marker extends State.make('identity/marker', S.Number) {}
22
+ class Picked extends Event.make('identity/Picked', S.Struct({ id: S.String })) {}
23
+ class PickReducer extends Reducer.make('identity/PickReducer', {
24
+ states: [Selected],
25
+ events: [Picked],
26
+ }) {}
27
+ const PickReducerLive = Reducer.live(PickReducer, (_, event) => Option.some(event.id))
28
+ // A plain-schema witness folding the same event: once it moves, the frame is done.
29
+ class MarkReducer extends Reducer.make('identity/MarkReducer', {
30
+ states: [Marker],
31
+ events: [Picked],
32
+ }) {}
33
+ const MarkReducerLive = Reducer.live(MarkReducer, (n) => n + 1)
34
+
35
+ const TestLayer = Layer.mergeAll(PickReducerLive, MarkReducerLive).pipe(
36
+ Layer.provideMerge(
37
+ Layer.mergeAll(State.live(Selected, Option.none<string>()), State.live(Marker, 0), Engine),
38
+ ),
39
+ )
40
+
41
+ return Effect.gen(function* () {
42
+ const selected = yield* Selected.store
43
+ const marker = yield* Marker.store
44
+
45
+ yield* publish('High', Event.construct(Picked, { id: 'a' }))
46
+ yield* until(
47
+ () => marker.get(),
48
+ (n) => n === 1,
49
+ )
50
+
51
+ expect(selected.get()).toStrictEqual(Option.some('a'))
52
+ }).pipe(Effect.provide(TestLayer), silent)
53
+ })
54
+
55
+ // ---------------------------------------------------------------------------
56
+ // — state/stateGroup.ts:116 decodes seeds from the ENCODED side while
57
+ // `GroupSeeds` types them as the DECODED side (`StateValue` = Schema.Type).
58
+ // Handing the group exactly the value its type demands dies the layer.
59
+ // ---------------------------------------------------------------------------
60
+ it.effect('StateGroup.live dies on the very seed value its type demands', () => {
61
+ class Selected extends State.make('identity/group-selected', S.Option(S.String)) {}
62
+ class Group extends StateGroup.make(Selected) {}
63
+
64
+ return Effect.gen(function* () {
65
+ const exit = yield* Effect.scoped(
66
+ Layer.build(StateGroup.live(Group, { 'identity/group-selected': Option.some('a') })),
67
+ ).pipe(Effect.exit)
68
+
69
+ expect(Exit.isSuccess(exit)).toBe(true)
70
+ }).pipe(silent)
71
+ })
72
+
73
+ // ---------------------------------------------------------------------------
74
+ // — state/stateFamily.ts:74. `forget(key)` drops the entry without
75
+ // waking anyone subscribed to it, and `at(key)` then mints a NEW store. Every
76
+ // consumer that read the key through `readTracked` is holding the dead store:
77
+ // it never fires again, and its value diverges from the family's forever.
78
+ // Same shape as #22 — state that outlives the identity it was derived for.
79
+ // ---------------------------------------------------------------------------
80
+ it.live('forgetting a family key orphans every live subscriber of that key', () => {
81
+ class Items extends StateFamily.make('identity/items', S.String, S.Number) {}
82
+ class Marker extends State.make('identity/family-marker', S.Number) {}
83
+ class Bumped extends Event.make('identity/Bumped', S.Struct({ id: S.String })) {}
84
+ class Removed extends Event.make('identity/Removed', S.Struct({ id: S.String })) {}
85
+
86
+ class ItemsReducer extends Reducer.make('identity/ItemsReducer', {
87
+ family: Items,
88
+ keyOf: (event) => event.id,
89
+ events: [Bumped, Removed],
90
+ }) {}
91
+ const ItemsLive = Reducer.live(ItemsReducer, (n, event) =>
92
+ event._tag === Removed.tag ? StateFamily.Tombstone : n + 1,
93
+ )
94
+ class MarkReducer extends Reducer.make('identity/FamilyMarkReducer', {
95
+ states: [Marker],
96
+ events: [Bumped],
97
+ }) {}
98
+ const MarkReducerLive = Reducer.live(MarkReducer, (n) => n + 1)
99
+
100
+ const TestLayer = Layer.mergeAll(ItemsLive, MarkReducerLive).pipe(
101
+ Layer.provideMerge(Layer.mergeAll(StateFamily.live(Items, 0), State.live(Marker, 0), Engine)),
102
+ )
103
+
104
+ return Effect.gen(function* () {
105
+ const family = yield* Items.store
106
+ const marker = yield* Marker.store
107
+
108
+ // A consumer reads key 'a' once and subscribes to it — exactly what
109
+ // `StateFamily.read` does for a component through `readTracked`.
110
+ const tracked = family.at('a')
111
+ const wakes = { n: 0 }
112
+ tracked.subscribe(() => {
113
+ wakes.n += 1
114
+ })
115
+
116
+ yield* publish('High', Event.construct(Bumped, { id: 'a' }))
117
+ yield* until(
118
+ () => marker.get(),
119
+ (n) => n === 1,
120
+ )
121
+ yield* publish('High', Event.construct(Bumped, { id: 'a' }))
122
+ yield* until(
123
+ () => marker.get(),
124
+ (n) => n === 2,
125
+ )
126
+ yield* until(
127
+ () => wakes.n,
128
+ (n) => n >= 2,
129
+ )
130
+ expect(tracked.get()).toBe(2)
131
+ const wakesBefore = wakes.n
132
+
133
+ // Remove the key, then re-create it. What key 'a' holds is now 1, not 2.
134
+ yield* publish('High', Event.construct(Removed, { id: 'a' }))
135
+ yield* publish('High', Event.construct(Bumped, { id: 'a' }))
136
+ yield* until(
137
+ () => marker.get(),
138
+ (n) => n === 3,
139
+ )
140
+ // Notifications ride the same microtask flush as the write that just
141
+ // landed, so draining turns is exact where a duration would be a guess.
142
+ yield* flush()
143
+
144
+ // Key 'a' now reads 1 where the orphaned consumer still sees 2 …
145
+ expect(family.at('a').get()).toBe(1)
146
+ expect(tracked.get()).toBe(2)
147
+ // … and nothing ever woke that consumer to tell it.
148
+ expect(wakes.n).toBeGreaterThan(wakesBefore)
149
+ }).pipe(Effect.provide(TestLayer), silent)
150
+ })
151
+
152
+ // ---------------------------------------------------------------------------
153
+ // — internal/scheduler.ts:41 copies live listeners into `pending` at
154
+ // write time, and `store.set` (internal/store.ts:34) schedules them there. An
155
+ // unsubscribe that lands between the write and the microtask flush removes the
156
+ // listener from the store but not from `pending`, so a torn-down subscription
157
+ // still fires. `event.ts:113` hand-guards against exactly this with its own
158
+ // `active` flag; nothing else does.
159
+ // ---------------------------------------------------------------------------
160
+ it.live('a listener unsubscribed after a write still fires on the next flush', () =>
161
+ Effect.gen(function* () {
162
+ const store = makeStore(0)
163
+ const witness = makeStore(0)
164
+ const wakes = { n: 0 }
165
+ const witnessWakes = { n: 0 }
166
+
167
+ const off = store.subscribe(() => {
168
+ wakes.n += 1
169
+ })
170
+ witness.subscribe(() => {
171
+ witnessWakes.n += 1
172
+ })
173
+
174
+ store.set(1)
175
+ // Teardown before the flush: the subscription is gone by the time it runs.
176
+ off()
177
+
178
+ // Both stores share the default scheduler, so the witness flush drains
179
+ // anything the first write could have queued.
180
+ witness.set(1)
181
+ yield* until(
182
+ () => witnessWakes.n,
183
+ (n) => n > 0,
184
+ )
185
+
186
+ expect(wakes.n).toBe(0)
187
+ }),
188
+ )
@@ -49,8 +49,12 @@ interface StateStatics<N extends string, A> {
49
49
  readonly name: N
50
50
  readonly capture: <Result>(visit: StateCapture<Result>) => Result
51
51
  readonly store: Context.Tag<StateStore<N, A>, Store<A>>
52
- readonly decodeUnknown: (input: unknown) => Effect.Effect<A, ParseResult.ParseError, never>
53
- readonly decodeUnknownSync: (input: unknown) => A
52
+ // Writes and seeds carry already-decoded domain values, so they are checked
53
+ // against the Type side. `decodeUnknownSync` would reject a transforming schema
54
+ // (a real Option, whose _tag lives on the prototype). Only `decodeUnknownOption`
55
+ // decodes — seed overrides arrive encoded, from outside the graph.
56
+ readonly validate: (input: unknown) => Effect.Effect<A, ParseResult.ParseError, never>
57
+ readonly validateSync: (input: unknown) => A
54
58
  readonly decodeUnknownOption: (input: unknown) => Option.Option<A>
55
59
  }
56
60
 
@@ -98,8 +102,8 @@ export const make = <const N extends string, StateSchema extends Schema.Schema.A
98
102
  manifest,
99
103
  name,
100
104
  store,
101
- decodeUnknown: Schema.decodeUnknown(schema),
102
- decodeUnknownSync: Schema.decodeUnknownSync(schema),
105
+ validate: Schema.validate(schema),
106
+ validateSync: Schema.validateSync(schema),
103
107
  decodeUnknownOption: Schema.decodeUnknownOption(schema),
104
108
  capture: <Result>(visit: StateCapture<Result>): Result => visit(state),
105
109
  })