@playfast/reform 0.0.5 → 0.0.8
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/package.json +31 -10
- package/src/boundary/boundary.test.ts +301 -0
- package/src/calc/asyncCalc.test.ts +556 -0
- package/src/calc/asyncData.ts +9 -1
- package/src/calc/calc.test.ts +287 -0
- package/src/calc/calcFamily.test.ts +206 -0
- package/src/calc/compose.test.ts +68 -0
- package/src/channel/channel.ts +9 -5
- package/src/compose/host.ts +4 -1
- package/src/compose/props.ts +5 -1
- package/src/compose/ui.test.ts +62 -0
- package/src/compose/ui.ts +173 -44
- package/src/compose/ui.typecheck.ts +56 -0
- package/src/definition/definition.ts +2 -0
- package/src/event/event.test.ts +23 -0
- package/src/feature/feature.mount.test.ts +82 -0
- package/src/feature/feature.test.ts +60 -0
- package/src/feature/feature.typecheck.ts +108 -0
- package/src/index.ts +38 -2
- package/src/internal/capture.ts +4 -4
- package/src/internal/errors.test.ts +33 -0
- package/src/internal/errors.ts +92 -16
- package/src/internal/inspect.test.ts +28 -0
- package/src/internal/queryDriver.ts +1 -1
- package/src/internal/reuse.test.ts +116 -0
- package/src/internal/scheduler.ts +3 -1
- package/src/internal/sources.ts +2 -2
- package/src/internal/stateRegistry.ts +32 -0
- package/src/internal/store.test.ts +80 -0
- package/src/internal/track.ts +3 -1
- package/src/remote/remoteState.test.ts +695 -0
- package/src/remote/remoteState.typecheck.ts +195 -0
- package/src/runtime/bus.ts +6 -2
- package/src/runtime/hardening.test.ts +69 -0
- package/src/runtime/loop.test.ts +178 -0
- package/src/runtime/loop.ts +4 -2
- package/src/scene/seedScene.test.ts +169 -0
- package/src/state/state.ts +4 -1
- package/src/state/stateFamily.test.ts +138 -0
- package/src/state/stateFamily.ts +4 -1
- package/src/state/stateGroup.ts +31 -4
- package/src/state/token.ts +5 -4
- package/src/synced/syncedStore.ts +99 -0
- package/src/wire/tree.test.ts +81 -0
- package/src/wire/tree.ts +129 -0
- package/src/wire/triggers.test.ts +76 -0
- package/src/wire/triggers.ts +98 -0
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { expect, it } from '@effect/vitest'
|
|
2
|
+
import { Duration, Effect, Layer, Schema as S } from 'effect'
|
|
3
|
+
import { Engine, Event, publish, Reducer, StateFamily } from '../index'
|
|
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
|
+
const tick = Effect.sleep(Duration.millis(1))
|
|
9
|
+
|
|
10
|
+
it.live("writing one key does not notify another key's subscribers", () => {
|
|
11
|
+
class Items extends StateFamily.make('items', S.String, S.Number) {}
|
|
12
|
+
const TestLayer = StateFamily.live(Items, 0)
|
|
13
|
+
|
|
14
|
+
return Effect.gen(function* () {
|
|
15
|
+
const family = yield* Items.store
|
|
16
|
+
const a = family.at('a')
|
|
17
|
+
const b = family.at('b')
|
|
18
|
+
const aWakes = { n: 0 }
|
|
19
|
+
const bWakes = { n: 0 }
|
|
20
|
+
a.subscribe(() => {
|
|
21
|
+
aWakes.n += 1
|
|
22
|
+
})
|
|
23
|
+
b.subscribe(() => {
|
|
24
|
+
bWakes.n += 1
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
a.set(1)
|
|
28
|
+
yield* tick
|
|
29
|
+
expect(aWakes.n).toBe(1)
|
|
30
|
+
expect(bWakes.n).toBe(0)
|
|
31
|
+
expect(b.get()).toBe(0)
|
|
32
|
+
}).pipe(Effect.provide(TestLayer))
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it.live('at(key) returns the same store each call, so subscriptions are stable', () => {
|
|
36
|
+
class Items extends StateFamily.make('items', S.String, S.Number) {}
|
|
37
|
+
const TestLayer = StateFamily.live(Items, 0)
|
|
38
|
+
|
|
39
|
+
return Effect.gen(function* () {
|
|
40
|
+
const family = yield* Items.store
|
|
41
|
+
expect(family.at('k')).toBe(family.at('k'))
|
|
42
|
+
}).pipe(Effect.provide(TestLayer))
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it.live('forget(key) drops the store; re-reading the key reseeds it', () => {
|
|
46
|
+
class Items extends StateFamily.make('items', S.String, S.Number) {}
|
|
47
|
+
const TestLayer = StateFamily.live(Items, 0)
|
|
48
|
+
|
|
49
|
+
return Effect.gen(function* () {
|
|
50
|
+
const family = yield* Items.store
|
|
51
|
+
const first = family.at('k')
|
|
52
|
+
first.set(42)
|
|
53
|
+
expect(family.at('k').get()).toBe(42)
|
|
54
|
+
|
|
55
|
+
family.forget('k')
|
|
56
|
+
// A fresh store, reseeded to the family's initial value — not the old 42.
|
|
57
|
+
const reseeded = family.at('k')
|
|
58
|
+
expect(reseeded).not.toBe(first)
|
|
59
|
+
expect(reseeded.get()).toBe(0)
|
|
60
|
+
}).pipe(Effect.provide(TestLayer))
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it.live('evictWhenUnused drops a key once its last subscriber leaves', () => {
|
|
64
|
+
class Items extends StateFamily.make('items', S.String, S.Number) {}
|
|
65
|
+
const TestLayer = StateFamily.live(Items, 0, { evictWhenUnused: true })
|
|
66
|
+
|
|
67
|
+
return Effect.gen(function* () {
|
|
68
|
+
const family = yield* Items.store
|
|
69
|
+
const store = family.at('k')
|
|
70
|
+
const offA = store.subscribe(() => {})
|
|
71
|
+
const offB = store.subscribe(() => {})
|
|
72
|
+
expect(family.size()).toBe(1)
|
|
73
|
+
|
|
74
|
+
// One subscriber leaving keeps the key (another still reads it).
|
|
75
|
+
offA()
|
|
76
|
+
yield* tick
|
|
77
|
+
expect(family.size()).toBe(1)
|
|
78
|
+
|
|
79
|
+
// The last subscriber leaving evicts the key on the next microtask.
|
|
80
|
+
offB()
|
|
81
|
+
yield* tick
|
|
82
|
+
expect(family.size()).toBe(0)
|
|
83
|
+
// Re-reading reseeds a fresh store.
|
|
84
|
+
expect(family.at('k').get()).toBe(0)
|
|
85
|
+
}).pipe(Effect.provide(TestLayer))
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it.live('evictWhenUnused: a same-tick re-subscribe cancels eviction', () => {
|
|
89
|
+
class Items extends StateFamily.make('items', S.String, S.Number) {}
|
|
90
|
+
const TestLayer = StateFamily.live(Items, 0, { evictWhenUnused: true })
|
|
91
|
+
|
|
92
|
+
return Effect.gen(function* () {
|
|
93
|
+
const family = yield* Items.store
|
|
94
|
+
const store = family.at('k')
|
|
95
|
+
store.set(7)
|
|
96
|
+
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
|
|
99
|
+
yield* tick
|
|
100
|
+
// Not evicted: the value survives and `at` returns the same live store.
|
|
101
|
+
expect(family.size()).toBe(1)
|
|
102
|
+
expect(family.at('k').get()).toBe(7)
|
|
103
|
+
}).pipe(Effect.provide(TestLayer))
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it.live('a family reducer fold returning Tombstone evicts the key', () => {
|
|
107
|
+
class Items extends StateFamily.make('items', S.String, S.Number) {}
|
|
108
|
+
class Bumped extends Event.make('Bumped', S.Struct({ id: S.String })) {}
|
|
109
|
+
class Removed extends Event.make('Removed', S.Struct({ id: S.String })) {}
|
|
110
|
+
class ItemsReducer extends Reducer.make('ItemsReducer', {
|
|
111
|
+
family: Items,
|
|
112
|
+
keyOf: (event) => event.id,
|
|
113
|
+
events: [Bumped, Removed],
|
|
114
|
+
}) {}
|
|
115
|
+
const ItemsLive = Reducer.live(ItemsReducer, (n, event) =>
|
|
116
|
+
event._tag === 'Removed' ? StateFamily.Tombstone : n + 1,
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
const TestLayer = ItemsLive.pipe(
|
|
120
|
+
Layer.provideMerge(Layer.mergeAll(StateFamily.live(Items, 0), Engine)),
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
return Effect.gen(function* () {
|
|
124
|
+
const family = yield* Items.store
|
|
125
|
+
|
|
126
|
+
yield* publish('High', Event.construct(Bumped, { id: 'a' }))
|
|
127
|
+
yield* Effect.sleep(Duration.millis(5))
|
|
128
|
+
const before = family.at('a')
|
|
129
|
+
expect(before.get()).toBe(1)
|
|
130
|
+
|
|
131
|
+
// The fold returns the eviction sentinel: the key's store is dropped.
|
|
132
|
+
yield* publish('High', Event.construct(Removed, { id: 'a' }))
|
|
133
|
+
yield* Effect.sleep(Duration.millis(5))
|
|
134
|
+
const after = family.at('a')
|
|
135
|
+
expect(after).not.toBe(before)
|
|
136
|
+
expect(after.get()).toBe(0)
|
|
137
|
+
}).pipe(Effect.provide(TestLayer))
|
|
138
|
+
})
|
package/src/state/stateFamily.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Context, Effect, Layer, type Schema } from 'effect'
|
|
2
2
|
import { type Manifest, definitionClass } from '../definition/definition'
|
|
3
3
|
import { resolveScheduler, type Scheduler } from '../internal/scheduler'
|
|
4
|
+
import { claimStateTag } from '../internal/stateRegistry'
|
|
4
5
|
import { makeStore, type Store } from '../internal/store'
|
|
5
6
|
import { readTracked } from '../internal/track'
|
|
6
7
|
|
|
@@ -131,7 +132,9 @@ export const make = <const N extends string, K, V>(
|
|
|
131
132
|
value: Schema.Schema<V, any>,
|
|
132
133
|
options: { readonly title?: string; readonly description?: string } = {},
|
|
133
134
|
): StateFamilyClass<N, K, V> => {
|
|
134
|
-
const
|
|
135
|
+
const identifier = `reform/family/${name}`
|
|
136
|
+
claimStateTag(identifier)
|
|
137
|
+
const store = Context.GenericTag<FamilyStore<K, V>, FamilyStore<K, V>>(identifier)
|
|
135
138
|
const manifest: StateFamilyManifest<N, K, V> = {
|
|
136
139
|
kind: 'StateFamily',
|
|
137
140
|
name,
|
package/src/state/stateGroup.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Layer } from 'effect'
|
|
2
2
|
import { definitionClass } from '../definition/definition'
|
|
3
|
-
import { UnknownGroupState } from '../internal/errors'
|
|
3
|
+
import { DuplicateRegistration, UnknownGroupState } from '../internal/errors'
|
|
4
4
|
// A group's members each carry a `store` tag, so the shared `StoresOf` (used for
|
|
5
5
|
// calc inputs too) distributes them into the union of distinct stores their
|
|
6
6
|
// `live` layer provides — no group-specific mapped type needed.
|
|
@@ -21,6 +21,27 @@ export interface StateGroupClass<Members extends ReadonlyArray<AnyState>> {
|
|
|
21
21
|
|
|
22
22
|
export type AnyStateGroup = StateGroupClass<ReadonlyArray<AnyState>>
|
|
23
23
|
|
|
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
|
+
type DuplicateStateName<N extends string> = readonly ['reform: duplicate state name in group', N]
|
|
34
|
+
|
|
35
|
+
type NoDuplicateNames<
|
|
36
|
+
Members extends ReadonlyArray<AnyState>,
|
|
37
|
+
Seen extends string = never,
|
|
38
|
+
> = Members extends readonly [infer Head extends AnyState, ...infer Tail extends ReadonlyArray<AnyState>]
|
|
39
|
+
? readonly [
|
|
40
|
+
StateName<Head> extends Seen ? DuplicateStateName<StateName<Head>> : Head,
|
|
41
|
+
...NoDuplicateNames<Tail, Seen | StateName<Head>>,
|
|
42
|
+
]
|
|
43
|
+
: readonly []
|
|
44
|
+
|
|
24
45
|
// The seed record `StateGroup.live` requires: one entry per member, keyed by the
|
|
25
46
|
// member's name and typed to that member's value (so a missing or mistyped seed
|
|
26
47
|
// is a compile error). Written against the group class, so call sites read
|
|
@@ -32,13 +53,19 @@ export type GroupSeeds<G extends AnyStateGroup> =
|
|
|
32
53
|
|
|
33
54
|
/** Compose atomic States into a group provided (and addressed) as a unit. */
|
|
34
55
|
export const make = <const Members extends ReadonlyArray<AnyState>>(
|
|
35
|
-
...members: Members
|
|
36
|
-
): StateGroupClass<Members> =>
|
|
37
|
-
|
|
56
|
+
...members: Members & NoDuplicateNames<Members>
|
|
57
|
+
): StateGroupClass<Members> => {
|
|
58
|
+
// `byName` already de-dupes, so a smaller map than the member list means two
|
|
59
|
+
// members claimed one name — unaddressable, and a silent footgun on merge.
|
|
60
|
+
const names = members.map((m) => m.manifest.name)
|
|
61
|
+
const duplicate = names.find((name, i) => names.indexOf(name) !== i)
|
|
62
|
+
if (duplicate !== undefined) throw new DuplicateRegistration({ kind: 'state in group', name: duplicate })
|
|
63
|
+
return definitionClass<StateGroupClass<Members>>({
|
|
38
64
|
kind: 'StateGroup' as const,
|
|
39
65
|
members,
|
|
40
66
|
byName: new Map(members.map((m) => [m.manifest.name, m] as const)),
|
|
41
67
|
})
|
|
68
|
+
}
|
|
42
69
|
|
|
43
70
|
/**
|
|
44
71
|
* Address a member by its tag — `StateGroup.select(TodosStates, 'feed')` —
|
package/src/state/token.ts
CHANGED
|
@@ -8,11 +8,12 @@ import { readTracked } from '../internal/track'
|
|
|
8
8
|
* (its `name` and backing store tag). Returned by `StateGroup.select(group, name)`.
|
|
9
9
|
*/
|
|
10
10
|
export class StateToken<out N extends string, in out A> extends Effectable.Class<A, never, Store<A>> {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
) {
|
|
11
|
+
readonly name: N
|
|
12
|
+
readonly store: Context.Tag<Store<A>, Store<A>>
|
|
13
|
+
constructor(name: N, store: Context.Tag<Store<A>, Store<A>>) {
|
|
15
14
|
super()
|
|
15
|
+
this.name = name
|
|
16
|
+
this.store = store
|
|
16
17
|
}
|
|
17
18
|
commit(): Effect.Effect<A, never, Store<A>> {
|
|
18
19
|
return Effect.flatMap(this.store, readTracked)
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { Context, Effect, Layer, type Schema, type Scope } from 'effect'
|
|
2
|
+
import { type Manifest, yieldableClass } from '../definition/definition'
|
|
3
|
+
import { resolveScheduler } from '../internal/scheduler'
|
|
4
|
+
import { makeDerivedStore, type Store } from '../internal/store'
|
|
5
|
+
import { readTracked } from '../internal/track'
|
|
6
|
+
import type { Source } from '../state/token'
|
|
7
|
+
import type { StateOptions } from '../state/state'
|
|
8
|
+
|
|
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
|
+
export interface SyncedSource<A> {
|
|
26
|
+
readonly snapshot: () => A
|
|
27
|
+
readonly subscribe: (onChange: () => void) => () => void
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface SyncedStoreManifest<N extends string, A> extends Manifest {
|
|
31
|
+
readonly kind: 'SyncedStore'
|
|
32
|
+
readonly name: N
|
|
33
|
+
readonly schema: Schema.Schema<A, any>
|
|
34
|
+
readonly title?: string
|
|
35
|
+
readonly description?: string
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface SyncedStoreClass<out N extends string, in out A>
|
|
39
|
+
extends Effect.Effect<A, never, Store<A>>,
|
|
40
|
+
Source<N, A> {
|
|
41
|
+
new (): {}
|
|
42
|
+
readonly manifest: SyncedStoreManifest<N, A>
|
|
43
|
+
/** Internal DI tag holding the live store. `SyncedStore.live` allocates it. */
|
|
44
|
+
readonly store: Context.Tag<Store<A>, Store<A>>
|
|
45
|
+
/** The name, so the class doubles as a `Source` input to a calc/composition. */
|
|
46
|
+
readonly name: N
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export type AnySyncedStore = SyncedStoreClass<string, any>
|
|
50
|
+
export type SyncedValue<S> = S extends SyncedStoreClass<string, infer A> ? A : never
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Define an externally-synced slice: a reflectable manifest + an internal store
|
|
54
|
+
* tag, yieldable to its current value and usable as a calc/composition `Source`.
|
|
55
|
+
* Carries no source — that is supplied at wiring time by `SyncedStore.live`,
|
|
56
|
+
* exactly as `State.make` defers its seed to `State.live`.
|
|
57
|
+
*/
|
|
58
|
+
export const make = <const N extends string, A>(
|
|
59
|
+
name: N,
|
|
60
|
+
schema: Schema.Schema<A, any>,
|
|
61
|
+
options: StateOptions = {},
|
|
62
|
+
): SyncedStoreClass<N, A> => {
|
|
63
|
+
const store = Context.GenericTag<Store<A>, Store<A>>(`reform/syncedStore/${name}`)
|
|
64
|
+
const manifest: SyncedStoreManifest<N, A> = {
|
|
65
|
+
kind: 'SyncedStore',
|
|
66
|
+
name,
|
|
67
|
+
schema,
|
|
68
|
+
...(options.title !== undefined ? { title: options.title } : {}),
|
|
69
|
+
...(options.description !== undefined ? { description: options.description } : {}),
|
|
70
|
+
}
|
|
71
|
+
const read = Effect.flatMap(store, readTracked)
|
|
72
|
+
return yieldableClass(read, { manifest, store, name })
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Allocate a synced store's backing cell and bind it to an external source.
|
|
77
|
+
* `acquire` yields the source within the layer's scope (so it can open a live
|
|
78
|
+
* query / subscription whose `R` — e.g. a `Db` driver — is captured here); the
|
|
79
|
+
* store mirrors `snapshot()` and re-notifies on every `subscribe` change through
|
|
80
|
+
* the runtime's coalescing scheduler. The upstream subscription is released with
|
|
81
|
+
* the layer's scope.
|
|
82
|
+
*/
|
|
83
|
+
export const live = <A, R>(
|
|
84
|
+
// A minimal store-carrier — satisfied by a `SyncedStoreClass` and by any
|
|
85
|
+
// companion primitive (e.g. `@playfast/reform-db`'s `DbQuery`) that allocates
|
|
86
|
+
// its own `Store` tag, so the seam is reusable beyond `SyncedStore` itself.
|
|
87
|
+
def: { readonly store: Context.Tag<Store<A>, Store<A>> },
|
|
88
|
+
acquire: Effect.Effect<SyncedSource<A>, never, R>,
|
|
89
|
+
): Layer.Layer<Store<A>, never, Exclude<R, Scope.Scope>> =>
|
|
90
|
+
Layer.scoped(
|
|
91
|
+
def.store,
|
|
92
|
+
Effect.gen(function* () {
|
|
93
|
+
const scheduler = yield* resolveScheduler
|
|
94
|
+
const source = yield* acquire
|
|
95
|
+
const { store, unsubscribe } = makeDerivedStore(source.snapshot, source.subscribe, scheduler)
|
|
96
|
+
yield* Effect.addFinalizer(() => Effect.sync(unsubscribe))
|
|
97
|
+
return store
|
|
98
|
+
}),
|
|
99
|
+
)
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { expect, test } from 'vitest'
|
|
2
|
+
import { Wire } from '../index'
|
|
3
|
+
import type { WireNode, WireTree } from '../index'
|
|
4
|
+
|
|
5
|
+
const node = (id: string, over: Partial<WireNode> = {}): WireNode => ({
|
|
6
|
+
id,
|
|
7
|
+
name: 'View',
|
|
8
|
+
parentId: null,
|
|
9
|
+
childIndex: 0,
|
|
10
|
+
slot: null,
|
|
11
|
+
key: null,
|
|
12
|
+
props: [],
|
|
13
|
+
...over,
|
|
14
|
+
})
|
|
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
|
+
test('a fresh tree diffs to one upsert per node', () => {
|
|
20
|
+
const next: WireTree = [node('a'), node('b', { parentId: 'a', slot: 'body' })]
|
|
21
|
+
const patches = Wire.diff([], next)
|
|
22
|
+
expect(patches).toHaveLength(2)
|
|
23
|
+
expect(patches.every((patch) => patch._tag === 'Upsert')).toBe(true)
|
|
24
|
+
expect(Wire.apply([], patches)).toEqual(next)
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
test('unchanged nodes produce no patches', () => {
|
|
28
|
+
const tree: WireTree = [node('a'), node('b', { parentId: 'a' })]
|
|
29
|
+
expect(Wire.diff(tree, tree)).toHaveLength(0)
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
test('a changed prop upserts only that node', () => {
|
|
33
|
+
const prev: WireTree = [node('a', { props: [{ _tag: 'Data', name: 'count', value: 1 }] })]
|
|
34
|
+
const next: WireTree = [node('a', { props: [{ _tag: 'Data', name: 'count', value: 2 }] })]
|
|
35
|
+
const patches = Wire.diff(prev, next)
|
|
36
|
+
expect(patches).toEqual([{ _tag: 'Upsert', node: next[0] }])
|
|
37
|
+
expect(Wire.apply(prev, patches)).toEqual(next)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
test('a removed node diffs to a delete', () => {
|
|
41
|
+
const prev: WireTree = [node('a'), node('b', { parentId: 'a' })]
|
|
42
|
+
const next: WireTree = [node('a')]
|
|
43
|
+
const patches = Wire.diff(prev, next)
|
|
44
|
+
expect(patches).toEqual([{ _tag: 'Delete', id: 'b' }])
|
|
45
|
+
expect(Wire.apply(prev, patches)).toEqual(next)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
test('event props compare by handle, not by identity', () => {
|
|
49
|
+
const prev: WireTree = [node('a', { props: [{ _tag: 'Event', name: 'bump', handle: 'h1' }] })]
|
|
50
|
+
const same: WireTree = [node('a', { props: [{ _tag: 'Event', name: 'bump', handle: 'h1' }] })]
|
|
51
|
+
const changed: WireTree = [node('a', { props: [{ _tag: 'Event', name: 'bump', handle: 'h2' }] })]
|
|
52
|
+
expect(Wire.diff(prev, same)).toHaveLength(0)
|
|
53
|
+
expect(Wire.diff(prev, changed)).toHaveLength(1)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
test('deletes precede upserts within a frame', () => {
|
|
57
|
+
const prev: WireTree = [node('old', { parentId: 'r', slot: 'body' }), node('r')]
|
|
58
|
+
const next: WireTree = [node('r'), node('new', { parentId: 'r', slot: 'body' })]
|
|
59
|
+
const patches = Wire.diff(prev, next)
|
|
60
|
+
const firstUpsert = patches.findIndex((patch) => patch._tag === 'Upsert')
|
|
61
|
+
const lastDelete = patches.map((patch) => patch._tag).lastIndexOf('Delete')
|
|
62
|
+
expect(lastDelete).toBeLessThan(firstUpsert)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
test('roots and childrenOf order siblings by childIndex', () => {
|
|
66
|
+
const tree: WireTree = [
|
|
67
|
+
node('r1', { childIndex: 1 }),
|
|
68
|
+
node('r0', { childIndex: 0 }),
|
|
69
|
+
node('c1', { parentId: 'r0', childIndex: 1 }),
|
|
70
|
+
node('c0', { parentId: 'r0', childIndex: 0 }),
|
|
71
|
+
]
|
|
72
|
+
expect(Wire.roots(tree).map((n) => n.id)).toEqual(['r0', 'r1'])
|
|
73
|
+
expect(Wire.childrenOf(tree, 'r0').map((n) => n.id)).toEqual(['c0', 'c1'])
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
test('nested deep prop changes are detected', () => {
|
|
77
|
+
const prev: WireTree = [node('a', { props: [{ _tag: 'Data', name: 'rows', value: [{ id: 1 }, { id: 2 }] }] })]
|
|
78
|
+
const next: WireTree = [node('a', { props: [{ _tag: 'Data', name: 'rows', value: [{ id: 1 }, { id: 3 }] }] })]
|
|
79
|
+
expect(Wire.diff(prev, next)).toHaveLength(1)
|
|
80
|
+
expect(Wire.diff(prev, prev)).toHaveLength(0)
|
|
81
|
+
})
|
package/src/wire/tree.ts
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { Match } from 'effect'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The serializable model of a rendered UI tree, and the pure diff/apply over it.
|
|
5
|
+
*
|
|
6
|
+
* This is the renderer-neutral heart of the remote transport (REMOTE_UI.md §3):
|
|
7
|
+
* the server renders a scene to a `WireTree`, sends `diff(prev, next)` as
|
|
8
|
+
* `WirePatch`es, and the client folds them back with `apply`. No React, no
|
|
9
|
+
* Effect — pure data, so both ends and the proofs share one source of truth.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* A prop on a wire node: either an already-encoded data value, or a handle the
|
|
14
|
+
* client invokes to fire the server-side trigger (resolved by the trigger
|
|
15
|
+
* registry, REMOTE_UI.md §4). Streams, when added, become a third arm.
|
|
16
|
+
*/
|
|
17
|
+
export type WireProp =
|
|
18
|
+
| { readonly _tag: 'Data'; readonly name: string; readonly value: unknown }
|
|
19
|
+
| { readonly _tag: 'Event'; readonly name: string; readonly handle: string }
|
|
20
|
+
|
|
21
|
+
/** One rendered UI contract instance, identified stably across frames. */
|
|
22
|
+
export interface WireNode {
|
|
23
|
+
/** Stable identity across renders — the unit of diffing. */
|
|
24
|
+
readonly id: string
|
|
25
|
+
/** The UI contract name (`UiCapture.name`) the client looks up a presentation by. */
|
|
26
|
+
readonly name: string
|
|
27
|
+
/** Parent node id, or `null` for a root. */
|
|
28
|
+
readonly parentId: string | null
|
|
29
|
+
/** Order among siblings under the same parent. */
|
|
30
|
+
readonly childIndex: number
|
|
31
|
+
/** The slot name this node fills in its parent, or `null` for a root / direct child. */
|
|
32
|
+
readonly slot: string | null
|
|
33
|
+
/**
|
|
34
|
+
* The React `key` the parent gave this slot child (`createElement(slots.Row, { key })`),
|
|
35
|
+
* or `null` when none was set. It is the per-child identity a KEYED slot selects on: the
|
|
36
|
+
* client renders `<slots.Row slotKey={id} />` and the slot thunk picks the one wire child
|
|
37
|
+
* whose `key` matches, instead of rendering every child of that slot at every call site.
|
|
38
|
+
* Without it the client slot is render-all-children (correct for singleton slots, wrong for
|
|
39
|
+
* a list slot invoked once per item — it duplicates the whole list under each call).
|
|
40
|
+
*/
|
|
41
|
+
readonly key: string | null
|
|
42
|
+
readonly props: ReadonlyArray<WireProp>
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export type WireTree = ReadonlyArray<WireNode>
|
|
46
|
+
|
|
47
|
+
/** A change to apply to a client's tree: upsert a node, or drop one by id. */
|
|
48
|
+
export type WirePatch =
|
|
49
|
+
| { readonly _tag: 'Upsert'; readonly node: WireNode }
|
|
50
|
+
| { readonly _tag: 'Delete'; readonly id: string }
|
|
51
|
+
|
|
52
|
+
const arraysEqual = (a: ReadonlyArray<unknown>, b: ReadonlyArray<unknown>): boolean =>
|
|
53
|
+
a.length === b.length && a.every((item, index) => deepEqual(item, b[index]))
|
|
54
|
+
|
|
55
|
+
const recordsEqual = (a: Record<string, unknown>, b: Record<string, unknown>): boolean => {
|
|
56
|
+
const aKeys = Object.keys(a)
|
|
57
|
+
const bKeys = Object.keys(b)
|
|
58
|
+
return aKeys.length === bKeys.length && aKeys.every((key) => deepEqual(a[key], b[key]))
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Structural equality over serializable wire values (and the tagged props that carry them). */
|
|
62
|
+
const deepEqual = (a: unknown, b: unknown): boolean => {
|
|
63
|
+
if (a === b) return true
|
|
64
|
+
if (a === null || b === null) return false
|
|
65
|
+
if (Array.isArray(a)) return Array.isArray(b) && arraysEqual(a, b)
|
|
66
|
+
if (Array.isArray(b)) return false
|
|
67
|
+
if (typeof a === 'object' && typeof b === 'object') {
|
|
68
|
+
return recordsEqual(a as Record<string, unknown>, b as Record<string, unknown>)
|
|
69
|
+
}
|
|
70
|
+
return false
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const nodesEqual = (a: WireNode, b: WireNode): boolean =>
|
|
74
|
+
a.name === b.name &&
|
|
75
|
+
a.parentId === b.parentId &&
|
|
76
|
+
a.childIndex === b.childIndex &&
|
|
77
|
+
a.slot === b.slot &&
|
|
78
|
+
a.key === b.key &&
|
|
79
|
+
deepEqual(a.props, b.props)
|
|
80
|
+
|
|
81
|
+
const isUnchanged = (node: WireNode, previousById: ReadonlyMap<string, WireNode>): boolean => {
|
|
82
|
+
const previous = previousById.get(node.id)
|
|
83
|
+
return previous !== undefined && nodesEqual(previous, node)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The patches that turn `previous` into `next`. Deletes precede upserts so a
|
|
88
|
+
* client never holds a child whose reparented slot was freed in the same frame.
|
|
89
|
+
*/
|
|
90
|
+
export const diff = (previous: WireTree, next: WireTree): ReadonlyArray<WirePatch> => {
|
|
91
|
+
const previousById = new Map(previous.map((node) => [node.id, node]))
|
|
92
|
+
const nextById = new Map(next.map((node) => [node.id, node]))
|
|
93
|
+
|
|
94
|
+
const deletes = previous
|
|
95
|
+
.filter((node) => !nextById.has(node.id))
|
|
96
|
+
.map((node): WirePatch => ({ _tag: 'Delete', id: node.id }))
|
|
97
|
+
|
|
98
|
+
const upserts = next
|
|
99
|
+
.filter((node) => !isUnchanged(node, previousById))
|
|
100
|
+
.map((node): WirePatch => ({ _tag: 'Upsert', node }))
|
|
101
|
+
|
|
102
|
+
return [...deletes, ...upserts]
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const upsertNode = (state: WireTree, node: WireNode): WireTree => {
|
|
106
|
+
const index = state.findIndex((existing) => existing.id === node.id)
|
|
107
|
+
if (index < 0) return [...state, node]
|
|
108
|
+
return [...state.slice(0, index), node, ...state.slice(index + 1)]
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Fold patches into a client's tree (the receiving side's reducer). */
|
|
112
|
+
export const apply = (state: WireTree, patches: ReadonlyArray<WirePatch>): WireTree =>
|
|
113
|
+
patches.reduce(
|
|
114
|
+
(current, patch) =>
|
|
115
|
+
Match.value(patch).pipe(
|
|
116
|
+
Match.tag('Delete', ({ id }) => current.filter((node) => node.id !== id)),
|
|
117
|
+
Match.tag('Upsert', ({ node }) => upsertNode(current, node)),
|
|
118
|
+
Match.exhaustive,
|
|
119
|
+
),
|
|
120
|
+
state,
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
/** The root nodes of a tree, in sibling order. */
|
|
124
|
+
export const roots = (tree: WireTree): WireTree =>
|
|
125
|
+
tree.filter((node) => node.parentId === null).sort((a, b) => a.childIndex - b.childIndex)
|
|
126
|
+
|
|
127
|
+
/** The children of a node, in sibling order. */
|
|
128
|
+
export const childrenOf = (tree: WireTree, parentId: string): WireTree =>
|
|
129
|
+
tree.filter((node) => node.parentId === parentId).sort((a, b) => a.childIndex - b.childIndex)
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { expect, test } from 'vitest'
|
|
2
|
+
import { Effect, Exit, Schema as S } from 'effect'
|
|
3
|
+
import { Triggers } from '../index'
|
|
4
|
+
import type { Trigger } from '../index'
|
|
5
|
+
|
|
6
|
+
const ByPayload = S.Struct({ by: S.Number })
|
|
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
|
+
test('re-registering a handle overwrites it with the fresh trigger', () => {
|
|
13
|
+
const seen = Effect.runSync(
|
|
14
|
+
Effect.gen(function* () {
|
|
15
|
+
const registry = yield* Triggers.make
|
|
16
|
+
const calls: string[] = []
|
|
17
|
+
yield* registry.register('0:bump', () => void calls.push('stale'), ByPayload)
|
|
18
|
+
yield* registry.register('0:bump', () => void calls.push('fresh'), ByPayload)
|
|
19
|
+
yield* registry.invoke('0:bump', { by: 1 })
|
|
20
|
+
return calls
|
|
21
|
+
}),
|
|
22
|
+
)
|
|
23
|
+
expect(seen).toEqual(['fresh'])
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
test('invoke decodes the payload and fires the trigger with the decoded value', () => {
|
|
27
|
+
const seen = Effect.runSync(
|
|
28
|
+
Effect.gen(function* () {
|
|
29
|
+
const registry = yield* Triggers.make
|
|
30
|
+
const received: Array<{ by: number }> = []
|
|
31
|
+
const bump: Trigger<{ by: number }> = (payload) => void received.push(payload)
|
|
32
|
+
yield* registry.register('0:bump', bump, ByPayload)
|
|
33
|
+
yield* registry.invoke('0:bump', { by: 5 })
|
|
34
|
+
return received
|
|
35
|
+
}),
|
|
36
|
+
)
|
|
37
|
+
expect(seen).toEqual([{ by: 5 }])
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
test('invoke fails with ParseError when the wire payload violates the schema', () => {
|
|
41
|
+
const exit = Effect.runSyncExit(
|
|
42
|
+
Effect.gen(function* () {
|
|
43
|
+
const registry = yield* Triggers.make
|
|
44
|
+
yield* registry.register('0:bump', (() => {}) as Trigger<{ by: number }>, ByPayload)
|
|
45
|
+
yield* registry.invoke('0:bump', { by: 'not-a-number' })
|
|
46
|
+
}),
|
|
47
|
+
)
|
|
48
|
+
expect(Exit.isFailure(exit)).toBe(true)
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
test('invoke on an unknown handle fails with UnknownTrigger', () => {
|
|
52
|
+
const exit = Effect.runSyncExit(
|
|
53
|
+
Effect.gen(function* () {
|
|
54
|
+
const registry = yield* Triggers.make
|
|
55
|
+
yield* registry.invoke('t999', { by: 1 })
|
|
56
|
+
}),
|
|
57
|
+
)
|
|
58
|
+
expect(Exit.isFailure(exit)).toBe(true)
|
|
59
|
+
const error = Exit.isFailure(exit) ? exit.cause : undefined
|
|
60
|
+
expect(JSON.stringify(error)).toContain('UnknownTrigger')
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
test('a revoked handle no longer fires', () => {
|
|
64
|
+
const fired = Effect.runSync(
|
|
65
|
+
Effect.gen(function* () {
|
|
66
|
+
const registry = yield* Triggers.make
|
|
67
|
+
const calls: number[] = []
|
|
68
|
+
yield* registry.register('0:bump', ((p: { by: number }) => void calls.push(p.by)) as Trigger<{ by: number }>, ByPayload)
|
|
69
|
+
yield* registry.revoke('0:bump')
|
|
70
|
+
const exit = yield* Effect.exit(registry.invoke('0:bump', { by: 1 }))
|
|
71
|
+
return { calls, failed: Exit.isFailure(exit) }
|
|
72
|
+
}),
|
|
73
|
+
)
|
|
74
|
+
expect(fired.calls).toEqual([])
|
|
75
|
+
expect(fired.failed).toBe(true)
|
|
76
|
+
})
|