@playfast/reform 1.2.1 → 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/boundary/boundary.d.ts +64 -0
- package/dist/boundary/boundary.d.ts.map +1 -0
- package/dist/boundary/boundary.js +102 -0
- package/dist/boundary/boundary.js.map +1 -0
- package/dist/calc/asyncCalc.d.ts +23 -0
- package/dist/calc/asyncCalc.d.ts.map +1 -0
- package/dist/calc/asyncCalc.js +100 -0
- package/dist/calc/asyncCalc.js.map +1 -0
- package/dist/calc/asyncCalcTypes.d.ts +80 -0
- package/dist/calc/asyncCalcTypes.d.ts.map +1 -0
- package/dist/calc/asyncCalcTypes.js +2 -0
- package/dist/calc/asyncCalcTypes.js.map +1 -0
- package/dist/calc/asyncData.d.ts +29 -0
- package/dist/calc/asyncData.d.ts.map +1 -0
- package/dist/calc/asyncData.js +21 -0
- package/dist/calc/asyncData.js.map +1 -0
- package/dist/calc/calc.d.ts +39 -0
- package/dist/calc/calc.d.ts.map +1 -0
- package/dist/calc/calc.js +48 -0
- package/dist/calc/calc.js.map +1 -0
- package/dist/calc/calcFamily.d.ts +40 -0
- package/dist/calc/calcFamily.d.ts.map +1 -0
- package/dist/calc/calcFamily.js +156 -0
- package/dist/calc/calcFamily.js.map +1 -0
- package/dist/calc/compose.d.ts +5 -0
- package/dist/calc/compose.d.ts.map +1 -0
- package/dist/calc/compose.js +5 -0
- package/dist/calc/compose.js.map +1 -0
- package/dist/calc/queryState.d.ts +14 -0
- package/dist/calc/queryState.d.ts.map +1 -0
- package/dist/calc/queryState.js +39 -0
- package/dist/calc/queryState.js.map +1 -0
- package/dist/channel/channel.d.ts +51 -0
- package/dist/channel/channel.d.ts.map +1 -0
- package/dist/channel/channel.js +91 -0
- package/dist/channel/channel.js.map +1 -0
- package/dist/channel/procedures.d.ts +24 -0
- package/dist/channel/procedures.d.ts.map +1 -0
- package/dist/channel/procedures.js +82 -0
- package/dist/channel/procedures.js.map +1 -0
- package/dist/compose/composition.d.ts +12 -0
- package/dist/compose/composition.d.ts.map +1 -0
- package/dist/compose/composition.js +62 -0
- package/dist/compose/composition.js.map +1 -0
- package/dist/compose/compositionTypes.d.ts +127 -0
- package/dist/compose/compositionTypes.d.ts.map +1 -0
- package/dist/compose/compositionTypes.js +2 -0
- package/dist/compose/compositionTypes.js.map +1 -0
- package/dist/compose/host.d.ts +6 -0
- package/dist/compose/host.d.ts.map +1 -0
- package/dist/compose/host.js +2 -0
- package/dist/compose/host.js.map +1 -0
- package/dist/compose/props.d.ts +10 -0
- package/dist/compose/props.d.ts.map +1 -0
- package/dist/compose/props.js +6 -0
- package/dist/compose/props.js.map +1 -0
- package/dist/compose/provide.d.ts +18 -0
- package/dist/compose/provide.d.ts.map +1 -0
- package/dist/compose/provide.js +49 -0
- package/dist/compose/provide.js.map +1 -0
- package/dist/compose/slot.d.ts +70 -0
- package/dist/compose/slot.d.ts.map +1 -0
- package/dist/compose/slot.js +26 -0
- package/dist/compose/slot.js.map +1 -0
- package/dist/compose/structure.d.ts +51 -0
- package/dist/compose/structure.d.ts.map +1 -0
- package/dist/compose/structure.js +117 -0
- package/dist/compose/structure.js.map +1 -0
- package/dist/compose/ui.d.ts +112 -0
- package/dist/compose/ui.d.ts.map +1 -0
- package/dist/compose/ui.js +58 -0
- package/dist/compose/ui.js.map +1 -0
- package/dist/definition/definition.d.ts +11 -0
- package/dist/definition/definition.d.ts.map +1 -0
- package/dist/definition/definition.js +35 -0
- package/dist/definition/definition.js.map +1 -0
- package/dist/event/event.d.ts +52 -0
- package/dist/event/event.d.ts.map +1 -0
- package/dist/event/event.js +88 -0
- package/dist/event/event.js.map +1 -0
- package/dist/event/eventGroup.d.ts +8 -0
- package/dist/event/eventGroup.d.ts.map +1 -0
- package/dist/event/eventGroup.js +3 -0
- package/dist/event/eventGroup.js.map +1 -0
- package/dist/feature/feature.d.ts +27 -0
- package/dist/feature/feature.d.ts.map +1 -0
- package/dist/feature/feature.js +20 -0
- package/dist/feature/feature.js.map +1 -0
- package/dist/feature/featureBinding.d.ts +85 -0
- package/dist/feature/featureBinding.d.ts.map +1 -0
- package/dist/feature/featureBinding.js +10 -0
- package/dist/feature/featureBinding.js.map +1 -0
- package/dist/feature/featureClass.d.ts +10 -0
- package/dist/feature/featureClass.d.ts.map +1 -0
- package/dist/feature/featureClass.js +2 -0
- package/dist/feature/featureClass.js.map +1 -0
- package/dist/feature/featureConfig.d.ts +86 -0
- package/dist/feature/featureConfig.d.ts.map +1 -0
- package/dist/feature/featureConfig.js +13 -0
- package/dist/feature/featureConfig.js.map +1 -0
- package/dist/feature/featureFactory.d.ts +15 -0
- package/dist/feature/featureFactory.d.ts.map +1 -0
- package/dist/feature/featureFactory.js +59 -0
- package/dist/feature/featureFactory.js.map +1 -0
- package/dist/feature/featureModule.d.ts +28 -0
- package/dist/feature/featureModule.d.ts.map +1 -0
- package/dist/feature/featureModule.js +33 -0
- package/dist/feature/featureModule.js.map +1 -0
- package/dist/feature/featureNativeTree.d.ts +26 -0
- package/dist/feature/featureNativeTree.d.ts.map +1 -0
- package/dist/feature/featureNativeTree.js +2 -0
- package/dist/feature/featureNativeTree.js.map +1 -0
- package/dist/feature/featureRequirement.d.ts +43 -0
- package/dist/feature/featureRequirement.d.ts.map +1 -0
- package/dist/feature/featureRequirement.js +6 -0
- package/dist/feature/featureRequirement.js.map +1 -0
- package/dist/feature/featureVariants.d.ts +9 -0
- package/dist/feature/featureVariants.d.ts.map +1 -0
- package/dist/feature/featureVariants.js +59 -0
- package/dist/feature/featureVariants.js.map +1 -0
- package/dist/feature/mountFeature.d.ts +10 -0
- package/dist/feature/mountFeature.d.ts.map +1 -0
- package/dist/feature/mountFeature.js +14 -0
- package/dist/feature/mountFeature.js.map +1 -0
- package/dist/graph/closure.d.ts +77 -0
- package/dist/graph/closure.d.ts.map +1 -0
- package/dist/graph/closure.js +4 -0
- package/dist/graph/closure.js.map +1 -0
- package/dist/graph/services.d.ts +30 -0
- package/dist/graph/services.d.ts.map +1 -0
- package/dist/graph/services.js +2 -0
- package/dist/graph/services.js.map +1 -0
- package/dist/graph/summary.d.ts +60 -0
- package/dist/graph/summary.d.ts.map +1 -0
- package/dist/graph/summary.js +7 -0
- package/dist/graph/summary.js.map +1 -0
- package/dist/index.d.ts +51 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +34 -0
- package/dist/index.js.map +1 -0
- package/dist/internal/bucketCache.d.ts +19 -0
- package/dist/internal/bucketCache.d.ts.map +1 -0
- package/dist/internal/bucketCache.js +20 -0
- package/dist/internal/bucketCache.js.map +1 -0
- package/dist/internal/capture.d.ts +17 -0
- package/dist/internal/capture.d.ts.map +1 -0
- package/dist/internal/capture.js +5 -0
- package/dist/internal/capture.js.map +1 -0
- package/dist/internal/env.d.ts +2 -0
- package/dist/internal/env.d.ts.map +1 -0
- package/dist/internal/env.js +5 -0
- package/dist/internal/env.js.map +1 -0
- package/dist/internal/errors.d.ts +48 -0
- package/dist/internal/errors.d.ts.map +1 -0
- package/dist/internal/errors.js +69 -0
- package/dist/internal/errors.js.map +1 -0
- 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/inspect.d.ts +5 -0
- package/dist/internal/inspect.d.ts.map +1 -0
- package/dist/internal/inspect.js +20 -0
- package/dist/internal/inspect.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 +7 -0
- package/dist/internal/queryDriver.d.ts.map +1 -0
- package/dist/internal/queryDriver.js +222 -0
- package/dist/internal/queryDriver.js.map +1 -0
- 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 +17 -0
- package/dist/internal/queryDriverHydrate.d.ts.map +1 -0
- package/dist/internal/queryDriverHydrate.js +25 -0
- package/dist/internal/queryDriverHydrate.js.map +1 -0
- 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 +28 -0
- package/dist/internal/queryDriverStore.d.ts.map +1 -0
- package/dist/internal/queryDriverStore.js +53 -0
- package/dist/internal/queryDriverStore.js.map +1 -0
- package/dist/internal/queryDriverTypes.d.ts +44 -0
- package/dist/internal/queryDriverTypes.d.ts.map +1 -0
- package/dist/internal/queryDriverTypes.js +9 -0
- package/dist/internal/queryDriverTypes.js.map +1 -0
- package/dist/internal/queryEvents.d.ts +12 -0
- package/dist/internal/queryEvents.d.ts.map +1 -0
- package/dist/internal/queryEvents.js +11 -0
- package/dist/internal/queryEvents.js.map +1 -0
- package/dist/internal/queryStore.d.ts +13 -0
- package/dist/internal/queryStore.d.ts.map +1 -0
- package/dist/internal/queryStore.js +11 -0
- package/dist/internal/queryStore.js.map +1 -0
- package/dist/internal/reuse.d.ts +2 -0
- package/dist/internal/reuse.d.ts.map +1 -0
- package/dist/internal/reuse.js +55 -0
- package/dist/internal/reuse.js.map +1 -0
- package/dist/internal/scheduler.d.ts +14 -0
- package/dist/internal/scheduler.d.ts.map +1 -0
- package/dist/internal/scheduler.js +101 -0
- package/dist/internal/scheduler.js.map +1 -0
- package/dist/internal/seeds.d.ts +3 -0
- package/dist/internal/seeds.d.ts.map +1 -0
- package/dist/internal/seeds.js +3 -0
- package/dist/internal/seeds.js.map +1 -0
- package/dist/internal/sources.d.ts +20 -0
- package/dist/internal/sources.d.ts.map +1 -0
- package/dist/internal/sources.js +39 -0
- package/dist/internal/sources.js.map +1 -0
- package/dist/internal/stateRegistry.d.ts +2 -0
- package/dist/internal/stateRegistry.d.ts.map +1 -0
- package/dist/internal/stateRegistry.js +15 -0
- package/dist/internal/stateRegistry.js.map +1 -0
- package/dist/internal/store.d.ts +15 -0
- package/dist/internal/store.d.ts.map +1 -0
- package/dist/internal/store.js +61 -0
- package/dist/internal/store.js.map +1 -0
- package/dist/internal/track.d.ts +15 -0
- package/dist/internal/track.d.ts.map +1 -0
- package/dist/internal/track.js +11 -0
- package/dist/internal/track.js.map +1 -0
- package/dist/internal/variance.d.ts +4 -0
- package/dist/internal/variance.d.ts.map +1 -0
- package/dist/internal/variance.js +2 -0
- package/dist/internal/variance.js.map +1 -0
- package/dist/internal.d.ts +43 -0
- package/dist/internal.d.ts.map +1 -0
- package/dist/internal.js +59 -0
- package/dist/internal.js.map +1 -0
- package/dist/namespace/namespace.d.ts +24 -0
- package/dist/namespace/namespace.d.ts.map +1 -0
- package/dist/namespace/namespace.js +20 -0
- package/dist/namespace/namespace.js.map +1 -0
- package/dist/procedure/procedure.d.ts +32 -0
- package/dist/procedure/procedure.d.ts.map +1 -0
- package/dist/procedure/procedure.js +34 -0
- package/dist/procedure/procedure.js.map +1 -0
- package/dist/reducer/reducer.d.ts +35 -0
- package/dist/reducer/reducer.d.ts.map +1 -0
- package/dist/reducer/reducer.js +73 -0
- package/dist/reducer/reducer.js.map +1 -0
- package/dist/remote/pendingQueue.d.ts +22 -0
- package/dist/remote/pendingQueue.d.ts.map +1 -0
- package/dist/remote/pendingQueue.js +129 -0
- package/dist/remote/pendingQueue.js.map +1 -0
- package/dist/remote/remoteState.d.ts +13 -0
- package/dist/remote/remoteState.d.ts.map +1 -0
- package/dist/remote/remoteState.js +189 -0
- package/dist/remote/remoteState.js.map +1 -0
- package/dist/remote/remoteStateMake.d.ts +8 -0
- package/dist/remote/remoteStateMake.d.ts.map +1 -0
- package/dist/remote/remoteStateMake.js +46 -0
- package/dist/remote/remoteStateMake.js.map +1 -0
- package/dist/remote/remoteStateSend.d.ts +43 -0
- package/dist/remote/remoteStateSend.d.ts.map +1 -0
- package/dist/remote/remoteStateSend.js +83 -0
- package/dist/remote/remoteStateSend.js.map +1 -0
- package/dist/remote/remoteStateTypes.d.ts +106 -0
- package/dist/remote/remoteStateTypes.d.ts.map +1 -0
- package/dist/remote/remoteStateTypes.js +4 -0
- package/dist/remote/remoteStateTypes.js.map +1 -0
- package/dist/runtime/appRuntime.d.ts +10 -0
- package/dist/runtime/appRuntime.d.ts.map +1 -0
- package/dist/runtime/appRuntime.js +6 -0
- package/dist/runtime/appRuntime.js.map +1 -0
- package/dist/runtime/bus.d.ts +22 -0
- package/dist/runtime/bus.d.ts.map +1 -0
- package/dist/runtime/bus.js +12 -0
- package/dist/runtime/bus.js.map +1 -0
- package/dist/runtime/capturedAppRuntime.d.ts +7 -0
- package/dist/runtime/capturedAppRuntime.d.ts.map +1 -0
- package/dist/runtime/capturedAppRuntime.js +137 -0
- package/dist/runtime/capturedAppRuntime.js.map +1 -0
- package/dist/runtime/eventBudget.d.ts +32 -0
- package/dist/runtime/eventBudget.d.ts.map +1 -0
- package/dist/runtime/eventBudget.js +68 -0
- package/dist/runtime/eventBudget.js.map +1 -0
- package/dist/runtime/featureMount.d.ts +48 -0
- package/dist/runtime/featureMount.d.ts.map +1 -0
- package/dist/runtime/featureMount.js +118 -0
- package/dist/runtime/featureMount.js.map +1 -0
- package/dist/runtime/instrumentation.d.ts +18 -0
- package/dist/runtime/instrumentation.d.ts.map +1 -0
- package/dist/runtime/instrumentation.js +16 -0
- package/dist/runtime/instrumentation.js.map +1 -0
- package/dist/runtime/loop.d.ts +44 -0
- package/dist/runtime/loop.d.ts.map +1 -0
- package/dist/runtime/loop.js +131 -0
- package/dist/runtime/loop.js.map +1 -0
- package/dist/runtime/queries.d.ts +20 -0
- package/dist/runtime/queries.d.ts.map +1 -0
- package/dist/runtime/queries.js +21 -0
- package/dist/runtime/queries.js.map +1 -0
- package/dist/runtime/runtimeHandle.d.ts +56 -0
- package/dist/runtime/runtimeHandle.d.ts.map +1 -0
- package/dist/runtime/runtimeHandle.js +2 -0
- package/dist/runtime/runtimeHandle.js.map +1 -0
- package/dist/scene/scene.d.ts +55 -0
- package/dist/scene/scene.d.ts.map +1 -0
- package/dist/scene/scene.js +74 -0
- package/dist/scene/scene.js.map +1 -0
- package/dist/state/state.d.ts +54 -0
- package/dist/state/state.d.ts.map +1 -0
- package/dist/state/state.js +41 -0
- package/dist/state/state.js.map +1 -0
- package/dist/state/stateFamily.d.ts +59 -0
- package/dist/state/stateFamily.d.ts.map +1 -0
- package/dist/state/stateFamily.js +139 -0
- package/dist/state/stateFamily.js.map +1 -0
- package/dist/state/stateGroup.d.ts +36 -0
- package/dist/state/stateGroup.d.ts.map +1 -0
- package/dist/state/stateGroup.js +33 -0
- package/dist/state/stateGroup.js.map +1 -0
- package/dist/state/token.d.ts +35 -0
- package/dist/state/token.d.ts.map +1 -0
- package/dist/state/token.js +18 -0
- package/dist/state/token.js.map +1 -0
- package/dist/synced/syncedStore.d.ts +44 -0
- package/dist/synced/syncedStore.d.ts.map +1 -0
- package/dist/synced/syncedStore.js +32 -0
- package/dist/synced/syncedStore.js.map +1 -0
- package/dist/testkit/flight.testkit.d.ts +18 -0
- package/dist/testkit/flight.testkit.d.ts.map +1 -0
- package/dist/testkit/flight.testkit.js +46 -0
- package/dist/testkit/flight.testkit.js.map +1 -0
- package/dist/ui/node.d.ts +3 -0
- package/dist/ui/node.d.ts.map +1 -0
- package/dist/ui/node.js +2 -0
- package/dist/ui/node.js.map +1 -0
- package/dist/ui/trigger.d.ts +2 -0
- package/dist/ui/trigger.d.ts.map +1 -0
- package/dist/ui/trigger.js +2 -0
- package/dist/ui/trigger.js.map +1 -0
- package/dist/wire/tree.d.ts +32 -0
- package/dist/wire/tree.d.ts.map +1 -0
- package/dist/wire/tree.js +64 -0
- package/dist/wire/tree.js.map +1 -0
- package/dist/wire/triggers.d.ts +25 -0
- package/dist/wire/triggers.d.ts.map +1 -0
- package/dist/wire/triggers.js +31 -0
- package/dist/wire/triggers.js.map +1 -0
- package/package.json +17 -5
- 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.test.ts +64 -0
- package/src/calc/asyncCalcTypes.ts +8 -10
- 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,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
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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(() =>
|
|
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 {
|
package/src/runtime/bus.ts
CHANGED
|
@@ -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 }))
|