@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/token.ts
CHANGED
|
@@ -1,39 +1,56 @@
|
|
|
1
1
|
import { Context, Effect, Effectable } from 'effect'
|
|
2
2
|
import type { Store } from '../internal/store'
|
|
3
3
|
import { readTracked } from '../internal/track'
|
|
4
|
+
import type { StateStore } from './state'
|
|
4
5
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
export class StateToken<out N extends string, in out A> extends Effectable.Class<A, never, Store<A>> {
|
|
6
|
+
export class StateToken<
|
|
7
|
+
N extends string,
|
|
8
|
+
in out A,
|
|
9
|
+
Identifier = StateStore<N, A>,
|
|
10
|
+
> extends Effectable.Class<A, never, Identifier> {
|
|
11
11
|
readonly name: N
|
|
12
|
-
readonly store: Context.Tag<
|
|
13
|
-
constructor(name: N, store: Context.Tag<
|
|
12
|
+
readonly store: Context.Tag<Identifier, Store<A>>
|
|
13
|
+
constructor(name: N, store: Context.Tag<Identifier, Store<A>>) {
|
|
14
14
|
super()
|
|
15
15
|
this.name = name
|
|
16
16
|
this.store = store
|
|
17
17
|
}
|
|
18
|
-
commit(): Effect.Effect<A, never,
|
|
18
|
+
commit(): Effect.Effect<A, never, Identifier> {
|
|
19
19
|
return Effect.flatMap(this.store, readTracked)
|
|
20
20
|
}
|
|
21
|
+
capture<Result>(visit: SourceCapture<Result>): Result {
|
|
22
|
+
return visit(this)
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type SourceCapture<Result> = <N extends string, A, Identifier>(
|
|
27
|
+
source: Source<N, A, Identifier>,
|
|
28
|
+
) => Result
|
|
29
|
+
|
|
30
|
+
export interface AnySource {
|
|
31
|
+
readonly name: string
|
|
32
|
+
readonly store: { readonly key: string }
|
|
21
33
|
}
|
|
22
34
|
|
|
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
|
-
export interface Source<out N extends string, in out A> {
|
|
35
|
+
export interface Source<N extends string, A, Identifier> extends AnySource {
|
|
33
36
|
readonly name: N
|
|
34
|
-
readonly store: Context.Tag<
|
|
37
|
+
readonly store: Context.Tag<Identifier, Store<A>>
|
|
35
38
|
}
|
|
36
39
|
|
|
37
|
-
export type
|
|
38
|
-
|
|
39
|
-
|
|
40
|
+
export type SourceName<S> = S extends {
|
|
41
|
+
readonly manifest: { readonly name: infer N extends string }
|
|
42
|
+
}
|
|
43
|
+
? N
|
|
44
|
+
: S extends { readonly name: infer N extends string }
|
|
45
|
+
? N
|
|
46
|
+
: never
|
|
47
|
+
export type SourceValue<S> = S extends {
|
|
48
|
+
readonly store: Context.Tag<infer _Identifier, Store<infer A>>
|
|
49
|
+
}
|
|
50
|
+
? A
|
|
51
|
+
: never
|
|
52
|
+
export type SourceIdentifier<S> = S extends {
|
|
53
|
+
readonly store: Context.Tag<infer Identifier, infer _Service>
|
|
54
|
+
}
|
|
55
|
+
? Identifier
|
|
56
|
+
: never
|
|
@@ -1,76 +1,66 @@
|
|
|
1
1
|
import { Context, Effect, Layer, Option, type Schema, 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
|
-
import type { Source } from '../state/token'
|
|
7
|
+
import type { AnySource, Source, SourceCapture } from '../state/token'
|
|
7
8
|
import type { StateOptions } from '../state/state'
|
|
8
9
|
|
|
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
10
|
export interface SyncedSource<A> {
|
|
26
11
|
readonly snapshot: () => A
|
|
27
12
|
readonly subscribe: (onChange: () => void) => () => void
|
|
28
13
|
}
|
|
29
14
|
|
|
30
|
-
export interface
|
|
15
|
+
export interface SyncedStoreSchemaReflection {
|
|
16
|
+
readonly ast: Schema.Schema<unknown, unknown, unknown>['ast']
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface SyncedStoreManifest<N extends string> extends Manifest {
|
|
31
20
|
readonly kind: 'SyncedStore'
|
|
32
21
|
readonly name: N
|
|
33
|
-
readonly schema:
|
|
22
|
+
readonly schema: SyncedStoreSchemaReflection
|
|
34
23
|
readonly title: Option.Option<string>
|
|
35
24
|
readonly description: Option.Option<string>
|
|
36
25
|
}
|
|
37
26
|
|
|
38
|
-
export
|
|
39
|
-
|
|
40
|
-
|
|
27
|
+
export const SyncedStoreIdentifierTypeId: unique symbol = Symbol.for('reform/SyncedStoreIdentifier')
|
|
28
|
+
export type SyncedStoreIdentifierTypeId = typeof SyncedStoreIdentifierTypeId
|
|
29
|
+
|
|
30
|
+
export interface SyncedStoreIdentifier<N extends string, in out A> extends Store<A> {
|
|
31
|
+
readonly [SyncedStoreIdentifierTypeId]: N
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface SyncedStoreClass<N extends string, in out A>
|
|
35
|
+
extends
|
|
36
|
+
EffectType<A, never, SyncedStoreIdentifier<N, A>>,
|
|
37
|
+
Source<N, A, SyncedStoreIdentifier<N, A>> {
|
|
41
38
|
new (): {}
|
|
42
|
-
readonly manifest: SyncedStoreManifest<N
|
|
43
|
-
|
|
44
|
-
readonly store: Context.Tag<Store<A>, Store<A>>
|
|
45
|
-
/** The name, so the class doubles as a `Source` input to a calc/composition. */
|
|
39
|
+
readonly manifest: SyncedStoreManifest<N>
|
|
40
|
+
readonly store: Context.Tag<SyncedStoreIdentifier<N, A>, Store<A>>
|
|
46
41
|
readonly name: N
|
|
42
|
+
readonly capture: <Result>(visit: SourceCapture<Result>) => Result
|
|
47
43
|
}
|
|
48
44
|
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
export interface StoreCarrier<A> {
|
|
55
|
-
readonly store: Context.Tag<Store<A>, Store<A>>
|
|
45
|
+
export interface StoreCarrier<Identifier, A> {
|
|
46
|
+
readonly store: Context.Tag<Identifier, Store<A>>
|
|
56
47
|
}
|
|
57
48
|
|
|
58
|
-
export type AnySyncedStore =
|
|
59
|
-
|
|
49
|
+
export type AnySyncedStore = AnySource & {
|
|
50
|
+
readonly manifest: Manifest & { readonly kind: 'SyncedStore' }
|
|
51
|
+
}
|
|
52
|
+
export type SyncedValue<S> = S extends SyncedStoreClass<infer _N, infer A> ? A : never
|
|
60
53
|
|
|
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
|
-
export const make = <const N extends string, A>(
|
|
54
|
+
export const make = <const N extends string, ValueSchema extends Schema.Schema.AnyNoContext>(
|
|
68
55
|
name: N,
|
|
69
|
-
schema:
|
|
56
|
+
schema: ValueSchema,
|
|
70
57
|
options: StateOptions = {},
|
|
71
|
-
): SyncedStoreClass<N,
|
|
72
|
-
|
|
73
|
-
const
|
|
58
|
+
): SyncedStoreClass<N, Schema.Schema.Type<ValueSchema>> => {
|
|
59
|
+
type A = Schema.Schema.Type<ValueSchema>
|
|
60
|
+
const store = Context.GenericTag<SyncedStoreIdentifier<N, A>, Store<A>>(
|
|
61
|
+
`reform/syncedStore/${name}`,
|
|
62
|
+
)
|
|
63
|
+
const manifest: SyncedStoreManifest<N> = {
|
|
74
64
|
kind: 'SyncedStore',
|
|
75
65
|
name,
|
|
76
66
|
schema,
|
|
@@ -78,21 +68,19 @@ export const make = <const N extends string, A>(
|
|
|
78
68
|
description: Option.fromNullable(options.description),
|
|
79
69
|
}
|
|
80
70
|
const read = Effect.flatMap(store, readTracked)
|
|
81
|
-
|
|
71
|
+
const synced: SyncedStoreClass<N, A> = yieldableClass(read, {
|
|
72
|
+
manifest,
|
|
73
|
+
store,
|
|
74
|
+
name,
|
|
75
|
+
capture: <Result>(visit: SourceCapture<Result>): Result => visit(synced),
|
|
76
|
+
})
|
|
77
|
+
return synced
|
|
82
78
|
}
|
|
83
79
|
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
export const live = <A, R>(
|
|
93
|
-
def: StoreCarrier<A>,
|
|
80
|
+
export const live = <Identifier, A, R>(
|
|
81
|
+
def: StoreCarrier<Identifier, A>,
|
|
94
82
|
acquire: Effect.Effect<SyncedSource<A>, never, R>,
|
|
95
|
-
): Layer.Layer<
|
|
83
|
+
): Layer.Layer<Identifier, never, Exclude<R, Scope.Scope>> =>
|
|
96
84
|
Layer.scoped(
|
|
97
85
|
def.store,
|
|
98
86
|
Effect.gen(function* () {
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { Deferred, Duration, Effect, MutableRef, Option } from 'effect'
|
|
2
|
+
|
|
3
|
+
// Coalescing/cancellation semantics are only meaningful while a query is still
|
|
4
|
+
// in flight. Tests used to build that overlap by racing wall-clock — a 25ms
|
|
5
|
+
// query delay against 5ms gaps between input writes — which silently stops
|
|
6
|
+
// overlapping on a loaded machine: the runs serialize, every key gets its own
|
|
7
|
+
// flight, and the assertion fails reporting a coalescing bug that isn't there.
|
|
8
|
+
// A gate makes the overlap explicit: hold the flight open, land the burst,
|
|
9
|
+
// release.
|
|
10
|
+
|
|
11
|
+
export const tick = (ms = 10): Effect.Effect<void> => Effect.sleep(Duration.millis(ms))
|
|
12
|
+
|
|
13
|
+
export const until = <A>(
|
|
14
|
+
read: () => A,
|
|
15
|
+
predicate: (value: A) => boolean,
|
|
16
|
+
rounds = 200,
|
|
17
|
+
): Effect.Effect<A> =>
|
|
18
|
+
Effect.suspend(() => {
|
|
19
|
+
const value = read()
|
|
20
|
+
return predicate(value) || rounds <= 0
|
|
21
|
+
? Effect.succeed(value)
|
|
22
|
+
: Effect.flatMap(tick(10), () => until(read, predicate, rounds - 1))
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
export interface FlightGate {
|
|
26
|
+
/** Wrap a query body: it records its entry, then parks while the gate holds. */
|
|
27
|
+
readonly through: <A, E, R>(body: Effect.Effect<A, E, R>) => Effect.Effect<A, E, R>
|
|
28
|
+
/** Park every flight that reaches the gate from now on. */
|
|
29
|
+
readonly hold: Effect.Effect<void>
|
|
30
|
+
/** Let the parked flights settle; later flights pass straight through. */
|
|
31
|
+
readonly release: Effect.Effect<void>
|
|
32
|
+
/** Resolve once `count` flights have reached the gate. */
|
|
33
|
+
readonly awaitEntry: (count: number) => Effect.Effect<void>
|
|
34
|
+
/** How many flights have reached the gate. */
|
|
35
|
+
readonly entered: () => number
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export const makeFlightGate = (): FlightGate => {
|
|
39
|
+
const latch = MutableRef.make(Option.none<Deferred.Deferred<void>>())
|
|
40
|
+
const entered = MutableRef.make(0)
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
through: (body) =>
|
|
44
|
+
body.pipe(
|
|
45
|
+
Effect.tap(() =>
|
|
46
|
+
Effect.suspend(() => {
|
|
47
|
+
MutableRef.update(entered, (count) => count + 1)
|
|
48
|
+
return Option.match(MutableRef.get(latch), {
|
|
49
|
+
onNone: () => Effect.void,
|
|
50
|
+
onSome: (parked) => Deferred.await(parked),
|
|
51
|
+
})
|
|
52
|
+
}),
|
|
53
|
+
),
|
|
54
|
+
),
|
|
55
|
+
hold: Effect.flatMap(Deferred.make<void>(), (parked) =>
|
|
56
|
+
Effect.sync(() => MutableRef.set(latch, Option.some(parked))),
|
|
57
|
+
),
|
|
58
|
+
release: Effect.suspend(() => {
|
|
59
|
+
const parked = MutableRef.get(latch)
|
|
60
|
+
MutableRef.set(latch, Option.none())
|
|
61
|
+
return Option.match(parked, {
|
|
62
|
+
onNone: () => Effect.void,
|
|
63
|
+
onSome: (deferred) => Effect.asVoid(Deferred.succeed(deferred, undefined)),
|
|
64
|
+
})
|
|
65
|
+
}),
|
|
66
|
+
awaitEntry: (count) =>
|
|
67
|
+
Effect.asVoid(
|
|
68
|
+
until(
|
|
69
|
+
() => MutableRef.get(entered),
|
|
70
|
+
(reached) => reached >= count,
|
|
71
|
+
),
|
|
72
|
+
),
|
|
73
|
+
entered: () => MutableRef.get(entered),
|
|
74
|
+
}
|
|
75
|
+
}
|
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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { expect, test } from 'vitest'
|
|
2
|
-
import
|
|
3
|
-
import type { WireNode, WireTree } from '
|
|
2
|
+
import * as Wire from './tree'
|
|
3
|
+
import type { WireNode, WireTree } from './tree'
|
|
4
4
|
|
|
5
5
|
const node = (id: string, over: Partial<WireNode> = {}): WireNode => ({
|
|
6
6
|
id,
|
|
@@ -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)
|
|
@@ -74,8 +71,12 @@ test('roots and childrenOf order siblings by childIndex', () => {
|
|
|
74
71
|
})
|
|
75
72
|
|
|
76
73
|
test('nested deep prop changes are detected', () => {
|
|
77
|
-
const prev: WireTree = [
|
|
78
|
-
|
|
74
|
+
const prev: WireTree = [
|
|
75
|
+
node('a', { props: [{ _tag: 'Data', name: 'rows', value: [{ id: 1 }, { id: 2 }] }] }),
|
|
76
|
+
]
|
|
77
|
+
const next: WireTree = [
|
|
78
|
+
node('a', { props: [{ _tag: 'Data', name: 'rows', value: [{ id: 1 }, { id: 3 }] }] }),
|
|
79
|
+
]
|
|
79
80
|
expect(Wire.diff(prev, next)).toHaveLength(1)
|
|
80
81
|
expect(Wire.diff(prev, prev)).toHaveLength(0)
|
|
81
82
|
})
|
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),
|
|
@@ -1,14 +1,10 @@
|
|
|
1
1
|
import { expect, test } from 'vitest'
|
|
2
2
|
import { Effect, Exit, Schema as S } from 'effect'
|
|
3
|
-
import
|
|
3
|
+
import * as Triggers from './triggers'
|
|
4
4
|
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* () {
|
|
@@ -65,7 +61,11 @@ test('a revoked handle no longer fires', () => {
|
|
|
65
61
|
Effect.gen(function* () {
|
|
66
62
|
const registry = yield* Triggers.make
|
|
67
63
|
const calls: number[] = []
|
|
68
|
-
yield* registry.register(
|
|
64
|
+
yield* registry.register(
|
|
65
|
+
'0:bump',
|
|
66
|
+
((p: { by: number }) => void calls.push(p.by)) as Trigger<{ by: number }>,
|
|
67
|
+
ByPayload,
|
|
68
|
+
)
|
|
69
69
|
yield* registry.revoke('0:bump')
|
|
70
70
|
const exit = yield* Effect.exit(registry.invoke('0:bump', { by: 1 }))
|
|
71
71
|
return { calls, failed: Exit.isFailure(exit) }
|
package/src/wire/triggers.ts
CHANGED
|
@@ -1,29 +1,10 @@
|
|
|
1
1
|
import { type Cause, Context, Data, Effect, Layer, type ParseResult, Ref, Schema } from 'effect'
|
|
2
2
|
import type { Trigger } from '../ui/trigger'
|
|
3
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
|
-
*/
|
|
18
|
-
|
|
19
4
|
export type TriggerHandle = string
|
|
20
5
|
|
|
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
6
|
interface TriggerEntry {
|
|
25
|
-
readonly
|
|
26
|
-
readonly schema: Schema.Schema<any, any>
|
|
7
|
+
readonly invoke: (encodedPayload: unknown) => Effect.Effect<void, ParseResult.ParseError, never>
|
|
27
8
|
}
|
|
28
9
|
|
|
29
10
|
const UnknownTriggerBase: new (args: { readonly handle: TriggerHandle }) => Cause.YieldableError & {
|
|
@@ -32,7 +13,6 @@ const UnknownTriggerBase: new (args: { readonly handle: TriggerHandle }) => Caus
|
|
|
32
13
|
readonly handle: TriggerHandle
|
|
33
14
|
}>
|
|
34
15
|
|
|
35
|
-
/** A wire message named a trigger handle the server no longer holds (a stale client). */
|
|
36
16
|
export class UnknownTrigger extends UnknownTriggerBase {
|
|
37
17
|
override get message(): string {
|
|
38
18
|
return `reform: no trigger registered under handle '${this.handle}'`
|
|
@@ -40,18 +20,15 @@ export class UnknownTrigger extends UnknownTriggerBase {
|
|
|
40
20
|
}
|
|
41
21
|
|
|
42
22
|
export interface TriggerRegistryApi {
|
|
43
|
-
|
|
44
|
-
readonly register: <P>(
|
|
23
|
+
readonly register: <P, Encoded>(
|
|
45
24
|
handle: TriggerHandle,
|
|
46
25
|
trigger: Trigger<P>,
|
|
47
|
-
schema: Schema.Schema<P,
|
|
26
|
+
schema: Schema.Schema<P, Encoded, never>,
|
|
48
27
|
) => Effect.Effect<void>
|
|
49
|
-
/** Decode an encoded payload via the handle's schema and fire the trigger. */
|
|
50
28
|
readonly invoke: (
|
|
51
29
|
handle: TriggerHandle,
|
|
52
30
|
encodedPayload: unknown,
|
|
53
31
|
) => Effect.Effect<void, UnknownTrigger | ParseResult.ParseError>
|
|
54
|
-
/** Drop a handle once its node unmounts (the Phase 2 tree diff signals it). */
|
|
55
32
|
readonly revoke: (handle: TriggerHandle) => Effect.Effect<void>
|
|
56
33
|
}
|
|
57
34
|
|
|
@@ -66,12 +43,19 @@ export class TriggerRegistry extends TriggerRegistryBase {}
|
|
|
66
43
|
export const make: Effect.Effect<TriggerRegistryApi> = Effect.gen(function* () {
|
|
67
44
|
const entries = yield* Ref.make(new Map<TriggerHandle, TriggerEntry>())
|
|
68
45
|
|
|
69
|
-
const register = <P>(
|
|
46
|
+
const register = <P, Encoded>(
|
|
70
47
|
handle: TriggerHandle,
|
|
71
48
|
trigger: Trigger<P>,
|
|
72
|
-
schema: Schema.Schema<P,
|
|
49
|
+
schema: Schema.Schema<P, Encoded, never>,
|
|
73
50
|
): Effect.Effect<void> =>
|
|
74
|
-
Ref.update(entries, (map) =>
|
|
51
|
+
Ref.update(entries, (map) =>
|
|
52
|
+
new Map(map).set(handle, {
|
|
53
|
+
invoke: (encodedPayload) =>
|
|
54
|
+
Schema.decodeUnknown(schema)(encodedPayload).pipe(
|
|
55
|
+
Effect.map((payload) => trigger(payload)),
|
|
56
|
+
),
|
|
57
|
+
}),
|
|
58
|
+
)
|
|
75
59
|
|
|
76
60
|
const invoke = Effect.fn('invoke')(function* (
|
|
77
61
|
handle: TriggerHandle,
|
|
@@ -81,8 +65,7 @@ export const make: Effect.Effect<TriggerRegistryApi> = Effect.gen(function* () {
|
|
|
81
65
|
if (entry === undefined) {
|
|
82
66
|
return yield* Effect.fail(new UnknownTrigger({ handle }))
|
|
83
67
|
}
|
|
84
|
-
|
|
85
|
-
entry.trigger(payload)
|
|
68
|
+
yield* entry.invoke(encodedPayload)
|
|
86
69
|
})
|
|
87
70
|
|
|
88
71
|
const revoke = (handle: TriggerHandle): Effect.Effect<void> =>
|
|
@@ -95,5 +78,4 @@ export const make: Effect.Effect<TriggerRegistryApi> = Effect.gen(function* () {
|
|
|
95
78
|
return { register, invoke, revoke }
|
|
96
79
|
})
|
|
97
80
|
|
|
98
|
-
/** Provide a fresh registry — one per remote connection (each client gets its own handle space). */
|
|
99
81
|
export const layer: Layer.Layer<TriggerRegistry> = Layer.effect(TriggerRegistry, make)
|
package/src/internal/ctx.ts
DELETED
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
import type { Effect } from 'effect'
|
|
2
|
-
import type { YieldWrap } from 'effect/Utils'
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* The context (`R`) a generator body requires, recovered from the effects it
|
|
6
|
-
* yields. Shared by `Composition.live` and `Procedure.live`, whose bodies are
|
|
7
|
-
* both `Generator`s whose requirements must surface as their layer's `RIn`.
|
|
8
|
-
*
|
|
9
|
-
* A body that never yields (`Eff = never`) requires nothing — guard that case
|
|
10
|
-
* first, or `infer R` resolves to `unknown` and leaks into the layer's `RIn`.
|
|
11
|
-
*/
|
|
12
|
-
export type Ctx<Eff> = [Eff] extends [never]
|
|
13
|
-
? never
|
|
14
|
-
: [Eff] extends [YieldWrap<Effect.Effect<any, any, infer R>>]
|
|
15
|
-
? R
|
|
16
|
-
: never
|