@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.
- package/README.md +15 -6
- package/dist/calc/asyncCalc.d.ts +3 -1
- package/dist/calc/asyncCalc.d.ts.map +1 -1
- package/dist/calc/asyncCalc.js +15 -5
- package/dist/calc/asyncCalc.js.map +1 -1
- package/dist/calc/asyncCalcTypes.d.ts +2 -0
- package/dist/calc/asyncCalcTypes.d.ts.map +1 -1
- package/dist/calc/calcFamily.d.ts.map +1 -1
- package/dist/calc/calcFamily.js +77 -34
- package/dist/calc/calcFamily.js.map +1 -1
- package/dist/calc/queryState.d.ts +3 -1
- package/dist/calc/queryState.d.ts.map +1 -1
- package/dist/calc/queryState.js +31 -9
- package/dist/calc/queryState.js.map +1 -1
- package/dist/channel/channel.d.ts.map +1 -1
- package/dist/channel/channel.js +6 -1
- package/dist/channel/channel.js.map +1 -1
- package/dist/compose/composition.d.ts.map +1 -1
- package/dist/compose/composition.js +7 -1
- package/dist/compose/composition.js.map +1 -1
- package/dist/compose/structure.d.ts.map +1 -1
- package/dist/compose/structure.js +33 -8
- package/dist/compose/structure.js.map +1 -1
- package/dist/compose/ui.d.ts.map +1 -1
- package/dist/compose/ui.js +16 -5
- package/dist/compose/ui.js.map +1 -1
- package/dist/event/event.d.ts.map +1 -1
- package/dist/event/event.js +23 -2
- package/dist/event/event.js.map +1 -1
- package/dist/internal/familyKeys.d.ts +16 -0
- package/dist/internal/familyKeys.d.ts.map +1 -0
- package/dist/internal/familyKeys.js +35 -0
- package/dist/internal/familyKeys.js.map +1 -0
- package/dist/internal/platform.d.ts +43 -0
- package/dist/internal/platform.d.ts.map +1 -0
- package/dist/internal/platform.js +68 -0
- package/dist/internal/platform.js.map +1 -0
- package/dist/internal/queryDriver.d.ts.map +1 -1
- package/dist/internal/queryDriver.js +88 -64
- package/dist/internal/queryDriver.js.map +1 -1
- package/dist/internal/queryDriverConsume.d.ts +9 -0
- package/dist/internal/queryDriverConsume.d.ts.map +1 -0
- package/dist/internal/queryDriverConsume.js +16 -0
- package/dist/internal/queryDriverConsume.js.map +1 -0
- package/dist/internal/queryDriverHydrate.d.ts.map +1 -1
- package/dist/internal/queryDriverHydrate.js +9 -3
- package/dist/internal/queryDriverHydrate.js.map +1 -1
- package/dist/internal/queryDriverPersist.d.ts +16 -0
- package/dist/internal/queryDriverPersist.d.ts.map +1 -0
- package/dist/internal/queryDriverPersist.js +15 -0
- package/dist/internal/queryDriverPersist.js.map +1 -0
- package/dist/internal/queryDriverPersistWarn.d.ts +12 -0
- package/dist/internal/queryDriverPersistWarn.d.ts.map +1 -0
- package/dist/internal/queryDriverPersistWarn.js +31 -0
- package/dist/internal/queryDriverPersistWarn.js.map +1 -0
- package/dist/internal/queryDriverSettle.d.ts +23 -0
- package/dist/internal/queryDriverSettle.d.ts.map +1 -0
- package/dist/internal/queryDriverSettle.js +52 -0
- package/dist/internal/queryDriverSettle.js.map +1 -0
- package/dist/internal/queryDriverStore.d.ts +2 -28
- package/dist/internal/queryDriverStore.d.ts.map +1 -1
- package/dist/internal/queryDriverStore.js +5 -29
- package/dist/internal/queryDriverStore.js.map +1 -1
- package/dist/internal/queryDriverTypes.d.ts +3 -0
- package/dist/internal/queryDriverTypes.d.ts.map +1 -1
- package/dist/internal/scheduler.d.ts +1 -0
- package/dist/internal/scheduler.d.ts.map +1 -1
- package/dist/internal/scheduler.js +69 -6
- package/dist/internal/scheduler.js.map +1 -1
- package/dist/internal.d.ts +1 -0
- package/dist/internal.d.ts.map +1 -1
- package/dist/internal.js +1 -0
- package/dist/internal.js.map +1 -1
- package/dist/reducer/reducer.js +3 -3
- package/dist/reducer/reducer.js.map +1 -1
- package/dist/remote/pendingQueue.d.ts +4 -2
- package/dist/remote/pendingQueue.d.ts.map +1 -1
- package/dist/remote/pendingQueue.js +20 -4
- package/dist/remote/pendingQueue.js.map +1 -1
- package/dist/remote/remoteState.d.ts.map +1 -1
- package/dist/remote/remoteState.js +27 -14
- package/dist/remote/remoteState.js.map +1 -1
- package/dist/remote/remoteStateSend.d.ts +3 -1
- package/dist/remote/remoteStateSend.d.ts.map +1 -1
- package/dist/remote/remoteStateSend.js +19 -3
- package/dist/remote/remoteStateSend.js.map +1 -1
- package/dist/remote/remoteStateTypes.d.ts +3 -0
- package/dist/remote/remoteStateTypes.d.ts.map +1 -1
- package/dist/runtime/bus.d.ts +5 -0
- package/dist/runtime/bus.d.ts.map +1 -1
- package/dist/runtime/bus.js +1 -1
- package/dist/runtime/bus.js.map +1 -1
- package/dist/runtime/capturedAppRuntime.d.ts.map +1 -1
- package/dist/runtime/capturedAppRuntime.js +37 -51
- package/dist/runtime/capturedAppRuntime.js.map +1 -1
- package/dist/runtime/eventBudget.d.ts.map +1 -1
- package/dist/runtime/eventBudget.js +2 -1
- package/dist/runtime/eventBudget.js.map +1 -1
- package/dist/runtime/featureMount.d.ts +34 -2
- package/dist/runtime/featureMount.d.ts.map +1 -1
- package/dist/runtime/featureMount.js +66 -2
- package/dist/runtime/featureMount.js.map +1 -1
- package/dist/runtime/loop.d.ts +12 -0
- package/dist/runtime/loop.d.ts.map +1 -1
- package/dist/runtime/loop.js +8 -5
- package/dist/runtime/loop.js.map +1 -1
- package/dist/state/state.d.ts +2 -2
- package/dist/state/state.d.ts.map +1 -1
- package/dist/state/state.js +2 -2
- package/dist/state/state.js.map +1 -1
- package/dist/state/stateFamily.d.ts +2 -2
- package/dist/state/stateFamily.d.ts.map +1 -1
- package/dist/state/stateFamily.js +80 -32
- package/dist/state/stateFamily.js.map +1 -1
- package/dist/state/stateGroup.d.ts +5 -5
- package/dist/state/stateGroup.d.ts.map +1 -1
- package/dist/state/stateGroup.js +1 -1
- package/dist/state/stateGroup.js.map +1 -1
- package/dist/testkit/flight.testkit.d.ts.map +1 -1
- package/dist/testkit/flight.testkit.js +2 -1
- package/dist/testkit/flight.testkit.js.map +1 -1
- package/dist/wire/tree.d.ts.map +1 -1
- package/dist/wire/tree.js +6 -1
- package/dist/wire/tree.js.map +1 -1
- package/package.json +1 -1
- package/src/calc/asyncCalc-key-attribution.test.ts +182 -0
- package/src/calc/asyncCalc-registry-name.test.ts +82 -0
- package/src/calc/asyncCalc.superseded-commit.test.ts +162 -0
- package/src/calc/asyncCalc.test.ts +35 -7
- package/src/calc/asyncCalc.ts +33 -10
- package/src/calc/asyncCalcTypes.ts +6 -0
- package/src/calc/calcFamily.ts +81 -34
- package/src/calc/family-identity.test.ts +231 -0
- package/src/calc/queryState.ts +36 -5
- package/src/channel/channel-throttle-frames.test.ts +163 -0
- package/src/channel/channel.ts +5 -0
- package/src/compose/compose-identity.test.ts +175 -0
- package/src/compose/composition.ts +8 -4
- package/src/compose/structure.ts +45 -13
- package/src/compose/ui.ts +22 -5
- package/src/event/event.ts +31 -2
- package/src/event/syncTrigger.test.ts +66 -0
- package/src/internal/familyKeys.ts +41 -0
- package/src/internal/platform.test.ts +55 -0
- package/src/internal/platform.ts +72 -0
- package/src/internal/queryDriver-defect-attribution.test.ts +107 -0
- package/src/internal/queryDriver-persist-key.test.ts +118 -0
- package/src/internal/queryDriver.persistence-staleness.test.ts +209 -0
- package/src/internal/queryDriver.ts +92 -80
- package/src/internal/queryDriverConsume.ts +42 -0
- package/src/internal/queryDriverHydrate.ts +9 -3
- package/src/internal/queryDriverPersist.ts +42 -0
- package/src/internal/queryDriverPersistWarn.ts +61 -0
- package/src/internal/queryDriverSettle.ts +85 -0
- package/src/internal/queryDriverStore.ts +9 -74
- package/src/internal/queryDriverTypes.ts +10 -0
- package/src/internal/queryState-and-scheduler-audit.test.ts +170 -0
- package/src/internal/scheduler-multi-source-wake.test.ts +111 -0
- package/src/internal/scheduler.ts +80 -6
- package/src/internal.ts +1 -0
- package/src/reducer/reducer.ts +3 -3
- package/src/remote/pendingQueue.ts +35 -5
- package/src/remote/remoteState.intent-key-stamp.test.ts +207 -0
- package/src/remote/remoteState.key-attribution.test.ts +174 -0
- package/src/remote/remoteState.ts +27 -15
- package/src/remote/remoteStateSend.ts +29 -8
- package/src/remote/remoteStateTypes.ts +7 -0
- package/src/runtime/bus.ts +6 -1
- package/src/runtime/capturedAppRuntime.ts +134 -110
- package/src/runtime/eventBudget.ts +2 -1
- package/src/runtime/feature-mount-ownership.test.ts +539 -0
- package/src/runtime/featureMount.ts +110 -3
- package/src/runtime/loop.ts +29 -6
- package/src/runtime/mount-inflight-ownership.test.ts +361 -0
- package/src/runtime/mount-scope-hardening.test.ts +440 -0
- package/src/runtime/runtime.schema-identity.test.ts +188 -0
- package/src/state/state.ts +8 -4
- package/src/state/stateFamily.ts +86 -36
- package/src/state/stateGroup.ts +24 -6
- package/src/testkit/flight.testkit.ts +2 -1
- package/src/wire/tree-key-presence.test.ts +46 -0
- 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
|
+
)
|
package/src/state/state.ts
CHANGED
|
@@ -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
|
-
|
|
53
|
-
|
|
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
|
-
|
|
102
|
-
|
|
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
|
})
|