@playfast/reform 1.0.1 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +103 -56
- package/package.json +17 -17
- package/src/boundary/boundary.test.ts +63 -49
- package/src/boundary/boundary.ts +144 -113
- package/src/calc/asyncCalc.invalidate.test.ts +518 -32
- package/src/calc/asyncCalc.test.ts +207 -213
- package/src/calc/asyncCalc.ts +131 -154
- package/src/calc/asyncData.ts +0 -37
- package/src/calc/calc.test.ts +7 -33
- package/src/calc/calc.ts +44 -58
- package/src/calc/calcFamily.test.ts +80 -34
- package/src/calc/calcFamily.ts +54 -65
- package/src/calc/compose.test.ts +1 -12
- package/src/calc/compose.ts +1 -36
- package/src/calc/queryState.ts +0 -23
- package/src/channel/channel.ts +126 -111
- package/src/compose/composition.test.ts +19 -0
- package/src/compose/composition.ts +351 -127
- package/src/compose/host.ts +0 -6
- package/src/compose/props.ts +13 -12
- package/src/compose/provide.ts +189 -62
- package/src/compose/slot.ts +158 -49
- package/src/compose/structure.test.ts +6 -9
- package/src/compose/structure.ts +108 -79
- package/src/compose/ui.test.ts +19 -7
- package/src/compose/ui.ts +155 -156
- package/src/compose/ui.typecheck.ts +40 -18
- package/src/definition/definition.ts +22 -41
- package/src/event/event.fromSource.test.ts +172 -0
- package/src/event/event.test.ts +10 -3
- package/src/event/event.ts +102 -27
- package/src/event/eventGroup.ts +0 -1
- package/src/feature/feature.mount.test.ts +122 -43
- package/src/feature/feature.test.ts +46 -21
- package/src/feature/feature.ts +1347 -256
- package/src/feature/feature.typecheck.ts +273 -68
- package/src/graph/closure.ts +403 -0
- package/src/index.ts +68 -126
- package/src/internal/bucketCache.ts +39 -0
- package/src/internal/capture.ts +9 -24
- package/src/internal/env.ts +7 -0
- package/src/internal/errors.test.ts +0 -6
- package/src/internal/errors.ts +37 -60
- package/src/internal/inspect.test.ts +0 -6
- package/src/internal/inspect.ts +0 -12
- package/src/internal/queryDriver.ts +105 -201
- package/src/internal/queryDriverStore.ts +156 -0
- package/src/internal/queryDriverTypes.ts +59 -0
- package/src/internal/queryEvents.ts +0 -12
- package/src/internal/queryStore.ts +0 -14
- package/src/internal/reuse.test.ts +10 -14
- package/src/internal/reuse.ts +5 -30
- package/src/internal/scheduler.ts +4 -44
- package/src/internal/seeds.ts +0 -14
- package/src/internal/sources.ts +26 -49
- 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 +3 -20
- package/src/internal/variance.ts +5 -0
- package/src/internal.ts +222 -0
- package/src/namespace/namespace.test.ts +46 -0
- package/src/namespace/namespace.ts +106 -0
- package/src/procedure/procedure.ts +40 -35
- package/src/reducer/reducer.ts +78 -52
- package/src/remote/remoteState.test.ts +590 -397
- package/src/remote/remoteState.ts +425 -347
- package/src/remote/remoteState.typecheck.ts +47 -56
- package/src/runtime/appRuntime.activation.test.ts +100 -0
- package/src/runtime/appRuntime.test.ts +249 -0
- package/src/runtime/appRuntime.ts +415 -97
- package/src/runtime/bus.ts +2 -15
- package/src/runtime/eventBudget.test.ts +152 -0
- package/src/runtime/eventBudget.ts +120 -0
- package/src/runtime/hardening.test.ts +73 -13
- package/src/runtime/instrumentation.test.ts +55 -52
- package/src/runtime/instrumentation.ts +6 -60
- package/src/runtime/loop.test.ts +36 -17
- package/src/runtime/loop.ts +87 -88
- package/src/runtime/queries.ts +0 -17
- package/src/scene/featureScene.test.ts +61 -0
- package/src/scene/scene.ts +247 -104
- package/src/scene/seedScene.test.ts +42 -79
- package/src/state/state.nominal.typecheck.ts +68 -0
- package/src/state/state.test.ts +17 -0
- package/src/state/state.ts +80 -46
- package/src/state/stateFamily.test.ts +16 -11
- package/src/state/stateFamily.ts +55 -65
- package/src/state/stateGroup.ts +53 -64
- package/src/state/token.ts +40 -23
- package/src/synced/syncedStore.ts +46 -58
- package/src/testkit/flight.testkit.ts +75 -0
- package/src/ui/node.ts +0 -6
- package/src/ui/trigger.ts +0 -5
- package/src/wire/tree.test.ts +8 -7
- package/src/wire/tree.ts +0 -39
- package/src/wire/triggers.test.ts +6 -6
- package/src/wire/triggers.ts +14 -32
- package/src/internal/ctx.ts +0 -16
package/src/state/state.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { Context, Effect, FiberRef, Layer, Option, Schema } from 'effect'
|
|
1
|
+
import { Context, Effect, FiberRef, Layer, Option, type ParseResult, 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'
|
|
@@ -13,41 +14,72 @@ export interface StateOptionsExternalApi {
|
|
|
13
14
|
}
|
|
14
15
|
export type StateOptions = StateOptionsExternalApi
|
|
15
16
|
|
|
16
|
-
|
|
17
|
+
// Full Schema parameters are invariant; heterogeneous manifests expose only their AST.
|
|
18
|
+
export interface StateSchemaReflection {
|
|
19
|
+
readonly ast: Schema.Schema<unknown, unknown, unknown>['ast']
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface StateManifestExternalApi<N extends string> extends Manifest {
|
|
17
23
|
readonly kind: 'State'
|
|
18
24
|
readonly name: N
|
|
19
|
-
readonly schema:
|
|
25
|
+
readonly schema: StateSchemaReflection
|
|
20
26
|
readonly title?: string
|
|
21
27
|
readonly description?: string
|
|
22
28
|
}
|
|
23
|
-
export type StateManifest<N extends string
|
|
29
|
+
export type StateManifest<N extends string> = StateManifestExternalApi<N>
|
|
30
|
+
|
|
31
|
+
export const StateStoreTypeId: unique symbol = Symbol.for('reform/StateStore')
|
|
32
|
+
export type StateStoreTypeId = typeof StateStoreTypeId
|
|
33
|
+
|
|
34
|
+
export interface StateStore<N extends string, in out A> extends Store<A> {
|
|
35
|
+
readonly [StateStoreTypeId]: N
|
|
36
|
+
}
|
|
24
37
|
|
|
25
|
-
export
|
|
38
|
+
export type StateCapture<Result> = <N extends string, A>(state: StateClass<N, A>) => Result
|
|
39
|
+
|
|
40
|
+
export interface AnyState {
|
|
26
41
|
new (): {}
|
|
27
|
-
readonly manifest: StateManifest<
|
|
28
|
-
|
|
29
|
-
readonly store: Context.Tag<Store<A>, Store<A>>
|
|
42
|
+
readonly manifest: StateManifest<string>
|
|
43
|
+
readonly capture: <Result>(visit: StateCapture<Result>) => Result
|
|
30
44
|
}
|
|
31
45
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
46
|
+
interface StateStatics<N extends string, A> {
|
|
47
|
+
readonly manifest: StateManifest<N>
|
|
48
|
+
// Subclasses change the class function name; this preserves the authored source name.
|
|
49
|
+
readonly name: N
|
|
50
|
+
readonly capture: <Result>(visit: StateCapture<Result>) => Result
|
|
51
|
+
readonly store: Context.Tag<StateStore<N, A>, Store<A>>
|
|
52
|
+
readonly decodeUnknown: (input: unknown) => Effect.Effect<A, ParseResult.ParseError, never>
|
|
53
|
+
readonly decodeUnknownSync: (input: unknown) => A
|
|
54
|
+
readonly decodeUnknownOption: (input: unknown) => Option.Option<A>
|
|
55
|
+
}
|
|
35
56
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
57
|
+
export interface StateClass<N extends string, A>
|
|
58
|
+
extends EffectType<A, never, StateStore<N, A>>, StateStatics<N, A> {
|
|
59
|
+
new (): {}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export type StateDefinition<
|
|
63
|
+
N extends string,
|
|
64
|
+
A,
|
|
65
|
+
StateSchema extends Schema.Schema.AnyNoContext,
|
|
66
|
+
> = StateClass<N, A> & {
|
|
67
|
+
readonly manifest: StateManifest<N> & { readonly schema: StateSchema }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export type StateValue<S> = S extends StateClass<infer _N, infer A> ? A : never
|
|
71
|
+
export type StateName<S> = S extends StateClass<infer N, infer _A> ? N : never
|
|
72
|
+
|
|
73
|
+
export const make = <const N extends string, StateSchema extends Schema.Schema.AnyNoContext>(
|
|
43
74
|
name: N,
|
|
44
|
-
schema:
|
|
75
|
+
schema: StateSchema,
|
|
45
76
|
options: StateOptions = {},
|
|
46
|
-
):
|
|
77
|
+
): StateDefinition<N, Schema.Schema.Type<StateSchema>, StateSchema> => {
|
|
78
|
+
type A = Schema.Schema.Type<StateSchema>
|
|
47
79
|
const identifier = `reform/state/${name}`
|
|
48
80
|
claimStateTag(identifier)
|
|
49
|
-
const store = Context.GenericTag<
|
|
50
|
-
const manifest: StateManifest<N
|
|
81
|
+
const store = Context.GenericTag<StateStore<N, A>, Store<A>>(identifier)
|
|
82
|
+
const manifest: StateManifest<N> & { readonly schema: StateSchema } = {
|
|
51
83
|
kind: 'State',
|
|
52
84
|
name,
|
|
53
85
|
schema,
|
|
@@ -55,39 +87,41 @@ export const make = <const N extends string, A>(
|
|
|
55
87
|
...(options.description !== undefined ? { description: options.description } : {}),
|
|
56
88
|
}
|
|
57
89
|
const read = Effect.flatMap(store, readTracked)
|
|
58
|
-
|
|
90
|
+
const state: StateDefinition<N, A, StateSchema> = yieldableClass<
|
|
91
|
+
A,
|
|
92
|
+
never,
|
|
93
|
+
StateStore<N, A>,
|
|
94
|
+
StateStatics<N, A> & {
|
|
95
|
+
readonly manifest: StateManifest<N> & { readonly schema: StateSchema }
|
|
96
|
+
}
|
|
97
|
+
>(read, {
|
|
98
|
+
manifest,
|
|
99
|
+
name,
|
|
100
|
+
store,
|
|
101
|
+
decodeUnknown: Schema.decodeUnknown(schema),
|
|
102
|
+
decodeUnknownSync: Schema.decodeUnknownSync(schema),
|
|
103
|
+
decodeUnknownOption: Schema.decodeUnknownOption(schema),
|
|
104
|
+
capture: <Result>(visit: StateCapture<Result>): Result => visit(state),
|
|
105
|
+
})
|
|
106
|
+
return state
|
|
59
107
|
}
|
|
60
108
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
const resolveSeed = <S extends AnyState>(
|
|
71
|
-
state: S,
|
|
72
|
-
initial: StateValue<S>,
|
|
73
|
-
): Effect.Effect<StateValue<S>> =>
|
|
109
|
+
const resolveSeed = <N extends string, A>(
|
|
110
|
+
state: StateClass<N, A>,
|
|
111
|
+
initial: A,
|
|
112
|
+
): Effect.Effect<A, never, never> =>
|
|
74
113
|
Effect.map(FiberRef.get(CurrentSeedOverrides), (overrides) => {
|
|
75
114
|
if (!(state.manifest.name in overrides)) {
|
|
76
115
|
return initial
|
|
77
116
|
}
|
|
78
|
-
const override =
|
|
117
|
+
const override = state.decodeUnknownOption(overrides[state.manifest.name])
|
|
79
118
|
return Option.getOrElse(override, () => initial)
|
|
80
119
|
})
|
|
81
120
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
*/
|
|
87
|
-
export const live = <S extends AnyState>(
|
|
88
|
-
state: S,
|
|
89
|
-
initial: StateValue<S>,
|
|
90
|
-
): Layer.Layer<Store<StateValue<S>>> =>
|
|
121
|
+
export const live = <N extends string, A>(
|
|
122
|
+
state: StateClass<N, A>,
|
|
123
|
+
initial: A,
|
|
124
|
+
): Layer.Layer<StateStore<N, A>> =>
|
|
91
125
|
Layer.effect(
|
|
92
126
|
state.store,
|
|
93
127
|
Effect.all([resolveScheduler, resolveSeed(state, initial), resolveInstrumentation]).pipe(
|
|
@@ -2,11 +2,22 @@ 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
|
|
|
7
|
+
it.live('same-shaped families resolve their own stores', () => {
|
|
8
|
+
class First extends StateFamily.make('family.nominal.first', S.String, S.Number) {}
|
|
9
|
+
class Second extends StateFamily.make('family.nominal.second', S.String, S.Number) {}
|
|
10
|
+
const Live = Layer.mergeAll(StateFamily.live(First, 1), StateFamily.live(Second, 2))
|
|
11
|
+
|
|
12
|
+
return Effect.gen(function* () {
|
|
13
|
+
const first = yield* First.store
|
|
14
|
+
const second = yield* Second.store
|
|
15
|
+
|
|
16
|
+
expect(first.at('key').get()).toBe(1)
|
|
17
|
+
expect(second.at('key').get()).toBe(2)
|
|
18
|
+
}).pipe(Effect.provide(Live))
|
|
19
|
+
})
|
|
20
|
+
|
|
10
21
|
it.live("writing one key does not notify another key's subscribers", () => {
|
|
11
22
|
class Items extends StateFamily.make('items', S.String, S.Number) {}
|
|
12
23
|
const TestLayer = StateFamily.live(Items, 0)
|
|
@@ -53,7 +64,6 @@ it.live('forget(key) drops the store; re-reading the key reseeds it', () => {
|
|
|
53
64
|
expect(family.at('k').get()).toBe(42)
|
|
54
65
|
|
|
55
66
|
family.forget('k')
|
|
56
|
-
// A fresh store, reseeded to the family's initial value — not the old 42.
|
|
57
67
|
const reseeded = family.at('k')
|
|
58
68
|
expect(reseeded).not.toBe(first)
|
|
59
69
|
expect(reseeded.get()).toBe(0)
|
|
@@ -71,16 +81,13 @@ it.live('evictWhenUnused drops a key once its last subscriber leaves', () => {
|
|
|
71
81
|
const offB = store.subscribe(() => {})
|
|
72
82
|
expect(family.size()).toBe(1)
|
|
73
83
|
|
|
74
|
-
// One subscriber leaving keeps the key (another still reads it).
|
|
75
84
|
offA()
|
|
76
85
|
yield* tick
|
|
77
86
|
expect(family.size()).toBe(1)
|
|
78
87
|
|
|
79
|
-
// The last subscriber leaving evicts the key on the next microtask.
|
|
80
88
|
offB()
|
|
81
89
|
yield* tick
|
|
82
90
|
expect(family.size()).toBe(0)
|
|
83
|
-
// Re-reading reseeds a fresh store.
|
|
84
91
|
expect(family.at('k').get()).toBe(0)
|
|
85
92
|
}).pipe(Effect.provide(TestLayer))
|
|
86
93
|
})
|
|
@@ -94,10 +101,9 @@ it.live('evictWhenUnused: a same-tick re-subscribe cancels eviction', () => {
|
|
|
94
101
|
const store = family.at('k')
|
|
95
102
|
store.set(7)
|
|
96
103
|
const off = store.subscribe(() => {})
|
|
97
|
-
off()
|
|
98
|
-
store.subscribe(() => {})
|
|
104
|
+
off()
|
|
105
|
+
store.subscribe(() => {})
|
|
99
106
|
yield* tick
|
|
100
|
-
// Not evicted: the value survives and `at` returns the same live store.
|
|
101
107
|
expect(family.size()).toBe(1)
|
|
102
108
|
expect(family.at('k').get()).toBe(7)
|
|
103
109
|
}).pipe(Effect.provide(TestLayer))
|
|
@@ -128,7 +134,6 @@ it.live('a family reducer fold returning Tombstone evicts the key', () => {
|
|
|
128
134
|
const before = family.at('a')
|
|
129
135
|
expect(before.get()).toBe(1)
|
|
130
136
|
|
|
131
|
-
// The fold returns the eviction sentinel: the key's store is dropped.
|
|
132
137
|
yield* publish('High', Event.construct(Removed, { id: 'a' }))
|
|
133
138
|
yield* Effect.sleep(Duration.millis(5))
|
|
134
139
|
const after = family.at('a')
|
package/src/state/stateFamily.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Context, Effect, Layer, Option,
|
|
1
|
+
import { Context, Effect, Layer, Option, Schema } 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'
|
|
@@ -6,43 +6,17 @@ import { resolveInstrumentation, stateUpdateHook } from '../runtime/instrumentat
|
|
|
6
6
|
import { makeStore, type Store } from '../internal/store'
|
|
7
7
|
import { readTracked } from '../internal/track'
|
|
8
8
|
|
|
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
9
|
export const Tombstone: unique symbol = Symbol.for('reform/StateFamily/Tombstone')
|
|
16
10
|
export type Tombstone = typeof Tombstone
|
|
17
11
|
|
|
18
|
-
/** Per-key stores, created lazily so a write to one key wakes only its subscribers. */
|
|
19
12
|
export interface FamilyStore<K, V> {
|
|
20
13
|
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
14
|
forget(key: K): void
|
|
28
|
-
/** Drop every key's store — a wholesale reset of the family. */
|
|
29
15
|
clear(): void
|
|
30
|
-
/** Number of live keys — for tests/diagnostics. */
|
|
31
16
|
size(): number
|
|
32
17
|
}
|
|
33
18
|
|
|
34
|
-
/** Options for a family's keyed store. */
|
|
35
19
|
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
20
|
readonly evictWhenUnused?: boolean
|
|
47
21
|
}
|
|
48
22
|
export type FamilyOptions = FamilyOptionsExternalApi
|
|
@@ -55,14 +29,10 @@ const makeFamilyStore = <K, V>(
|
|
|
55
29
|
): FamilyStore<K, V> => {
|
|
56
30
|
const entries = new Map<K, Store<V>>()
|
|
57
31
|
const evictWhenUnused = options.evictWhenUnused === true
|
|
58
|
-
// Live subscriber count per key — maintained only when eviction is on.
|
|
59
32
|
const subscribers = new Map<K, number>()
|
|
60
33
|
const liveSubscriberCount = (key: K): number =>
|
|
61
34
|
Option.getOrElse(Option.fromNullable(subscribers.get(key)), () => 0)
|
|
62
35
|
|
|
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
36
|
const refCounted = (key: K, store: Store<V>): Store<V> => ({
|
|
67
37
|
...store,
|
|
68
38
|
subscribe: (listener) => {
|
|
@@ -113,46 +83,66 @@ const makeFamilyStore = <K, V>(
|
|
|
113
83
|
}
|
|
114
84
|
}
|
|
115
85
|
|
|
116
|
-
|
|
86
|
+
// Full Schema parameters are invariant; heterogeneous manifests expose only their AST.
|
|
87
|
+
export interface FamilySchemaReflection {
|
|
88
|
+
readonly ast: Schema.Schema<unknown, unknown, unknown>['ast']
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface StateFamilyManifestExternalApi<N extends string> extends Manifest {
|
|
117
92
|
readonly kind: 'StateFamily'
|
|
118
93
|
readonly name: N
|
|
119
|
-
readonly key:
|
|
120
|
-
readonly value:
|
|
94
|
+
readonly key: FamilySchemaReflection
|
|
95
|
+
readonly value: FamilySchemaReflection
|
|
121
96
|
readonly title?: string
|
|
122
97
|
readonly description?: string
|
|
123
98
|
}
|
|
124
|
-
export type StateFamilyManifest<N extends string
|
|
99
|
+
export type StateFamilyManifest<N extends string> = StateFamilyManifestExternalApi<N>
|
|
100
|
+
|
|
101
|
+
export const StateFamilyStoreTypeId: unique symbol = Symbol.for('reform/StateFamilyStore')
|
|
102
|
+
export type StateFamilyStoreTypeId = typeof StateFamilyStoreTypeId
|
|
103
|
+
|
|
104
|
+
export interface StateFamilyStore<N extends string, in out K, in out V> extends FamilyStore<K, V> {
|
|
105
|
+
readonly [StateFamilyStoreTypeId]: N
|
|
106
|
+
}
|
|
125
107
|
|
|
126
|
-
export
|
|
127
|
-
|
|
108
|
+
export type StateFamilyCapture<Result> = <N extends string, K, V>(
|
|
109
|
+
family: StateFamilyClass<N, K, V>,
|
|
110
|
+
) => Result
|
|
111
|
+
|
|
112
|
+
export interface AnyFamily {
|
|
113
|
+
new (): {}
|
|
114
|
+
readonly manifest: StateFamilyManifest<string>
|
|
115
|
+
readonly capture: <Result>(visit: StateFamilyCapture<Result>) => Result
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export interface StateFamilyClass<N extends string, K, V> {
|
|
128
119
|
new (): { readonly Key: K }
|
|
129
|
-
readonly manifest: StateFamilyManifest<N
|
|
130
|
-
readonly
|
|
120
|
+
readonly manifest: StateFamilyManifest<N>
|
|
121
|
+
readonly capture: <Result>(visit: StateFamilyCapture<Result>) => Result
|
|
122
|
+
readonly store: Context.Tag<StateFamilyStore<N, K, V>, FamilyStore<K, V>>
|
|
123
|
+
readonly decodeKeySync: (input: unknown) => K
|
|
124
|
+
readonly decodeValueSync: (input: unknown) => V
|
|
131
125
|
}
|
|
132
126
|
|
|
133
|
-
export type
|
|
134
|
-
export type FamilyKey<F> = F extends StateFamilyClass<
|
|
135
|
-
export type FamilyValue<F> = F extends StateFamilyClass<
|
|
127
|
+
export type FamilyName<F> = F extends StateFamilyClass<infer N, infer _K, infer _V> ? N : never
|
|
128
|
+
export type FamilyKey<F> = F extends StateFamilyClass<infer _N, infer K, infer _V> ? K : never
|
|
129
|
+
export type FamilyValue<F> = F extends StateFamilyClass<infer _N, infer _K, infer V> ? V : never
|
|
136
130
|
|
|
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
131
|
export interface StateFamilyMakeOptionsExternalApi {
|
|
142
132
|
readonly title?: string
|
|
143
133
|
readonly description?: string
|
|
144
134
|
}
|
|
145
135
|
|
|
146
|
-
export const make = <const N extends string, K, V>(
|
|
136
|
+
export const make = <const N extends string, K, KeyEncoded, V, ValueEncoded>(
|
|
147
137
|
name: N,
|
|
148
|
-
key: Schema.Schema<K,
|
|
149
|
-
valueSchema: Schema.Schema<V,
|
|
138
|
+
key: Schema.Schema<K, KeyEncoded, never>,
|
|
139
|
+
valueSchema: Schema.Schema<V, ValueEncoded, never>,
|
|
150
140
|
options: StateFamilyMakeOptionsExternalApi = {},
|
|
151
141
|
): StateFamilyClass<N, K, V> => {
|
|
152
142
|
const identifier = `reform/family/${name}`
|
|
153
143
|
claimStateTag(identifier)
|
|
154
|
-
const store = Context.GenericTag<
|
|
155
|
-
const manifest: StateFamilyManifest<N
|
|
144
|
+
const store = Context.GenericTag<StateFamilyStore<N, K, V>, FamilyStore<K, V>>(identifier)
|
|
145
|
+
const manifest: StateFamilyManifest<N> = {
|
|
156
146
|
kind: 'StateFamily',
|
|
157
147
|
name,
|
|
158
148
|
key,
|
|
@@ -160,31 +150,31 @@ export const make = <const N extends string, K, V>(
|
|
|
160
150
|
...(options.title !== undefined ? { title: options.title } : {}),
|
|
161
151
|
...(options.description !== undefined ? { description: options.description } : {}),
|
|
162
152
|
}
|
|
163
|
-
|
|
153
|
+
const family: StateFamilyClass<N, K, V> = definitionClass<StateFamilyClass<N, K, V>>({
|
|
154
|
+
manifest,
|
|
155
|
+
store,
|
|
156
|
+
decodeKeySync: Schema.decodeUnknownSync(key),
|
|
157
|
+
decodeValueSync: Schema.decodeUnknownSync(valueSchema),
|
|
158
|
+
capture: <Result>(visit: StateFamilyCapture<Result>): Result => visit(family),
|
|
159
|
+
})
|
|
160
|
+
return family
|
|
164
161
|
}
|
|
165
162
|
|
|
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
163
|
export const read = <N extends string, K, V>(
|
|
171
164
|
family: StateFamilyClass<N, K, V>,
|
|
172
165
|
key: K,
|
|
173
|
-
): Effect.Effect<V, never,
|
|
166
|
+
): Effect.Effect<V, never, StateFamilyStore<N, K, V>> =>
|
|
174
167
|
Effect.flatMap(family.store, (familyStore) => readTracked(familyStore.at(key)))
|
|
175
168
|
|
|
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
169
|
export const live = <N extends string, K, V>(
|
|
182
170
|
family: StateFamilyClass<N, K, V>,
|
|
183
171
|
initial: V | ((key: K) => V),
|
|
184
172
|
options: FamilyOptions = {},
|
|
185
|
-
): Layer.Layer<
|
|
186
|
-
|
|
187
|
-
|
|
173
|
+
): Layer.Layer<StateFamilyStore<N, K, V>> => {
|
|
174
|
+
const seed = (key: K): V =>
|
|
175
|
+
typeof initial === 'function'
|
|
176
|
+
? family.decodeValueSync(Reflect.apply(initial, undefined, [key]))
|
|
177
|
+
: initial
|
|
188
178
|
return Layer.effect(
|
|
189
179
|
family.store,
|
|
190
180
|
Effect.zipWith(resolveScheduler, resolveInstrumentation, (scheduler, instrumentation) =>
|
package/src/state/stateGroup.ts
CHANGED
|
@@ -1,82 +1,70 @@
|
|
|
1
|
-
import { Array as Arr, Layer, Option } from 'effect'
|
|
1
|
+
import { Array as Arr, Context, Effect, 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
|
-
import
|
|
5
|
+
import type { Store } from '../internal/store'
|
|
6
|
+
import {
|
|
7
|
+
type AnyState,
|
|
8
|
+
live as stateLive,
|
|
9
|
+
type StateName,
|
|
10
|
+
type StateStore,
|
|
11
|
+
type StateValue,
|
|
12
|
+
} from './state'
|
|
9
13
|
import { StateToken } from './token'
|
|
10
14
|
|
|
11
|
-
type
|
|
12
|
-
|
|
15
|
+
type MemberForName<Members extends ReadonlyArray<AnyState>, N extends string> = Extract<
|
|
16
|
+
Members[number],
|
|
17
|
+
{ readonly manifest: { readonly name: N } }
|
|
18
|
+
>
|
|
19
|
+
|
|
20
|
+
type ValueForName<Members extends ReadonlyArray<AnyState>, N extends string> = StateValue<
|
|
21
|
+
MemberForName<Members, N>
|
|
22
|
+
>
|
|
13
23
|
|
|
14
24
|
export interface StateGroupClass<Members extends ReadonlyArray<AnyState>> {
|
|
15
25
|
new (): {}
|
|
16
26
|
readonly kind: 'StateGroup'
|
|
17
27
|
readonly members: Members
|
|
18
|
-
/** Members indexed by name, so `select` is an O(1) lookup instead of a scan. */
|
|
19
28
|
readonly byName: ReadonlyMap<string, AnyState>
|
|
20
29
|
}
|
|
21
30
|
|
|
22
31
|
export type AnyStateGroup = StateGroupClass<ReadonlyArray<AnyState>>
|
|
23
32
|
|
|
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
33
|
type DuplicateStateName<N extends string> = readonly ['reform: duplicate state name in group', N]
|
|
34
34
|
|
|
35
35
|
type NoDuplicateNames<
|
|
36
36
|
Members extends ReadonlyArray<AnyState>,
|
|
37
37
|
Seen extends string = never,
|
|
38
|
-
> = Members extends readonly [
|
|
38
|
+
> = Members extends readonly [
|
|
39
|
+
infer Head extends AnyState,
|
|
40
|
+
...infer Tail extends ReadonlyArray<AnyState>,
|
|
41
|
+
]
|
|
39
42
|
? readonly [
|
|
40
43
|
StateName<Head> extends Seen ? DuplicateStateName<StateName<Head>> : Head,
|
|
41
44
|
...NoDuplicateNames<Tail, Seen | StateName<Head>>,
|
|
42
45
|
]
|
|
43
46
|
: readonly []
|
|
44
47
|
|
|
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
48
|
export type GroupSeeds<G extends AnyStateGroup> =
|
|
50
49
|
G extends StateGroupClass<infer Members>
|
|
51
50
|
? { readonly [N in StateName<Members[number]>]: ValueForName<Members, N> }
|
|
52
51
|
: never
|
|
53
52
|
|
|
54
|
-
// Collapse a union of records into their intersection — merges every group's
|
|
55
|
-
// seed record into one. (The classic contravariant-position trick.)
|
|
56
53
|
type UnionToIntersection<U> = (U extends unknown ? (k: U) => void : never) extends (
|
|
57
54
|
k: infer I,
|
|
58
55
|
) => void
|
|
59
56
|
? I
|
|
60
57
|
: never
|
|
61
58
|
|
|
62
|
-
/** The seed record for one tuple entry: a group's `GroupSeeds`, else nothing. */
|
|
63
59
|
type EntrySeeds<G> = G extends AnyStateGroup ? GroupSeeds<G> : never
|
|
64
60
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
export type SeedsOf<St extends ReadonlyArray<unknown>> = Partial<UnionToIntersection<EntrySeeds<St[number]>>>
|
|
61
|
+
export type SeedsOf<St extends ReadonlyArray<unknown>> = Partial<
|
|
62
|
+
UnionToIntersection<EntrySeeds<St[number]>>
|
|
63
|
+
>
|
|
73
64
|
|
|
74
|
-
/** Compose atomic States into a group provided (and addressed) as a unit. */
|
|
75
65
|
export const make = <const Members extends ReadonlyArray<AnyState>>(
|
|
76
66
|
...members: Members & NoDuplicateNames<Members>
|
|
77
67
|
): 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
68
|
const names = members.map((member) => member.manifest.name)
|
|
81
69
|
const duplicate = Arr.findFirst(names, (name, index) =>
|
|
82
70
|
Option.exists(
|
|
@@ -95,17 +83,10 @@ export const make = <const Members extends ReadonlyArray<AnyState>>(
|
|
|
95
83
|
})
|
|
96
84
|
}
|
|
97
85
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
export const select = <Members extends ReadonlyArray<AnyState>, N extends StateName<Members[number]>>(
|
|
86
|
+
export const select = <
|
|
87
|
+
Members extends ReadonlyArray<AnyState>,
|
|
88
|
+
N extends StateName<Members[number]>,
|
|
89
|
+
>(
|
|
109
90
|
group: StateGroupClass<Members>,
|
|
110
91
|
name: N,
|
|
111
92
|
): StateToken<N, ValueForName<Members, N>> => {
|
|
@@ -114,25 +95,33 @@ export const select = <Members extends ReadonlyArray<AnyState>, N extends StateN
|
|
|
114
95
|
// oxlint-disable-next-line reform-rules/no-throw -- definition-time invariant; sync lookup has no Effect context (see internal/errors.ts)
|
|
115
96
|
throw new UnknownGroupState({ name })
|
|
116
97
|
}
|
|
117
|
-
|
|
98
|
+
const store = Context.GenericTag<
|
|
99
|
+
StateStore<N, ValueForName<Members, N>>,
|
|
100
|
+
Store<ValueForName<Members, N>>
|
|
101
|
+
>(`reform/state/${name}`)
|
|
102
|
+
return new StateToken(name, store)
|
|
118
103
|
}
|
|
119
104
|
|
|
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
|
-
export const live = <Members extends ReadonlyArray<AnyState>>(
|
|
105
|
+
export function live<Members extends ReadonlyArray<AnyState>>(
|
|
125
106
|
group: StateGroupClass<Members>,
|
|
126
107
|
seeds: GroupSeeds<StateGroupClass<Members>>,
|
|
127
|
-
): Layer.Layer<StoresOf<Members>>
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
108
|
+
): Layer.Layer<StoresOf<Members>>
|
|
109
|
+
export function live(
|
|
110
|
+
group: AnyStateGroup,
|
|
111
|
+
seeds: Readonly<Record<string, unknown>>,
|
|
112
|
+
): Layer.Layer<never> {
|
|
113
|
+
const layers = group.members.map((member) =>
|
|
114
|
+
member.capture<Layer.Layer<never>>((state) =>
|
|
115
|
+
Layer.unwrapEffect(
|
|
116
|
+
state.decodeUnknown(seeds[state.manifest.name]).pipe(
|
|
117
|
+
Effect.orDie,
|
|
118
|
+
Effect.map((seed) => stateLive(state, seed)),
|
|
119
|
+
),
|
|
120
|
+
),
|
|
121
|
+
),
|
|
122
|
+
)
|
|
123
|
+
return layers.reduce<Layer.Layer<never>>(
|
|
124
|
+
(merged, memberLayer) => Layer.merge(merged, memberLayer),
|
|
125
|
+
Layer.empty,
|
|
126
|
+
)
|
|
138
127
|
}
|