@playfast/reform 0.0.7 → 0.0.8
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/package.json +2 -2
- package/src/boundary/boundary.test.ts +301 -0
- package/src/boundary/boundary.ts +192 -0
- package/src/calc/asyncCalc.test.ts +556 -0
- package/src/calc/asyncCalc.ts +267 -0
- package/src/calc/asyncData.ts +96 -0
- package/src/calc/calc.test.ts +287 -0
- package/src/calc/calc.ts +120 -0
- package/src/calc/calcFamily.test.ts +206 -0
- package/src/calc/calcFamily.ts +194 -0
- package/src/calc/compose.test.ts +68 -0
- package/src/calc/compose.ts +69 -0
- package/src/channel/channel.ts +269 -0
- package/src/compose/composition.ts +112 -0
- package/{dist/compose/host.d.ts → src/compose/host.ts} +9 -8
- package/{dist/compose/props.js → src/compose/props.ts} +7 -5
- package/src/compose/provide.ts +70 -0
- package/{dist/compose/slot.d.ts → src/compose/slot.ts} +36 -19
- package/src/compose/ui.test.ts +62 -0
- package/src/compose/ui.ts +226 -0
- package/src/compose/ui.typecheck.ts +56 -0
- package/src/definition/definition.ts +76 -0
- package/src/event/event.test.ts +23 -0
- package/src/event/event.ts +65 -0
- package/src/event/eventGroup.ts +14 -0
- package/src/feature/feature.mount.test.ts +82 -0
- package/src/feature/feature.test.ts +60 -0
- package/{dist/feature/feature.d.ts → src/feature/feature.ts} +178 -92
- package/src/feature/feature.typecheck.ts +108 -0
- package/src/index.ts +176 -0
- package/{dist/internal/capture.d.ts → src/internal/capture.ts} +13 -11
- package/{dist/internal/ctx.d.ts → src/internal/ctx.ts} +8 -4
- package/src/internal/errors.test.ts +33 -0
- package/src/internal/errors.ts +145 -0
- package/src/internal/inspect.test.ts +28 -0
- package/src/internal/inspect.ts +34 -0
- package/src/internal/queryDriver.ts +247 -0
- package/src/internal/reuse.test.ts +116 -0
- package/src/internal/reuse.ts +73 -0
- package/src/internal/scheduler.ts +93 -0
- package/{dist/internal/seeds.d.ts → src/internal/seeds.ts} +6 -3
- package/src/internal/sources.ts +104 -0
- package/{dist/internal/stateRegistry.js → src/internal/stateRegistry.ts} +18 -14
- package/src/internal/store.test.ts +80 -0
- package/src/internal/store.ts +105 -0
- package/{dist/internal/track.d.ts → src/internal/track.ts} +17 -11
- package/src/procedure/procedure.ts +89 -0
- package/src/reducer/reducer.ts +137 -0
- package/src/remote/remoteState.test.ts +695 -0
- package/src/remote/remoteState.ts +571 -0
- package/src/remote/remoteState.typecheck.ts +195 -0
- package/src/runtime/bus.ts +40 -0
- package/src/runtime/hardening.test.ts +69 -0
- package/src/runtime/loop.test.ts +178 -0
- package/src/runtime/loop.ts +171 -0
- package/src/scene/scene.ts +76 -0
- package/src/scene/seedScene.test.ts +169 -0
- package/src/state/state.ts +94 -0
- package/src/state/stateFamily.test.ts +138 -0
- package/src/state/stateFamily.ts +174 -0
- package/src/state/stateGroup.ts +105 -0
- package/{dist/state/token.d.ts → src/state/token.ts} +22 -13
- package/src/synced/syncedStore.ts +99 -0
- package/{dist/ui/node.d.ts → src/ui/node.ts} +3 -3
- package/{dist/ui/trigger.d.ts → src/ui/trigger.ts} +1 -2
- package/src/wire/tree.test.ts +81 -0
- package/src/wire/tree.ts +129 -0
- package/src/wire/triggers.test.ts +76 -0
- package/src/wire/triggers.ts +98 -0
- package/dist/boundary/boundary.d.ts +0 -72
- package/dist/boundary/boundary.d.ts.map +0 -1
- package/dist/boundary/boundary.js +0 -83
- package/dist/boundary/boundary.js.map +0 -1
- package/dist/calc/asyncCalc.d.ts +0 -91
- package/dist/calc/asyncCalc.d.ts.map +0 -1
- package/dist/calc/asyncCalc.js +0 -95
- package/dist/calc/asyncCalc.js.map +0 -1
- package/dist/calc/asyncData.d.ts +0 -57
- package/dist/calc/asyncData.d.ts.map +0 -1
- package/dist/calc/asyncData.js +0 -34
- package/dist/calc/asyncData.js.map +0 -1
- package/dist/calc/calc.d.ts +0 -57
- package/dist/calc/calc.d.ts.map +0 -1
- package/dist/calc/calc.js +0 -58
- package/dist/calc/calc.js.map +0 -1
- package/dist/calc/calcFamily.d.ts +0 -57
- package/dist/calc/calcFamily.d.ts.map +0 -1
- package/dist/calc/calcFamily.js +0 -124
- package/dist/calc/calcFamily.js.map +0 -1
- package/dist/calc/compose.d.ts +0 -16
- package/dist/calc/compose.d.ts.map +0 -1
- package/dist/calc/compose.js +0 -7
- package/dist/calc/compose.js.map +0 -1
- package/dist/channel/channel.d.ts +0 -115
- package/dist/channel/channel.d.ts.map +0 -1
- package/dist/channel/channel.js +0 -138
- package/dist/channel/channel.js.map +0 -1
- package/dist/compose/composition.d.ts +0 -72
- package/dist/compose/composition.d.ts.map +0 -1
- package/dist/compose/composition.js +0 -46
- package/dist/compose/composition.js.map +0 -1
- package/dist/compose/host.d.ts.map +0 -1
- package/dist/compose/host.js +0 -6
- package/dist/compose/host.js.map +0 -1
- package/dist/compose/props.d.ts +0 -13
- package/dist/compose/props.d.ts.map +0 -1
- package/dist/compose/props.js.map +0 -1
- package/dist/compose/provide.d.ts +0 -22
- package/dist/compose/provide.d.ts.map +0 -1
- package/dist/compose/provide.js +0 -28
- package/dist/compose/provide.js.map +0 -1
- package/dist/compose/slot.d.ts.map +0 -1
- package/dist/compose/slot.js +0 -23
- package/dist/compose/slot.js.map +0 -1
- package/dist/compose/ui.d.ts +0 -135
- package/dist/compose/ui.d.ts.map +0 -1
- package/dist/compose/ui.js +0 -63
- package/dist/compose/ui.js.map +0 -1
- package/dist/definition/definition.d.ts +0 -33
- package/dist/definition/definition.d.ts.map +0 -1
- package/dist/definition/definition.js +0 -42
- package/dist/definition/definition.js.map +0 -1
- package/dist/event/event.d.ts +0 -33
- package/dist/event/event.d.ts.map +0 -1
- package/dist/event/event.js +0 -30
- package/dist/event/event.js.map +0 -1
- package/dist/event/eventGroup.d.ts +0 -9
- package/dist/event/eventGroup.d.ts.map +0 -1
- package/dist/event/eventGroup.js +0 -4
- package/dist/event/eventGroup.js.map +0 -1
- package/dist/feature/feature.d.ts.map +0 -1
- package/dist/feature/feature.js +0 -98
- package/dist/feature/feature.js.map +0 -1
- package/dist/index.d.ts +0 -53
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js +0 -62
- package/dist/index.js.map +0 -1
- package/dist/internal/capture.d.ts.map +0 -1
- package/dist/internal/capture.js +0 -12
- package/dist/internal/capture.js.map +0 -1
- package/dist/internal/ctx.d.ts.map +0 -1
- package/dist/internal/ctx.js +0 -2
- package/dist/internal/ctx.js.map +0 -1
- package/dist/internal/errors.d.ts +0 -89
- package/dist/internal/errors.d.ts.map +0 -1
- package/dist/internal/errors.js +0 -102
- package/dist/internal/errors.js.map +0 -1
- package/dist/internal/inspect.d.ts +0 -17
- package/dist/internal/inspect.d.ts.map +0 -1
- package/dist/internal/inspect.js +0 -32
- package/dist/internal/inspect.js.map +0 -1
- package/dist/internal/queryDriver.d.ts +0 -65
- package/dist/internal/queryDriver.d.ts.map +0 -1
- package/dist/internal/queryDriver.js +0 -134
- package/dist/internal/queryDriver.js.map +0 -1
- package/dist/internal/reuse.d.ts +0 -10
- package/dist/internal/reuse.d.ts.map +0 -1
- package/dist/internal/reuse.js +0 -68
- package/dist/internal/reuse.js.map +0 -1
- package/dist/internal/scheduler.d.ts +0 -47
- package/dist/internal/scheduler.d.ts.map +0 -1
- package/dist/internal/scheduler.js +0 -70
- package/dist/internal/scheduler.js.map +0 -1
- package/dist/internal/seeds.d.ts.map +0 -1
- package/dist/internal/seeds.js +0 -17
- package/dist/internal/seeds.js.map +0 -1
- package/dist/internal/sources.d.ts +0 -39
- package/dist/internal/sources.d.ts.map +0 -1
- package/dist/internal/sources.js +0 -59
- package/dist/internal/sources.js.map +0 -1
- package/dist/internal/stateRegistry.d.ts +0 -2
- package/dist/internal/stateRegistry.d.ts.map +0 -1
- package/dist/internal/stateRegistry.js.map +0 -1
- package/dist/internal/store.d.ts +0 -47
- package/dist/internal/store.d.ts.map +0 -1
- package/dist/internal/store.js +0 -73
- package/dist/internal/store.js.map +0 -1
- package/dist/internal/track.d.ts.map +0 -1
- package/dist/internal/track.js +0 -19
- package/dist/internal/track.js.map +0 -1
- package/dist/procedure/procedure.d.ts +0 -40
- package/dist/procedure/procedure.d.ts.map +0 -1
- package/dist/procedure/procedure.js +0 -50
- package/dist/procedure/procedure.js.map +0 -1
- package/dist/reducer/reducer.d.ts +0 -44
- package/dist/reducer/reducer.d.ts.map +0 -1
- package/dist/reducer/reducer.js +0 -63
- package/dist/reducer/reducer.js.map +0 -1
- package/dist/remote/remoteState.d.ts +0 -119
- package/dist/remote/remoteState.d.ts.map +0 -1
- package/dist/remote/remoteState.js +0 -270
- package/dist/remote/remoteState.js.map +0 -1
- package/dist/runtime/bus.d.ts +0 -27
- package/dist/runtime/bus.d.ts.map +0 -1
- package/dist/runtime/bus.js +0 -21
- package/dist/runtime/bus.js.map +0 -1
- package/dist/runtime/loop.d.ts +0 -45
- package/dist/runtime/loop.d.ts.map +0 -1
- package/dist/runtime/loop.js +0 -117
- package/dist/runtime/loop.js.map +0 -1
- package/dist/scene/scene.d.ts +0 -44
- package/dist/scene/scene.d.ts.map +0 -1
- package/dist/scene/scene.js +0 -31
- package/dist/scene/scene.js.map +0 -1
- package/dist/state/state.d.ts +0 -37
- package/dist/state/state.d.ts.map +0 -1
- package/dist/state/state.js +0 -46
- package/dist/state/state.js.map +0 -1
- package/dist/state/stateFamily.d.ts +0 -79
- package/dist/state/stateFamily.d.ts.map +0 -1
- package/dist/state/stateFamily.js +0 -99
- package/dist/state/stateFamily.js.map +0 -1
- package/dist/state/stateGroup.d.ts +0 -50
- package/dist/state/stateGroup.d.ts.map +0 -1
- package/dist/state/stateGroup.js +0 -54
- package/dist/state/stateGroup.js.map +0 -1
- package/dist/state/token.d.ts.map +0 -1
- package/dist/state/token.js +0 -20
- package/dist/state/token.js.map +0 -1
- package/dist/synced/syncedStore.d.ts +0 -51
- package/dist/synced/syncedStore.d.ts.map +0 -1
- package/dist/synced/syncedStore.js +0 -43
- package/dist/synced/syncedStore.js.map +0 -1
- package/dist/ui/node.d.ts.map +0 -1
- package/dist/ui/node.js +0 -2
- package/dist/ui/node.js.map +0 -1
- package/dist/ui/trigger.d.ts.map +0 -1
- package/dist/ui/trigger.js +0 -2
- package/dist/ui/trigger.js.map +0 -1
- package/dist/wire/tree.d.ts +0 -66
- package/dist/wire/tree.d.ts.map +0 -1
- package/dist/wire/tree.js +0 -60
- package/dist/wire/tree.js.map +0 -1
- package/dist/wire/triggers.d.ts +0 -44
- package/dist/wire/triggers.d.ts.map +0 -1
- package/dist/wire/triggers.js +0 -31
- package/dist/wire/triggers.js.map +0 -1
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { Equal } from 'effect'
|
|
2
|
+
|
|
3
|
+
// Structural sharing for recomputed calc outputs: reconcile a fresh output
|
|
4
|
+
// against the previous one, substituting previous nodes wherever value equality
|
|
5
|
+
// holds, so identities only move where values moved. Downstream memo boundaries
|
|
6
|
+
// (React subtrees keyed on object identity) then skip everything that didn't
|
|
7
|
+
// change. Referentially transparent: the result is value-equal to `next` — only
|
|
8
|
+
// identities shift toward `previous` — and neither argument is mutated, so the
|
|
9
|
+
// pass composes with the framework's Equal-based invalidation untouched.
|
|
10
|
+
|
|
11
|
+
// Bounds the walk; calc outputs are shallow plain data by framework convention.
|
|
12
|
+
const maxDepth = 16
|
|
13
|
+
|
|
14
|
+
const isPlainRecord = (v: unknown): v is Record<string, unknown> =>
|
|
15
|
+
typeof v === 'object' &&
|
|
16
|
+
v !== null &&
|
|
17
|
+
(Object.getPrototypeOf(v) === Object.prototype || Object.getPrototypeOf(v) === null)
|
|
18
|
+
|
|
19
|
+
const go = (prev: unknown, next: unknown, depth: number): unknown => {
|
|
20
|
+
if (Object.is(prev, next)) return prev
|
|
21
|
+
// Data/Schema classes implement Equal+Hash: substitute wholesale on value
|
|
22
|
+
// equality. Plain objects/arrays fall through (their Equal is referential).
|
|
23
|
+
if (Equal.equals(prev, next)) return prev
|
|
24
|
+
if (depth <= 0) return next
|
|
25
|
+
if (Array.isArray(prev) && Array.isArray(next)) {
|
|
26
|
+
const out = next.map((item, i) => (i < prev.length ? go(prev[i], item, depth - 1) : item))
|
|
27
|
+
return prev.length === next.length && out.every((v, i) => Object.is(v, prev[i]))
|
|
28
|
+
? prev
|
|
29
|
+
: out
|
|
30
|
+
}
|
|
31
|
+
if (isPlainRecord(prev) && isPlainRecord(next)) {
|
|
32
|
+
const keys = Object.keys(next)
|
|
33
|
+
const out: Record<string, unknown> = {}
|
|
34
|
+
for (const key of keys) out[key] = key in prev ? go(prev[key], next[key], depth - 1) : next[key]
|
|
35
|
+
const allPrev =
|
|
36
|
+
keys.length === Object.keys(prev).length &&
|
|
37
|
+
keys.every((key) => key in prev && Object.is(out[key], prev[key]))
|
|
38
|
+
return allPrev ? prev : out
|
|
39
|
+
}
|
|
40
|
+
// Data/Schema class instances that DIFFER still get walked: their Equal is
|
|
41
|
+
// fieldwise over own enumerable fields, so a reconstruction over reconciled
|
|
42
|
+
// fields (same prototype; the constructor is bypassed, but every leaf is a
|
|
43
|
+
// validated value out of `prev` or `next`) stays value-equal to `next` while
|
|
44
|
+
// unchanged children — e.g. one untouched element inside a class-typed
|
|
45
|
+
// container — keep their `prev` identity.
|
|
46
|
+
if (
|
|
47
|
+
Equal.isEqual(prev) &&
|
|
48
|
+
Equal.isEqual(next) &&
|
|
49
|
+
Object.getPrototypeOf(prev) === Object.getPrototypeOf(next)
|
|
50
|
+
) {
|
|
51
|
+
const prevFields = Object.fromEntries(Object.entries(prev))
|
|
52
|
+
const nextFields = Object.entries(next)
|
|
53
|
+
const out: Record<string, unknown> = {}
|
|
54
|
+
for (const [key, value] of nextFields)
|
|
55
|
+
out[key] = key in prevFields ? go(prevFields[key], value, depth - 1) : value
|
|
56
|
+
const allPrev =
|
|
57
|
+
nextFields.length === Object.keys(prevFields).length &&
|
|
58
|
+
nextFields.every(([key]) => key in prevFields && Object.is(out[key], prevFields[key]))
|
|
59
|
+
return allPrev ? prev : Object.assign(Object.create(Object.getPrototypeOf(next)), out)
|
|
60
|
+
}
|
|
61
|
+
// Class instances without Equal (Date, Map, …) are opaque leaves.
|
|
62
|
+
return next
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Substitute `previous` nodes into `next` wherever they are value-equal. The
|
|
67
|
+
* walker only ever returns `previous`, `next`, or a key/index-wise
|
|
68
|
+
* reconstruction of `next` whose every leaf came from one of them, so the
|
|
69
|
+
* result is value-equal to `next` and the type is preserved by construction —
|
|
70
|
+
* the single cast below is that argument, in the style of `wireSources` /
|
|
71
|
+
* `narrowStore`.
|
|
72
|
+
*/
|
|
73
|
+
export const reuse = <A>(previous: A, next: A): A => go(previous, next, maxDepth) as A
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { Context, Effect, Layer, MutableRef, Option } from 'effect'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Coalesces store notifications into a single microtask flush so a batch of
|
|
5
|
+
* writes wakes each subscriber at most once (RESEARCH #3). React owns frame
|
|
6
|
+
* timing; we only collapse the notifications.
|
|
7
|
+
*
|
|
8
|
+
* One scheduler is shared by every store in a runtime, so writes across
|
|
9
|
+
* different stores in the same tick batch into one flush. The instance is
|
|
10
|
+
* resolved per store at creation: `Notifications` when a runtime provides it
|
|
11
|
+
* (per-runtime isolation — see `resolveScheduler`), else the process-wide
|
|
12
|
+
* `defaultScheduler`.
|
|
13
|
+
*/
|
|
14
|
+
export interface Scheduler {
|
|
15
|
+
readonly schedule: (listeners: Iterable<() => void>) => void
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const makeScheduler = (): Scheduler => {
|
|
19
|
+
const pending = new Set<() => void>()
|
|
20
|
+
const armed = MutableRef.make(false)
|
|
21
|
+
|
|
22
|
+
const flush = () => {
|
|
23
|
+
MutableRef.set(armed, false)
|
|
24
|
+
// Drain to a fixpoint within ONE microtask. A derived store's `onChange`
|
|
25
|
+
// re-schedules during the pass (it is itself a listener of its upstream), so
|
|
26
|
+
// resolving the whole dependency graph here — instead of re-arming a fresh
|
|
27
|
+
// microtask per layer — collapses a depth-N propagation into a single flush
|
|
28
|
+
// and wakes each leaf subscriber once at its final value. Converges because
|
|
29
|
+
// a derived store schedules only when its output actually moves (Equal).
|
|
30
|
+
while (pending.size > 0) {
|
|
31
|
+
const due = [...pending]
|
|
32
|
+
pending.clear()
|
|
33
|
+
for (const listener of due) {
|
|
34
|
+
// One listener's throw (a defect in a user calc body) must not abandon
|
|
35
|
+
// the rest of the flush; surface it to the host's global handler on a
|
|
36
|
+
// fresh microtask instead of unwinding this loop.
|
|
37
|
+
try {
|
|
38
|
+
listener()
|
|
39
|
+
} catch (error) {
|
|
40
|
+
queueMicrotask(() => {
|
|
41
|
+
throw error
|
|
42
|
+
})
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const schedule = (listeners: Iterable<() => void>) => {
|
|
49
|
+
for (const listener of listeners) pending.add(listener)
|
|
50
|
+
if (pending.size > 0 && !MutableRef.get(armed)) {
|
|
51
|
+
MutableRef.set(armed, true)
|
|
52
|
+
queueMicrotask(flush)
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return { schedule }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The process-wide scheduler, used by any store created outside a runtime that
|
|
61
|
+
* provides its own `Notifications`. Fine for a single client app (microtask
|
|
62
|
+
* ordering is global anyway); a runtime that needs isolation — concurrent SSR,
|
|
63
|
+
* multiple mounted roots — provides `Notifications` upstream of its stores.
|
|
64
|
+
*/
|
|
65
|
+
export const defaultScheduler: Scheduler = makeScheduler()
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Optional per-runtime scheduler. When a runtime provides it upstream of its
|
|
69
|
+
* state/calc layers, those stores coalesce on it instead of the global default,
|
|
70
|
+
* isolating their notification timing from other runtimes in the same process.
|
|
71
|
+
*/
|
|
72
|
+
const NotificationsBase: Context.TagClass<Notifications, 'reform/Notifications', Scheduler> =
|
|
73
|
+
Context.Tag('reform/Notifications')<Notifications, Scheduler>()
|
|
74
|
+
export class Notifications extends NotificationsBase {}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* A fresh per-runtime scheduler. Merged into `Engine`, so a runtime that wires
|
|
78
|
+
* its state/calc layers *downstream* of `Engine` gets isolated notification
|
|
79
|
+
* timing; the common sibling wiring falls back to `defaultScheduler` (still
|
|
80
|
+
* correct, just process-shared).
|
|
81
|
+
*/
|
|
82
|
+
export const notificationsLayer: Layer.Layer<Notifications> = Layer.sync(Notifications, makeScheduler)
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Resolve the scheduler a store should use: the runtime's `Notifications` if one
|
|
86
|
+
* is in context, else the global default. Uses `serviceOption` so it imposes no
|
|
87
|
+
* hard requirement — a store layer wired as a sibling of the runtime (the common
|
|
88
|
+
* pattern) simply falls back to the default.
|
|
89
|
+
*/
|
|
90
|
+
export const resolveScheduler: Effect.Effect<Scheduler> = Effect.map(
|
|
91
|
+
Effect.serviceOption(Notifications),
|
|
92
|
+
Option.getOrElse(() => defaultScheduler),
|
|
93
|
+
)
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { FiberRef } from 'effect'
|
|
1
|
+
import { FiberRef, GlobalValue } from 'effect'
|
|
2
|
+
|
|
2
3
|
/**
|
|
3
4
|
* The sanctioned tooling/test seam for seeding CLOSED scenes. A scene's layers
|
|
4
5
|
* are pre-composed (each state group seeds its own live store), so an outer
|
|
@@ -13,5 +14,7 @@ import { FiberRef } from 'effect';
|
|
|
13
14
|
* keeps a single ref instance even if the module is loaded twice (duplicated
|
|
14
15
|
* bundles, HMR), so the writer and the reader always agree.
|
|
15
16
|
*/
|
|
16
|
-
export
|
|
17
|
-
|
|
17
|
+
export const CurrentSeedOverrides: FiberRef.FiberRef<Readonly<Record<string, unknown>>> =
|
|
18
|
+
GlobalValue.globalValue(Symbol.for('reform/CurrentSeedOverrides'), () =>
|
|
19
|
+
FiberRef.unsafeMake<Readonly<Record<string, unknown>>>({}),
|
|
20
|
+
)
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { type Context, Effect, Equal, Predicate } from 'effect'
|
|
2
|
+
import { type AnySource, type SourceName, type SourceValue } from '../state/token'
|
|
3
|
+
import { type Store } from './store'
|
|
4
|
+
|
|
5
|
+
// The reactive plumbing shared by `Calc` and `AsyncCalc`: read a list of input
|
|
6
|
+
// `Source`s, snapshot them by name, project a comparable invalidation key, and
|
|
7
|
+
// subscribe to changes. Both derived primitives sense their inputs the same way
|
|
8
|
+
// and differ only in what they do with a change (recompute vs. re-run a query).
|
|
9
|
+
|
|
10
|
+
/** The decoded inputs handed to a calc body, keyed by each source's name. */
|
|
11
|
+
export type InputsObject<Inputs extends ReadonlyArray<AnySource>> = {
|
|
12
|
+
readonly [S in Inputs[number] as SourceName<S>]: SourceValue<S>
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The store services a list of store-carriers requires, as a *union of distinct*
|
|
17
|
+
* stores (each carrier has its own backing tag), not a single store of a union.
|
|
18
|
+
* Structural over the `store` tag, so it serves both calc inputs (`Source`s) and
|
|
19
|
+
* state-group members (`StateClass`es) — anything carrying a `store` tag.
|
|
20
|
+
*/
|
|
21
|
+
export type StoresOf<Items extends ReadonlyArray<unknown>> = {
|
|
22
|
+
[I in keyof Items]: Items[I] extends { readonly store: Context.Tag<infer Svc, any> } ? Svc : never
|
|
23
|
+
}[number]
|
|
24
|
+
|
|
25
|
+
/** The store requirement a calc's inputs impose. */
|
|
26
|
+
export type InputStores<Inputs extends ReadonlyArray<AnySource>> = StoresOf<Inputs>
|
|
27
|
+
|
|
28
|
+
/** Project inputs into a comparable key; the calc reacts only when it moves. */
|
|
29
|
+
export type InvalidateBy<Inputs extends ReadonlyArray<AnySource>> = (
|
|
30
|
+
inputs: InputsObject<Inputs>,
|
|
31
|
+
) => ReadonlyArray<unknown>
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The runtime key an input contributes to the inputs object. Prefer the
|
|
35
|
+
* explicit `manifest.name` — the string handed to `make` — over the structural
|
|
36
|
+
* `name`: a `Calc`/`AsyncCalc` *class* satisfies `Source` through its static
|
|
37
|
+
* `name`, and a user subclass declaration (`class Feed extends Calc.make(…)`)
|
|
38
|
+
* defines its OWN `name` from the class binding, shadowing the explicit one.
|
|
39
|
+
* That binding is what minifiers rename, so keying the snapshot by `input.name`
|
|
40
|
+
* made every subclassed calc input `undefined` in production builds (and would
|
|
41
|
+
* silently mis-key in dev whenever the binding differs from the `make` name —
|
|
42
|
+
* the type-level key, `SourceName<S>`, is always the `make` string). The
|
|
43
|
+
* manifest is inherited through the static prototype chain and never shadowed.
|
|
44
|
+
* `StateToken`s carry no manifest; their `name` is an instance field holding
|
|
45
|
+
* the explicit member key, so the fallback is always the declared string.
|
|
46
|
+
*/
|
|
47
|
+
const inputKey = (input: AnySource): string =>
|
|
48
|
+
Predicate.hasProperty(input, 'manifest') &&
|
|
49
|
+
Predicate.isRecord(input.manifest) &&
|
|
50
|
+
Predicate.isString(input.manifest['name'])
|
|
51
|
+
? input.manifest['name']
|
|
52
|
+
: input.name
|
|
53
|
+
|
|
54
|
+
/** Element-wise value equality of two invalidation keys. */
|
|
55
|
+
export const sameKey = (a: ReadonlyArray<unknown>, b: ReadonlyArray<unknown>): boolean =>
|
|
56
|
+
a.length === b.length && a.every((value, i) => Equal.equals(value, b[i]))
|
|
57
|
+
|
|
58
|
+
/** The sensing surface both `Calc.live` and `AsyncCalc.live` build on. */
|
|
59
|
+
export interface WiredSources<Inputs extends ReadonlyArray<AnySource>> {
|
|
60
|
+
/** Snapshot every input by name — the decoded inputs object a calc body sees. */
|
|
61
|
+
readonly snapshot: () => InputsObject<Inputs>
|
|
62
|
+
/** The invalidation key for an inputs object: the projection, or all input values. */
|
|
63
|
+
readonly keyOf: (inputs: InputsObject<Inputs>) => ReadonlyArray<unknown>
|
|
64
|
+
/** Subscribe to every source at once; the returned thunk unsubscribes from all. */
|
|
65
|
+
readonly subscribe: (listener: () => void) => () => void
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Read each input's backing store and return the shared sensing surface. The
|
|
70
|
+
* caller owns lifecycle: register the `subscribe` result's unsubscribe as a
|
|
71
|
+
* scope finalizer (both live bodies run under `Layer.scoped`).
|
|
72
|
+
*/
|
|
73
|
+
export const wireSources = <Inputs extends ReadonlyArray<AnySource>>(
|
|
74
|
+
inputs: Inputs,
|
|
75
|
+
invalidateBy?: InvalidateBy<Inputs>,
|
|
76
|
+
): Effect.Effect<WiredSources<Inputs>, never, InputStores<Inputs>> =>
|
|
77
|
+
Effect.gen(function* () {
|
|
78
|
+
const sources: Array<Store<unknown>> = []
|
|
79
|
+
for (const input of inputs) sources.push(yield* input.store)
|
|
80
|
+
|
|
81
|
+
const snapshot = (): InputsObject<Inputs> => {
|
|
82
|
+
const args: Record<string, unknown> = {}
|
|
83
|
+
inputs.forEach((input, i) => {
|
|
84
|
+
// `noUncheckedIndexedAccess`: `sources` is built 1:1 with `inputs`, but
|
|
85
|
+
// guard the index so the read is honestly total.
|
|
86
|
+
const source = sources[i]
|
|
87
|
+
if (source !== undefined) args[inputKey(input)] = source.getSnapshot()
|
|
88
|
+
})
|
|
89
|
+
return args as InputsObject<Inputs>
|
|
90
|
+
}
|
|
91
|
+
const keyOf = (args: InputsObject<Inputs>): ReadonlyArray<unknown> =>
|
|
92
|
+
invalidateBy ? invalidateBy(args) : Object.values(args)
|
|
93
|
+
const subscribe = (listener: () => void): (() => void) => {
|
|
94
|
+
const unsubscribes = sources.map((source) => source.subscribe(listener))
|
|
95
|
+
return () => {
|
|
96
|
+
for (const unsubscribe of unsubscribes) unsubscribe()
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return { snapshot, keyOf, subscribe }
|
|
100
|
+
// Yielding each `input.store` (a `Context.Tag<Store<unknown>>`) widens the
|
|
101
|
+
// inferred requirement to `Store<unknown>`; restate the precise per-input
|
|
102
|
+
// `InputStores<Inputs>` union the signature promises. This is the single
|
|
103
|
+
// place that cast lives — `Calc.live`/`AsyncCalc.live` inherit it.
|
|
104
|
+
}) as Effect.Effect<WiredSources<Inputs>, never, InputStores<Inputs>>
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { Effect } from 'effect'
|
|
1
|
+
import { Effect } from 'effect'
|
|
2
|
+
|
|
2
3
|
/**
|
|
3
4
|
* Module-load registry of the DI tag identifiers minted by `State.make` /
|
|
4
5
|
* `StateFamily.make`. Two distinct definitions that claim one identifier (e.g.
|
|
@@ -13,16 +14,19 @@ import { Effect } from 'effect';
|
|
|
13
14
|
* In-group clashes (where every member is addressable by name) are a harder error
|
|
14
15
|
* — see `StateGroup.make`, which throws.
|
|
15
16
|
*/
|
|
16
|
-
const claimed = new Set()
|
|
17
|
-
const warned = new Set()
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
17
|
+
const claimed = new Set<string>()
|
|
18
|
+
const warned = new Set<string>()
|
|
19
|
+
|
|
20
|
+
export const claimStateTag = (identifier: string): void => {
|
|
21
|
+
if (!claimed.has(identifier)) {
|
|
22
|
+
claimed.add(identifier)
|
|
23
|
+
return
|
|
24
|
+
}
|
|
25
|
+
if (warned.has(identifier)) return
|
|
26
|
+
warned.add(identifier)
|
|
27
|
+
Effect.runSync(
|
|
28
|
+
Effect.logWarning(
|
|
29
|
+
`reform: duplicate state tag '${identifier}' — two definitions collide and the last merged wins; namespace the name per feature`,
|
|
30
|
+
),
|
|
31
|
+
)
|
|
32
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { expect, test } from 'vitest'
|
|
2
|
+
import { makeStore } from './store'
|
|
3
|
+
|
|
4
|
+
const tick = () => new Promise<void>((resolve) => queueMicrotask(resolve))
|
|
5
|
+
|
|
6
|
+
// A const counter we increment in place — subscriber callbacks need somewhere to
|
|
7
|
+
// tally without a reassigned `let`.
|
|
8
|
+
const counter = () => ({ n: 0 })
|
|
9
|
+
|
|
10
|
+
test('subscribe observes changes; getSnapshot is always current', async () => {
|
|
11
|
+
const store = makeStore(0)
|
|
12
|
+
const notifications = counter()
|
|
13
|
+
store.subscribe(() => {
|
|
14
|
+
notifications.n++
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
store.set(1)
|
|
18
|
+
expect(store.getSnapshot()).toBe(1) // snapshot is synchronous
|
|
19
|
+
await tick()
|
|
20
|
+
expect(notifications.n).toBe(1)
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
test('a no-op write notifies no one', async () => {
|
|
24
|
+
const store = makeStore('same')
|
|
25
|
+
const notifications = counter()
|
|
26
|
+
store.subscribe(() => {
|
|
27
|
+
notifications.n++
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
store.set('same')
|
|
31
|
+
await tick()
|
|
32
|
+
expect(notifications.n).toBe(0)
|
|
33
|
+
expect(store.getSnapshot()).toBe('same')
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
test('a batch of writes coalesces into a single notification per microtask', async () => {
|
|
37
|
+
const store = makeStore(0)
|
|
38
|
+
const notifications = counter()
|
|
39
|
+
store.subscribe(() => {
|
|
40
|
+
notifications.n++
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
store.set(1)
|
|
44
|
+
store.set(2)
|
|
45
|
+
store.set(3)
|
|
46
|
+
expect(store.getSnapshot()).toBe(3)
|
|
47
|
+
await tick()
|
|
48
|
+
expect(notifications.n).toBe(1)
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
test('stores are independent — one slice does not wake another', async () => {
|
|
52
|
+
const a = makeStore(0)
|
|
53
|
+
const b = makeStore(0)
|
|
54
|
+
const aWakes = counter()
|
|
55
|
+
const bWakes = counter()
|
|
56
|
+
a.subscribe(() => {
|
|
57
|
+
aWakes.n++
|
|
58
|
+
})
|
|
59
|
+
b.subscribe(() => {
|
|
60
|
+
bWakes.n++
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
a.set(1)
|
|
64
|
+
await tick()
|
|
65
|
+
expect(aWakes.n).toBe(1)
|
|
66
|
+
expect(bWakes.n).toBe(0)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
test('unsubscribe stops notifications', async () => {
|
|
70
|
+
const store = makeStore(0)
|
|
71
|
+
const notifications = counter()
|
|
72
|
+
const unsubscribe = store.subscribe(() => {
|
|
73
|
+
notifications.n++
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
unsubscribe()
|
|
77
|
+
store.set(1)
|
|
78
|
+
await tick()
|
|
79
|
+
expect(notifications.n).toBe(0)
|
|
80
|
+
})
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { Equal, MutableRef } from 'effect'
|
|
2
|
+
import { type Inspectable, inspectable } from './inspect'
|
|
3
|
+
import { defaultScheduler, type Scheduler } from './scheduler'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A reactive cell living outside Effect. The single source of truth for one
|
|
7
|
+
* slice of state. `set` is internal: only the reduce loop ever holds a Store
|
|
8
|
+
* (reducers return values, the loop writes them), which is how "state changes
|
|
9
|
+
* only in the reduce loop" is enforced structurally — there is no public writer.
|
|
10
|
+
*
|
|
11
|
+
* This is the synchronous, React-facing analog of Effect's `SubscriptionRef`
|
|
12
|
+
* (a `Ref` + `PubSub` + a `changes` `Stream`). We don't use `SubscriptionRef`
|
|
13
|
+
* directly because `useSyncExternalStore` requires a *synchronous* `getSnapshot`
|
|
14
|
+
* and a *synchronous* `subscribe` returning an unsubscribe thunk, whereas
|
|
15
|
+
* `SubscriptionRef.get` is an `Effect` and its `changes` is a `Stream` (a fiber
|
|
16
|
+
* per subscription). Mutable state is held in a `MutableRef` (Effect's mutable
|
|
17
|
+
* cell), so there is no reassigned binding.
|
|
18
|
+
*
|
|
19
|
+
* `getSnapshot` + `subscribe` are the stable pair the React bridge binds to;
|
|
20
|
+
* `getSnapshot` returns the same reference until the value actually changes.
|
|
21
|
+
* `getVersion` returns a monotonic counter bumped on every real change — the
|
|
22
|
+
* host sums it across a render's tracked stores for a `useSyncExternalStore`
|
|
23
|
+
* snapshot, which closes the tear/dropped-update window a value snapshot can't.
|
|
24
|
+
*/
|
|
25
|
+
export interface Store<in out A> extends Inspectable {
|
|
26
|
+
get(): A
|
|
27
|
+
set(value: A): void
|
|
28
|
+
getSnapshot(): A
|
|
29
|
+
getVersion(): number
|
|
30
|
+
subscribe(listener: () => void): () => void
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export const makeStore = <A>(initial: A, scheduler: Scheduler = defaultScheduler): Store<A> => {
|
|
34
|
+
// The store is a mutable reactive slot by design, so its value lives in a
|
|
35
|
+
// `MutableRef` we update in place (no reassigned binding).
|
|
36
|
+
const value = MutableRef.make(initial)
|
|
37
|
+
const version = MutableRef.make(0)
|
|
38
|
+
const listeners = new Set<() => void>()
|
|
39
|
+
return {
|
|
40
|
+
get: () => MutableRef.get(value),
|
|
41
|
+
getSnapshot: () => MutableRef.get(value),
|
|
42
|
+
getVersion: () => MutableRef.get(version),
|
|
43
|
+
set: (next) => {
|
|
44
|
+
if (Equal.equals(MutableRef.get(value), next)) return
|
|
45
|
+
MutableRef.set(value, next)
|
|
46
|
+
MutableRef.update(version, (n) => n + 1)
|
|
47
|
+
scheduler.schedule(listeners)
|
|
48
|
+
},
|
|
49
|
+
subscribe: (listener) => {
|
|
50
|
+
listeners.add(listener)
|
|
51
|
+
return () => {
|
|
52
|
+
listeners.delete(listener)
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
...inspectable(() => ({ _id: 'reform/Store', value: MutableRef.get(value) })),
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* A read-only `Store` whose value is computed from upstream sources.
|
|
61
|
+
*
|
|
62
|
+
* Reads are pull-fresh: `get`/`getSnapshot` call `compute` directly, so a
|
|
63
|
+
* headless snapshot always reflects the current source state without waiting for
|
|
64
|
+
* a notification (`compute` must memoize so an unchanged read returns a stable
|
|
65
|
+
* reference — `useSyncExternalStore` requires it). *Notifications*, by contrast,
|
|
66
|
+
* run through the same coalescing scheduler as `makeStore`: an upstream change
|
|
67
|
+
* recomputes once and wakes subscribers only when the output actually moves (by
|
|
68
|
+
* `Equal.equals`), so every store kind shares one notification timing. `set` is
|
|
69
|
+
* a no-op — the only writer is the upstream subscription.
|
|
70
|
+
*/
|
|
71
|
+
export const makeDerivedStore = <A>(
|
|
72
|
+
compute: () => A,
|
|
73
|
+
subscribeUpstream: (onChange: () => void) => () => void,
|
|
74
|
+
scheduler: Scheduler = defaultScheduler,
|
|
75
|
+
): { readonly store: Store<A>; readonly unsubscribe: () => void } => {
|
|
76
|
+
const listeners = new Set<() => void>()
|
|
77
|
+
const version = MutableRef.make(0)
|
|
78
|
+
// The last output we notified against — tracked only here, never touched by
|
|
79
|
+
// reads, so a `get` racing ahead of `onChange` can't suppress a notification.
|
|
80
|
+
const seen = MutableRef.make<{ readonly value: A } | undefined>({ value: compute() })
|
|
81
|
+
const onChange = () => {
|
|
82
|
+
const previous = MutableRef.get(seen)
|
|
83
|
+
const next = compute()
|
|
84
|
+
MutableRef.set(seen, { value: next })
|
|
85
|
+
if (previous === undefined || !Equal.equals(previous.value, next)) {
|
|
86
|
+
MutableRef.update(version, (n) => n + 1)
|
|
87
|
+
scheduler.schedule(listeners)
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
const unsubscribe = subscribeUpstream(onChange)
|
|
91
|
+
const store: Store<A> = {
|
|
92
|
+
get: compute,
|
|
93
|
+
getSnapshot: compute,
|
|
94
|
+
getVersion: () => MutableRef.get(version),
|
|
95
|
+
set: () => {},
|
|
96
|
+
subscribe: (listener) => {
|
|
97
|
+
listeners.add(listener)
|
|
98
|
+
return () => {
|
|
99
|
+
listeners.delete(listener)
|
|
100
|
+
}
|
|
101
|
+
},
|
|
102
|
+
...inspectable(() => ({ _id: 'reform/Store', value: compute() })),
|
|
103
|
+
}
|
|
104
|
+
return { store, unsubscribe }
|
|
105
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { Context, Effect } from 'effect'
|
|
2
|
-
import type { Store } from './store'
|
|
1
|
+
import { Context, Effect, Option } from 'effect'
|
|
2
|
+
import type { Store } from './store'
|
|
3
|
+
|
|
3
4
|
/**
|
|
4
5
|
* Anything a render can depend on: a source it can subscribe to for changes and
|
|
5
6
|
* snapshot a monotonic version from. `Store` (and the calc/family stores)
|
|
@@ -9,25 +10,30 @@ import type { Store } from './store';
|
|
|
9
10
|
* window a per-store value snapshot cannot.
|
|
10
11
|
*/
|
|
11
12
|
export interface Subscribable {
|
|
12
|
-
|
|
13
|
-
|
|
13
|
+
subscribe(listener: () => void): () => void
|
|
14
|
+
getVersion(): number
|
|
14
15
|
}
|
|
16
|
+
|
|
15
17
|
/** A per-render dependency sink. The renderer provides one; reads record into it. */
|
|
16
18
|
export interface Tracker {
|
|
17
|
-
|
|
19
|
+
add(source: Subscribable): void
|
|
18
20
|
}
|
|
21
|
+
|
|
19
22
|
/**
|
|
20
23
|
* The active render's dependency tracker (D5). Present only while a composition
|
|
21
24
|
* is rendering under `@reform/react`; absent in procedures and headless reads.
|
|
22
25
|
*/
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
}
|
|
26
|
+
const CurrentTrackerBase: Context.TagClass<CurrentTracker, 'reform/Tracker', Tracker> =
|
|
27
|
+
Context.Tag('reform/Tracker')<CurrentTracker, Tracker>()
|
|
28
|
+
export class CurrentTracker extends CurrentTrackerBase {}
|
|
29
|
+
|
|
26
30
|
/**
|
|
27
31
|
* Read a store's current snapshot and, if a tracker is active, record the store
|
|
28
32
|
* as a dependency of the current render. One read API, two behaviors by context
|
|
29
33
|
* (D5): inside a render it subscribes the slice; elsewhere it just snapshots.
|
|
30
34
|
*/
|
|
31
|
-
export
|
|
32
|
-
|
|
33
|
-
|
|
35
|
+
export const readTracked = <A>(store: Store<A>): Effect.Effect<A> =>
|
|
36
|
+
Effect.map(Effect.serviceOption(CurrentTracker), (tracker) => {
|
|
37
|
+
if (Option.isSome(tracker)) tracker.value.add(store)
|
|
38
|
+
return store.getSnapshot()
|
|
39
|
+
})
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { Effect, Layer } from 'effect'
|
|
2
|
+
import type { YieldWrap } from 'effect/Utils'
|
|
3
|
+
import { type ChannelClass, type ProcedureEntry, Procedures } from '../channel/channel'
|
|
4
|
+
import { type Manifest, definitionClass } from '../definition/definition'
|
|
5
|
+
import { type AnyEvent, type EventType } from '../event/event'
|
|
6
|
+
import { type Ctx } from '../internal/ctx'
|
|
7
|
+
import { Bus, narrowHandled } from '../runtime/bus'
|
|
8
|
+
|
|
9
|
+
/** The reflectable descriptor a procedure carries. */
|
|
10
|
+
export interface ProcedureManifest extends Manifest {
|
|
11
|
+
readonly kind: 'Procedure'
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface ProcedureClass<Events extends ReadonlyArray<AnyEvent>> {
|
|
15
|
+
new (): {}
|
|
16
|
+
readonly manifest: ProcedureManifest
|
|
17
|
+
/** The trigger events (marker data; read by `Procedure.live`). */
|
|
18
|
+
readonly events: Events
|
|
19
|
+
/** The channel this procedure's runs are scheduled on (marker data). */
|
|
20
|
+
readonly channel: ChannelClass
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface ProcedureConfig<Events extends ReadonlyArray<AnyEvent>> {
|
|
24
|
+
readonly events: Events
|
|
25
|
+
/** The scheduling lane. Replaces the old `concurrency` option. */
|
|
26
|
+
readonly channel: ChannelClass
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type AnyProcedure = ProcedureClass<ReadonlyArray<AnyEvent>>
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* A cross-event-loop flow. The *definition* declares its trigger events and the
|
|
33
|
+
* channel it runs on; the *implementation* is the effectful body. Procedures no
|
|
34
|
+
* longer subscribe to the bus themselves — the single drain loop routes events
|
|
35
|
+
* to the channel, which runs the registered bodies under its policy.
|
|
36
|
+
*/
|
|
37
|
+
export const make = <const Events extends ReadonlyArray<AnyEvent>>(
|
|
38
|
+
name: string,
|
|
39
|
+
config: ProcedureConfig<Events>,
|
|
40
|
+
): ProcedureClass<Events> =>
|
|
41
|
+
definitionClass<ProcedureClass<Events>>({
|
|
42
|
+
manifest: { kind: 'Procedure' as const, name } satisfies ProcedureManifest,
|
|
43
|
+
events: config.events,
|
|
44
|
+
channel: config.channel,
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Register the flow body into its channel. The body may read state and dispatch
|
|
49
|
+
* events, but never writes state (the core invariant); its context requirements
|
|
50
|
+
* (RPC clients, etc.) are captured here so the channel fiber can run it with no
|
|
51
|
+
* outstanding R.
|
|
52
|
+
*/
|
|
53
|
+
export const live = <
|
|
54
|
+
Events extends ReadonlyArray<AnyEvent>,
|
|
55
|
+
Eff extends YieldWrap<Effect.Effect<any, any, any>>,
|
|
56
|
+
Done,
|
|
57
|
+
>(
|
|
58
|
+
procedure: ProcedureClass<Events>,
|
|
59
|
+
body: (event: EventType<Events[number]>) => Generator<Eff, Done, never>,
|
|
60
|
+
): Layer.Layer<never, never, Procedures | Bus | Ctx<Eff>> => {
|
|
61
|
+
const name = procedure.manifest.name
|
|
62
|
+
const channelName = procedure.channel.manifest.name
|
|
63
|
+
const handles = new Set(procedure.events.map((e) => e.tag))
|
|
64
|
+
// Scoped registration (see `Reducer.live`): an eager procedure's scope is the
|
|
65
|
+
// root runtime's, so behavior is unchanged; a lazy feature's procedure registers
|
|
66
|
+
// on mount and unregisters on unmount, so it stops running and is reclaimed.
|
|
67
|
+
return Layer.scopedDiscard(
|
|
68
|
+
Effect.gen(function* () {
|
|
69
|
+
const procedures = yield* Procedures
|
|
70
|
+
if (procedures.entries.some((e) => e.name === name)) {
|
|
71
|
+
yield* Effect.logWarning(`reform: duplicate procedure name '${name}' registered`)
|
|
72
|
+
}
|
|
73
|
+
// Snapshot the body's full context (Bus + RPC clients) so `run` is total.
|
|
74
|
+
const runtime = yield* Effect.runtime<Bus | Ctx<Eff>>()
|
|
75
|
+
const entry: ProcedureEntry = {
|
|
76
|
+
name,
|
|
77
|
+
channelName,
|
|
78
|
+
// The channel only routes events whose tag is in `handles`, so at runtime
|
|
79
|
+
// `event` is always one of `Events`; `narrowHandled` restores the body's
|
|
80
|
+
// declared event union from the erased `Tagged` envelope.
|
|
81
|
+
handles,
|
|
82
|
+
run: (event) => Effect.provide(Effect.gen(() => body(narrowHandled(event))), runtime),
|
|
83
|
+
}
|
|
84
|
+
yield* Effect.acquireRelease(Effect.sync(() => procedures.register(entry)), () =>
|
|
85
|
+
Effect.sync(() => procedures.unregister(entry)),
|
|
86
|
+
)
|
|
87
|
+
}),
|
|
88
|
+
)
|
|
89
|
+
}
|