@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.
Files changed (49) hide show
  1. package/package.json +1 -1
  2. package/src/boundary/boundary.ts +16 -7
  3. package/src/calc/asyncCalc.invalidate.test.ts +166 -0
  4. package/src/calc/asyncCalc.ts +116 -25
  5. package/src/calc/asyncData.ts +8 -4
  6. package/src/calc/calc.ts +7 -3
  7. package/src/calc/calcFamily.ts +24 -10
  8. package/src/calc/compose.ts +1 -1
  9. package/src/calc/queryState.ts +52 -0
  10. package/src/channel/channel.ts +63 -37
  11. package/src/compose/composition.ts +20 -1
  12. package/src/compose/provide.ts +5 -1
  13. package/src/compose/slot.ts +4 -2
  14. package/src/compose/structure.ts +43 -19
  15. package/src/compose/ui.ts +21 -2
  16. package/src/compose/ui.typecheck.ts +4 -4
  17. package/src/definition/definition.ts +20 -7
  18. package/src/feature/feature.test.ts +4 -4
  19. package/src/feature/feature.ts +30 -16
  20. package/src/feature/feature.typecheck.ts +2 -2
  21. package/src/index.ts +21 -0
  22. package/src/internal/capture.ts +1 -0
  23. package/src/internal/errors.ts +9 -4
  24. package/src/internal/inspect.ts +4 -4
  25. package/src/internal/queryDriver.ts +273 -86
  26. package/src/internal/queryEvents.ts +34 -0
  27. package/src/internal/queryStore.ts +36 -0
  28. package/src/internal/reuse.ts +67 -30
  29. package/src/internal/scheduler.ts +35 -23
  30. package/src/internal/sources.ts +14 -8
  31. package/src/internal/stateRegistry.ts +3 -1
  32. package/src/internal/store.ts +10 -8
  33. package/src/internal/track.ts +3 -1
  34. package/src/procedure/procedure.ts +2 -2
  35. package/src/reducer/reducer.ts +17 -9
  36. package/src/remote/remoteState.test.ts +188 -1
  37. package/src/remote/remoteState.ts +112 -51
  38. package/src/remote/remoteState.typecheck.ts +4 -1
  39. package/src/runtime/bus.ts +3 -1
  40. package/src/runtime/hardening.test.ts +1 -1
  41. package/src/runtime/loop.ts +81 -53
  42. package/src/runtime/queries.ts +55 -0
  43. package/src/scene/scene.ts +16 -8
  44. package/src/state/state.ts +11 -10
  45. package/src/state/stateFamily.ts +27 -11
  46. package/src/state/stateGroup.ts +22 -9
  47. package/src/synced/syncedStore.ts +15 -9
  48. package/src/wire/tree.ts +66 -30
  49. package/src/wire/triggers.ts +9 -8
@@ -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 maxDepth = 16
12
+ const MAX_DEPTH = 16
13
13
 
14
- const isPlainRecord = (v: unknown): v is Record<string, unknown> =>
15
- typeof v === 'object' &&
16
- v !== null &&
17
- (Object.getPrototypeOf(v) === Object.prototype || Object.getPrototypeOf(v) === null)
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
- const go = (prev: unknown, next: unknown, depth: number): unknown => {
20
- if (Object.is(prev, next)) return prev
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)) return prev
24
- if (depth <= 0) return next
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 out = next.map((item, i) => (i < prev.length ? go(prev[i], item, depth - 1) : item))
27
- return prev.length === next.length && out.every((v, i) => Object.is(v, prev[i]))
28
- ? prev
29
- : out
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 keys = Object.keys(next)
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 prevFields = Object.fromEntries(Object.entries(prev))
52
- const nextFields = Object.entries(next)
53
- const out: Record<string, unknown> = {}
54
- for (const [key, value] of nextFields)
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 => go(previous, next, maxDepth) as 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
- // Drain to a fixpoint within ONE microtask. A derived store's `onChange`
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
- for (const listener of listeners) pending.add(listener)
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)
@@ -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 = (a: ReadonlyArray<unknown>, b: ReadonlyArray<unknown>): boolean =>
56
- a.length === b.length && a.every((value, i) => Equal.equals(value, b[i]))
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: Array<Store<unknown>> = []
79
- for (const input of inputs) sources.push(yield* input.store)
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, i) => {
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[i]
87
- if (source !== undefined) args[inputKey(input)] = source.getSnapshot()
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
- for (const unsubscribe of unsubscribes) unsubscribe()
102
+ unsubscribes.forEach((unsubscribe) => unsubscribe())
97
103
  }
98
104
  }
99
105
  return { snapshot, keyOf, subscribe }
@@ -22,7 +22,9 @@ export const claimStateTag = (identifier: string): void => {
22
22
  claimed.add(identifier)
23
23
  return
24
24
  }
25
- if (warned.has(identifier)) return
25
+ if (warned.has(identifier)) {
26
+ return
27
+ }
26
28
  warned.add(identifier)
27
29
  Effect.runSync(
28
30
  Effect.logWarning(
@@ -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 value = MutableRef.make(initial)
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(value),
41
- getSnapshot: () => MutableRef.get(value),
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(value), next)) return
45
- MutableRef.set(value, next)
46
- MutableRef.update(version, (n) => n + 1)
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(value) })),
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, (n) => n + 1)
88
+ MutableRef.update(version, (count) => count + 1)
87
89
  scheduler.schedule(listeners)
88
90
  }
89
91
  }
@@ -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)) tracker.value.add(store)
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((e) => e.tag))
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((e) => e.name === name)) {
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.
@@ -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((e) => e.tag)),
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>(value: A): A => {
77
- // Loose `!= null` so a reducer returning `undefined` doesn't crash the guard.
78
- if (value != null && typeof (value as { then?: unknown }).then === 'function') {
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 value
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((e) => e.name === name)) {
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) family.forget(key)
125
- else family.at(key).set(sync(next))
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
+ })