@playfast/reform 0.0.1 → 0.0.3
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 +551 -53
- package/package.json +15 -13
- package/src/boundary/boundary.ts +192 -0
- package/src/calc/asyncCalc.ts +267 -0
- package/{dist/dts/calc/asyncData.d.ts → src/calc/asyncData.ts} +53 -20
- package/src/calc/calc.ts +120 -0
- package/src/calc/calcFamily.ts +194 -0
- package/src/calc/compose.ts +69 -0
- package/src/channel/channel.ts +265 -0
- package/src/compose/composition.ts +112 -0
- package/{dist/dts/compose/host.d.ts → src/compose/host.ts} +6 -8
- package/{dist/esm/compose/props.js → src/compose/props.ts} +3 -4
- package/src/compose/provide.ts +70 -0
- package/{dist/dts/compose/slot.d.ts → src/compose/slot.ts} +36 -19
- package/src/compose/ui.ts +97 -0
- package/src/definition/definition.ts +74 -0
- package/src/event/event.ts +65 -0
- package/src/event/eventGroup.ts +14 -0
- package/{dist/dts/feature/feature.d.ts → src/feature/feature.ts} +178 -92
- package/src/index.ts +140 -0
- package/{dist/dts/internal/capture.d.ts → src/internal/capture.ts} +13 -11
- package/{dist/dts/internal/ctx.d.ts → src/internal/ctx.ts} +8 -4
- package/{dist/esm/internal/errors.js → src/internal/errors.ts} +41 -26
- package/src/internal/inspect.ts +34 -0
- package/src/internal/queryDriver.ts +247 -0
- package/src/internal/reuse.ts +73 -0
- package/src/internal/scheduler.ts +91 -0
- package/{dist/dts/internal/seeds.d.ts → src/internal/seeds.ts} +6 -3
- package/src/internal/sources.ts +104 -0
- package/src/internal/store.ts +105 -0
- package/{dist/dts/internal/track.d.ts → src/internal/track.ts} +15 -11
- package/src/procedure/procedure.ts +89 -0
- package/src/reducer/reducer.ts +137 -0
- package/src/remote/remoteState.ts +571 -0
- package/{dist/cjs/runtime/bus.js → src/runtime/bus.ts} +23 -12
- package/src/runtime/loop.ts +169 -0
- package/src/scene/scene.ts +76 -0
- package/src/state/state.ts +91 -0
- package/src/state/stateFamily.ts +171 -0
- package/src/state/stateGroup.ts +78 -0
- package/{dist/dts/state/token.d.ts → src/state/token.ts} +21 -13
- package/{dist/dts/ui/node.d.ts → src/ui/node.ts} +3 -3
- package/{dist/dts/ui/trigger.d.ts → src/ui/trigger.ts} +1 -2
- package/dist/cjs/boundary/boundary.js +0 -86
- package/dist/cjs/calc/asyncCalc.js +0 -128
- package/dist/cjs/calc/asyncData.js +0 -37
- package/dist/cjs/calc/calc.js +0 -58
- package/dist/cjs/calc/calcFamily.js +0 -127
- package/dist/cjs/channel/channel.js +0 -142
- package/dist/cjs/compose/composition.js +0 -50
- package/dist/cjs/compose/host.js +0 -8
- package/dist/cjs/compose/props.js +0 -14
- package/dist/cjs/compose/provide.js +0 -30
- package/dist/cjs/compose/slot.js +0 -27
- package/dist/cjs/compose/ui.js +0 -61
- package/dist/cjs/definition/definition.js +0 -46
- package/dist/cjs/event/event.js +0 -36
- package/dist/cjs/event/eventGroup.js +0 -7
- package/dist/cjs/feature/feature.js +0 -102
- package/dist/cjs/index.js +0 -116
- package/dist/cjs/internal/capture.js +0 -14
- package/dist/cjs/internal/ctx.js +0 -2
- package/dist/cjs/internal/errors.js +0 -62
- package/dist/cjs/internal/inspect.js +0 -36
- package/dist/cjs/internal/queryDriver.js +0 -138
- package/dist/cjs/internal/reuse.js +0 -71
- package/dist/cjs/internal/scheduler.js +0 -73
- package/dist/cjs/internal/seeds.js +0 -19
- package/dist/cjs/internal/sources.js +0 -61
- package/dist/cjs/internal/store.js +0 -77
- package/dist/cjs/internal/track.js +0 -22
- package/dist/cjs/package.json +0 -4
- package/dist/cjs/procedure/procedure.js +0 -52
- package/dist/cjs/reducer/reducer.js +0 -64
- package/dist/cjs/remote/remoteState.js +0 -307
- package/dist/cjs/runtime/loop.js +0 -119
- package/dist/cjs/scene/scene.js +0 -36
- package/dist/cjs/state/state.js +0 -47
- package/dist/cjs/state/stateFamily.js +0 -101
- package/dist/cjs/state/stateGroup.js +0 -47
- package/dist/cjs/state/token.js +0 -23
- package/dist/cjs/ui/node.js +0 -2
- package/dist/cjs/ui/trigger.js +0 -2
- package/dist/dts/boundary/boundary.d.ts +0 -72
- package/dist/dts/boundary/boundary.d.ts.map +0 -1
- package/dist/dts/calc/asyncCalc.d.ts +0 -91
- package/dist/dts/calc/asyncCalc.d.ts.map +0 -1
- package/dist/dts/calc/asyncData.d.ts.map +0 -1
- package/dist/dts/calc/calc.d.ts +0 -57
- package/dist/dts/calc/calc.d.ts.map +0 -1
- package/dist/dts/calc/calcFamily.d.ts +0 -57
- package/dist/dts/calc/calcFamily.d.ts.map +0 -1
- package/dist/dts/channel/channel.d.ts +0 -115
- package/dist/dts/channel/channel.d.ts.map +0 -1
- package/dist/dts/compose/composition.d.ts +0 -72
- package/dist/dts/compose/composition.d.ts.map +0 -1
- package/dist/dts/compose/host.d.ts.map +0 -1
- package/dist/dts/compose/props.d.ts +0 -13
- package/dist/dts/compose/props.d.ts.map +0 -1
- package/dist/dts/compose/provide.d.ts +0 -22
- package/dist/dts/compose/provide.d.ts.map +0 -1
- package/dist/dts/compose/slot.d.ts.map +0 -1
- package/dist/dts/compose/ui.d.ts +0 -50
- package/dist/dts/compose/ui.d.ts.map +0 -1
- package/dist/dts/definition/definition.d.ts +0 -33
- package/dist/dts/definition/definition.d.ts.map +0 -1
- package/dist/dts/event/event.d.ts +0 -33
- package/dist/dts/event/event.d.ts.map +0 -1
- package/dist/dts/event/eventGroup.d.ts +0 -9
- package/dist/dts/event/eventGroup.d.ts.map +0 -1
- package/dist/dts/feature/feature.d.ts.map +0 -1
- package/dist/dts/index.d.ts +0 -43
- package/dist/dts/index.d.ts.map +0 -1
- package/dist/dts/internal/capture.d.ts.map +0 -1
- package/dist/dts/internal/ctx.d.ts.map +0 -1
- package/dist/dts/internal/errors.d.ts +0 -69
- package/dist/dts/internal/errors.d.ts.map +0 -1
- package/dist/dts/internal/inspect.d.ts +0 -17
- package/dist/dts/internal/inspect.d.ts.map +0 -1
- package/dist/dts/internal/queryDriver.d.ts +0 -65
- package/dist/dts/internal/queryDriver.d.ts.map +0 -1
- package/dist/dts/internal/reuse.d.ts +0 -10
- package/dist/dts/internal/reuse.d.ts.map +0 -1
- package/dist/dts/internal/scheduler.d.ts +0 -47
- package/dist/dts/internal/scheduler.d.ts.map +0 -1
- package/dist/dts/internal/seeds.d.ts.map +0 -1
- package/dist/dts/internal/sources.d.ts +0 -39
- package/dist/dts/internal/sources.d.ts.map +0 -1
- package/dist/dts/internal/store.d.ts +0 -47
- package/dist/dts/internal/store.d.ts.map +0 -1
- package/dist/dts/internal/track.d.ts.map +0 -1
- package/dist/dts/procedure/procedure.d.ts +0 -40
- package/dist/dts/procedure/procedure.d.ts.map +0 -1
- package/dist/dts/reducer/reducer.d.ts +0 -44
- package/dist/dts/reducer/reducer.d.ts.map +0 -1
- package/dist/dts/remote/remoteState.d.ts +0 -119
- package/dist/dts/remote/remoteState.d.ts.map +0 -1
- package/dist/dts/runtime/bus.d.ts +0 -27
- package/dist/dts/runtime/bus.d.ts.map +0 -1
- package/dist/dts/runtime/loop.d.ts +0 -45
- package/dist/dts/runtime/loop.d.ts.map +0 -1
- package/dist/dts/scene/scene.d.ts +0 -44
- package/dist/dts/scene/scene.d.ts.map +0 -1
- package/dist/dts/state/state.d.ts +0 -37
- package/dist/dts/state/state.d.ts.map +0 -1
- package/dist/dts/state/stateFamily.d.ts +0 -79
- package/dist/dts/state/stateFamily.d.ts.map +0 -1
- package/dist/dts/state/stateGroup.d.ts +0 -36
- package/dist/dts/state/stateGroup.d.ts.map +0 -1
- package/dist/dts/state/token.d.ts.map +0 -1
- package/dist/dts/ui/node.d.ts.map +0 -1
- package/dist/dts/ui/trigger.d.ts.map +0 -1
- package/dist/esm/boundary/boundary.js +0 -83
- package/dist/esm/boundary/boundary.js.map +0 -1
- package/dist/esm/calc/asyncCalc.js +0 -95
- package/dist/esm/calc/asyncCalc.js.map +0 -1
- package/dist/esm/calc/asyncData.js +0 -34
- package/dist/esm/calc/asyncData.js.map +0 -1
- package/dist/esm/calc/calc.js +0 -58
- package/dist/esm/calc/calc.js.map +0 -1
- package/dist/esm/calc/calcFamily.js +0 -124
- package/dist/esm/calc/calcFamily.js.map +0 -1
- package/dist/esm/channel/channel.js +0 -136
- package/dist/esm/channel/channel.js.map +0 -1
- package/dist/esm/compose/composition.js +0 -46
- package/dist/esm/compose/composition.js.map +0 -1
- package/dist/esm/compose/host.js +0 -5
- package/dist/esm/compose/host.js.map +0 -1
- package/dist/esm/compose/props.js.map +0 -1
- package/dist/esm/compose/provide.js +0 -28
- package/dist/esm/compose/provide.js.map +0 -1
- package/dist/esm/compose/slot.js +0 -23
- package/dist/esm/compose/slot.js.map +0 -1
- package/dist/esm/compose/ui.js +0 -57
- package/dist/esm/compose/ui.js.map +0 -1
- package/dist/esm/definition/definition.js +0 -42
- package/dist/esm/definition/definition.js.map +0 -1
- package/dist/esm/event/event.js +0 -30
- package/dist/esm/event/event.js.map +0 -1
- package/dist/esm/event/eventGroup.js +0 -4
- package/dist/esm/event/eventGroup.js.map +0 -1
- package/dist/esm/feature/feature.js +0 -98
- package/dist/esm/feature/feature.js.map +0 -1
- package/dist/esm/index.js +0 -45
- package/dist/esm/index.js.map +0 -1
- package/dist/esm/internal/capture.js +0 -11
- package/dist/esm/internal/capture.js.map +0 -1
- package/dist/esm/internal/ctx.js +0 -2
- package/dist/esm/internal/ctx.js.map +0 -1
- package/dist/esm/internal/errors.js.map +0 -1
- package/dist/esm/internal/inspect.js +0 -32
- package/dist/esm/internal/inspect.js.map +0 -1
- package/dist/esm/internal/queryDriver.js +0 -134
- package/dist/esm/internal/queryDriver.js.map +0 -1
- package/dist/esm/internal/reuse.js +0 -68
- package/dist/esm/internal/reuse.js.map +0 -1
- package/dist/esm/internal/scheduler.js +0 -69
- package/dist/esm/internal/scheduler.js.map +0 -1
- package/dist/esm/internal/seeds.js +0 -17
- package/dist/esm/internal/seeds.js.map +0 -1
- package/dist/esm/internal/sources.js +0 -59
- package/dist/esm/internal/sources.js.map +0 -1
- package/dist/esm/internal/store.js +0 -73
- package/dist/esm/internal/store.js.map +0 -1
- package/dist/esm/internal/track.js +0 -18
- package/dist/esm/internal/track.js.map +0 -1
- package/dist/esm/package.json +0 -4
- package/dist/esm/procedure/procedure.js +0 -50
- package/dist/esm/procedure/procedure.js.map +0 -1
- package/dist/esm/reducer/reducer.js +0 -63
- package/dist/esm/reducer/reducer.js.map +0 -1
- package/dist/esm/remote/remoteState.js +0 -270
- package/dist/esm/remote/remoteState.js.map +0 -1
- package/dist/esm/runtime/bus.js +0 -20
- package/dist/esm/runtime/bus.js.map +0 -1
- package/dist/esm/runtime/loop.js +0 -116
- package/dist/esm/runtime/loop.js.map +0 -1
- package/dist/esm/scene/scene.js +0 -31
- package/dist/esm/scene/scene.js.map +0 -1
- package/dist/esm/state/state.js +0 -43
- package/dist/esm/state/state.js.map +0 -1
- package/dist/esm/state/stateFamily.js +0 -96
- package/dist/esm/state/stateFamily.js.map +0 -1
- package/dist/esm/state/stateGroup.js +0 -46
- package/dist/esm/state/stateGroup.js.map +0 -1
- package/dist/esm/state/token.js +0 -20
- package/dist/esm/state/token.js.map +0 -1
- package/dist/esm/ui/node.js +0 -2
- package/dist/esm/ui/node.js.map +0 -1
- package/dist/esm/ui/trigger.js +0 -2
- package/dist/esm/ui/trigger.js.map +0 -1
|
@@ -0,0 +1,571 @@
|
|
|
1
|
+
import { Context, Effect, Either, Layer, MutableRef, Option, Runtime, Schema } from 'effect'
|
|
2
|
+
import { type AnyAsyncData, AsyncData, narrowStore, widenStore } from '../calc/asyncData'
|
|
3
|
+
import * as Channel from '../channel/channel'
|
|
4
|
+
import { type Manifest, yieldableClass } from '../definition/definition'
|
|
5
|
+
import * as Event from '../event/event'
|
|
6
|
+
import {
|
|
7
|
+
bumpRevision,
|
|
8
|
+
type GatedOf,
|
|
9
|
+
gatedFlag,
|
|
10
|
+
makeQueryDriver,
|
|
11
|
+
RevisionSchema,
|
|
12
|
+
revisionZero,
|
|
13
|
+
} from '../internal/queryDriver'
|
|
14
|
+
import { reuse as shareStructure } from '../internal/reuse'
|
|
15
|
+
import { resolveScheduler } from '../internal/scheduler'
|
|
16
|
+
import {
|
|
17
|
+
type InputsObject,
|
|
18
|
+
type InputStores,
|
|
19
|
+
type InvalidateBy,
|
|
20
|
+
sameKey,
|
|
21
|
+
} from '../internal/sources'
|
|
22
|
+
import { makeDerivedStore, makeStore, type Store } from '../internal/store'
|
|
23
|
+
import { readTracked } from '../internal/track'
|
|
24
|
+
import * as Reducer from '../reducer/reducer'
|
|
25
|
+
import { Bus, narrowHandled, publish } from '../runtime/bus'
|
|
26
|
+
import { type ReducerEntry, Reducers } from '../runtime/loop'
|
|
27
|
+
import * as State from '../state/state'
|
|
28
|
+
import { type AnySource, StateToken } from '../state/token'
|
|
29
|
+
|
|
30
|
+
// `RemoteState` is server-owned state with optimistic mutations, as ONE
|
|
31
|
+
// primitive. The model: remote state is *derived only* — the visible value is
|
|
32
|
+
// `pending.reduce(apply, serverTruth)`, where `serverTruth` is a query's
|
|
33
|
+
// lifecycle (the AsyncCalc machinery) and `pending` is the queue of intents
|
|
34
|
+
// dispatched but not yet reflected by the server. The only write surface is
|
|
35
|
+
// dispatching an intent event; there is no rollback machinery — a failed or
|
|
36
|
+
// settled intent simply leaves the queue and the derivation converges.
|
|
37
|
+
//
|
|
38
|
+
// Lifecycle of one intent (dispatch → visible → durable):
|
|
39
|
+
// 1. Dispatched like any event; the send procedure mints an `opId` and the
|
|
40
|
+
// hidden queue reducer appends `{opId, intent, status: 'sending'}` — the
|
|
41
|
+
// overlay shows the change in the same flush (no fetch fired yet).
|
|
42
|
+
// 2. `send(intent)` runs on the channel. Failure (or a defect, or a cancel
|
|
43
|
+
// by a `latest`/`exclusive` channel): the intent settles immediately and
|
|
44
|
+
// the public `Failed` event carries `{intent, error}` — the overlay
|
|
45
|
+
// reverts by construction.
|
|
46
|
+
// 3. Success: status flips to `'confirmed'` and the query is invalidated.
|
|
47
|
+
// 4. THE GENERATION RULE: an intent acked while query-run generation g was
|
|
48
|
+
// the newest requested may be settled only by a run with generation > g
|
|
49
|
+
// that lands `Success`. A refetch that started before the ack can never
|
|
50
|
+
// settle it (it may predate the server applying the mutation), and an
|
|
51
|
+
// `Error` run settles nobody — so the optimistic change never visibly
|
|
52
|
+
// vanishes and reappears while the server converges.
|
|
53
|
+
// 5. The settle lands in the same scheduler flush as the converged value:
|
|
54
|
+
// the overlay swaps optimistic-over-stale for server truth atomically
|
|
55
|
+
// from a subscriber's point of view.
|
|
56
|
+
//
|
|
57
|
+
// Queue order = dispatch order = apply order. While the query is disabled the
|
|
58
|
+
// view is `Idle` but intents still queue and send; settles wait for the run
|
|
59
|
+
// after re-enable. `apply` must be IDEMPOTENT against server truth that
|
|
60
|
+
// already carries the intent: between the refetch landing and the settle
|
|
61
|
+
// folding (adjacent flushes) the intent is applied over a value that already
|
|
62
|
+
// contains it — `apply` seeing its own effect must be a no-op (e.g. skip an
|
|
63
|
+
// insert whose id is already present). Everything is scoped: registry entries
|
|
64
|
+
// unregister and the driver fiber/subscriptions/channel release with the
|
|
65
|
+
// layer's scope.
|
|
66
|
+
|
|
67
|
+
/** One queued intent: the dispatched event value plus the lifecycle metadata. */
|
|
68
|
+
export interface PendingIntent<I> {
|
|
69
|
+
readonly opId: string
|
|
70
|
+
/** The dispatched intent event (`{ _tag, ...payload }`). */
|
|
71
|
+
readonly intent: I
|
|
72
|
+
/** `'sending'` until the RPC acks; `'confirmed'` until the post-ack refetch lands. */
|
|
73
|
+
readonly status: 'sending' | 'confirmed'
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The payload of the public `Failed` event: which intent, and what `send` raised. */
|
|
77
|
+
export interface FailedIntent<I> {
|
|
78
|
+
readonly intent: I
|
|
79
|
+
readonly error: unknown
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface RemoteStateManifest<N extends string, A, E> extends Manifest {
|
|
83
|
+
readonly kind: 'RemoteState'
|
|
84
|
+
readonly name: N
|
|
85
|
+
/** Schema of the server value (`Success` arm / `apply`'s domain). */
|
|
86
|
+
readonly output: Schema.Schema<A, any>
|
|
87
|
+
readonly error?: Schema.Schema<E, any>
|
|
88
|
+
/** Whether the query can be disabled (drives the `Idle` arm). */
|
|
89
|
+
readonly gated: boolean
|
|
90
|
+
/** The mutation vocabulary — intent event tags, reflectable without running logic. */
|
|
91
|
+
readonly intents: ReadonlyArray<string>
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export interface RemoteStateClass<
|
|
95
|
+
out N extends string,
|
|
96
|
+
out Inputs extends ReadonlyArray<AnySource>,
|
|
97
|
+
in out Intents extends ReadonlyArray<Event.AnyEvent>,
|
|
98
|
+
in out A,
|
|
99
|
+
in out E,
|
|
100
|
+
in out Gated extends boolean,
|
|
101
|
+
> extends Effect.Effect<AsyncData<A, E, Gated>, never, Store<AsyncData<A, E, Gated>>> {
|
|
102
|
+
new (): {}
|
|
103
|
+
readonly manifest: RemoteStateManifest<N, A, E>
|
|
104
|
+
/** The OVERLAID view (`pending.reduce(apply, truth)`) — what `yield*` reads. */
|
|
105
|
+
readonly store: Context.Tag<Store<AsyncData<A, E, Gated>>, Store<AsyncData<A, E, Gated>>>
|
|
106
|
+
/** The name, so the class doubles as a `Source` input to another calc. */
|
|
107
|
+
readonly name: N
|
|
108
|
+
/** Marker data read by `RemoteState.live`. */
|
|
109
|
+
readonly inputs: Inputs
|
|
110
|
+
/** The intent event definitions (marker data; read by `RemoteState.live`). */
|
|
111
|
+
readonly intents: Intents
|
|
112
|
+
/** Runtime mirror of `Gated`: whether `disabled` is honored / `Idle` can occur. */
|
|
113
|
+
readonly gated: Gated
|
|
114
|
+
/** The un-overlaid query lifecycle — for chrome that must show server truth. */
|
|
115
|
+
readonly truth: StateToken<`${N}/truth`, AsyncData<A, E, Gated>>
|
|
116
|
+
/**
|
|
117
|
+
* The pending queue as a read-only `Source` (badges, "saving…" chrome). Not a
|
|
118
|
+
* `StateClass`, so no user reducer can target it — the hidden queue reducer
|
|
119
|
+
* is its sole writer.
|
|
120
|
+
*/
|
|
121
|
+
readonly pending: StateToken<
|
|
122
|
+
`${N}/pending`,
|
|
123
|
+
ReadonlyArray<PendingIntent<Event.EventType<Intents[number]>>>
|
|
124
|
+
>
|
|
125
|
+
/** Public fact: a send failed; its intent left the queue and the view reverted. */
|
|
126
|
+
readonly Failed: Event.EventClass<`${N}/Failed`, FailedIntent<Event.EventType<Intents[number]>>>
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export interface RemoteStateConfig<
|
|
130
|
+
Inputs extends ReadonlyArray<AnySource>,
|
|
131
|
+
Intents extends ReadonlyArray<Event.AnyEvent>,
|
|
132
|
+
A,
|
|
133
|
+
E,
|
|
134
|
+
AlwaysOn extends boolean,
|
|
135
|
+
> {
|
|
136
|
+
readonly inputs: Inputs
|
|
137
|
+
/** Schema of the server value. */
|
|
138
|
+
readonly output: Schema.Schema<A, any>
|
|
139
|
+
/** Schema of the query failure. Omitted ⇒ infallible query, no `Error` arm. */
|
|
140
|
+
readonly error?: Schema.Schema<E, any>
|
|
141
|
+
/** `true` ⇒ the query is always on: no `Idle` arm and `disabled` is rejected on `.live`. */
|
|
142
|
+
readonly alwaysOn?: AlwaysOn
|
|
143
|
+
/** The mutation vocabulary: ordinary `Event.make` definitions. */
|
|
144
|
+
readonly intents: Intents
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Intent payloads are typed by the definition's `Intents`, but `Intents` is an
|
|
148
|
+
// open type parameter inside `make`/`live`, so the runtime schemas can only be
|
|
149
|
+
// structural (`Schema.Unknown` in the intent slot). Event schemas are
|
|
150
|
+
// reflective metadata — never decoded on the dispatch path — so the typed view
|
|
151
|
+
// is granted here once (the `narrowStore`/`narrowHandled` style; `any`
|
|
152
|
+
// short-circuits the schema's invariance, no value is cast).
|
|
153
|
+
const looseSchema = <P>(schema: Schema.Schema<any, any>): Schema.Schema<P, any> => schema
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Define a remote state: a query (like `AsyncCalc.make`) plus the intent
|
|
157
|
+
* events that mutate it. `yield* TheClass` reads the overlaid view;
|
|
158
|
+
* `TheClass.truth` / `TheClass.pending` / `TheClass.Failed` expose the rest of
|
|
159
|
+
* the surface. The query/send/apply logic is supplied by `RemoteState.live`.
|
|
160
|
+
*/
|
|
161
|
+
export const make = <
|
|
162
|
+
const N extends string,
|
|
163
|
+
const Inputs extends ReadonlyArray<AnySource>,
|
|
164
|
+
const Intents extends ReadonlyArray<Event.AnyEvent>,
|
|
165
|
+
A,
|
|
166
|
+
E = never,
|
|
167
|
+
const AlwaysOn extends boolean = false,
|
|
168
|
+
>(
|
|
169
|
+
name: N,
|
|
170
|
+
config: RemoteStateConfig<Inputs, Intents, A, E, AlwaysOn>,
|
|
171
|
+
): RemoteStateClass<N, Inputs, Intents, A, E, GatedOf<AlwaysOn>> => {
|
|
172
|
+
type I = Event.EventType<Intents[number]>
|
|
173
|
+
const store = Context.GenericTag<Store<AsyncData<A, E, GatedOf<AlwaysOn>>>>(
|
|
174
|
+
`reform/remoteState/${name}`,
|
|
175
|
+
)
|
|
176
|
+
const truthTag = Context.GenericTag<Store<AsyncData<A, E, GatedOf<AlwaysOn>>>>(
|
|
177
|
+
`reform/remoteState/${name}/truth`,
|
|
178
|
+
)
|
|
179
|
+
const pendingTag = Context.GenericTag<Store<ReadonlyArray<PendingIntent<I>>>>(
|
|
180
|
+
`reform/remoteState/${name}/pending`,
|
|
181
|
+
)
|
|
182
|
+
const gated = gatedFlag(config.alwaysOn)
|
|
183
|
+
const manifest: RemoteStateManifest<N, A, E> = {
|
|
184
|
+
kind: 'RemoteState',
|
|
185
|
+
name,
|
|
186
|
+
output: config.output,
|
|
187
|
+
gated,
|
|
188
|
+
intents: config.intents.map((event) => event.tag),
|
|
189
|
+
...(config.error !== undefined ? { error: config.error } : {}),
|
|
190
|
+
}
|
|
191
|
+
// Annotated consts so the template literal expressions keep their literal
|
|
192
|
+
// types (contextual typing) — the statics must match the interface exactly.
|
|
193
|
+
const truthName: `${N}/truth` = `${name}/truth`
|
|
194
|
+
const pendingName: `${N}/pending` = `${name}/pending`
|
|
195
|
+
const failedName: `${N}/Failed` = `${name}/Failed`
|
|
196
|
+
const read = Effect.flatMap(store, readTracked)
|
|
197
|
+
return yieldableClass(read, {
|
|
198
|
+
manifest,
|
|
199
|
+
store,
|
|
200
|
+
name,
|
|
201
|
+
inputs: config.inputs,
|
|
202
|
+
intents: config.intents,
|
|
203
|
+
gated,
|
|
204
|
+
truth: new StateToken(truthName, truthTag),
|
|
205
|
+
pending: new StateToken(pendingName, pendingTag),
|
|
206
|
+
Failed: Event.make(
|
|
207
|
+
failedName,
|
|
208
|
+
looseSchema<FailedIntent<I>>(
|
|
209
|
+
Schema.Struct({ intent: Schema.Unknown, error: Schema.Unknown }),
|
|
210
|
+
),
|
|
211
|
+
),
|
|
212
|
+
})
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* The `.live` config: the query (same contract as `AsyncCalc.live`), the
|
|
217
|
+
* delivery effect, and the pure per-intent fold.
|
|
218
|
+
*/
|
|
219
|
+
export type RemoteStateLive<
|
|
220
|
+
Inputs extends ReadonlyArray<AnySource>,
|
|
221
|
+
Intents extends ReadonlyArray<Event.AnyEvent>,
|
|
222
|
+
A,
|
|
223
|
+
E,
|
|
224
|
+
Gated extends boolean,
|
|
225
|
+
R,
|
|
226
|
+
R2,
|
|
227
|
+
> = {
|
|
228
|
+
readonly query: (inputs: InputsObject<Inputs>) => Effect.Effect<A, E, R>
|
|
229
|
+
/**
|
|
230
|
+
* Deliver one intent to the server. ANY failure (typed failure or defect)
|
|
231
|
+
* settles the intent and dispatches `Failed`; the result value is ignored —
|
|
232
|
+
* truth comes back through the invalidated query, never through the response.
|
|
233
|
+
*/
|
|
234
|
+
readonly send: (intent: Event.EventType<Intents[number]>) => Effect.Effect<unknown, unknown, R2>
|
|
235
|
+
/**
|
|
236
|
+
* The pure per-intent fold over the server value (typically one
|
|
237
|
+
* `Match.exhaustive` over the intent union). MUST be idempotent against
|
|
238
|
+
* truth that already carries the intent — see the module doc.
|
|
239
|
+
*/
|
|
240
|
+
readonly apply: (value: A, intent: Event.EventType<Intents[number]>) => A
|
|
241
|
+
/** The send lane. Default: a generated `${name}/sends` merge channel. */
|
|
242
|
+
readonly channel?: Channel.ChannelClass
|
|
243
|
+
readonly invalidateBy?: InvalidateBy<Inputs>
|
|
244
|
+
/** Extra refetch triggers (realtime pushes, unrelated mutations) — `AsyncCalc`'s sugar. */
|
|
245
|
+
readonly invalidateOn?: ReadonlyArray<Event.AnyEvent>
|
|
246
|
+
/** Refetch conciliation — see `AsyncCalc.live`. Mutation bursts favor `'trailing'`. */
|
|
247
|
+
readonly coalesce?: 'switch' | 'trailing'
|
|
248
|
+
/** Structural sharing across refetches AND overlay recomputes — see `AsyncCalc.live`. */
|
|
249
|
+
readonly reuse?: boolean
|
|
250
|
+
} & (Gated extends true
|
|
251
|
+
? { readonly disabled?: (inputs: InputsObject<Inputs>) => boolean }
|
|
252
|
+
: { readonly disabled?: never })
|
|
253
|
+
|
|
254
|
+
/** The seam between the send procedure and the query driver (module-private). */
|
|
255
|
+
interface SettleLink {
|
|
256
|
+
/** The driver's highest REQUESTED run generation (see `QueryDriver.requested`). */
|
|
257
|
+
readonly requested: () => number
|
|
258
|
+
/** Settle `opId` when a run with generation ≥ `waitFor` lands `Success`. */
|
|
259
|
+
readonly register: (opId: string, waitFor: number) => void
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Wire a remote state: the query driver (truth), the overlay (visible view),
|
|
264
|
+
* the hidden queue reducer, the send procedure, and the settle link between
|
|
265
|
+
* them — one layer. Hidden names minted per call, namespaced by the remote
|
|
266
|
+
* state's name: events `…/Queued`/`…/Acked`/`…/Settled`/`…/Invalidated`, state
|
|
267
|
+
* `…/revision`, reducers `…/pending`/`…/revision`, procedure `…/send`, default
|
|
268
|
+
* channel `…/sends`.
|
|
269
|
+
*/
|
|
270
|
+
export const live = <
|
|
271
|
+
N extends string,
|
|
272
|
+
Inputs extends ReadonlyArray<AnySource>,
|
|
273
|
+
Intents extends ReadonlyArray<Event.AnyEvent>,
|
|
274
|
+
A,
|
|
275
|
+
E,
|
|
276
|
+
Gated extends boolean,
|
|
277
|
+
R,
|
|
278
|
+
R2,
|
|
279
|
+
>(
|
|
280
|
+
remote: RemoteStateClass<N, Inputs, Intents, A, E, Gated>,
|
|
281
|
+
config: RemoteStateLive<Inputs, Intents, A, E, Gated, R, R2>,
|
|
282
|
+
): Layer.Layer<
|
|
283
|
+
| Store<AsyncData<A, E, Gated>>
|
|
284
|
+
| Store<ReadonlyArray<PendingIntent<Event.EventType<Intents[number]>>>>,
|
|
285
|
+
never,
|
|
286
|
+
| InputStores<Inputs>
|
|
287
|
+
| R
|
|
288
|
+
| R2
|
|
289
|
+
| Reducers
|
|
290
|
+
| Channel.Procedures
|
|
291
|
+
| Channel.Channels
|
|
292
|
+
| Bus
|
|
293
|
+
> => {
|
|
294
|
+
type I = Event.EventType<Intents[number]>
|
|
295
|
+
const name = remote.manifest.name
|
|
296
|
+
const truthTag = remote.truth.store
|
|
297
|
+
const pendingTag = remote.pending.store
|
|
298
|
+
|
|
299
|
+
// The hidden event vocabulary. `Queued` carries the intent (the queue
|
|
300
|
+
// reducer's only source of it); `Acked`/`Settled` name the op; `Invalidated`
|
|
301
|
+
// bumps the hidden revision exactly like an `invalidateOn` event.
|
|
302
|
+
const Queued = Event.make(
|
|
303
|
+
`${name}/Queued`,
|
|
304
|
+
looseSchema<{ readonly opId: string; readonly intent: I }>(
|
|
305
|
+
Schema.Struct({ opId: Schema.String, intent: Schema.Unknown }),
|
|
306
|
+
),
|
|
307
|
+
)
|
|
308
|
+
const Acked = Event.make(`${name}/Acked`, Schema.Struct({ opId: Schema.String }))
|
|
309
|
+
const Settled = Event.make(`${name}/Settled`, Schema.Struct({ opId: Schema.String }))
|
|
310
|
+
const Invalidated = Event.make(`${name}/Invalidated`, Schema.Struct({}))
|
|
311
|
+
|
|
312
|
+
// The hidden revision pair (the `invalidateOn` machinery, unconditional here:
|
|
313
|
+
// `Invalidated` always drives it; user `invalidateOn` events join the same fold).
|
|
314
|
+
const revisionState = State.make(`${name}/revision`, RevisionSchema)
|
|
315
|
+
const revisionReducer = Reducer.make(`${name}/revision`, {
|
|
316
|
+
states: [revisionState],
|
|
317
|
+
events: [Invalidated, ...(config.invalidateOn ?? [])],
|
|
318
|
+
})
|
|
319
|
+
|
|
320
|
+
// The settle link, keyed per live so two remote states never cross-resolve.
|
|
321
|
+
const linkTag = Context.GenericTag<SettleLink>(`reform/remoteState/${name}/link`)
|
|
322
|
+
|
|
323
|
+
// ── 1. Driver + link: the truth store and the generation bookkeeping. ──────
|
|
324
|
+
const driverLayer = Layer.scopedContext(
|
|
325
|
+
Effect.gen(function* () {
|
|
326
|
+
// `disabled` is rejected at the type level for non-gated definitions
|
|
327
|
+
// (erased to `never` there); read it through a loose view for the runtime.
|
|
328
|
+
const cfg = config as {
|
|
329
|
+
readonly query: (inputs: InputsObject<Inputs>) => Effect.Effect<A, E, R>
|
|
330
|
+
readonly invalidateBy?: InvalidateBy<Inputs>
|
|
331
|
+
readonly disabled?: (inputs: InputsObject<Inputs>) => boolean
|
|
332
|
+
readonly coalesce?: 'switch' | 'trailing'
|
|
333
|
+
readonly reuse?: boolean
|
|
334
|
+
}
|
|
335
|
+
// Requirement-free read (`serviceOption`): the assembly below always
|
|
336
|
+
// provides the revision store alongside this driver.
|
|
337
|
+
const revision = Option.getOrUndefined(yield* Effect.serviceOption(revisionState.store))
|
|
338
|
+
const runtime = yield* Effect.runtime<Bus>()
|
|
339
|
+
|
|
340
|
+
// Settle waiters: a plain registry, not fibers parked per intent — so the
|
|
341
|
+
// settle path is independent of the user channel's policy (`latest`/
|
|
342
|
+
// `exclusive` lanes can't orphan a pending entry by cancelling its fiber).
|
|
343
|
+
const waiters = MutableRef.make<
|
|
344
|
+
ReadonlyArray<{ readonly opId: string; readonly waitFor: number }>
|
|
345
|
+
>([])
|
|
346
|
+
const onSettled = (generation: number) => {
|
|
347
|
+
const due = MutableRef.get(waiters).filter((waiter) => waiter.waitFor <= generation)
|
|
348
|
+
if (due.length === 0) return
|
|
349
|
+
MutableRef.update(waiters, (all) => all.filter((waiter) => waiter.waitFor > generation))
|
|
350
|
+
// Dispatch synchronously (`publish` only enqueues on the unbounded bus):
|
|
351
|
+
// the settle is enqueued in the same flush as the converged value write.
|
|
352
|
+
for (const waiter of due) {
|
|
353
|
+
Runtime.runSync(runtime)(
|
|
354
|
+
publish('Normal', Event.construct(Settled, { opId: waiter.opId })),
|
|
355
|
+
)
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const driver = yield* makeQueryDriver({
|
|
360
|
+
name,
|
|
361
|
+
label: 'RemoteState',
|
|
362
|
+
gated: remote.gated,
|
|
363
|
+
inputs: remote.inputs,
|
|
364
|
+
query: cfg.query,
|
|
365
|
+
invalidateBy: cfg.invalidateBy,
|
|
366
|
+
disabled: cfg.disabled,
|
|
367
|
+
coalesce: cfg.coalesce,
|
|
368
|
+
reuse: cfg.reuse,
|
|
369
|
+
extraKey:
|
|
370
|
+
revision === undefined
|
|
371
|
+
? undefined
|
|
372
|
+
: {
|
|
373
|
+
read: () => revision.getSnapshot(),
|
|
374
|
+
subscribe: (listener) => revision.subscribe(listener),
|
|
375
|
+
},
|
|
376
|
+
onSettled,
|
|
377
|
+
})
|
|
378
|
+
const link: SettleLink = {
|
|
379
|
+
requested: driver.requested,
|
|
380
|
+
register: (opId, waitFor) =>
|
|
381
|
+
MutableRef.update(waiters, (all) => [...all, { opId, waitFor }]),
|
|
382
|
+
}
|
|
383
|
+
return Context.make(truthTag, narrowStore<A, E, Gated>(driver.store)).pipe(
|
|
384
|
+
Context.add(linkTag, link),
|
|
385
|
+
)
|
|
386
|
+
}),
|
|
387
|
+
)
|
|
388
|
+
|
|
389
|
+
// ── 2. Overlay: visible = pending.reduce(apply, truth). ────────────────────
|
|
390
|
+
const overlayLayer = Layer.scoped(
|
|
391
|
+
remote.store,
|
|
392
|
+
Effect.gen(function* () {
|
|
393
|
+
const scheduler = yield* resolveScheduler
|
|
394
|
+
// Widened so the arm dispatch below is a plain tag union (read-only view).
|
|
395
|
+
const feedStore = widenStore<A, E, Gated>(yield* truthTag)
|
|
396
|
+
const queueStore = yield* pendingTag
|
|
397
|
+
const memo = MutableRef.make<
|
|
398
|
+
{ readonly key: ReadonlyArray<unknown>; readonly output: AnyAsyncData<A, E> } | undefined
|
|
399
|
+
>(undefined)
|
|
400
|
+
const recompute = (): AnyAsyncData<A, E> => {
|
|
401
|
+
const feed = feedStore.getSnapshot()
|
|
402
|
+
const pending = queueStore.getSnapshot()
|
|
403
|
+
const key = [feed, pending]
|
|
404
|
+
const prev = MutableRef.get(memo)
|
|
405
|
+
if (prev !== undefined && sameKey(key, prev.key)) return prev.output
|
|
406
|
+
// `apply` runs over every `Success` — including `refetching: true`, so
|
|
407
|
+
// queued intents stay visible while the server converges; the other
|
|
408
|
+
// arms pass through by reference.
|
|
409
|
+
const overlaid: AnyAsyncData<A, E> =
|
|
410
|
+
feed._tag === 'Success'
|
|
411
|
+
? AsyncData.success(
|
|
412
|
+
pending.reduce((value, entry) => config.apply(value, entry.intent), feed.value),
|
|
413
|
+
feed.refetching,
|
|
414
|
+
)
|
|
415
|
+
: feed
|
|
416
|
+
// With `reuse`, also reconcile consecutive overlay outputs: the fold
|
|
417
|
+
// rebuilds the value per recompute, so untouched subtrees would
|
|
418
|
+
// otherwise lose identity every time the queue moves.
|
|
419
|
+
const output =
|
|
420
|
+
config.reuse === true &&
|
|
421
|
+
prev !== undefined &&
|
|
422
|
+
prev.output._tag === 'Success' &&
|
|
423
|
+
overlaid._tag === 'Success'
|
|
424
|
+
? AsyncData.success(shareStructure(prev.output.value, overlaid.value), overlaid.refetching)
|
|
425
|
+
: overlaid
|
|
426
|
+
MutableRef.set(memo, { key, output })
|
|
427
|
+
return output
|
|
428
|
+
}
|
|
429
|
+
const subscribe = (listener: () => void): (() => void) => {
|
|
430
|
+
const offFeed = feedStore.subscribe(listener)
|
|
431
|
+
const offQueue = queueStore.subscribe(listener)
|
|
432
|
+
return () => {
|
|
433
|
+
offFeed()
|
|
434
|
+
offQueue()
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
const derived = makeDerivedStore(recompute, subscribe, scheduler)
|
|
438
|
+
yield* Effect.addFinalizer(() => Effect.sync(derived.unsubscribe))
|
|
439
|
+
return narrowStore<A, E, Gated>(derived.store)
|
|
440
|
+
}),
|
|
441
|
+
)
|
|
442
|
+
|
|
443
|
+
// ── 3. Queue reducer: the pending queue's SOLE writer. ─────────────────────
|
|
444
|
+
// Assembled directly against the registry rather than through `Reducer.live`,
|
|
445
|
+
// whose parameter types are conditionals TS keeps deferred while `Intents` is
|
|
446
|
+
// an open type parameter (user code instantiates the public surface
|
|
447
|
+
// concretely, so this is the one generic caller).
|
|
448
|
+
const queueReducerName = `${name}/pending`
|
|
449
|
+
const queueHandles: ReadonlySet<string> = new Set([Queued.tag, Acked.tag, Settled.tag])
|
|
450
|
+
const foldQueue = (
|
|
451
|
+
queue: ReadonlyArray<PendingIntent<I>>,
|
|
452
|
+
event: { readonly _tag: string; readonly opId: string; readonly intent?: I },
|
|
453
|
+
): ReadonlyArray<PendingIntent<I>> =>
|
|
454
|
+
// `Queued` is the arm carrying the intent (the `undefined` guard is for the
|
|
455
|
+
// type only — every `Queued` carries one). An ack/settle for an id that is
|
|
456
|
+
// absent (or already confirmed) keeps the same reference — a true no-op.
|
|
457
|
+
event._tag === Queued.tag
|
|
458
|
+
? event.intent === undefined
|
|
459
|
+
? queue
|
|
460
|
+
: [...queue, { opId: event.opId, intent: event.intent, status: 'sending' }]
|
|
461
|
+
: event._tag === Acked.tag
|
|
462
|
+
? queue.some((entry) => entry.opId === event.opId && entry.status === 'sending')
|
|
463
|
+
? queue.map((entry) =>
|
|
464
|
+
entry.opId === event.opId ? { ...entry, status: 'confirmed' as const } : entry,
|
|
465
|
+
)
|
|
466
|
+
: queue
|
|
467
|
+
: queue.some((entry) => entry.opId === event.opId)
|
|
468
|
+
? queue.filter((entry) => entry.opId !== event.opId)
|
|
469
|
+
: queue
|
|
470
|
+
const queueReducerLayer = Layer.scopedDiscard(
|
|
471
|
+
Effect.gen(function* () {
|
|
472
|
+
const reducers = yield* Reducers
|
|
473
|
+
if (reducers.entries.some((entry) => entry.name === queueReducerName)) {
|
|
474
|
+
yield* Effect.logWarning(`reform: duplicate reducer name '${queueReducerName}' registered`)
|
|
475
|
+
}
|
|
476
|
+
const store = yield* pendingTag
|
|
477
|
+
const entry: ReducerEntry = {
|
|
478
|
+
name: queueReducerName,
|
|
479
|
+
handles: queueHandles,
|
|
480
|
+
// The loop only invokes `apply` for events in `handles`; `narrowHandled`
|
|
481
|
+
// restores that typed view from the erased envelope.
|
|
482
|
+
apply: (event) => store.set(foldQueue(store.get(), narrowHandled(event))),
|
|
483
|
+
}
|
|
484
|
+
yield* Effect.acquireRelease(Effect.sync(() => reducers.register(entry)), () =>
|
|
485
|
+
Effect.sync(() => reducers.unregister(entry)),
|
|
486
|
+
)
|
|
487
|
+
}),
|
|
488
|
+
)
|
|
489
|
+
|
|
490
|
+
// ── 4. Send procedure: intent dispatch → deliver → ack/settle bookkeeping. ─
|
|
491
|
+
// Hand-assembled `ProcedureEntry` for the same deferred-conditional reason as
|
|
492
|
+
// the queue reducer (the `Procedure.live` internals, verbatim).
|
|
493
|
+
const channel = config.channel ?? Channel.make(`${name}/sends`, { policy: { _tag: 'merge' } })
|
|
494
|
+
const procedureName = `${name}/send`
|
|
495
|
+
const sendHandles: ReadonlySet<string> = new Set(remote.intents.map((event) => event.tag))
|
|
496
|
+
const sendProcedureLayer = Layer.scopedDiscard(
|
|
497
|
+
Effect.gen(function* () {
|
|
498
|
+
const procedures = yield* Channel.Procedures
|
|
499
|
+
if (procedures.entries.some((entry) => entry.name === procedureName)) {
|
|
500
|
+
yield* Effect.logWarning(`reform: duplicate procedure name '${procedureName}' registered`)
|
|
501
|
+
}
|
|
502
|
+
const link = yield* linkTag
|
|
503
|
+
// Snapshot the body's full context (Bus + the send's R2) so `run` is total.
|
|
504
|
+
const runtime = yield* Effect.runtime<Bus | R2>()
|
|
505
|
+
const deliver = (intent: I): Effect.Effect<void, never, Bus | R2> =>
|
|
506
|
+
Effect.gen(function* () {
|
|
507
|
+
const opId = yield* Effect.sync(() => crypto.randomUUID())
|
|
508
|
+
yield* Event.dispatch(Queued, { opId, intent })
|
|
509
|
+
const outcome = yield* config.send(intent).pipe(
|
|
510
|
+
// A defect in `send` is a bug, but it must not strand the entry:
|
|
511
|
+
// fold it into the failure arm so the intent settles and `Failed`
|
|
512
|
+
// fires (the channel would otherwise swallow it after logging).
|
|
513
|
+
Effect.catchAllDefect((defect) => Effect.fail(defect)),
|
|
514
|
+
Effect.either,
|
|
515
|
+
// A cancelled send (a `latest`/`exclusive` user channel tearing the
|
|
516
|
+
// run down) settles too: the overlay reverts, and if the request
|
|
517
|
+
// did reach the server the next refetch shows it as truth.
|
|
518
|
+
Effect.onInterrupt(() => Event.dispatch(Settled, { opId })),
|
|
519
|
+
)
|
|
520
|
+
yield* Either.match(outcome, {
|
|
521
|
+
onLeft: (error) =>
|
|
522
|
+
Effect.zipRight(
|
|
523
|
+
Event.dispatch(remote.Failed, { intent, error }),
|
|
524
|
+
Event.dispatch(Settled, { opId }),
|
|
525
|
+
),
|
|
526
|
+
onRight: () =>
|
|
527
|
+
// Register the waiter BEFORE the invalidation bump: `Invalidated`
|
|
528
|
+
// routes through the drain loop later, so the run it triggers is
|
|
529
|
+
// necessarily requested after `requested()` here — the waiter can
|
|
530
|
+
// never be satisfied by a refetch that predates the ack.
|
|
531
|
+
Effect.sync(() => link.register(opId, link.requested() + 1)).pipe(
|
|
532
|
+
Effect.zipRight(Event.dispatch(Acked, { opId })),
|
|
533
|
+
Effect.zipRight(Event.dispatch(Invalidated, {})),
|
|
534
|
+
),
|
|
535
|
+
})
|
|
536
|
+
})
|
|
537
|
+
const entry: Channel.ProcedureEntry = {
|
|
538
|
+
name: procedureName,
|
|
539
|
+
channelName: channel.manifest.name,
|
|
540
|
+
handles: sendHandles,
|
|
541
|
+
run: (event) => Effect.provide(deliver(narrowHandled<I>(event)), runtime),
|
|
542
|
+
}
|
|
543
|
+
yield* Effect.acquireRelease(Effect.sync(() => procedures.register(entry)), () =>
|
|
544
|
+
Effect.sync(() => procedures.unregister(entry)),
|
|
545
|
+
)
|
|
546
|
+
}),
|
|
547
|
+
)
|
|
548
|
+
|
|
549
|
+
// ── Assembly. ───────────────────────────────────────────────────────────────
|
|
550
|
+
// The pending store backs both the overlay and the queue reducer; the hidden
|
|
551
|
+
// revision store feeds both the driver (`extraKey`) and its reducer; the
|
|
552
|
+
// channel `.live` is idempotent by name+policy, so a user channel shared with
|
|
553
|
+
// other procedures wires up exactly once.
|
|
554
|
+
const pendingStoreLayer = Layer.effect(
|
|
555
|
+
pendingTag,
|
|
556
|
+
Effect.map(resolveScheduler, (scheduler) =>
|
|
557
|
+
makeStore<ReadonlyArray<PendingIntent<I>>>([], scheduler),
|
|
558
|
+
),
|
|
559
|
+
)
|
|
560
|
+
return Layer.mergeAll(
|
|
561
|
+
overlayLayer,
|
|
562
|
+
queueReducerLayer,
|
|
563
|
+
Reducer.live(revisionReducer, bumpRevision),
|
|
564
|
+
sendProcedureLayer,
|
|
565
|
+
Channel.live(channel),
|
|
566
|
+
).pipe(
|
|
567
|
+
Layer.provideMerge(driverLayer),
|
|
568
|
+
Layer.provide(State.live(revisionState, revisionZero)),
|
|
569
|
+
Layer.provideMerge(pendingStoreLayer),
|
|
570
|
+
)
|
|
571
|
+
}
|
|
@@ -1,7 +1,10 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
1
|
+
import { Context, Effect, Layer, PubSub } from 'effect'
|
|
2
|
+
|
|
3
|
+
/** A dispatched event: a tagged data record (`{ _tag, ...payload }`). */
|
|
4
|
+
export interface Tagged {
|
|
5
|
+
readonly _tag: string
|
|
6
|
+
}
|
|
7
|
+
|
|
5
8
|
/**
|
|
6
9
|
* Re-narrow an erased `Tagged` envelope to a handler's declared event union. The
|
|
7
10
|
* loop and channels only route events whose tag is in a handler's `handles`, so
|
|
@@ -9,17 +12,25 @@ const effect_1 = require("effect");
|
|
|
9
12
|
* generic `Tagged`, and this restores the typed view. The one documented home
|
|
10
13
|
* for that boundary cast, shared by `Reducer.live` and `Procedure.live`.
|
|
11
14
|
*/
|
|
12
|
-
const narrowHandled = (event) => event
|
|
13
|
-
|
|
14
|
-
/**
|
|
15
|
-
|
|
15
|
+
export const narrowHandled = <E>(event: Tagged): E => event as never
|
|
16
|
+
|
|
17
|
+
/** UI-originated events are High; procedure-emitted follow-ups are Normal (DESIGN #46). */
|
|
18
|
+
export type Priority = 'High' | 'Normal'
|
|
19
|
+
|
|
20
|
+
export interface Envelope {
|
|
21
|
+
readonly priority: Priority
|
|
22
|
+
readonly event: Tagged
|
|
16
23
|
}
|
|
17
|
-
|
|
24
|
+
|
|
25
|
+
/** The single dispatch bus: an unbounded PubSub fanned out to the loop and procedures. */
|
|
26
|
+
export class Bus extends Context.Tag('reform/Bus')<Bus, PubSub.PubSub<Envelope>>() {}
|
|
27
|
+
|
|
18
28
|
// Unbounded so `publish` never suspends: UI triggers dispatch synchronously
|
|
19
29
|
// (`Runtime.runSync`, no fiber fork per event), and an event storm can't apply
|
|
20
30
|
// backpressure to the UI thread. The single drain loop empties the bus every
|
|
21
31
|
// microtask, so it does not accumulate. Swap to `PubSub.dropping(N)` if bounded
|
|
22
32
|
// memory is preferred over never dropping (publish stays synchronous either way).
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
33
|
+
export const busLayer = Layer.scoped(Bus, PubSub.unbounded<Envelope>())
|
|
34
|
+
|
|
35
|
+
export const publish = (priority: Priority, event: Tagged): Effect.Effect<void, never, Bus> =>
|
|
36
|
+
Effect.flatMap(Bus, (bus) => PubSub.publish(bus, { priority, event }))
|