@playfast/reform 0.1.0 → 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 +16 -44
- package/src/calc/calcFamily.test.ts +0 -12
- package/src/calc/calcFamily.ts +12 -49
- 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 +78 -173
- 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 -202
- 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 +48 -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 +10 -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 +131 -0
- 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 +324 -0
- package/src/runtime/instrumentation.ts +44 -0
- package/src/runtime/loop.test.ts +0 -17
- package/src/runtime/loop.ts +41 -58
- package/src/runtime/queries.ts +0 -17
- package/src/scene/featureScene.test.ts +59 -0
- package/src/scene/scene.ts +51 -44
- package/src/scene/seedScene.test.ts +96 -116
- package/src/state/state.ts +12 -28
- package/src/state/stateFamily.test.ts +2 -11
- package/src/state/stateFamily.ts +20 -54
- 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
package/src/state/stateFamily.ts
CHANGED
|
@@ -1,47 +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
|
+
import { resolveInstrumentation, stateUpdateHook } from '../runtime/instrumentation'
|
|
5
6
|
import { makeStore, type Store } from '../internal/store'
|
|
6
7
|
import { readTracked } from '../internal/track'
|
|
8
|
+
import type { AnySchema, AnyValue } from '../internal/variance'
|
|
7
9
|
|
|
8
|
-
/**
|
|
9
|
-
* A reducer fold returns this to evict the key it folded over, instead of a new
|
|
10
|
-
* value. The loop drops the entry's store from the family, so a normalized
|
|
11
|
-
* collection (chat messages, search hits, transient rows) does not grow without
|
|
12
|
-
* bound — the one fix for the otherwise-unbounded per-key `Map`.
|
|
13
|
-
*/
|
|
14
10
|
export const Tombstone: unique symbol = Symbol.for('reform/StateFamily/Tombstone')
|
|
15
11
|
export type Tombstone = typeof Tombstone
|
|
16
12
|
|
|
17
|
-
/** Per-key stores, created lazily so a write to one key wakes only its subscribers. */
|
|
18
13
|
export interface FamilyStore<K, V> {
|
|
19
14
|
at(key: K): Store<V>
|
|
20
|
-
/**
|
|
21
|
-
* Drop a key's store so it stops occupying memory. A live subscriber keeps its
|
|
22
|
-
* own closure over the previous store, so eviction is for keys whose entity is
|
|
23
|
-
* gone (the component is unmounting) — re-`at`-ing the key allocates a fresh,
|
|
24
|
-
* reseeded store.
|
|
25
|
-
*/
|
|
26
15
|
forget(key: K): void
|
|
27
|
-
/** Drop every key's store — a wholesale reset of the family. */
|
|
28
16
|
clear(): void
|
|
29
|
-
/** Number of live keys — for tests/diagnostics. */
|
|
30
17
|
size(): number
|
|
31
18
|
}
|
|
32
19
|
|
|
33
|
-
/** Options for a family's keyed store. */
|
|
34
20
|
export interface FamilyOptionsExternalApi {
|
|
35
|
-
/**
|
|
36
|
-
* Drop a key's store automatically once it has no live subscribers (checked on
|
|
37
|
-
* the next microtask, so a same-commit re-subscribe — e.g. a key that just moved
|
|
38
|
-
* position in a list — cancels the eviction). This bounds an otherwise-unbounded
|
|
39
|
-
* family without explicit `Tombstone`s, but it also DISCARDS the key's value —
|
|
40
|
-
* re-`at`-ing a dropped key reseeds from `initial`. Use only for keys whose value
|
|
41
|
-
* is disposable when nothing renders them (search hits, transient rows); for a
|
|
42
|
-
* source-of-truth entity that may be temporarily off-screen (a virtualized list),
|
|
43
|
-
* keep this off and reclaim with an explicit reducer `Tombstone`. Default: off.
|
|
44
|
-
*/
|
|
45
21
|
readonly evictWhenUnused?: boolean
|
|
46
22
|
}
|
|
47
23
|
export type FamilyOptions = FamilyOptionsExternalApi
|
|
@@ -50,17 +26,14 @@ const makeFamilyStore = <K, V>(
|
|
|
50
26
|
seed: (key: K) => V,
|
|
51
27
|
scheduler: Scheduler,
|
|
52
28
|
options: FamilyOptions = {},
|
|
29
|
+
onChange: () => void = () => {},
|
|
53
30
|
): FamilyStore<K, V> => {
|
|
54
31
|
const entries = new Map<K, Store<V>>()
|
|
55
32
|
const evictWhenUnused = options.evictWhenUnused === true
|
|
56
|
-
// Live subscriber count per key — maintained only when eviction is on.
|
|
57
33
|
const subscribers = new Map<K, number>()
|
|
58
34
|
const liveSubscriberCount = (key: K): number =>
|
|
59
35
|
Option.getOrElse(Option.fromNullable(subscribers.get(key)), () => 0)
|
|
60
36
|
|
|
61
|
-
// Wrap a store's `subscribe` to ref-count, evicting the key when it falls idle.
|
|
62
|
-
// `get`/`set`/`getVersion` are delegated unchanged, so the loop writes and the
|
|
63
|
-
// host reads see the same cell; only subscribe/unsubscribe are instrumented.
|
|
64
37
|
const refCounted = (key: K, store: Store<V>): Store<V> => ({
|
|
65
38
|
...store,
|
|
66
39
|
subscribe: (listener) => {
|
|
@@ -94,7 +67,7 @@ const makeFamilyStore = <K, V>(
|
|
|
94
67
|
if (existing !== undefined) {
|
|
95
68
|
return existing
|
|
96
69
|
}
|
|
97
|
-
const base = makeStore(seed(key), scheduler)
|
|
70
|
+
const base = makeStore(seed(key), scheduler, onChange)
|
|
98
71
|
const created = evictWhenUnused ? refCounted(key, base) : base
|
|
99
72
|
entries.set(key, created)
|
|
100
73
|
return created
|
|
@@ -114,28 +87,23 @@ const makeFamilyStore = <K, V>(
|
|
|
114
87
|
export interface StateFamilyManifestExternalApi<N extends string, K, V> extends Manifest {
|
|
115
88
|
readonly kind: 'StateFamily'
|
|
116
89
|
readonly name: N
|
|
117
|
-
readonly key:
|
|
118
|
-
readonly value:
|
|
90
|
+
readonly key: AnySchema<K>
|
|
91
|
+
readonly value: AnySchema<V>
|
|
119
92
|
readonly title?: string
|
|
120
93
|
readonly description?: string
|
|
121
94
|
}
|
|
122
95
|
export type StateFamilyManifest<N extends string, K, V> = StateFamilyManifestExternalApi<N, K, V>
|
|
123
96
|
|
|
124
97
|
export interface StateFamilyClass<out N extends string, in out K, in out V> {
|
|
125
|
-
/** Instance carries the key phantom so `Family['Key']` resolves in `keyOf` types. */
|
|
126
98
|
new (): { readonly Key: K }
|
|
127
99
|
readonly manifest: StateFamilyManifest<N, K, V>
|
|
128
100
|
readonly store: Context.Tag<FamilyStore<K, V>, FamilyStore<K, V>>
|
|
129
101
|
}
|
|
130
102
|
|
|
131
|
-
export type AnyFamily = StateFamilyClass<string,
|
|
132
|
-
export type FamilyKey<F> = F extends StateFamilyClass<
|
|
133
|
-
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
|
|
134
106
|
|
|
135
|
-
/**
|
|
136
|
-
* Normalized keyed state: one logical State per key, backed by a single family
|
|
137
|
-
* store. Scales with the collection without re-rendering unrelated entries.
|
|
138
|
-
*/
|
|
139
107
|
export interface StateFamilyMakeOptionsExternalApi {
|
|
140
108
|
readonly title?: string
|
|
141
109
|
readonly description?: string
|
|
@@ -143,8 +111,8 @@ export interface StateFamilyMakeOptionsExternalApi {
|
|
|
143
111
|
|
|
144
112
|
export const make = <const N extends string, K, V>(
|
|
145
113
|
name: N,
|
|
146
|
-
key:
|
|
147
|
-
valueSchema:
|
|
114
|
+
key: AnySchema<K>,
|
|
115
|
+
valueSchema: AnySchema<V>,
|
|
148
116
|
options: StateFamilyMakeOptionsExternalApi = {},
|
|
149
117
|
): StateFamilyClass<N, K, V> => {
|
|
150
118
|
const identifier = `reform/family/${name}`
|
|
@@ -161,21 +129,12 @@ export const make = <const N extends string, K, V>(
|
|
|
161
129
|
return definitionClass<StateFamilyClass<N, K, V>>({ manifest, store })
|
|
162
130
|
}
|
|
163
131
|
|
|
164
|
-
/**
|
|
165
|
-
* Read one entry by key — `StateFamily.read(ItemUi, id)`. Inside a render it
|
|
166
|
-
* subscribes that key's slice; elsewhere it just snapshots (D5).
|
|
167
|
-
*/
|
|
168
132
|
export const read = <N extends string, K, V>(
|
|
169
133
|
family: StateFamilyClass<N, K, V>,
|
|
170
134
|
key: K,
|
|
171
135
|
): Effect.Effect<V, never, FamilyStore<K, V>> =>
|
|
172
136
|
Effect.flatMap(family.store, (familyStore) => readTracked(familyStore.at(key)))
|
|
173
137
|
|
|
174
|
-
/**
|
|
175
|
-
* Allocate a family's keyed store — `StateFamily.live(ItemUi, seed)`. The seed
|
|
176
|
-
* is a plain value or a factory; a factory receives the key, so a new entry can
|
|
177
|
-
* derive its starting value from its own id (`(id) => ({ ...blank, id }))`).
|
|
178
|
-
*/
|
|
179
138
|
export const live = <N extends string, K, V>(
|
|
180
139
|
family: StateFamilyClass<N, K, V>,
|
|
181
140
|
initial: V | ((key: K) => V),
|
|
@@ -185,6 +144,13 @@ export const live = <N extends string, K, V>(
|
|
|
185
144
|
const seed: (key: K) => V = typeof initial === 'function' ? (initial as (key: K) => V) : () => initial
|
|
186
145
|
return Layer.effect(
|
|
187
146
|
family.store,
|
|
188
|
-
Effect.
|
|
147
|
+
Effect.zipWith(resolveScheduler, resolveInstrumentation, (scheduler, instrumentation) =>
|
|
148
|
+
makeFamilyStore<K, V>(
|
|
149
|
+
seed,
|
|
150
|
+
scheduler,
|
|
151
|
+
options,
|
|
152
|
+
stateUpdateHook(instrumentation, family.manifest.name),
|
|
153
|
+
),
|
|
154
|
+
),
|
|
189
155
|
)
|
|
190
156
|
}
|
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
|
package/src/state/token.ts
CHANGED
|
@@ -1,12 +1,8 @@
|
|
|
1
1
|
import { Context, Effect, Effectable } from 'effect'
|
|
2
2
|
import type { Store } from '../internal/store'
|
|
3
|
+
import type { AnyValue } from '../internal/variance'
|
|
3
4
|
import { readTracked } from '../internal/track'
|
|
4
5
|
|
|
5
|
-
/**
|
|
6
|
-
* A reference to one slice of state: yieldable to its current value (`yield*`
|
|
7
|
-
* reads + — under React — subscribes) and carrying the metadata calcs need
|
|
8
|
-
* (its `name` and backing store tag). Returned by `StateGroup.select(group, name)`.
|
|
9
|
-
*/
|
|
10
6
|
export class StateToken<out N extends string, in out A> extends Effectable.Class<A, never, Store<A>> {
|
|
11
7
|
readonly name: N
|
|
12
8
|
readonly store: Context.Tag<Store<A>, Store<A>>
|
|
@@ -20,20 +16,11 @@ export class StateToken<out N extends string, in out A> extends Effectable.Class
|
|
|
20
16
|
}
|
|
21
17
|
}
|
|
22
18
|
|
|
23
|
-
/**
|
|
24
|
-
* Anything a derived value can read and depend on: a named slice backed by a
|
|
25
|
-
* subscribable store tag. A `StateToken` (group member), a `Calc`, and an
|
|
26
|
-
* `AsyncCalc` all structurally satisfy this — so a `Calc`/`AsyncCalc` can take
|
|
27
|
-
* any of them as an input. The `name` becomes the key in the inputs object.
|
|
28
|
-
*
|
|
29
|
-
* `StateToken` is the one concrete, *yieldable* `Source`; `Calc`/`AsyncCalc`
|
|
30
|
-
* satisfy the same shape so the three are interchangeable as calc inputs.
|
|
31
|
-
*/
|
|
32
19
|
export interface Source<out N extends string, in out A> {
|
|
33
20
|
readonly name: N
|
|
34
21
|
readonly store: Context.Tag<Store<A>, Store<A>>
|
|
35
22
|
}
|
|
36
23
|
|
|
37
|
-
export type AnySource = Source<string,
|
|
38
|
-
export type SourceName<S> = S extends Source<infer N,
|
|
39
|
-
export type SourceValue<S> = S extends Source<
|
|
24
|
+
export type AnySource = Source<string, AnyValue>
|
|
25
|
+
export type SourceName<S> = S extends Source<infer N, AnyValue> ? N : never
|
|
26
|
+
export type SourceValue<S> = S extends Source<AnyValue, infer A> ? A : never
|
|
@@ -1,27 +1,13 @@
|
|
|
1
|
-
import { Context, Effect, Layer, Option, type
|
|
1
|
+
import { Context, Effect, Layer, Option, type Scope } 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 { makeDerivedStore, type Store } from '../internal/store'
|
|
5
6
|
import { readTracked } from '../internal/track'
|
|
6
7
|
import type { Source } from '../state/token'
|
|
7
8
|
import type { StateOptions } from '../state/state'
|
|
9
|
+
import type { AnySchema, AnyValue } from '../internal/variance'
|
|
8
10
|
|
|
9
|
-
// `SyncedStore` is the seam between Reform's reactive `Store` and any *external*
|
|
10
|
-
// reactive source — a PGlite live query, a TanStack DB collection, a socket feed.
|
|
11
|
-
// It is the synchronous, useSyncExternalStore-shaped bridge: a source exposes a
|
|
12
|
-
// `snapshot()` (the current value, synchronously) and a `subscribe(onChange)`,
|
|
13
|
-
// which is exactly what `makeDerivedStore` consumes. Like `RemoteState`'s `truth`
|
|
14
|
-
// store, a `SyncedStore` is written ONLY by its upstream subscription — never by a
|
|
15
|
-
// user reducer — so the "state changes only in the loop (or its driver)" invariant
|
|
16
|
-
// holds. `@playfast/reform-db` is the first consumer: it feeds a `SyncedStore` from
|
|
17
|
-
// a SQL live query whose value is an `AsyncData<Rows>`.
|
|
18
|
-
|
|
19
|
-
/**
|
|
20
|
-
* The contract an external reactive source implements. `snapshot` must return a
|
|
21
|
-
* stable reference until the value actually changes (the `makeDerivedStore`
|
|
22
|
-
* memoization contract `useSyncExternalStore` requires); `subscribe` returns an
|
|
23
|
-
* unsubscribe thunk, fired on every upstream change.
|
|
24
|
-
*/
|
|
25
11
|
export interface SyncedSource<A> {
|
|
26
12
|
readonly snapshot: () => A
|
|
27
13
|
readonly subscribe: (onChange: () => void) => () => void
|
|
@@ -30,43 +16,30 @@ export interface SyncedSource<A> {
|
|
|
30
16
|
export interface SyncedStoreManifest<N extends string, A> extends Manifest {
|
|
31
17
|
readonly kind: 'SyncedStore'
|
|
32
18
|
readonly name: N
|
|
33
|
-
readonly schema:
|
|
19
|
+
readonly schema: AnySchema<A>
|
|
34
20
|
readonly title: Option.Option<string>
|
|
35
21
|
readonly description: Option.Option<string>
|
|
36
22
|
}
|
|
37
23
|
|
|
38
24
|
export interface SyncedStoreClass<out N extends string, in out A>
|
|
39
|
-
extends
|
|
25
|
+
extends EffectType<A, never, Store<A>>,
|
|
40
26
|
Source<N, A> {
|
|
41
27
|
new (): {}
|
|
42
28
|
readonly manifest: SyncedStoreManifest<N, A>
|
|
43
|
-
/** Internal DI tag holding the live store. `SyncedStore.live` allocates it. */
|
|
44
29
|
readonly store: Context.Tag<Store<A>, Store<A>>
|
|
45
|
-
/** The name, so the class doubles as a `Source` input to a calc/composition. */
|
|
46
30
|
readonly name: N
|
|
47
31
|
}
|
|
48
32
|
|
|
49
|
-
/**
|
|
50
|
-
* A minimal store-carrier — satisfied by a `SyncedStoreClass` and by any
|
|
51
|
-
* companion primitive (e.g. `@playfast/reform-db`'s `DbQuery`) that allocates
|
|
52
|
-
* its own `Store` tag, so `SyncedStore.live` is reusable beyond `SyncedStore`.
|
|
53
|
-
*/
|
|
54
33
|
export interface StoreCarrier<A> {
|
|
55
34
|
readonly store: Context.Tag<Store<A>, Store<A>>
|
|
56
35
|
}
|
|
57
36
|
|
|
58
|
-
export type AnySyncedStore = SyncedStoreClass<string,
|
|
37
|
+
export type AnySyncedStore = SyncedStoreClass<string, AnyValue>
|
|
59
38
|
export type SyncedValue<S> = S extends SyncedStoreClass<string, infer A> ? A : never
|
|
60
39
|
|
|
61
|
-
/**
|
|
62
|
-
* Define an externally-synced slice: a reflectable manifest + an internal store
|
|
63
|
-
* tag, yieldable to its current value and usable as a calc/composition `Source`.
|
|
64
|
-
* Carries no source — that is supplied at wiring time by `SyncedStore.live`,
|
|
65
|
-
* exactly as `State.make` defers its seed to `State.live`.
|
|
66
|
-
*/
|
|
67
40
|
export const make = <const N extends string, A>(
|
|
68
41
|
name: N,
|
|
69
|
-
schema:
|
|
42
|
+
schema: AnySchema<A>,
|
|
70
43
|
options: StateOptions = {},
|
|
71
44
|
): SyncedStoreClass<N, A> => {
|
|
72
45
|
const store = Context.GenericTag<Store<A>, Store<A>>(`reform/syncedStore/${name}`)
|
|
@@ -81,14 +54,6 @@ export const make = <const N extends string, A>(
|
|
|
81
54
|
return yieldableClass(read, { manifest, store, name })
|
|
82
55
|
}
|
|
83
56
|
|
|
84
|
-
/**
|
|
85
|
-
* Allocate a synced store's backing cell and bind it to an external source.
|
|
86
|
-
* `acquire` yields the source within the layer's scope (so it can open a live
|
|
87
|
-
* query / subscription whose `R` — e.g. a `Db` driver — is captured here); the
|
|
88
|
-
* store mirrors `snapshot()` and re-notifies on every `subscribe` change through
|
|
89
|
-
* the runtime's coalescing scheduler. The upstream subscription is released with
|
|
90
|
-
* the layer's scope.
|
|
91
|
-
*/
|
|
92
57
|
export const live = <A, R>(
|
|
93
58
|
def: StoreCarrier<A>,
|
|
94
59
|
acquire: Effect.Effect<SyncedSource<A>, never, R>,
|
package/src/ui/node.ts
CHANGED
|
@@ -1,9 +1,3 @@
|
|
|
1
1
|
import type { ReactNode } from 'react'
|
|
2
2
|
|
|
3
|
-
/**
|
|
4
|
-
* What a view renders. Reform targets React, so a node is a `ReactNode` — this
|
|
5
|
-
* is a *type-only* dependency: core never imports the React runtime nor renders
|
|
6
|
-
* anything itself (that is `@reform/react`). Typing it precisely lets the `.ui`
|
|
7
|
-
* presentations author real JSX and lets slots be valid components.
|
|
8
|
-
*/
|
|
9
3
|
export type Node = ReactNode
|
package/src/ui/trigger.ts
CHANGED
package/src/wire/tree.test.ts
CHANGED
|
@@ -13,9 +13,6 @@ const node = (id: string, over: Partial<WireNode> = {}): WireNode => ({
|
|
|
13
13
|
...over,
|
|
14
14
|
})
|
|
15
15
|
|
|
16
|
-
// `diff` / `apply` are a round trip: folding `diff(prev, next)` into `prev`
|
|
17
|
-
// reconstructs `next` exactly, for any pair of trees.
|
|
18
|
-
|
|
19
16
|
test('a fresh tree diffs to one upsert per node', () => {
|
|
20
17
|
const next: WireTree = [node('a'), node('b', { parentId: 'a', slot: 'body' })]
|
|
21
18
|
const patches = Wire.diff([], next)
|
package/src/wire/tree.ts
CHANGED
|
@@ -1,55 +1,24 @@
|
|
|
1
1
|
import { Array, Match, Option, Order, Record as Rec } from 'effect'
|
|
2
2
|
import { sort as sortArray } from 'effect/Array'
|
|
3
3
|
|
|
4
|
-
/**
|
|
5
|
-
* The serializable model of a rendered UI tree, and the pure diff/apply over it.
|
|
6
|
-
*
|
|
7
|
-
* This is the renderer-neutral heart of the remote transport (REMOTE_UI.md §3):
|
|
8
|
-
* the server renders a scene to a `WireTree`, sends `diff(prev, next)` as
|
|
9
|
-
* `WirePatch`es, and the client folds them back with `apply`. No React, no
|
|
10
|
-
* Effect — pure data, so both ends and the proofs share one source of truth.
|
|
11
|
-
*/
|
|
12
|
-
|
|
13
|
-
/**
|
|
14
|
-
* A prop on a wire node: either an already-encoded data value, or a handle the
|
|
15
|
-
* client invokes to fire the server-side trigger (resolved by the trigger
|
|
16
|
-
* registry, REMOTE_UI.md §4). Streams, when added, become a third arm.
|
|
17
|
-
*/
|
|
18
4
|
export type WireProp =
|
|
19
5
|
| { readonly _tag: 'Data'; readonly name: string; readonly value: unknown }
|
|
20
6
|
| { readonly _tag: 'Event'; readonly name: string; readonly handle: string }
|
|
21
7
|
|
|
22
|
-
/** One rendered UI contract instance, identified stably across frames. The serializable
|
|
23
|
-
* wire-boundary shape (hence the `ExternalApi` postfix; `null` is part of the JSON contract). */
|
|
24
8
|
export interface WireNodeExternalApi {
|
|
25
|
-
/** Stable identity across renders — the unit of diffing. */
|
|
26
9
|
readonly id: string
|
|
27
|
-
/** The UI contract name (`UiCapture.name`) the client looks up a presentation by. */
|
|
28
10
|
readonly name: string
|
|
29
|
-
/** Parent node id, or `null` for a root. */
|
|
30
11
|
readonly parentId: string | null
|
|
31
|
-
/** Order among siblings under the same parent. */
|
|
32
12
|
readonly childIndex: number
|
|
33
|
-
/** The slot name this node fills in its parent, or `null` for a root / direct child. */
|
|
34
13
|
readonly slot: string | null
|
|
35
|
-
/**
|
|
36
|
-
* The React `key` the parent gave this slot child (`createElement(slots.Row, { key })`),
|
|
37
|
-
* or `null` when none was set. It is the per-child identity a KEYED slot selects on: the
|
|
38
|
-
* client renders `<slots.Row slotKey={id} />` and the slot thunk picks the one wire child
|
|
39
|
-
* whose `key` matches, instead of rendering every child of that slot at every call site.
|
|
40
|
-
* Without it the client slot is render-all-children (correct for singleton slots, wrong for
|
|
41
|
-
* a list slot invoked once per item — it duplicates the whole list under each call).
|
|
42
|
-
*/
|
|
43
14
|
readonly key: string | null
|
|
44
15
|
readonly props: ReadonlyArray<WireProp>
|
|
45
16
|
}
|
|
46
17
|
|
|
47
|
-
/** Public alias preserving the established name across the codebase. */
|
|
48
18
|
export type WireNode = WireNodeExternalApi
|
|
49
19
|
|
|
50
20
|
export type WireTree = ReadonlyArray<WireNode>
|
|
51
21
|
|
|
52
|
-
/** A change to apply to a client's tree: upsert a node, or drop one by id. */
|
|
53
22
|
export type WirePatch =
|
|
54
23
|
| { readonly _tag: 'Upsert'; readonly node: WireNode }
|
|
55
24
|
| { readonly _tag: 'Delete'; readonly id: string }
|
|
@@ -75,7 +44,6 @@ const recordsEqual = (left: Record<string, unknown>, right: Record<string, unkno
|
|
|
75
44
|
)
|
|
76
45
|
}
|
|
77
46
|
|
|
78
|
-
/** Structural equality over serializable wire values (and the tagged props that carry them). */
|
|
79
47
|
const deepEqual = ({ left, right }: EqualPair): boolean => {
|
|
80
48
|
if (left === right) {
|
|
81
49
|
return true
|
|
@@ -108,10 +76,6 @@ const isUnchanged = (node: WireNode, previousById: ReadonlyMap<string, WireNode>
|
|
|
108
76
|
return previous !== undefined && nodesEqual(previous, node)
|
|
109
77
|
}
|
|
110
78
|
|
|
111
|
-
/**
|
|
112
|
-
* The patches that turn `previous` into `next`. Deletes precede upserts so a
|
|
113
|
-
* client never holds a child whose reparented slot was freed in the same frame.
|
|
114
|
-
*/
|
|
115
79
|
export const diff = (previous: WireTree, next: WireTree): ReadonlyArray<WirePatch> => {
|
|
116
80
|
const previousById = new Map(previous.map((node) => [node.id, node]))
|
|
117
81
|
const nextById = new Map(next.map((node) => [node.id, node]))
|
|
@@ -136,7 +100,6 @@ const upsertNode = (state: WireTree, node: WireNode): WireTree =>
|
|
|
136
100
|
},
|
|
137
101
|
)
|
|
138
102
|
|
|
139
|
-
/** Fold patches into a client's tree (the receiving side's reducer). */
|
|
140
103
|
export const apply = (state: WireTree, patches: ReadonlyArray<WirePatch>): WireTree =>
|
|
141
104
|
patches.reduce(
|
|
142
105
|
(current, patch) =>
|
|
@@ -150,14 +113,12 @@ export const apply = (state: WireTree, patches: ReadonlyArray<WirePatch>): WireT
|
|
|
150
113
|
|
|
151
114
|
const bySiblingOrder = Order.mapInput(Order.number, (node: WireNode) => node.childIndex)
|
|
152
115
|
|
|
153
|
-
/** The root nodes of a tree, in sibling order. */
|
|
154
116
|
export const roots = (tree: WireTree): WireTree =>
|
|
155
117
|
sortArray(
|
|
156
118
|
tree.filter((node) => node.parentId === null),
|
|
157
119
|
bySiblingOrder,
|
|
158
120
|
)
|
|
159
121
|
|
|
160
|
-
/** The children of a node, in sibling order. */
|
|
161
122
|
export const childrenOf = (tree: WireTree, parentId: string): WireTree =>
|
|
162
123
|
sortArray(
|
|
163
124
|
tree.filter((node) => node.parentId === parentId),
|
|
@@ -5,10 +5,6 @@ import type { Trigger } from '../index'
|
|
|
5
5
|
|
|
6
6
|
const ByPayload = S.Struct({ by: S.Number })
|
|
7
7
|
|
|
8
|
-
// The registry holds a live trigger behind a caller-provided handle, decodes the
|
|
9
|
-
// wire payload through the contract's event schema, and fires — never shipping a
|
|
10
|
-
// function, and validating at the seam.
|
|
11
|
-
|
|
12
8
|
test('re-registering a handle overwrites it with the fresh trigger', () => {
|
|
13
9
|
const seen = Effect.runSync(
|
|
14
10
|
Effect.gen(function* () {
|
package/src/wire/triggers.ts
CHANGED
|
@@ -1,29 +1,12 @@
|
|
|
1
1
|
import { type Cause, Context, Data, Effect, Layer, type ParseResult, Ref, Schema } from 'effect'
|
|
2
2
|
import type { Trigger } from '../ui/trigger'
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* The server-side bridge for trigger handles (REMOTE_UI.md §4). A rendered
|
|
6
|
-
* `WireProp` of `_tag: 'Event'` carries a `handle`; the client invokes it by
|
|
7
|
-
* sending `(handle, encodedPayload)`. Here we hold the live `Trigger` and its
|
|
8
|
-
* payload schema, decode the wire payload, and fire — so the UI never ships a
|
|
9
|
-
* function, only a handle, and the payload is validated at the seam.
|
|
10
|
-
*
|
|
11
|
-
* Handles are caller-provided keys, by convention `${nodeId}:${eventName}`: a
|
|
12
|
-
* re-render re-`register`s the same handle with that frame's fresh trigger, so the
|
|
13
|
-
* handle is stable across frames (the client's in-flight handle stays valid) and
|
|
14
|
-
* the handle space never grows. The schema is the contract's own per-event wire
|
|
15
|
-
* schema (`WiredUiManifest.events`, Phase 1) — the single source of truth for the
|
|
16
|
-
* UI boundary, decoupled from which `Event` the composition binds behind it.
|
|
17
|
-
*/
|
|
3
|
+
import type { AnySchema, AnyValue } from '../internal/variance'
|
|
18
4
|
|
|
19
5
|
export type TriggerHandle = string
|
|
20
6
|
|
|
21
|
-
// `any` in the schema/trigger slots is the documented Schema-variance escape
|
|
22
|
-
// (same reason as `CompositionConfig.props`); only the registrar's call site,
|
|
23
|
-
// where the trigger and its schema agree, sees the precise type.
|
|
24
7
|
interface TriggerEntry {
|
|
25
|
-
readonly trigger: Trigger<
|
|
26
|
-
readonly schema: Schema.Schema
|
|
8
|
+
readonly trigger: Trigger<AnyValue>
|
|
9
|
+
readonly schema: Schema.Schema.AnyNoContext
|
|
27
10
|
}
|
|
28
11
|
|
|
29
12
|
const UnknownTriggerBase: new (args: { readonly handle: TriggerHandle }) => Cause.YieldableError & {
|
|
@@ -32,7 +15,6 @@ const UnknownTriggerBase: new (args: { readonly handle: TriggerHandle }) => Caus
|
|
|
32
15
|
readonly handle: TriggerHandle
|
|
33
16
|
}>
|
|
34
17
|
|
|
35
|
-
/** A wire message named a trigger handle the server no longer holds (a stale client). */
|
|
36
18
|
export class UnknownTrigger extends UnknownTriggerBase {
|
|
37
19
|
override get message(): string {
|
|
38
20
|
return `reform: no trigger registered under handle '${this.handle}'`
|
|
@@ -40,18 +22,15 @@ export class UnknownTrigger extends UnknownTriggerBase {
|
|
|
40
22
|
}
|
|
41
23
|
|
|
42
24
|
export interface TriggerRegistryApi {
|
|
43
|
-
/** Register (or overwrite) a trigger + its payload schema under a handle key. */
|
|
44
25
|
readonly register: <P>(
|
|
45
26
|
handle: TriggerHandle,
|
|
46
27
|
trigger: Trigger<P>,
|
|
47
|
-
schema:
|
|
28
|
+
schema: AnySchema<P>,
|
|
48
29
|
) => Effect.Effect<void>
|
|
49
|
-
/** Decode an encoded payload via the handle's schema and fire the trigger. */
|
|
50
30
|
readonly invoke: (
|
|
51
31
|
handle: TriggerHandle,
|
|
52
32
|
encodedPayload: unknown,
|
|
53
33
|
) => Effect.Effect<void, UnknownTrigger | ParseResult.ParseError>
|
|
54
|
-
/** Drop a handle once its node unmounts (the Phase 2 tree diff signals it). */
|
|
55
34
|
readonly revoke: (handle: TriggerHandle) => Effect.Effect<void>
|
|
56
35
|
}
|
|
57
36
|
|
|
@@ -69,7 +48,7 @@ export const make: Effect.Effect<TriggerRegistryApi> = Effect.gen(function* () {
|
|
|
69
48
|
const register = <P>(
|
|
70
49
|
handle: TriggerHandle,
|
|
71
50
|
trigger: Trigger<P>,
|
|
72
|
-
schema:
|
|
51
|
+
schema: AnySchema<P>,
|
|
73
52
|
): Effect.Effect<void> =>
|
|
74
53
|
Ref.update(entries, (map) => new Map(map).set(handle, { trigger, schema }))
|
|
75
54
|
|
|
@@ -95,5 +74,4 @@ export const make: Effect.Effect<TriggerRegistryApi> = Effect.gen(function* () {
|
|
|
95
74
|
return { register, invoke, revoke }
|
|
96
75
|
})
|
|
97
76
|
|
|
98
|
-
/** Provide a fresh registry — one per remote connection (each client gets its own handle space). */
|
|
99
77
|
export const layer: Layer.Layer<TriggerRegistry> = Layer.effect(TriggerRegistry, make)
|