@playfast/reform 1.0.1 → 1.1.1
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 +18 -1
- package/package.json +1 -1
- package/src/boundary/boundary.test.ts +1 -33
- package/src/boundary/boundary.ts +4 -82
- package/src/calc/asyncCalc.invalidate.test.ts +2 -14
- package/src/calc/asyncCalc.test.ts +0 -38
- package/src/calc/asyncCalc.ts +11 -163
- package/src/calc/asyncCalcDefinitions.ts +48 -0
- package/src/calc/asyncData.ts +0 -37
- package/src/calc/calc.test.ts +4 -28
- package/src/calc/calc.ts +6 -46
- package/src/calc/calcFamily.test.ts +0 -12
- package/src/calc/calcFamily.ts +6 -48
- package/src/calc/compose.test.ts +1 -12
- package/src/calc/compose.ts +0 -22
- package/src/calc/queryState.ts +0 -23
- package/src/channel/channel.ts +72 -174
- package/src/channel/procedureRegistry.ts +90 -0
- package/src/compose/composition.ts +7 -67
- package/src/compose/host.ts +0 -6
- package/src/compose/props.ts +3 -9
- package/src/compose/provide.ts +19 -33
- package/src/compose/slot.ts +2 -30
- package/src/compose/structure.test.ts +0 -6
- package/src/compose/structure.ts +3 -67
- package/src/compose/ui.test.ts +0 -4
- package/src/compose/ui.ts +17 -111
- package/src/compose/ui.typecheck.ts +0 -14
- package/src/definition/definition.ts +0 -32
- package/src/event/event.test.ts +0 -3
- package/src/event/event.ts +5 -14
- package/src/event/eventGroup.ts +0 -1
- package/src/feature/feature.mount.test.ts +66 -54
- package/src/feature/feature.test.ts +67 -46
- package/src/feature/feature.ts +185 -214
- package/src/feature/feature.typecheck.ts +29 -16
- package/src/feature/featureBinding.ts +48 -0
- package/src/index.ts +245 -229
- package/src/internal/capture.ts +0 -20
- package/src/internal/ctx.ts +2 -9
- package/src/internal/env.ts +9 -0
- package/src/internal/errors.test.ts +0 -6
- package/src/internal/errors.ts +14 -48
- package/src/internal/inspect.test.ts +0 -6
- package/src/internal/inspect.ts +0 -12
- package/src/internal/queryDriver.ts +42 -243
- package/src/internal/queryDriverStore.ts +153 -0
- package/src/internal/queryDriverTypes.ts +57 -0
- package/src/internal/queryEvents.ts +0 -12
- package/src/internal/queryStore.ts +0 -14
- package/src/internal/reuse.test.ts +0 -11
- package/src/internal/reuse.ts +0 -28
- package/src/internal/scheduler.ts +0 -43
- package/src/internal/seeds.ts +0 -14
- package/src/internal/sources.ts +4 -41
- package/src/internal/stateRegistry.ts +0 -14
- package/src/internal/store.test.ts +1 -3
- package/src/internal/store.ts +0 -37
- package/src/internal/track.ts +0 -18
- package/src/internal/variance.ts +5 -0
- package/src/namespace/namespace.test.ts +46 -0
- package/src/namespace/namespace.ts +85 -0
- package/src/procedure/procedure.ts +2 -24
- package/src/reducer/reducer.ts +3 -21
- package/src/remote/remoteState.test.ts +92 -75
- package/src/remote/remoteState.ts +99 -486
- package/src/remote/remoteState.typecheck.ts +0 -33
- package/src/remote/remoteStateDefinition.ts +135 -0
- package/src/remote/remoteStateLayers.ts +118 -0
- package/src/remote/remoteStateLiveTypes.ts +59 -0
- package/src/remote/remoteStateSend.ts +64 -0
- package/src/runtime/appRuntime.test.ts +197 -0
- package/src/runtime/appRuntime.ts +40 -83
- package/src/runtime/bus.ts +2 -15
- package/src/runtime/eventBudget.test.ts +155 -0
- package/src/runtime/eventBudget.ts +119 -0
- package/src/runtime/hardening.test.ts +74 -9
- package/src/runtime/instrumentation.test.ts +213 -192
- package/src/runtime/instrumentation.ts +0 -49
- package/src/runtime/loop.test.ts +0 -17
- package/src/runtime/loop.ts +25 -55
- package/src/runtime/queries.ts +0 -17
- package/src/scene/featureScene.test.ts +59 -0
- package/src/scene/scene.ts +32 -59
- package/src/scene/seedScene.test.ts +96 -116
- package/src/state/state.ts +7 -26
- package/src/state/stateFamily.test.ts +2 -11
- package/src/state/stateFamily.ts +9 -52
- package/src/state/stateGroup.ts +2 -49
- package/src/state/token.ts +4 -17
- package/src/synced/syncedStore.ts +7 -42
- package/src/ui/node.ts +0 -6
- package/src/ui/trigger.ts +0 -5
- package/src/wire/tree.test.ts +0 -3
- package/src/wire/tree.ts +0 -39
- package/src/wire/triggers.test.ts +0 -4
- package/src/wire/triggers.ts +5 -27
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { Effect, Layer,
|
|
2
|
-
import { expect,
|
|
1
|
+
import { Effect, Layer, Schema as S } from "effect";
|
|
2
|
+
import { expect, it } from "@effect/vitest";
|
|
3
3
|
import {
|
|
4
4
|
Composition,
|
|
5
5
|
Engine,
|
|
@@ -17,93 +17,84 @@ import {
|
|
|
17
17
|
StateGroup,
|
|
18
18
|
Ui,
|
|
19
19
|
ui,
|
|
20
|
-
} from
|
|
21
|
-
import { CurrentSeedOverrides } from
|
|
20
|
+
} from "../index";
|
|
21
|
+
import { CurrentSeedOverrides } from "../internal/seeds";
|
|
22
22
|
|
|
23
|
-
|
|
24
|
-
// `count: 1` itself (no open store), and the overlay must reach the nested
|
|
25
|
-
// `State.live` through `Layer.locally(CurrentSeedOverrides, …)` on the scene's
|
|
26
|
-
// pre-composed layers. The harness mirrors the dev-tool preview: merge the
|
|
27
|
-
// scene's `provide`, mount it in a ManagedRuntime, and read the root
|
|
28
|
-
// composition's rendered props through the headless Structure.
|
|
29
|
-
|
|
30
|
-
class CountState extends State.make('count', S.Number) {}
|
|
23
|
+
class CountState extends State.make("count", S.Number) {}
|
|
31
24
|
class MiniStates extends StateGroup.make(CountState) {}
|
|
32
25
|
|
|
33
|
-
class Bumped extends Event.make(
|
|
26
|
+
class Bumped extends Event.make("Bumped", S.Struct({})) {}
|
|
34
27
|
|
|
35
|
-
class BumpReducer extends Reducer.make(
|
|
28
|
+
class BumpReducer extends Reducer.make("BumpReducer", {
|
|
36
29
|
states: [CountState],
|
|
37
30
|
events: [Bumped],
|
|
38
31
|
}) {}
|
|
39
|
-
const BumpReducerLive = Reducer.live(BumpReducer, (n) => n + 1)
|
|
32
|
+
const BumpReducerLive = Reducer.live(BumpReducer, (n) => n + 1);
|
|
40
33
|
|
|
41
|
-
class CounterUi extends ui(
|
|
42
|
-
class Counter extends Composition.make(
|
|
43
|
-
title:
|
|
34
|
+
class CounterUi extends ui("Counter")<{ props: { count: number } }>() {}
|
|
35
|
+
class Counter extends Composition.make("Counter", {
|
|
36
|
+
title: "Counter",
|
|
44
37
|
states: [MiniStates],
|
|
45
38
|
events: [Bumped],
|
|
46
39
|
ui: CounterUi,
|
|
47
40
|
}) {}
|
|
48
41
|
const CounterLive = Composition.live(Counter, function* () {
|
|
49
|
-
const count = yield* StateGroup.select(MiniStates,
|
|
50
|
-
return mount({ props: { count }, slots: {} })
|
|
51
|
-
})
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
42
|
+
const count = yield* StateGroup.select(MiniStates, "count");
|
|
43
|
+
return mount({ props: { count }, slots: {} });
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const presentations = provide(
|
|
47
|
+
CounterUi,
|
|
48
|
+
Ui.make(CounterUi, () => null),
|
|
49
|
+
);
|
|
50
|
+
const Views = CounterLive.pipe(Layer.provideMerge(presentations));
|
|
57
51
|
const Logic = Layer.mergeAll(BumpReducerLive).pipe(
|
|
58
|
-
Layer.provideMerge(
|
|
59
|
-
)
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
52
|
+
Layer.provideMerge(
|
|
53
|
+
Layer.mergeAll(Engine, StateGroup.live(MiniStates, { count: 1 })),
|
|
54
|
+
),
|
|
55
|
+
);
|
|
56
|
+
const MiniApp = Views.pipe(Layer.provideMerge(Logic));
|
|
57
|
+
|
|
58
|
+
const CounterScene = scene(Counter, { provide: [MiniApp] });
|
|
59
|
+
|
|
60
|
+
const overlayRawSeeds = (
|
|
61
|
+
base: Scene,
|
|
62
|
+
seeds: Readonly<Record<string, unknown>>,
|
|
63
|
+
): Scene => ({
|
|
70
64
|
...base,
|
|
71
65
|
provide: base.provide.map(Layer.locally(CurrentSeedOverrides, seeds)),
|
|
72
|
-
})
|
|
66
|
+
});
|
|
73
67
|
|
|
74
68
|
const headlessEnv: RenderEnv = {
|
|
75
69
|
props: {},
|
|
76
70
|
tracker: { add: () => {} },
|
|
77
|
-
}
|
|
71
|
+
};
|
|
78
72
|
|
|
79
73
|
const isCountProps = (props: unknown): props is { readonly count: number } =>
|
|
80
|
-
typeof props ===
|
|
74
|
+
typeof props === "object" &&
|
|
81
75
|
props !== null &&
|
|
82
|
-
|
|
83
|
-
typeof props.count ===
|
|
76
|
+
"count" in props &&
|
|
77
|
+
typeof props.count === "number";
|
|
84
78
|
|
|
85
|
-
// One ManagedRuntime per scene under test. Each `read` re-renders the root
|
|
86
|
-
// composition and reads the Structure built from the stores that runtime actually
|
|
87
|
-
// constructed.
|
|
88
79
|
const makeHarness = (s: Scene) => {
|
|
89
|
-
const layer = s.provide.reduce((a, b) => Layer.merge(a, b))
|
|
90
|
-
const runtime = ManagedRuntime.make(layer)
|
|
80
|
+
const layer = s.provide.reduce((a, b) => Layer.merge(a, b));
|
|
91
81
|
const read = Effect.flatMap(Counter.tag, (c) =>
|
|
92
82
|
Composition.render(c, headlessEnv).pipe(
|
|
93
83
|
Effect.flatMap((frame) => {
|
|
94
84
|
if (!isStructure(frame)) {
|
|
95
|
-
return Effect.dieMessage(
|
|
85
|
+
return Effect.dieMessage("Counter must render a Structure");
|
|
96
86
|
}
|
|
97
87
|
return isCountProps(frame.props)
|
|
98
88
|
? Effect.succeed(frame.props.count)
|
|
99
|
-
: Effect.dieMessage(
|
|
89
|
+
: Effect.dieMessage(
|
|
90
|
+
"Counter Structure props must include numeric count",
|
|
91
|
+
);
|
|
100
92
|
}),
|
|
101
93
|
),
|
|
102
|
-
)
|
|
103
|
-
return {
|
|
104
|
-
}
|
|
94
|
+
);
|
|
95
|
+
return { layer, read };
|
|
96
|
+
};
|
|
105
97
|
|
|
106
|
-
// Drive a read until a predicate holds, handing forked fibers the scheduler.
|
|
107
98
|
const waitUntil = <A, R>(
|
|
108
99
|
read: Effect.Effect<A, never, R>,
|
|
109
100
|
pred: (a: A) => boolean,
|
|
@@ -112,65 +103,54 @@ const waitUntil = <A, R>(
|
|
|
112
103
|
Effect.flatMap(read, (a) =>
|
|
113
104
|
pred(a) || rounds <= 0
|
|
114
105
|
? Effect.succeed(a)
|
|
115
|
-
: Effect.flatMap(Effect.yieldNow(), () =>
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
}
|
|
125
|
-
})
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
const {
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
})
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
})
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
await seeded.runtime.dispose()
|
|
167
|
-
}
|
|
168
|
-
// Building the ORIGINAL scene's layers again boots from the authored seed —
|
|
169
|
-
// the overlay lives only on the seeded variant's wrapped layers.
|
|
170
|
-
const original = makeHarness(CounterScene)
|
|
171
|
-
try {
|
|
172
|
-
expect(await original.runtime.runPromise(original.read)).toBe(1)
|
|
173
|
-
} finally {
|
|
174
|
-
await original.runtime.dispose()
|
|
175
|
-
}
|
|
176
|
-
})
|
|
106
|
+
: Effect.flatMap(Effect.yieldNow(), () =>
|
|
107
|
+
waitUntil(read, pred, rounds - 1),
|
|
108
|
+
),
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
it.scopedLive("a seeded scene boots its store from the override", () => {
|
|
112
|
+
const { layer, read } = makeHarness(seedScene(CounterScene, { count: 5 }));
|
|
113
|
+
return Effect.gen(function* () {
|
|
114
|
+
expect(yield* read).toBe(5);
|
|
115
|
+
}).pipe(Effect.provide(layer));
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it.scopedLive("the live reducer drives the SAME overridden store", () => {
|
|
119
|
+
const { layer, read } = makeHarness(seedScene(CounterScene, { count: 5 }));
|
|
120
|
+
return Effect.gen(function* () {
|
|
121
|
+
expect(yield* read).toBe(5);
|
|
122
|
+
yield* publish("High", Event.construct(Bumped, {}));
|
|
123
|
+
const next = yield* waitUntil(read, (n) => n === 6);
|
|
124
|
+
expect(next).toBe(6);
|
|
125
|
+
}).pipe(Effect.provide(layer));
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it.scopedLive("an absent key leaves the authored seed in place", () => {
|
|
129
|
+
const { layer, read } = makeHarness(
|
|
130
|
+
overlayRawSeeds(CounterScene, { other: 1 }),
|
|
131
|
+
);
|
|
132
|
+
return Effect.gen(function* () {
|
|
133
|
+
expect(yield* read).toBe(1);
|
|
134
|
+
}).pipe(Effect.provide(layer));
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it.scopedLive(
|
|
138
|
+
"a schema-invalid override falls back to the authored seed",
|
|
139
|
+
() => {
|
|
140
|
+
const { layer, read } = makeHarness(
|
|
141
|
+
overlayRawSeeds(CounterScene, { count: "not-a-number" }),
|
|
142
|
+
);
|
|
143
|
+
return Effect.gen(function* () {
|
|
144
|
+
expect(yield* read).toBe(1);
|
|
145
|
+
}).pipe(Effect.provide(layer));
|
|
146
|
+
},
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
it.scopedLive("seeding does not leak into the original scene", () => {
|
|
150
|
+
const seeded = makeHarness(seedScene(CounterScene, { count: 5 }));
|
|
151
|
+
const original = makeHarness(CounterScene);
|
|
152
|
+
return Effect.gen(function* () {
|
|
153
|
+
expect(yield* seeded.read.pipe(Effect.provide(seeded.layer))).toBe(5);
|
|
154
|
+
expect(yield* original.read.pipe(Effect.provide(original.layer))).toBe(1);
|
|
155
|
+
});
|
|
156
|
+
});
|
package/src/state/state.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Context, Effect, FiberRef, Layer, Option, Schema } from 'effect'
|
|
2
|
+
import type { Effect as EffectType } from 'effect/Effect'
|
|
2
3
|
import { type Manifest, yieldableClass } from '../definition/definition'
|
|
3
4
|
import { resolveScheduler } from '../internal/scheduler'
|
|
4
5
|
import { CurrentSeedOverrides } from '../internal/seeds'
|
|
@@ -6,6 +7,7 @@ import { resolveInstrumentation, stateUpdateHook } from '../runtime/instrumentat
|
|
|
6
7
|
import { claimStateTag } from '../internal/stateRegistry'
|
|
7
8
|
import { makeStore, type Store } from '../internal/store'
|
|
8
9
|
import { readTracked } from '../internal/track'
|
|
10
|
+
import type { AnySchema, AnyValue } from '../internal/variance'
|
|
9
11
|
|
|
10
12
|
export interface StateOptionsExternalApi {
|
|
11
13
|
readonly title?: string
|
|
@@ -16,32 +18,25 @@ export type StateOptions = StateOptionsExternalApi
|
|
|
16
18
|
export interface StateManifestExternalApi<N extends string, A> extends Manifest {
|
|
17
19
|
readonly kind: 'State'
|
|
18
20
|
readonly name: N
|
|
19
|
-
readonly schema:
|
|
21
|
+
readonly schema: AnySchema<A>
|
|
20
22
|
readonly title?: string
|
|
21
23
|
readonly description?: string
|
|
22
24
|
}
|
|
23
25
|
export type StateManifest<N extends string, A> = StateManifestExternalApi<N, A>
|
|
24
26
|
|
|
25
|
-
export interface StateClass<out N extends string, in out A> extends
|
|
27
|
+
export interface StateClass<out N extends string, in out A> extends EffectType<A, never, Store<A>> {
|
|
26
28
|
new (): {}
|
|
27
29
|
readonly manifest: StateManifest<N, A>
|
|
28
|
-
/** Internal DI tag holding the live store. The loop reads/writes through it. */
|
|
29
30
|
readonly store: Context.Tag<Store<A>, Store<A>>
|
|
30
31
|
}
|
|
31
32
|
|
|
32
|
-
export type AnyState = StateClass<string,
|
|
33
|
+
export type AnyState = StateClass<string, AnyValue>
|
|
33
34
|
export type StateValue<S> = S extends StateClass<string, infer A> ? A : never
|
|
34
|
-
export type StateName<S> = S extends StateClass<infer N,
|
|
35
|
+
export type StateName<S> = S extends StateClass<infer N, AnyValue> ? N : never
|
|
35
36
|
|
|
36
|
-
/**
|
|
37
|
-
* Atomic reactive state. `State.make` is the *definition* (a reflectable
|
|
38
|
-
* manifest + an internal store tag); it carries no starting value — the seed is
|
|
39
|
-
* an implementation detail supplied at create-time by `State.live(state, seed)`.
|
|
40
|
-
* Composed into a `StateGroup` exactly as `@effect/rpc` composes rpcs.
|
|
41
|
-
*/
|
|
42
37
|
export const make = <const N extends string, A>(
|
|
43
38
|
name: N,
|
|
44
|
-
schema:
|
|
39
|
+
schema: AnySchema<A>,
|
|
45
40
|
options: StateOptions = {},
|
|
46
41
|
): StateClass<N, A> => {
|
|
47
42
|
const identifier = `reform/state/${name}`
|
|
@@ -58,15 +53,6 @@ export const make = <const N extends string, A>(
|
|
|
58
53
|
return yieldableClass(read, { manifest, store })
|
|
59
54
|
}
|
|
60
55
|
|
|
61
|
-
/**
|
|
62
|
-
* Resolve the value a store should boot from: the ambient seed override when
|
|
63
|
-
* one is present (and valid) for this state's member name, else the authored
|
|
64
|
-
* `initial`. Reads `CurrentSeedOverrides` — empty outside the tooling seam
|
|
65
|
-
* (`Scene.seedScene`), so production wiring always boots from `initial`. An
|
|
66
|
-
* override is decoded against the state's schema; an invalid value silently
|
|
67
|
-
* falls back to `initial` — a half-typed override from the dev tool must never
|
|
68
|
-
* crash a preview.
|
|
69
|
-
*/
|
|
70
56
|
const resolveSeed = <S extends AnyState>(
|
|
71
57
|
state: S,
|
|
72
58
|
initial: StateValue<S>,
|
|
@@ -79,11 +65,6 @@ const resolveSeed = <S extends AnyState>(
|
|
|
79
65
|
return Option.getOrElse(override, () => initial)
|
|
80
66
|
})
|
|
81
67
|
|
|
82
|
-
/**
|
|
83
|
-
* Allocate a state's store, seeded with `initial` — `State.live(Feed, seed)`.
|
|
84
|
-
* The seed lives here (implementation), not on the definition, so the same
|
|
85
|
-
* definition can boot from different values in app vs. test wiring.
|
|
86
|
-
*/
|
|
87
68
|
export const live = <S extends AnyState>(
|
|
88
69
|
state: S,
|
|
89
70
|
initial: StateValue<S>,
|
|
@@ -2,9 +2,6 @@ import { expect, it } from '@effect/vitest'
|
|
|
2
2
|
import { Duration, Effect, Layer, Schema as S } from 'effect'
|
|
3
3
|
import { Engine, Event, publish, Reducer, StateFamily } from '../index'
|
|
4
4
|
|
|
5
|
-
// A family is normalized keyed state: one logical slice per key, so a write to
|
|
6
|
-
// one key must wake only that key's subscribers (the point of the primitive).
|
|
7
|
-
|
|
8
5
|
const tick = Effect.sleep(Duration.millis(1))
|
|
9
6
|
|
|
10
7
|
it.live("writing one key does not notify another key's subscribers", () => {
|
|
@@ -53,7 +50,6 @@ it.live('forget(key) drops the store; re-reading the key reseeds it', () => {
|
|
|
53
50
|
expect(family.at('k').get()).toBe(42)
|
|
54
51
|
|
|
55
52
|
family.forget('k')
|
|
56
|
-
// A fresh store, reseeded to the family's initial value — not the old 42.
|
|
57
53
|
const reseeded = family.at('k')
|
|
58
54
|
expect(reseeded).not.toBe(first)
|
|
59
55
|
expect(reseeded.get()).toBe(0)
|
|
@@ -71,16 +67,13 @@ it.live('evictWhenUnused drops a key once its last subscriber leaves', () => {
|
|
|
71
67
|
const offB = store.subscribe(() => {})
|
|
72
68
|
expect(family.size()).toBe(1)
|
|
73
69
|
|
|
74
|
-
// One subscriber leaving keeps the key (another still reads it).
|
|
75
70
|
offA()
|
|
76
71
|
yield* tick
|
|
77
72
|
expect(family.size()).toBe(1)
|
|
78
73
|
|
|
79
|
-
// The last subscriber leaving evicts the key on the next microtask.
|
|
80
74
|
offB()
|
|
81
75
|
yield* tick
|
|
82
76
|
expect(family.size()).toBe(0)
|
|
83
|
-
// Re-reading reseeds a fresh store.
|
|
84
77
|
expect(family.at('k').get()).toBe(0)
|
|
85
78
|
}).pipe(Effect.provide(TestLayer))
|
|
86
79
|
})
|
|
@@ -94,10 +87,9 @@ it.live('evictWhenUnused: a same-tick re-subscribe cancels eviction', () => {
|
|
|
94
87
|
const store = family.at('k')
|
|
95
88
|
store.set(7)
|
|
96
89
|
const off = store.subscribe(() => {})
|
|
97
|
-
off()
|
|
98
|
-
store.subscribe(() => {})
|
|
90
|
+
off()
|
|
91
|
+
store.subscribe(() => {})
|
|
99
92
|
yield* tick
|
|
100
|
-
// Not evicted: the value survives and `at` returns the same live store.
|
|
101
93
|
expect(family.size()).toBe(1)
|
|
102
94
|
expect(family.at('k').get()).toBe(7)
|
|
103
95
|
}).pipe(Effect.provide(TestLayer))
|
|
@@ -128,7 +120,6 @@ it.live('a family reducer fold returning Tombstone evicts the key', () => {
|
|
|
128
120
|
const before = family.at('a')
|
|
129
121
|
expect(before.get()).toBe(1)
|
|
130
122
|
|
|
131
|
-
// The fold returns the eviction sentinel: the key's store is dropped.
|
|
132
123
|
yield* publish('High', Event.construct(Removed, { id: 'a' }))
|
|
133
124
|
yield* Effect.sleep(Duration.millis(5))
|
|
134
125
|
const after = family.at('a')
|
package/src/state/stateFamily.ts
CHANGED
|
@@ -1,48 +1,23 @@
|
|
|
1
|
-
import { Context, Effect, Layer, Option
|
|
1
|
+
import { Context, Effect, Layer, Option } from 'effect'
|
|
2
2
|
import { type Manifest, definitionClass } from '../definition/definition'
|
|
3
3
|
import { resolveScheduler, type Scheduler } from '../internal/scheduler'
|
|
4
4
|
import { claimStateTag } from '../internal/stateRegistry'
|
|
5
5
|
import { resolveInstrumentation, stateUpdateHook } from '../runtime/instrumentation'
|
|
6
6
|
import { makeStore, type Store } from '../internal/store'
|
|
7
7
|
import { readTracked } from '../internal/track'
|
|
8
|
+
import type { AnySchema, AnyValue } from '../internal/variance'
|
|
8
9
|
|
|
9
|
-
/**
|
|
10
|
-
* A reducer fold returns this to evict the key it folded over, instead of a new
|
|
11
|
-
* value. The loop drops the entry's store from the family, so a normalized
|
|
12
|
-
* collection (chat messages, search hits, transient rows) does not grow without
|
|
13
|
-
* bound — the one fix for the otherwise-unbounded per-key `Map`.
|
|
14
|
-
*/
|
|
15
10
|
export const Tombstone: unique symbol = Symbol.for('reform/StateFamily/Tombstone')
|
|
16
11
|
export type Tombstone = typeof Tombstone
|
|
17
12
|
|
|
18
|
-
/** Per-key stores, created lazily so a write to one key wakes only its subscribers. */
|
|
19
13
|
export interface FamilyStore<K, V> {
|
|
20
14
|
at(key: K): Store<V>
|
|
21
|
-
/**
|
|
22
|
-
* Drop a key's store so it stops occupying memory. A live subscriber keeps its
|
|
23
|
-
* own closure over the previous store, so eviction is for keys whose entity is
|
|
24
|
-
* gone (the component is unmounting) — re-`at`-ing the key allocates a fresh,
|
|
25
|
-
* reseeded store.
|
|
26
|
-
*/
|
|
27
15
|
forget(key: K): void
|
|
28
|
-
/** Drop every key's store — a wholesale reset of the family. */
|
|
29
16
|
clear(): void
|
|
30
|
-
/** Number of live keys — for tests/diagnostics. */
|
|
31
17
|
size(): number
|
|
32
18
|
}
|
|
33
19
|
|
|
34
|
-
/** Options for a family's keyed store. */
|
|
35
20
|
export interface FamilyOptionsExternalApi {
|
|
36
|
-
/**
|
|
37
|
-
* Drop a key's store automatically once it has no live subscribers (checked on
|
|
38
|
-
* the next microtask, so a same-commit re-subscribe — e.g. a key that just moved
|
|
39
|
-
* position in a list — cancels the eviction). This bounds an otherwise-unbounded
|
|
40
|
-
* family without explicit `Tombstone`s, but it also DISCARDS the key's value —
|
|
41
|
-
* re-`at`-ing a dropped key reseeds from `initial`. Use only for keys whose value
|
|
42
|
-
* is disposable when nothing renders them (search hits, transient rows); for a
|
|
43
|
-
* source-of-truth entity that may be temporarily off-screen (a virtualized list),
|
|
44
|
-
* keep this off and reclaim with an explicit reducer `Tombstone`. Default: off.
|
|
45
|
-
*/
|
|
46
21
|
readonly evictWhenUnused?: boolean
|
|
47
22
|
}
|
|
48
23
|
export type FamilyOptions = FamilyOptionsExternalApi
|
|
@@ -55,14 +30,10 @@ const makeFamilyStore = <K, V>(
|
|
|
55
30
|
): FamilyStore<K, V> => {
|
|
56
31
|
const entries = new Map<K, Store<V>>()
|
|
57
32
|
const evictWhenUnused = options.evictWhenUnused === true
|
|
58
|
-
// Live subscriber count per key — maintained only when eviction is on.
|
|
59
33
|
const subscribers = new Map<K, number>()
|
|
60
34
|
const liveSubscriberCount = (key: K): number =>
|
|
61
35
|
Option.getOrElse(Option.fromNullable(subscribers.get(key)), () => 0)
|
|
62
36
|
|
|
63
|
-
// Wrap a store's `subscribe` to ref-count, evicting the key when it falls idle.
|
|
64
|
-
// `get`/`set`/`getVersion` are delegated unchanged, so the loop writes and the
|
|
65
|
-
// host reads see the same cell; only subscribe/unsubscribe are instrumented.
|
|
66
37
|
const refCounted = (key: K, store: Store<V>): Store<V> => ({
|
|
67
38
|
...store,
|
|
68
39
|
subscribe: (listener) => {
|
|
@@ -116,28 +87,23 @@ const makeFamilyStore = <K, V>(
|
|
|
116
87
|
export interface StateFamilyManifestExternalApi<N extends string, K, V> extends Manifest {
|
|
117
88
|
readonly kind: 'StateFamily'
|
|
118
89
|
readonly name: N
|
|
119
|
-
readonly key:
|
|
120
|
-
readonly value:
|
|
90
|
+
readonly key: AnySchema<K>
|
|
91
|
+
readonly value: AnySchema<V>
|
|
121
92
|
readonly title?: string
|
|
122
93
|
readonly description?: string
|
|
123
94
|
}
|
|
124
95
|
export type StateFamilyManifest<N extends string, K, V> = StateFamilyManifestExternalApi<N, K, V>
|
|
125
96
|
|
|
126
97
|
export interface StateFamilyClass<out N extends string, in out K, in out V> {
|
|
127
|
-
/** Instance carries the key phantom so `Family['Key']` resolves in `keyOf` types. */
|
|
128
98
|
new (): { readonly Key: K }
|
|
129
99
|
readonly manifest: StateFamilyManifest<N, K, V>
|
|
130
100
|
readonly store: Context.Tag<FamilyStore<K, V>, FamilyStore<K, V>>
|
|
131
101
|
}
|
|
132
102
|
|
|
133
|
-
export type AnyFamily = StateFamilyClass<string,
|
|
134
|
-
export type FamilyKey<F> = F extends StateFamilyClass<
|
|
135
|
-
export type FamilyValue<F> = F extends StateFamilyClass<
|
|
103
|
+
export type AnyFamily = StateFamilyClass<string, AnyValue, AnyValue>
|
|
104
|
+
export type FamilyKey<F> = F extends StateFamilyClass<string, infer K, AnyValue> ? K : never
|
|
105
|
+
export type FamilyValue<F> = F extends StateFamilyClass<string, AnyValue, infer V> ? V : never
|
|
136
106
|
|
|
137
|
-
/**
|
|
138
|
-
* Normalized keyed state: one logical State per key, backed by a single family
|
|
139
|
-
* store. Scales with the collection without re-rendering unrelated entries.
|
|
140
|
-
*/
|
|
141
107
|
export interface StateFamilyMakeOptionsExternalApi {
|
|
142
108
|
readonly title?: string
|
|
143
109
|
readonly description?: string
|
|
@@ -145,8 +111,8 @@ export interface StateFamilyMakeOptionsExternalApi {
|
|
|
145
111
|
|
|
146
112
|
export const make = <const N extends string, K, V>(
|
|
147
113
|
name: N,
|
|
148
|
-
key:
|
|
149
|
-
valueSchema:
|
|
114
|
+
key: AnySchema<K>,
|
|
115
|
+
valueSchema: AnySchema<V>,
|
|
150
116
|
options: StateFamilyMakeOptionsExternalApi = {},
|
|
151
117
|
): StateFamilyClass<N, K, V> => {
|
|
152
118
|
const identifier = `reform/family/${name}`
|
|
@@ -163,21 +129,12 @@ export const make = <const N extends string, K, V>(
|
|
|
163
129
|
return definitionClass<StateFamilyClass<N, K, V>>({ manifest, store })
|
|
164
130
|
}
|
|
165
131
|
|
|
166
|
-
/**
|
|
167
|
-
* Read one entry by key — `StateFamily.read(ItemUi, id)`. Inside a render it
|
|
168
|
-
* subscribes that key's slice; elsewhere it just snapshots (D5).
|
|
169
|
-
*/
|
|
170
132
|
export const read = <N extends string, K, V>(
|
|
171
133
|
family: StateFamilyClass<N, K, V>,
|
|
172
134
|
key: K,
|
|
173
135
|
): Effect.Effect<V, never, FamilyStore<K, V>> =>
|
|
174
136
|
Effect.flatMap(family.store, (familyStore) => readTracked(familyStore.at(key)))
|
|
175
137
|
|
|
176
|
-
/**
|
|
177
|
-
* Allocate a family's keyed store — `StateFamily.live(ItemUi, seed)`. The seed
|
|
178
|
-
* is a plain value or a factory; a factory receives the key, so a new entry can
|
|
179
|
-
* derive its starting value from its own id (`(id) => ({ ...blank, id }))`).
|
|
180
|
-
*/
|
|
181
138
|
export const live = <N extends string, K, V>(
|
|
182
139
|
family: StateFamilyClass<N, K, V>,
|
|
183
140
|
initial: V | ((key: K) => V),
|
package/src/state/stateGroup.ts
CHANGED
|
@@ -1,35 +1,23 @@
|
|
|
1
1
|
import { Array as Arr, Layer, Option } from 'effect'
|
|
2
2
|
import { definitionClass } from '../definition/definition'
|
|
3
3
|
import { DuplicateRegistration, UnknownGroupState } from '../internal/errors'
|
|
4
|
-
// A group's members each carry a `store` tag, so the shared `StoresOf` (used for
|
|
5
|
-
// calc inputs too) distributes them into the union of distinct stores their
|
|
6
|
-
// `live` layer provides — no group-specific mapped type needed.
|
|
7
4
|
import { type StoresOf } from '../internal/sources'
|
|
8
5
|
import { type AnyState, live as stateLive, type StateClass, type StateName } from './state'
|
|
9
6
|
import { StateToken } from './token'
|
|
7
|
+
import type { AnyValue } from '../internal/variance'
|
|
10
8
|
|
|
11
9
|
type ValueForName<Members extends ReadonlyArray<AnyState>, N extends string> =
|
|
12
|
-
Extract<Members[number], StateClass<N,
|
|
10
|
+
Extract<Members[number], StateClass<N, AnyValue>> extends StateClass<string, infer A> ? A : never
|
|
13
11
|
|
|
14
12
|
export interface StateGroupClass<Members extends ReadonlyArray<AnyState>> {
|
|
15
13
|
new (): {}
|
|
16
14
|
readonly kind: 'StateGroup'
|
|
17
15
|
readonly members: Members
|
|
18
|
-
/** Members indexed by name, so `select` is an O(1) lookup instead of a scan. */
|
|
19
16
|
readonly byName: ReadonlyMap<string, AnyState>
|
|
20
17
|
}
|
|
21
18
|
|
|
22
19
|
export type AnyStateGroup = StateGroupClass<ReadonlyArray<AnyState>>
|
|
23
20
|
|
|
24
|
-
/**
|
|
25
|
-
* Compile-time uniqueness guard for `StateGroup.make`. Member names key the tag,
|
|
26
|
-
* the seed record, `byName`, and `select` — duplicates are unaddressable, so a
|
|
27
|
-
* repeated name is replaced (positionally) by this marker tuple, which a real
|
|
28
|
-
* `StateClass` is not assignable to. The offending argument fails to typecheck
|
|
29
|
-
* with the colliding name spelled out in the message, rather than collapsing
|
|
30
|
-
* silently. Runtime `make` throws the same clash for seeds the root tsconfig
|
|
31
|
-
* never typechecks (test files live outside it).
|
|
32
|
-
*/
|
|
33
21
|
type DuplicateStateName<N extends string> = readonly ['reform: duplicate state name in group', N]
|
|
34
22
|
|
|
35
23
|
type NoDuplicateNames<
|
|
@@ -42,41 +30,24 @@ type NoDuplicateNames<
|
|
|
42
30
|
]
|
|
43
31
|
: readonly []
|
|
44
32
|
|
|
45
|
-
// The seed record `StateGroup.live` requires: one entry per member, keyed by the
|
|
46
|
-
// member's name and typed to that member's value (so a missing or mistyped seed
|
|
47
|
-
// is a compile error). Written against the group class, so call sites read
|
|
48
|
-
// `GroupSeeds<typeof TodosStates>`.
|
|
49
33
|
export type GroupSeeds<G extends AnyStateGroup> =
|
|
50
34
|
G extends StateGroupClass<infer Members>
|
|
51
35
|
? { readonly [N in StateName<Members[number]>]: ValueForName<Members, N> }
|
|
52
36
|
: never
|
|
53
37
|
|
|
54
|
-
// Collapse a union of records into their intersection — merges every group's
|
|
55
|
-
// seed record into one. (The classic contravariant-position trick.)
|
|
56
38
|
type UnionToIntersection<U> = (U extends unknown ? (k: U) => void : never) extends (
|
|
57
39
|
k: infer I,
|
|
58
40
|
) => void
|
|
59
41
|
? I
|
|
60
42
|
: never
|
|
61
43
|
|
|
62
|
-
/** The seed record for one tuple entry: a group's `GroupSeeds`, else nothing. */
|
|
63
44
|
type EntrySeeds<G> = G extends AnyStateGroup ? GroupSeeds<G> : never
|
|
64
45
|
|
|
65
|
-
/**
|
|
66
|
-
* The optional seed overrides for a composition's whole `states` tuple, keyed by
|
|
67
|
-
* member name and typed to each member's value. Every key is optional (seeds are
|
|
68
|
-
* overrides over the authored initial). Tuple entries that aren't state groups
|
|
69
|
-
* contribute no keys, so a partially-grouped `states` still types its groups.
|
|
70
|
-
* Powers `seedScene` — a typo'd key or mistyped value is a compile error.
|
|
71
|
-
*/
|
|
72
46
|
export type SeedsOf<St extends ReadonlyArray<unknown>> = Partial<UnionToIntersection<EntrySeeds<St[number]>>>
|
|
73
47
|
|
|
74
|
-
/** Compose atomic States into a group provided (and addressed) as a unit. */
|
|
75
48
|
export const make = <const Members extends ReadonlyArray<AnyState>>(
|
|
76
49
|
...members: Members & NoDuplicateNames<Members>
|
|
77
50
|
): StateGroupClass<Members> => {
|
|
78
|
-
// `byName` already de-dupes, so a smaller map than the member list means two
|
|
79
|
-
// members claimed one name — unaddressable, and a silent footgun on merge.
|
|
80
51
|
const names = members.map((member) => member.manifest.name)
|
|
81
52
|
const duplicate = Arr.findFirst(names, (name, index) =>
|
|
82
53
|
Option.exists(
|
|
@@ -95,16 +66,6 @@ export const make = <const Members extends ReadonlyArray<AnyState>>(
|
|
|
95
66
|
})
|
|
96
67
|
}
|
|
97
68
|
|
|
98
|
-
/**
|
|
99
|
-
* Address a member by its tag — `StateGroup.select(TodosStates, 'feed')` —
|
|
100
|
-
* yielding a `StateToken` for reads and calc inputs (still `yield*`-able).
|
|
101
|
-
*
|
|
102
|
-
* Data-first only (not `dual`): the member name's type depends on the group, and
|
|
103
|
-
* a group is not `Pipeable`, so a data-last form would be both more verbose and
|
|
104
|
-
* harder to type than the direct call. Effect reserves `dual` for operators on a
|
|
105
|
-
* `Pipeable` self with an independent value arg (e.g. `ref.pipe(Ref.set(v))`);
|
|
106
|
-
* this is not that shape.
|
|
107
|
-
*/
|
|
108
69
|
export const select = <Members extends ReadonlyArray<AnyState>, N extends StateName<Members[number]>>(
|
|
109
70
|
group: StateGroupClass<Members>,
|
|
110
71
|
name: N,
|
|
@@ -117,18 +78,10 @@ export const select = <Members extends ReadonlyArray<AnyState>, N extends StateN
|
|
|
117
78
|
return new StateToken(name, member.store)
|
|
118
79
|
}
|
|
119
80
|
|
|
120
|
-
/**
|
|
121
|
-
* Allocate every member's store as one merged layer, each seeded from `seeds`
|
|
122
|
-
* keyed by member name — `StateGroup.live(BoardStates, { board: { _tag: 'Idle' } })`.
|
|
123
|
-
*/
|
|
124
81
|
export const live = <Members extends ReadonlyArray<AnyState>>(
|
|
125
82
|
group: StateGroupClass<Members>,
|
|
126
83
|
seeds: GroupSeeds<StateGroupClass<Members>>,
|
|
127
84
|
): Layer.Layer<StoresOf<Members>> => {
|
|
128
|
-
// Index the typed seed record by the member's runtime name — a genuine
|
|
129
|
-
// reflection boundary (string key into a mapped type). Each member's store
|
|
130
|
-
// layer is then merged; the union of stores is exactly `StoresOf<Members>`,
|
|
131
|
-
// which `reduce`'s single-layer accumulator can't express, so restate it.
|
|
132
85
|
// oxlint-disable-next-line reform-rules/no-type-assertion -- reflection boundary: index a mapped seed type by runtime member name
|
|
133
86
|
const seedRecord = seeds as Record<string, unknown>
|
|
134
87
|
// oxlint-disable-next-line reform-rules/no-type-assertion -- union-of-stores Rout can't be expressed by reduce's single-layer accumulator
|