@playfast/reform 0.0.10 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/README.md +11 -11
  2. package/package.json +1 -1
  3. package/src/boundary/boundary.ts +16 -7
  4. package/src/calc/asyncCalc.ts +84 -37
  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 +8 -8
  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/internal/capture.ts +1 -0
  22. package/src/internal/errors.ts +9 -4
  23. package/src/internal/inspect.ts +4 -4
  24. package/src/internal/queryDriver.ts +102 -53
  25. package/src/internal/reuse.ts +67 -30
  26. package/src/internal/scheduler.ts +35 -23
  27. package/src/internal/sources.ts +14 -8
  28. package/src/internal/stateRegistry.ts +3 -1
  29. package/src/internal/store.ts +10 -8
  30. package/src/internal/track.ts +3 -1
  31. package/src/procedure/procedure.ts +2 -2
  32. package/src/reducer/reducer.ts +17 -9
  33. package/src/remote/remoteState.test.ts +188 -1
  34. package/src/remote/remoteState.ts +112 -51
  35. package/src/remote/remoteState.typecheck.ts +4 -1
  36. package/src/runtime/bus.ts +3 -1
  37. package/src/runtime/hardening.test.ts +1 -1
  38. package/src/runtime/loop.ts +67 -46
  39. package/src/runtime/queries.ts +3 -1
  40. package/src/scene/scene.ts +16 -8
  41. package/src/state/state.ts +11 -10
  42. package/src/state/stateFamily.ts +27 -11
  43. package/src/state/stateGroup.ts +22 -9
  44. package/src/synced/syncedStore.ts +15 -9
  45. package/src/wire/tree.ts +66 -30
  46. package/src/wire/triggers.ts +9 -8
@@ -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
+ })