@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.
Files changed (99) hide show
  1. package/README.md +103 -56
  2. package/package.json +17 -17
  3. package/src/boundary/boundary.test.ts +63 -49
  4. package/src/boundary/boundary.ts +144 -113
  5. package/src/calc/asyncCalc.invalidate.test.ts +518 -32
  6. package/src/calc/asyncCalc.test.ts +207 -213
  7. package/src/calc/asyncCalc.ts +131 -154
  8. package/src/calc/asyncData.ts +0 -37
  9. package/src/calc/calc.test.ts +7 -33
  10. package/src/calc/calc.ts +44 -58
  11. package/src/calc/calcFamily.test.ts +80 -34
  12. package/src/calc/calcFamily.ts +54 -65
  13. package/src/calc/compose.test.ts +1 -12
  14. package/src/calc/compose.ts +1 -36
  15. package/src/calc/queryState.ts +0 -23
  16. package/src/channel/channel.ts +126 -111
  17. package/src/compose/composition.test.ts +19 -0
  18. package/src/compose/composition.ts +351 -127
  19. package/src/compose/host.ts +0 -6
  20. package/src/compose/props.ts +13 -12
  21. package/src/compose/provide.ts +189 -62
  22. package/src/compose/slot.ts +158 -49
  23. package/src/compose/structure.test.ts +6 -9
  24. package/src/compose/structure.ts +108 -79
  25. package/src/compose/ui.test.ts +19 -7
  26. package/src/compose/ui.ts +155 -156
  27. package/src/compose/ui.typecheck.ts +40 -18
  28. package/src/definition/definition.ts +22 -41
  29. package/src/event/event.fromSource.test.ts +172 -0
  30. package/src/event/event.test.ts +10 -3
  31. package/src/event/event.ts +102 -27
  32. package/src/event/eventGroup.ts +0 -1
  33. package/src/feature/feature.mount.test.ts +122 -43
  34. package/src/feature/feature.test.ts +46 -21
  35. package/src/feature/feature.ts +1347 -256
  36. package/src/feature/feature.typecheck.ts +273 -68
  37. package/src/graph/closure.ts +403 -0
  38. package/src/index.ts +68 -126
  39. package/src/internal/bucketCache.ts +39 -0
  40. package/src/internal/capture.ts +9 -24
  41. package/src/internal/env.ts +7 -0
  42. package/src/internal/errors.test.ts +0 -6
  43. package/src/internal/errors.ts +37 -60
  44. package/src/internal/inspect.test.ts +0 -6
  45. package/src/internal/inspect.ts +0 -12
  46. package/src/internal/queryDriver.ts +105 -201
  47. package/src/internal/queryDriverStore.ts +156 -0
  48. package/src/internal/queryDriverTypes.ts +59 -0
  49. package/src/internal/queryEvents.ts +0 -12
  50. package/src/internal/queryStore.ts +0 -14
  51. package/src/internal/reuse.test.ts +10 -14
  52. package/src/internal/reuse.ts +5 -30
  53. package/src/internal/scheduler.ts +4 -44
  54. package/src/internal/seeds.ts +0 -14
  55. package/src/internal/sources.ts +26 -49
  56. package/src/internal/stateRegistry.ts +0 -14
  57. package/src/internal/store.test.ts +1 -3
  58. package/src/internal/store.ts +0 -37
  59. package/src/internal/track.ts +3 -20
  60. package/src/internal/variance.ts +5 -0
  61. package/src/internal.ts +222 -0
  62. package/src/namespace/namespace.test.ts +46 -0
  63. package/src/namespace/namespace.ts +106 -0
  64. package/src/procedure/procedure.ts +40 -35
  65. package/src/reducer/reducer.ts +78 -52
  66. package/src/remote/remoteState.test.ts +590 -397
  67. package/src/remote/remoteState.ts +425 -347
  68. package/src/remote/remoteState.typecheck.ts +47 -56
  69. package/src/runtime/appRuntime.activation.test.ts +100 -0
  70. package/src/runtime/appRuntime.test.ts +249 -0
  71. package/src/runtime/appRuntime.ts +415 -97
  72. package/src/runtime/bus.ts +2 -15
  73. package/src/runtime/eventBudget.test.ts +152 -0
  74. package/src/runtime/eventBudget.ts +120 -0
  75. package/src/runtime/hardening.test.ts +73 -13
  76. package/src/runtime/instrumentation.test.ts +55 -52
  77. package/src/runtime/instrumentation.ts +6 -60
  78. package/src/runtime/loop.test.ts +36 -17
  79. package/src/runtime/loop.ts +87 -88
  80. package/src/runtime/queries.ts +0 -17
  81. package/src/scene/featureScene.test.ts +61 -0
  82. package/src/scene/scene.ts +247 -104
  83. package/src/scene/seedScene.test.ts +42 -79
  84. package/src/state/state.nominal.typecheck.ts +68 -0
  85. package/src/state/state.test.ts +17 -0
  86. package/src/state/state.ts +80 -46
  87. package/src/state/stateFamily.test.ts +16 -11
  88. package/src/state/stateFamily.ts +55 -65
  89. package/src/state/stateGroup.ts +53 -64
  90. package/src/state/token.ts +40 -23
  91. package/src/synced/syncedStore.ts +46 -58
  92. package/src/testkit/flight.testkit.ts +75 -0
  93. package/src/ui/node.ts +0 -6
  94. package/src/ui/trigger.ts +0 -5
  95. package/src/wire/tree.test.ts +8 -7
  96. package/src/wire/tree.ts +0 -39
  97. package/src/wire/triggers.test.ts +6 -6
  98. package/src/wire/triggers.ts +14 -32
  99. package/src/internal/ctx.ts +0 -16
