@playfast/reform 0.0.9 → 0.0.11
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 +1 -1
- package/src/boundary/boundary.ts +16 -7
- package/src/calc/asyncCalc.invalidate.test.ts +166 -0
- package/src/calc/asyncCalc.ts +116 -25
- package/src/calc/asyncData.ts +8 -4
- package/src/calc/calc.ts +7 -3
- package/src/calc/calcFamily.ts +24 -10
- package/src/calc/compose.ts +1 -1
- package/src/calc/queryState.ts +52 -0
- package/src/channel/channel.ts +63 -37
- package/src/compose/composition.ts +20 -1
- package/src/compose/provide.ts +5 -1
- package/src/compose/slot.ts +4 -2
- package/src/compose/structure.ts +43 -19
- package/src/compose/ui.ts +21 -2
- package/src/compose/ui.typecheck.ts +4 -4
- package/src/definition/definition.ts +20 -7
- package/src/feature/feature.test.ts +4 -4
- package/src/feature/feature.ts +30 -16
- package/src/feature/feature.typecheck.ts +2 -2
- package/src/index.ts +21 -0
- package/src/internal/capture.ts +1 -0
- package/src/internal/errors.ts +9 -4
- package/src/internal/inspect.ts +4 -4
- package/src/internal/queryDriver.ts +273 -86
- package/src/internal/queryEvents.ts +34 -0
- package/src/internal/queryStore.ts +36 -0
- package/src/internal/reuse.ts +67 -30
- package/src/internal/scheduler.ts +35 -23
- package/src/internal/sources.ts +14 -8
- package/src/internal/stateRegistry.ts +3 -1
- package/src/internal/store.ts +10 -8
- package/src/internal/track.ts +3 -1
- package/src/procedure/procedure.ts +2 -2
- package/src/reducer/reducer.ts +17 -9
- package/src/remote/remoteState.test.ts +188 -1
- package/src/remote/remoteState.ts +112 -51
- package/src/remote/remoteState.typecheck.ts +4 -1
- package/src/runtime/bus.ts +3 -1
- package/src/runtime/hardening.test.ts +1 -1
- package/src/runtime/loop.ts +81 -53
- package/src/runtime/queries.ts +55 -0
- package/src/scene/scene.ts +16 -8
- package/src/state/state.ts +11 -10
- package/src/state/stateFamily.ts +27 -11
- package/src/state/stateGroup.ts +22 -9
- package/src/synced/syncedStore.ts +15 -9
- package/src/wire/tree.ts +66 -30
- package/src/wire/triggers.ts +9 -8
package/src/internal/reuse.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Equal } from 'effect'
|
|
1
|
+
import { Equal, Record as Rec } from 'effect'
|
|
2
2
|
|
|
3
3
|
// Structural sharing for recomputed calc outputs: reconcile a fresh output
|
|
4
4
|
// against the previous one, substituting previous nodes wherever value equality
|
|
@@ -9,32 +9,72 @@ import { Equal } from 'effect'
|
|
|
9
9
|
// pass composes with the framework's Equal-based invalidation untouched.
|
|
10
10
|
|
|
11
11
|
// Bounds the walk; calc outputs are shallow plain data by framework convention.
|
|
12
|
-
const
|
|
12
|
+
const MAX_DEPTH = 16
|
|
13
13
|
|
|
14
|
-
const isPlainRecord = (
|
|
15
|
-
typeof
|
|
16
|
-
|
|
17
|
-
(Object.getPrototypeOf(
|
|
14
|
+
const isPlainRecord = (candidate: unknown): candidate is Record<string, unknown> =>
|
|
15
|
+
typeof candidate === 'object' &&
|
|
16
|
+
candidate !== null &&
|
|
17
|
+
(Object.getPrototypeOf(candidate) === Object.prototype ||
|
|
18
|
+
Object.getPrototypeOf(candidate) === null)
|
|
18
19
|
|
|
19
|
-
|
|
20
|
-
|
|
20
|
+
/** Own enumerable string-keyed fields of an object, as a plain record. */
|
|
21
|
+
const ownRecord = (source: object): Record<string, unknown> =>
|
|
22
|
+
Rec.fromEntries(
|
|
23
|
+
Reflect.ownKeys(source)
|
|
24
|
+
.filter(
|
|
25
|
+
(key): key is string =>
|
|
26
|
+
typeof key === 'string' && Object.prototype.propertyIsEnumerable.call(source, key),
|
|
27
|
+
)
|
|
28
|
+
.map((key): readonly [string, unknown] => [key, Reflect.get(source, key)]),
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
interface ReconcileArgs {
|
|
32
|
+
readonly prev: unknown
|
|
33
|
+
readonly next: unknown
|
|
34
|
+
readonly depth: number
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Reconcile two records field-by-field; reports whether every field stayed `prev`. */
|
|
38
|
+
const reconcileRecord = (
|
|
39
|
+
prev: Record<string, unknown>,
|
|
40
|
+
next: Record<string, unknown>,
|
|
41
|
+
depth: number,
|
|
42
|
+
): { readonly out: Record<string, unknown>; readonly allPrev: boolean } => {
|
|
43
|
+
const keys = Rec.keys(next)
|
|
44
|
+
const out = Rec.fromEntries(
|
|
45
|
+
keys.map((key): readonly [string, unknown] => [
|
|
46
|
+
key,
|
|
47
|
+
key in prev ? reconcile({ prev: prev[key], next: next[key], depth: depth - 1 }) : next[key],
|
|
48
|
+
]),
|
|
49
|
+
)
|
|
50
|
+
const allPrev =
|
|
51
|
+
keys.length === Rec.keys(prev).length &&
|
|
52
|
+
keys.every((key) => key in prev && Object.is(out[key], prev[key]))
|
|
53
|
+
return { out, allPrev }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const reconcile = ({ prev, next, depth }: ReconcileArgs): unknown => {
|
|
57
|
+
if (Object.is(prev, next)) {
|
|
58
|
+
return prev
|
|
59
|
+
}
|
|
21
60
|
// Data/Schema classes implement Equal+Hash: substitute wholesale on value
|
|
22
61
|
// equality. Plain objects/arrays fall through (their Equal is referential).
|
|
23
|
-
if (Equal.equals(prev, next))
|
|
24
|
-
|
|
62
|
+
if (Equal.equals(prev, next)) {
|
|
63
|
+
return prev
|
|
64
|
+
}
|
|
65
|
+
if (depth <= 0) {
|
|
66
|
+
return next
|
|
67
|
+
}
|
|
25
68
|
if (Array.isArray(prev) && Array.isArray(next)) {
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
69
|
+
const reconciled = next.map((element, index) =>
|
|
70
|
+
index < prev.length ? reconcile({ prev: prev[index], next: element, depth: depth - 1 }) : element,
|
|
71
|
+
)
|
|
72
|
+
const unchanged =
|
|
73
|
+
prev.length === next.length && reconciled.every((entry, index) => Object.is(entry, prev[index]))
|
|
74
|
+
return unchanged ? prev : reconciled
|
|
30
75
|
}
|
|
31
76
|
if (isPlainRecord(prev) && isPlainRecord(next)) {
|
|
32
|
-
const
|
|
33
|
-
const out: Record<string, unknown> = {}
|
|
34
|
-
for (const key of keys) out[key] = key in prev ? go(prev[key], next[key], depth - 1) : next[key]
|
|
35
|
-
const allPrev =
|
|
36
|
-
keys.length === Object.keys(prev).length &&
|
|
37
|
-
keys.every((key) => key in prev && Object.is(out[key], prev[key]))
|
|
77
|
+
const { out, allPrev } = reconcileRecord(prev, next, depth)
|
|
38
78
|
return allPrev ? prev : out
|
|
39
79
|
}
|
|
40
80
|
// Data/Schema class instances that DIFFER still get walked: their Equal is
|
|
@@ -48,15 +88,10 @@ const go = (prev: unknown, next: unknown, depth: number): unknown => {
|
|
|
48
88
|
Equal.isEqual(next) &&
|
|
49
89
|
Object.getPrototypeOf(prev) === Object.getPrototypeOf(next)
|
|
50
90
|
) {
|
|
51
|
-
const
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
out[key] = key in prevFields ? go(prevFields[key], value, depth - 1) : value
|
|
56
|
-
const allPrev =
|
|
57
|
-
nextFields.length === Object.keys(prevFields).length &&
|
|
58
|
-
nextFields.every(([key]) => key in prevFields && Object.is(out[key], prevFields[key]))
|
|
59
|
-
return allPrev ? prev : Object.assign(Object.create(Object.getPrototypeOf(next)), out)
|
|
91
|
+
const { out, allPrev } = reconcileRecord(ownRecord(prev), ownRecord(next), depth)
|
|
92
|
+
return allPrev
|
|
93
|
+
? prev
|
|
94
|
+
: Object.create(Object.getPrototypeOf(next), Object.getOwnPropertyDescriptors(out))
|
|
60
95
|
}
|
|
61
96
|
// Class instances without Equal (Date, Map, …) are opaque leaves.
|
|
62
97
|
return next
|
|
@@ -70,4 +105,6 @@ const go = (prev: unknown, next: unknown, depth: number): unknown => {
|
|
|
70
105
|
* the single cast below is that argument, in the style of `wireSources` /
|
|
71
106
|
* `narrowStore`.
|
|
72
107
|
*/
|
|
73
|
-
export const reuse = <A>(previous: A, next: A): A =>
|
|
108
|
+
export const reuse = <A>(previous: A, next: A): A =>
|
|
109
|
+
// oxlint-disable-next-line reform-rules/no-type-assertion -- the walker returns a value-equal reconstruction of `next`, type-preserved by construction (the single seam this cast lives at)
|
|
110
|
+
reconcile({ prev: previous, next, depth: MAX_DEPTH }) as A
|
|
@@ -19,34 +19,46 @@ export const makeScheduler = (): Scheduler => {
|
|
|
19
19
|
const pending = new Set<() => void>()
|
|
20
20
|
const armed = MutableRef.make(false)
|
|
21
21
|
|
|
22
|
+
// One listener's throw (a defect in a user calc body) must not abandon the
|
|
23
|
+
// rest of the flush; surface it to the host's global handler on a fresh
|
|
24
|
+
// microtask instead of unwinding the drain.
|
|
25
|
+
const runListener = (listener: () => void): void =>
|
|
26
|
+
Effect.runSync(
|
|
27
|
+
Effect.try({ try: listener, catch: (error) => error }).pipe(
|
|
28
|
+
Effect.catchAll((error) =>
|
|
29
|
+
Effect.sync(() =>
|
|
30
|
+
queueMicrotask(() => {
|
|
31
|
+
// oxlint-disable-next-line reform-rules/no-throw -- only way to surface a listener defect to the host's global error handler
|
|
32
|
+
throw error
|
|
33
|
+
}),
|
|
34
|
+
),
|
|
35
|
+
),
|
|
36
|
+
),
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
// Drain to a fixpoint within ONE microtask. A derived store's `onChange`
|
|
40
|
+
// re-schedules during the pass (it is itself a listener of its upstream), so
|
|
41
|
+
// resolving the whole dependency graph here — instead of re-arming a fresh
|
|
42
|
+
// microtask per layer — collapses a depth-N propagation into a single flush
|
|
43
|
+
// and wakes each leaf subscriber once at its final value. Converges because
|
|
44
|
+
// a derived store schedules only when its output actually moves (Equal).
|
|
45
|
+
const drain = (): void => {
|
|
46
|
+
if (pending.size === 0) {
|
|
47
|
+
return
|
|
48
|
+
}
|
|
49
|
+
const due = [...pending]
|
|
50
|
+
pending.clear()
|
|
51
|
+
due.forEach(runListener)
|
|
52
|
+
drain()
|
|
53
|
+
}
|
|
54
|
+
|
|
22
55
|
const flush = () => {
|
|
23
56
|
MutableRef.set(armed, false)
|
|
24
|
-
|
|
25
|
-
// re-schedules during the pass (it is itself a listener of its upstream), so
|
|
26
|
-
// resolving the whole dependency graph here — instead of re-arming a fresh
|
|
27
|
-
// microtask per layer — collapses a depth-N propagation into a single flush
|
|
28
|
-
// and wakes each leaf subscriber once at its final value. Converges because
|
|
29
|
-
// a derived store schedules only when its output actually moves (Equal).
|
|
30
|
-
while (pending.size > 0) {
|
|
31
|
-
const due = [...pending]
|
|
32
|
-
pending.clear()
|
|
33
|
-
for (const listener of due) {
|
|
34
|
-
// One listener's throw (a defect in a user calc body) must not abandon
|
|
35
|
-
// the rest of the flush; surface it to the host's global handler on a
|
|
36
|
-
// fresh microtask instead of unwinding this loop.
|
|
37
|
-
try {
|
|
38
|
-
listener()
|
|
39
|
-
} catch (error) {
|
|
40
|
-
queueMicrotask(() => {
|
|
41
|
-
throw error
|
|
42
|
-
})
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
}
|
|
57
|
+
drain()
|
|
46
58
|
}
|
|
47
59
|
|
|
48
60
|
const schedule = (listeners: Iterable<() => void>) => {
|
|
49
|
-
|
|
61
|
+
;[...listeners].forEach((listener) => pending.add(listener))
|
|
50
62
|
if (pending.size > 0 && !MutableRef.get(armed)) {
|
|
51
63
|
MutableRef.set(armed, true)
|
|
52
64
|
queueMicrotask(flush)
|
package/src/internal/sources.ts
CHANGED
|
@@ -52,8 +52,8 @@ const inputKey = (input: AnySource): string =>
|
|
|
52
52
|
: input.name
|
|
53
53
|
|
|
54
54
|
/** Element-wise value equality of two invalidation keys. */
|
|
55
|
-
export const sameKey = (
|
|
56
|
-
|
|
55
|
+
export const sameKey = (left: ReadonlyArray<unknown>, right: ReadonlyArray<unknown>): boolean =>
|
|
56
|
+
left.length === right.length && left.every((element, index) => Equal.equals(element, right[index]))
|
|
57
57
|
|
|
58
58
|
/** The sensing surface both `Calc.live` and `AsyncCalc.live` build on. */
|
|
59
59
|
export interface WiredSources<Inputs extends ReadonlyArray<AnySource>> {
|
|
@@ -74,18 +74,24 @@ export const wireSources = <Inputs extends ReadonlyArray<AnySource>>(
|
|
|
74
74
|
inputs: Inputs,
|
|
75
75
|
invalidateBy?: InvalidateBy<Inputs>,
|
|
76
76
|
): Effect.Effect<WiredSources<Inputs>, never, InputStores<Inputs>> =>
|
|
77
|
+
// oxlint-disable-next-line reform-rules/no-type-assertion -- restate the precise InputStores<Inputs> union the signature promises; Effect.forEach over a heterogeneous tuple widens R (the single seam this cast lives at)
|
|
77
78
|
Effect.gen(function* () {
|
|
78
|
-
const sources:
|
|
79
|
-
|
|
79
|
+
const sources: ReadonlyArray<Store<unknown>> = yield* Effect.forEach(
|
|
80
|
+
inputs,
|
|
81
|
+
(input) => input.store,
|
|
82
|
+
)
|
|
80
83
|
|
|
81
84
|
const snapshot = (): InputsObject<Inputs> => {
|
|
82
85
|
const args: Record<string, unknown> = {}
|
|
83
|
-
inputs.forEach((input,
|
|
86
|
+
inputs.forEach((input, index) => {
|
|
84
87
|
// `noUncheckedIndexedAccess`: `sources` is built 1:1 with `inputs`, but
|
|
85
88
|
// guard the index so the read is honestly total.
|
|
86
|
-
const source = sources[
|
|
87
|
-
if (source !== undefined)
|
|
89
|
+
const source = sources[index]
|
|
90
|
+
if (source !== undefined) {
|
|
91
|
+
args[inputKey(input)] = source.getSnapshot()
|
|
92
|
+
}
|
|
88
93
|
})
|
|
94
|
+
// oxlint-disable-next-line reform-rules/no-type-assertion -- `args` is built by reflection over input names; this restates the InputsObject mapped-type shape
|
|
89
95
|
return args as InputsObject<Inputs>
|
|
90
96
|
}
|
|
91
97
|
const keyOf = (args: InputsObject<Inputs>): ReadonlyArray<unknown> =>
|
|
@@ -93,7 +99,7 @@ export const wireSources = <Inputs extends ReadonlyArray<AnySource>>(
|
|
|
93
99
|
const subscribe = (listener: () => void): (() => void) => {
|
|
94
100
|
const unsubscribes = sources.map((source) => source.subscribe(listener))
|
|
95
101
|
return () => {
|
|
96
|
-
|
|
102
|
+
unsubscribes.forEach((unsubscribe) => unsubscribe())
|
|
97
103
|
}
|
|
98
104
|
}
|
|
99
105
|
return { snapshot, keyOf, subscribe }
|
package/src/internal/store.ts
CHANGED
|
@@ -33,17 +33,19 @@ export interface Store<in out A> extends Inspectable {
|
|
|
33
33
|
export const makeStore = <A>(initial: A, scheduler: Scheduler = defaultScheduler): Store<A> => {
|
|
34
34
|
// The store is a mutable reactive slot by design, so its value lives in a
|
|
35
35
|
// `MutableRef` we update in place (no reassigned binding).
|
|
36
|
-
const
|
|
36
|
+
const current = MutableRef.make(initial)
|
|
37
37
|
const version = MutableRef.make(0)
|
|
38
38
|
const listeners = new Set<() => void>()
|
|
39
39
|
return {
|
|
40
|
-
get: () => MutableRef.get(
|
|
41
|
-
getSnapshot: () => MutableRef.get(
|
|
40
|
+
get: () => MutableRef.get(current),
|
|
41
|
+
getSnapshot: () => MutableRef.get(current),
|
|
42
42
|
getVersion: () => MutableRef.get(version),
|
|
43
43
|
set: (next) => {
|
|
44
|
-
if (Equal.equals(MutableRef.get(
|
|
45
|
-
|
|
46
|
-
|
|
44
|
+
if (Equal.equals(MutableRef.get(current), next)) {
|
|
45
|
+
return
|
|
46
|
+
}
|
|
47
|
+
MutableRef.set(current, next)
|
|
48
|
+
MutableRef.update(version, (count) => count + 1)
|
|
47
49
|
scheduler.schedule(listeners)
|
|
48
50
|
},
|
|
49
51
|
subscribe: (listener) => {
|
|
@@ -52,7 +54,7 @@ export const makeStore = <A>(initial: A, scheduler: Scheduler = defaultScheduler
|
|
|
52
54
|
listeners.delete(listener)
|
|
53
55
|
}
|
|
54
56
|
},
|
|
55
|
-
...inspectable(() => ({ _id: 'reform/Store', value: MutableRef.get(
|
|
57
|
+
...inspectable(() => ({ _id: 'reform/Store', value: MutableRef.get(current) })),
|
|
56
58
|
}
|
|
57
59
|
}
|
|
58
60
|
|
|
@@ -83,7 +85,7 @@ export const makeDerivedStore = <A>(
|
|
|
83
85
|
const next = compute()
|
|
84
86
|
MutableRef.set(seen, { value: next })
|
|
85
87
|
if (previous === undefined || !Equal.equals(previous.value, next)) {
|
|
86
|
-
MutableRef.update(version, (
|
|
88
|
+
MutableRef.update(version, (count) => count + 1)
|
|
87
89
|
scheduler.schedule(listeners)
|
|
88
90
|
}
|
|
89
91
|
}
|
package/src/internal/track.ts
CHANGED
|
@@ -34,6 +34,8 @@ export class CurrentTracker extends CurrentTrackerBase {}
|
|
|
34
34
|
*/
|
|
35
35
|
export const readTracked = <A>(store: Store<A>): Effect.Effect<A> =>
|
|
36
36
|
Effect.map(Effect.serviceOption(CurrentTracker), (tracker) => {
|
|
37
|
-
if (Option.isSome(tracker))
|
|
37
|
+
if (Option.isSome(tracker)) {
|
|
38
|
+
tracker.value.add(store)
|
|
39
|
+
}
|
|
38
40
|
return store.getSnapshot()
|
|
39
41
|
})
|
|
@@ -60,14 +60,14 @@ export const live = <
|
|
|
60
60
|
): Layer.Layer<never, never, Procedures | Bus | Ctx<Eff>> => {
|
|
61
61
|
const name = procedure.manifest.name
|
|
62
62
|
const channelName = procedure.channel.manifest.name
|
|
63
|
-
const handles = new Set(procedure.events.map((
|
|
63
|
+
const handles = new Set(procedure.events.map((event) => event.tag))
|
|
64
64
|
// Scoped registration (see `Reducer.live`): an eager procedure's scope is the
|
|
65
65
|
// root runtime's, so behavior is unchanged; a lazy feature's procedure registers
|
|
66
66
|
// on mount and unregisters on unmount, so it stops running and is reclaimed.
|
|
67
67
|
return Layer.scopedDiscard(
|
|
68
68
|
Effect.gen(function* () {
|
|
69
69
|
const procedures = yield* Procedures
|
|
70
|
-
if (procedures.entries.some((
|
|
70
|
+
if (procedures.entries.some((entry) => entry.name === name)) {
|
|
71
71
|
yield* Effect.logWarning(`reform: duplicate procedure name '${name}' registered`)
|
|
72
72
|
}
|
|
73
73
|
// Snapshot the body's full context (Bus + RPC clients) so `run` is total.
|
package/src/reducer/reducer.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Effect, Layer } from 'effect'
|
|
1
|
+
import { Effect, Layer, Predicate } from 'effect'
|
|
2
2
|
import { type Manifest, definitionClass } from '../definition/definition'
|
|
3
3
|
import { AsyncReducer } from '../internal/errors'
|
|
4
4
|
import { type AnyEvent, type EventType } from '../event/event'
|
|
@@ -68,17 +68,21 @@ export function make(
|
|
|
68
68
|
return definitionClass<unknown>({
|
|
69
69
|
manifest: { kind: 'Reducer' as const, name } satisfies ReducerManifest,
|
|
70
70
|
config,
|
|
71
|
-
handles: new Set(config.events.map((
|
|
71
|
+
handles: new Set(config.events.map((event) => event.tag)),
|
|
72
72
|
})
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
+
/** A thenable check that excludes null/undefined without an assertion. */
|
|
76
|
+
const isThenable = (candidate: unknown): candidate is PromiseLike<unknown> =>
|
|
77
|
+
Predicate.isRecord(candidate) && typeof candidate['then'] === 'function'
|
|
78
|
+
|
|
75
79
|
/** Reject an accidentally-async fold up front: a reducer must be a pure sync write. */
|
|
76
|
-
const sync = <A>(
|
|
77
|
-
|
|
78
|
-
|
|
80
|
+
const sync = <A>(candidate: A): A => {
|
|
81
|
+
if (isThenable(candidate)) {
|
|
82
|
+
// oxlint-disable-next-line reform-rules/no-throw -- sync reducer-write boundary can't yield an Effect; an async fold is a programmer defect
|
|
79
83
|
throw new AsyncReducer()
|
|
80
84
|
}
|
|
81
|
-
return
|
|
85
|
+
return candidate
|
|
82
86
|
}
|
|
83
87
|
|
|
84
88
|
export function live<S extends AnyState, Events extends ReadonlyArray<AnyEvent>>(
|
|
@@ -93,6 +97,7 @@ export function live(
|
|
|
93
97
|
reducer:
|
|
94
98
|
| StateReducerClass<AnyState, ReadonlyArray<AnyEvent>>
|
|
95
99
|
| FamilyReducerClass<AnyFamily, ReadonlyArray<AnyEvent>>,
|
|
100
|
+
// oxlint-disable-next-line reform-rules/no-explicit-any-value -- overload-impl signature: `unknown` params fail contravariance against the State/Family folds (TS2394); only `any` subsumes both
|
|
96
101
|
fold: (acc: any, event: any) => any,
|
|
97
102
|
): Layer.Layer<never, never, any> {
|
|
98
103
|
const { config, handles } = reducer
|
|
@@ -104,7 +109,7 @@ export function live(
|
|
|
104
109
|
return Layer.scopedDiscard(
|
|
105
110
|
Effect.gen(function* () {
|
|
106
111
|
const reducers = yield* Reducers
|
|
107
|
-
if (reducers.entries.some((
|
|
112
|
+
if (reducers.entries.some((existing) => existing.name === name)) {
|
|
108
113
|
yield* Effect.logWarning(`reform: duplicate reducer name '${name}' registered`)
|
|
109
114
|
}
|
|
110
115
|
const entry: ReducerEntry = yield* Effect.gen(function* () {
|
|
@@ -121,8 +126,11 @@ export function live(
|
|
|
121
126
|
const next = fold(family.at(key).get(), event)
|
|
122
127
|
// A fold may return the eviction sentinel to drop the key's store
|
|
123
128
|
// (bounding an otherwise-unbounded family) instead of a new value.
|
|
124
|
-
if (next === Tombstone)
|
|
125
|
-
|
|
129
|
+
if (next === Tombstone) {
|
|
130
|
+
family.forget(key)
|
|
131
|
+
} else {
|
|
132
|
+
family.at(key).set(sync(next))
|
|
133
|
+
}
|
|
126
134
|
},
|
|
127
135
|
}
|
|
128
136
|
}
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { expect, it } from '@effect/vitest'
|
|
2
|
-
import { Duration, Effect, Layer, Match, Schema as S } from 'effect'
|
|
2
|
+
import { Duration, Effect, Layer, Match, Option, Schema as S } from 'effect'
|
|
3
3
|
import {
|
|
4
4
|
Channels,
|
|
5
5
|
Engine,
|
|
6
6
|
Event,
|
|
7
7
|
Procedures,
|
|
8
|
+
QueryStore,
|
|
8
9
|
Reducer,
|
|
9
10
|
Reducers,
|
|
10
11
|
RemoteState,
|
|
@@ -74,6 +75,19 @@ const makeServer = (initial: ReadonlyArray<Item>) => {
|
|
|
74
75
|
}
|
|
75
76
|
}
|
|
76
77
|
|
|
78
|
+
// An in-memory QueryStore (the reform-side double — stores the encoded value
|
|
79
|
+
// directly, no envelope), so RemoteState persistence is exercised with no DOM.
|
|
80
|
+
const fakeQueryStore = (seed: Record<string, unknown> = {}) => {
|
|
81
|
+
const map = new Map<string, unknown>(Object.entries(seed))
|
|
82
|
+
const layer = Layer.succeed(QueryStore, {
|
|
83
|
+
get: (key: string) =>
|
|
84
|
+
Effect.sync(() => Option.fromNullable(map.has(key) ? map.get(key) : undefined)),
|
|
85
|
+
set: (key: string, value: unknown) => Effect.sync(() => void map.set(key, value)),
|
|
86
|
+
remove: (key: string) => Effect.sync(() => void map.delete(key)),
|
|
87
|
+
})
|
|
88
|
+
return { map, layer }
|
|
89
|
+
}
|
|
90
|
+
|
|
77
91
|
it.live('an intent overlays instantly — no fetch fired, truth untouched', () => {
|
|
78
92
|
class Page extends State.make('page', S.Number) {}
|
|
79
93
|
class Inputs extends StateGroup.make(Page) {}
|
|
@@ -693,3 +707,176 @@ it.live('teardown: closing the scope unregisters the reducers, the procedure, an
|
|
|
693
707
|
expect(channels.byName.has('Items/sends')).toBe(false)
|
|
694
708
|
}).pipe(Effect.provide(Engine))
|
|
695
709
|
})
|
|
710
|
+
|
|
711
|
+
// ── persist (00b/00c): the converged truth round-trips a QueryStore. ─────────
|
|
712
|
+
|
|
713
|
+
it.live('persist: hydrates the seeded truth (stale) then writes the fresh truth through', () => {
|
|
714
|
+
class Page extends State.make('page', S.Number) {}
|
|
715
|
+
class Inputs extends StateGroup.make(Page) {}
|
|
716
|
+
const server = makeServer([{ id: 'a', name: 'alpha' }])
|
|
717
|
+
const store = fakeQueryStore({ Items: [{ id: 'seed', name: 'seeded' }] })
|
|
718
|
+
class Items extends RemoteState.make('Items', {
|
|
719
|
+
inputs: [StateGroup.select(Inputs, 'page')],
|
|
720
|
+
output: S.Array(Item),
|
|
721
|
+
intents: [Added, Renamed],
|
|
722
|
+
alwaysOn: true,
|
|
723
|
+
}) {}
|
|
724
|
+
const ItemsLive = RemoteState.live(Items, {
|
|
725
|
+
query: () => server.fetch(15),
|
|
726
|
+
send: (intent) => server.receive(intent, 15),
|
|
727
|
+
apply: applyIntent,
|
|
728
|
+
persist: true,
|
|
729
|
+
})
|
|
730
|
+
const TestLayer = ItemsLive.pipe(
|
|
731
|
+
Layer.provideMerge(StateGroup.live(Inputs, { page: 1 })),
|
|
732
|
+
Layer.provideMerge(store.layer),
|
|
733
|
+
Layer.provideMerge(Engine),
|
|
734
|
+
)
|
|
735
|
+
|
|
736
|
+
return Effect.gen(function* () {
|
|
737
|
+
const view = yield* Items.store
|
|
738
|
+
// The persisted truth shows instantly, marked refetching (stale-while-revalidate).
|
|
739
|
+
const hydrated = view.get()
|
|
740
|
+
expect(hydrated).toMatchObject({ _tag: 'Success', refetching: true })
|
|
741
|
+
if (hydrated._tag === 'Success') expect(hydrated.value).toEqual([{ id: 'seed', name: 'seeded' }])
|
|
742
|
+
|
|
743
|
+
// The fresh fetch lands and the converged truth is written through under the name key.
|
|
744
|
+
const settled = yield* until(
|
|
745
|
+
() => view.get(),
|
|
746
|
+
(v) => v._tag === 'Success' && v.refetching === false,
|
|
747
|
+
)
|
|
748
|
+
if (settled._tag === 'Success') expect(settled.value).toEqual([{ id: 'a', name: 'alpha' }])
|
|
749
|
+
yield* until(
|
|
750
|
+
() => store.map.get('Items'),
|
|
751
|
+
(v) => Array.isArray(v) && v.length === 1 && v[0].id === 'a',
|
|
752
|
+
)
|
|
753
|
+
expect(store.map.get('Items')).toEqual([{ id: 'a', name: 'alpha' }])
|
|
754
|
+
}).pipe(Effect.provide(TestLayer))
|
|
755
|
+
})
|
|
756
|
+
|
|
757
|
+
it.live('persist: the optimistic overlay is NEVER written — only the converged server truth is', () => {
|
|
758
|
+
class Page extends State.make('page', S.Number) {}
|
|
759
|
+
class Inputs extends StateGroup.make(Page) {}
|
|
760
|
+
const server = makeServer([{ id: 'a', name: 'alpha' }])
|
|
761
|
+
const store = fakeQueryStore()
|
|
762
|
+
class Items extends RemoteState.make('Items', {
|
|
763
|
+
inputs: [StateGroup.select(Inputs, 'page')],
|
|
764
|
+
output: S.Array(Item),
|
|
765
|
+
intents: [Added, Renamed],
|
|
766
|
+
alwaysOn: true,
|
|
767
|
+
}) {}
|
|
768
|
+
const ItemsLive = RemoteState.live(Items, {
|
|
769
|
+
query: () => server.fetch(10),
|
|
770
|
+
// Slow send so the pure-optimistic window stays open for the store assertion.
|
|
771
|
+
send: (intent) => server.receive(intent, 60),
|
|
772
|
+
apply: applyIntent,
|
|
773
|
+
persist: true,
|
|
774
|
+
})
|
|
775
|
+
const TestLayer = ItemsLive.pipe(
|
|
776
|
+
Layer.provideMerge(StateGroup.live(Inputs, { page: 1 })),
|
|
777
|
+
Layer.provideMerge(store.layer),
|
|
778
|
+
Layer.provideMerge(Engine),
|
|
779
|
+
)
|
|
780
|
+
|
|
781
|
+
return Effect.gen(function* () {
|
|
782
|
+
const view = yield* Items.store
|
|
783
|
+
const queue = yield* Items.pending.store
|
|
784
|
+
yield* until(() => view.get(), (v) => v._tag === 'Success')
|
|
785
|
+
// First truth is persisted.
|
|
786
|
+
yield* until(() => store.map.get('Items'), (v) => Array.isArray(v) && v.length === 1)
|
|
787
|
+
|
|
788
|
+
// Dispatch the optimistic intent: the overlay shows two rows...
|
|
789
|
+
yield* Event.dispatch(Added, { id: 'b', name: 'beta' })
|
|
790
|
+
yield* until(() => view.get(), (v) => v._tag === 'Success' && v.value.length === 2)
|
|
791
|
+
yield* until(() => queue.get(), (q) => q.length === 1)
|
|
792
|
+
// ...but the store still holds the PRE-mutation truth (no overlay write).
|
|
793
|
+
expect(store.map.get('Items')).toEqual([{ id: 'a', name: 'alpha' }])
|
|
794
|
+
|
|
795
|
+
// Once the send acks and the refetch lands, the converged truth is written.
|
|
796
|
+
yield* until(() => queue.get(), (q) => q.length === 0)
|
|
797
|
+
yield* until(
|
|
798
|
+
() => store.map.get('Items'),
|
|
799
|
+
(v) => Array.isArray(v) && v.length === 2,
|
|
800
|
+
)
|
|
801
|
+
expect(store.map.get('Items')).toEqual([
|
|
802
|
+
{ id: 'a', name: 'alpha' },
|
|
803
|
+
{ id: 'b', name: 'beta' },
|
|
804
|
+
])
|
|
805
|
+
}).pipe(Effect.provide(TestLayer))
|
|
806
|
+
})
|
|
807
|
+
|
|
808
|
+
it.live('persist: `true` keys by name; `{ key }` overrides — two definitions keep separate slots', () => {
|
|
809
|
+
class Page extends State.make('page', S.Number) {}
|
|
810
|
+
class Inputs extends StateGroup.make(Page) {}
|
|
811
|
+
const serverA = makeServer([{ id: 'a', name: 'alpha' }])
|
|
812
|
+
const serverB = makeServer([{ id: 'b', name: 'beta' }])
|
|
813
|
+
const store = fakeQueryStore()
|
|
814
|
+
class ItemsA extends RemoteState.make('ItemsA', {
|
|
815
|
+
inputs: [StateGroup.select(Inputs, 'page')],
|
|
816
|
+
output: S.Array(Item),
|
|
817
|
+
intents: [Added, Renamed],
|
|
818
|
+
alwaysOn: true,
|
|
819
|
+
}) {}
|
|
820
|
+
class ItemsB extends RemoteState.make('ItemsB', {
|
|
821
|
+
inputs: [StateGroup.select(Inputs, 'page')],
|
|
822
|
+
output: S.Array(Item),
|
|
823
|
+
intents: [Added, Renamed],
|
|
824
|
+
alwaysOn: true,
|
|
825
|
+
}) {}
|
|
826
|
+
const TestLayer = Layer.mergeAll(
|
|
827
|
+
RemoteState.live(ItemsA, { query: () => serverA.fetch(10), send: (i) => serverA.receive(i, 10), apply: applyIntent, persist: true }),
|
|
828
|
+
RemoteState.live(ItemsB, { query: () => serverB.fetch(10), send: (i) => serverB.receive(i, 10), apply: applyIntent, persist: { key: 'custom-B' } }),
|
|
829
|
+
).pipe(Layer.provideMerge(StateGroup.live(Inputs, { page: 1 })), Layer.provideMerge(store.layer), Layer.provideMerge(Engine))
|
|
830
|
+
|
|
831
|
+
return Effect.gen(function* () {
|
|
832
|
+
const a = yield* ItemsA.store
|
|
833
|
+
const b = yield* ItemsB.store
|
|
834
|
+
yield* until(() => a.get(), (v) => v._tag === 'Success')
|
|
835
|
+
yield* until(() => b.get(), (v) => v._tag === 'Success')
|
|
836
|
+
yield* until(() => store.map.get('ItemsA'), (v) => Array.isArray(v))
|
|
837
|
+
yield* until(() => store.map.get('custom-B'), (v) => Array.isArray(v))
|
|
838
|
+
expect(store.map.get('ItemsA')).toEqual([{ id: 'a', name: 'alpha' }])
|
|
839
|
+
expect(store.map.get('custom-B')).toEqual([{ id: 'b', name: 'beta' }])
|
|
840
|
+
// No collision: neither wrote the other's key, and the name key for B is empty.
|
|
841
|
+
expect(store.map.has('ItemsB')).toBe(false)
|
|
842
|
+
}).pipe(Effect.provide(TestLayer))
|
|
843
|
+
})
|
|
844
|
+
|
|
845
|
+
it.live('persist (00c): a function key keys a family per-input — two input keys, two slots, no collision', () => {
|
|
846
|
+
class Page extends State.make('page', S.Number) {}
|
|
847
|
+
class Inputs extends StateGroup.make(Page) {}
|
|
848
|
+
const inputs = StateGroup.live(Inputs, { page: 1 })
|
|
849
|
+
const store = fakeQueryStore()
|
|
850
|
+
// The server returns a distinct row per requested page, so a collision (one
|
|
851
|
+
// slot for both pages) would overwrite — the assertion catches it.
|
|
852
|
+
const fetchPage = (page: number) =>
|
|
853
|
+
Effect.as(tick(10), [{ id: `p${page}`, name: `page-${page}` }])
|
|
854
|
+
class Fam extends RemoteState.make('Fam', {
|
|
855
|
+
inputs: [StateGroup.select(Inputs, 'page')],
|
|
856
|
+
output: S.Array(Item),
|
|
857
|
+
intents: [Added, Renamed],
|
|
858
|
+
alwaysOn: true,
|
|
859
|
+
}) {}
|
|
860
|
+
const FamLive = RemoteState.live(Fam, {
|
|
861
|
+
query: ({ page }) => fetchPage(page),
|
|
862
|
+
send: (i) => Effect.sync(() => void i),
|
|
863
|
+
apply: applyIntent,
|
|
864
|
+
persist: { key: ({ page }) => `fam:${page}` },
|
|
865
|
+
})
|
|
866
|
+
const TestLayer = FamLive.pipe(Layer.provideMerge(inputs), Layer.provideMerge(store.layer), Layer.provideMerge(Engine))
|
|
867
|
+
|
|
868
|
+
return Effect.gen(function* () {
|
|
869
|
+
const view = yield* Fam.store
|
|
870
|
+
const page = yield* Page.store
|
|
871
|
+
yield* until(() => store.map.get('fam:1'), (v) => Array.isArray(v))
|
|
872
|
+
expect(store.map.get('fam:1')).toEqual([{ id: 'p1', name: 'page-1' }])
|
|
873
|
+
|
|
874
|
+
// Move the input to page 2: a new fetch writes to a DISTINCT slot.
|
|
875
|
+
page.set(2)
|
|
876
|
+
yield* until(() => view.get(), (v) => v._tag === 'Success' && v.value[0]?.id === 'p2')
|
|
877
|
+
yield* until(() => store.map.get('fam:2'), (v) => Array.isArray(v))
|
|
878
|
+
expect(store.map.get('fam:2')).toEqual([{ id: 'p2', name: 'page-2' }])
|
|
879
|
+
// Page 1's slot is intact — no collision.
|
|
880
|
+
expect(store.map.get('fam:1')).toEqual([{ id: 'p1', name: 'page-1' }])
|
|
881
|
+
}).pipe(Effect.provide(TestLayer))
|
|
882
|
+
})
|