@@ -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
- export interface StateManifestExternalApi<N extends string, A> extends Manifest {
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: Schema.Schema<A, any>
25
+ readonly schema: StateSchemaReflection
20
26
  readonly title?: string
21
27
  readonly description?: string
22
28
  }
23
- export type StateManifest<N extends string, A> = StateManifestExternalApi<N, A>
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 interface StateClass<out N extends string, in out A> extends Effect.Effect<A, never, Store<A>> {
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<N, A>
28
- /** Internal DI tag holding the live store. The loop reads/writes through it. */
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
- export type AnyState = StateClass<string, any>
33
- export type StateValue<S> = S extends StateClass<string, infer A> ? A : never
34
- export type StateName<S> = S extends StateClass<infer N, any> ? N : never
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
- * 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
- export const make = <const N extends string, A>(
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: Schema.Schema<A, any>,
75
+ schema: StateSchema,
45
76
  options: StateOptions = {},
46
- ): StateClass<N, A> => {
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<Store<A>, Store<A>>(identifier)
50
- const manifest: StateManifest<N, A> = {
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
- return yieldableClass(read, { manifest, store })
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
- * 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
- 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 = Schema.decodeUnknownOption(state.manifest.schema)(overrides[state.manifest.name])
117
+ const override = state.decodeUnknownOption(overrides[state.manifest.name])
79
118
  return Option.getOrElse(override, () => initial)
80
119
  })
81
120
 
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
- 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() // count → 0, eviction scheduled for the next microtask
98
- store.subscribe(() => {}) // re-subscribe in the same tick → count back to 1
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')
@@ -1,4 +1,4 @@
1
- import { Context, Effect, Layer, Option, type Schema } from 'effect'
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
- export interface StateFamilyManifestExternalApi<N extends string, K, V> extends Manifest {
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: Schema.Schema<K, any>
120
- readonly value: Schema.Schema<V, any>
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, K, V> = StateFamilyManifestExternalApi<N, K, V>
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 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. */
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, K, V>
130
- readonly store: Context.Tag<FamilyStore<K, V>, FamilyStore<K, V>>
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 AnyFamily = StateFamilyClass<string, any, any>
134
- export type FamilyKey<F> = F extends StateFamilyClass<any, infer K, any> ? K : never
135
- export type FamilyValue<F> = F extends StateFamilyClass<any, any, infer V> ? V : never
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, any>,
149
- valueSchema: Schema.Schema<V, any>,
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<FamilyStore<K, V>, FamilyStore<K, V>>(identifier)
155
- const manifest: StateFamilyManifest<N, K, V> = {
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
- return definitionClass<StateFamilyClass<N, K, V>>({ manifest, store })
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, FamilyStore<K, V>> =>
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<FamilyStore<K, V>> => {
186
- // oxlint-disable-next-line reform-rules/no-type-assertion -- value-or-factory union can't be discriminated for a generic V without a cast
187
- const seed: (key: K) => V = typeof initial === 'function' ? (initial as (key: K) => V) : () => initial
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) =>
@@ -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 { type AnyState, live as stateLive, type StateClass, type StateName } from './state'
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 ValueForName<Members extends ReadonlyArray<AnyState>, N extends string> =
12
- Extract<Members[number], StateClass<N, any>> extends StateClass<any, infer A> ? A : never
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 [infer Head extends AnyState, ...infer Tail extends ReadonlyArray<AnyState>]
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
- * 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
- 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
- * 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
- 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
- return new StateToken(name, member.store)
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
- // 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
- // oxlint-disable-next-line reform-rules/no-type-assertion -- reflection boundary: index a mapped seed type by runtime member name
133
- const seedRecord = seeds as Record<string, unknown>
134
- // oxlint-disable-next-line reform-rules/no-type-assertion -- union-of-stores Rout can't be expressed by reduce's single-layer accumulator
135
- return group.members
136
- .map((member) => stateLive(member, seedRecord[member.manifest.name]))
137
- .reduce((accumulator, layer) => Layer.merge(accumulator, layer)) as Layer.Layer<StoresOf<Members>>
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
  }