@playfast/reform 0.1.0 → 1.1.1

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 (97) hide show
  1. package/README.md +18 -1
  2. package/package.json +1 -1
  3. package/src/boundary/boundary.test.ts +1 -33
  4. package/src/boundary/boundary.ts +4 -82
  5. package/src/calc/asyncCalc.invalidate.test.ts +2 -14
  6. package/src/calc/asyncCalc.test.ts +0 -38
  7. package/src/calc/asyncCalc.ts +11 -163
  8. package/src/calc/asyncCalcDefinitions.ts +48 -0
  9. package/src/calc/asyncData.ts +0 -37
  10. package/src/calc/calc.test.ts +4 -28
  11. package/src/calc/calc.ts +16 -44
  12. package/src/calc/calcFamily.test.ts +0 -12
  13. package/src/calc/calcFamily.ts +12 -49
  14. package/src/calc/compose.test.ts +1 -12
  15. package/src/calc/compose.ts +0 -22
  16. package/src/calc/queryState.ts +0 -23
  17. package/src/channel/channel.ts +78 -173
  18. package/src/channel/procedureRegistry.ts +90 -0
  19. package/src/compose/composition.ts +7 -67
  20. package/src/compose/host.ts +0 -6
  21. package/src/compose/props.ts +3 -9
  22. package/src/compose/provide.ts +19 -33
  23. package/src/compose/slot.ts +2 -30
  24. package/src/compose/structure.test.ts +0 -6
  25. package/src/compose/structure.ts +3 -67
  26. package/src/compose/ui.test.ts +0 -4
  27. package/src/compose/ui.ts +17 -111
  28. package/src/compose/ui.typecheck.ts +0 -14
  29. package/src/definition/definition.ts +0 -32
  30. package/src/event/event.test.ts +0 -3
  31. package/src/event/event.ts +5 -14
  32. package/src/event/eventGroup.ts +0 -1
  33. package/src/feature/feature.mount.test.ts +66 -54
  34. package/src/feature/feature.test.ts +67 -46
  35. package/src/feature/feature.ts +185 -214
  36. package/src/feature/feature.typecheck.ts +29 -16
  37. package/src/feature/featureBinding.ts +48 -0
  38. package/src/index.ts +245 -202
  39. package/src/internal/capture.ts +0 -20
  40. package/src/internal/ctx.ts +2 -9
  41. package/src/internal/env.ts +9 -0
  42. package/src/internal/errors.test.ts +0 -6
  43. package/src/internal/errors.ts +14 -48
  44. package/src/internal/inspect.test.ts +0 -6
  45. package/src/internal/inspect.ts +0 -12
  46. package/src/internal/queryDriver.ts +48 -243
  47. package/src/internal/queryDriverStore.ts +153 -0
  48. package/src/internal/queryDriverTypes.ts +57 -0
  49. package/src/internal/queryEvents.ts +0 -12
  50. package/src/internal/queryStore.ts +0 -14
  51. package/src/internal/reuse.test.ts +0 -11
  52. package/src/internal/reuse.ts +0 -28
  53. package/src/internal/scheduler.ts +0 -43
  54. package/src/internal/seeds.ts +0 -14
  55. package/src/internal/sources.ts +4 -41
  56. package/src/internal/stateRegistry.ts +0 -14
  57. package/src/internal/store.test.ts +1 -3
  58. package/src/internal/store.ts +10 -37
  59. package/src/internal/track.ts +0 -18
  60. package/src/internal/variance.ts +5 -0
  61. package/src/namespace/namespace.test.ts +46 -0
  62. package/src/namespace/namespace.ts +85 -0
  63. package/src/procedure/procedure.ts +2 -24
  64. package/src/reducer/reducer.ts +3 -21
  65. package/src/remote/remoteState.test.ts +92 -75
  66. package/src/remote/remoteState.ts +99 -486
  67. package/src/remote/remoteState.typecheck.ts +0 -33
  68. package/src/remote/remoteStateDefinition.ts +135 -0
  69. package/src/remote/remoteStateLayers.ts +118 -0
  70. package/src/remote/remoteStateLiveTypes.ts +59 -0
  71. package/src/remote/remoteStateSend.ts +64 -0
  72. package/src/runtime/appRuntime.test.ts +197 -0
  73. package/src/runtime/appRuntime.ts +131 -0
  74. package/src/runtime/bus.ts +2 -15
  75. package/src/runtime/eventBudget.test.ts +155 -0
  76. package/src/runtime/eventBudget.ts +119 -0
  77. package/src/runtime/hardening.test.ts +74 -9
  78. package/src/runtime/instrumentation.test.ts +324 -0
  79. package/src/runtime/instrumentation.ts +44 -0
  80. package/src/runtime/loop.test.ts +0 -17
  81. package/src/runtime/loop.ts +41 -58
  82. package/src/runtime/queries.ts +0 -17
  83. package/src/scene/featureScene.test.ts +59 -0
  84. package/src/scene/scene.ts +51 -44
  85. package/src/scene/seedScene.test.ts +96 -116
  86. package/src/state/state.ts +12 -28
  87. package/src/state/stateFamily.test.ts +2 -11
  88. package/src/state/stateFamily.ts +20 -54
  89. package/src/state/stateGroup.ts +2 -49
  90. package/src/state/token.ts +4 -17
  91. package/src/synced/syncedStore.ts +7 -42
  92. package/src/ui/node.ts +0 -6
  93. package/src/ui/trigger.ts +0 -5
  94. package/src/wire/tree.test.ts +0 -3
  95. package/src/wire/tree.ts +0 -39
  96. package/src/wire/triggers.test.ts +0 -4
  97. package/src/wire/triggers.ts +5 -27
@@ -0,0 +1,153 @@
1
+ import { Effect, MutableRef, Option, Schema } from 'effect'
2
+ import type { AnyAsyncData } from '../calc/asyncData'
3
+ import { type QueryState, toAsyncData } from '../calc/queryState'
4
+ import type { QueryStoreApi } from './queryStore'
5
+ import type { Scheduler } from './scheduler'
6
+ import { makeDerivedStore, type Store } from './store'
7
+ import type { AnySchema } from './variance'
8
+ import type { InputsObject } from './sources'
9
+ import type { AnySource } from '../state/token'
10
+
11
+ interface HydrateQueryOptions<A, E> {
12
+ readonly cacheKey: string
13
+ readonly queryStore: QueryStoreApi
14
+ readonly schema: AnySchema<A>
15
+ readonly stateStore: Store<QueryState<A, E>>
16
+ }
17
+
18
+ export const hydrateQuery = <A, E>(
19
+ options: HydrateQueryOptions<A, E>,
20
+ ): Effect.Effect<void> =>
21
+ Effect.flatMap(options.queryStore.get(options.cacheKey), (cached) =>
22
+ Option.match(cached, {
23
+ onNone: () => Effect.void,
24
+ onSome: (raw) =>
25
+ Schema.decodeUnknown(options.schema)(raw).pipe(
26
+ Effect.match({
27
+ onFailure: () => undefined,
28
+ onSuccess: (decoded) =>
29
+ options.stateStore.set({
30
+ data: Option.some(decoded),
31
+ error: Option.none(),
32
+ isFetching: false,
33
+ isStale: true,
34
+ }),
35
+ }),
36
+ ),
37
+ }),
38
+ )
39
+
40
+ interface PersistQueryValueOptions<A> {
41
+ readonly cacheKey: string
42
+ readonly produced: A
43
+ readonly queryStore: QueryStoreApi
44
+ readonly schema: AnySchema<A>
45
+ }
46
+
47
+ export const persistQueryValue = <A>(options: PersistQueryValueOptions<A>): Effect.Effect<void> =>
48
+ Schema.encode(options.schema)(options.produced).pipe(
49
+ Effect.flatMap((encoded) => options.queryStore.set(options.cacheKey, encoded)),
50
+ Effect.ignore,
51
+ )
52
+
53
+ interface PersistWriterOptions<Inputs extends ReadonlyArray<AnySource>, A> {
54
+ readonly persist:
55
+ | { readonly key: string | ((inputs: InputsObject<Inputs>) => string); readonly schema: AnySchema<A> }
56
+ | undefined
57
+ readonly queryStore: QueryStoreApi
58
+ }
59
+
60
+ export const makePersistWriter = <Inputs extends ReadonlyArray<AnySource>, A>(
61
+ options: PersistWriterOptions<Inputs, A>,
62
+ ): ((produced: A, inputs: InputsObject<Inputs>) => Effect.Effect<void>) => {
63
+ if (options.persist === undefined) {
64
+ return () => Effect.void
65
+ }
66
+ const persist = options.persist
67
+ return (produced, inputs) =>
68
+ persistQueryValue({
69
+ cacheKey: typeof persist.key === 'function' ? persist.key(inputs) : persist.key,
70
+ produced,
71
+ queryStore: options.queryStore,
72
+ schema: persist.schema,
73
+ })
74
+ }
75
+
76
+ interface SubscribableSource {
77
+ readonly subscribe: (listener: () => void) => () => void
78
+ }
79
+
80
+ export const subscribeQuerySources = (
81
+ sources: SubscribableSource,
82
+ extraKey: SubscribableSource | undefined,
83
+ listener: () => void,
84
+ ): (() => void) => {
85
+ const offSources = sources.subscribe(listener)
86
+ const offExtra = extraKey?.subscribe(listener)
87
+ return () => {
88
+ offSources()
89
+ offExtra?.()
90
+ }
91
+ }
92
+
93
+ export const settleQueryError = <A, E>(stateStore: Store<QueryState<A, E>>, error: E): void => {
94
+ const previous = stateStore.get()
95
+ stateStore.set({ ...previous, error: Option.some(error), isFetching: false })
96
+ }
97
+
98
+ export const incrementGeneration = (generation: MutableRef.MutableRef<number>): number => {
99
+ MutableRef.update(generation, (current) => current + 1)
100
+ return MutableRef.get(generation)
101
+ }
102
+
103
+ interface QueryProjectionOptions<A, E> {
104
+ readonly gated: boolean
105
+ readonly scheduler: Scheduler
106
+ readonly stateStore: Store<QueryState<A, E>>
107
+ }
108
+
109
+ export const makeQueryProjection = <A, E>(
110
+ options: QueryProjectionOptions<A, E>,
111
+ ): { readonly store: Store<AnyAsyncData<A, E>>; readonly unsubscribe: () => void } => {
112
+ const projection = MutableRef.make<
113
+ { readonly state: QueryState<A, E>; readonly view: AnyAsyncData<A, E> } | undefined
114
+ >(undefined)
115
+ const project = (): AnyAsyncData<A, E> => {
116
+ const state = options.stateStore.get()
117
+ const previous = MutableRef.get(projection)
118
+ if (
119
+ previous !== undefined &&
120
+ previous.state.data === state.data &&
121
+ previous.state.error === state.error &&
122
+ previous.state.isFetching === state.isFetching
123
+ ) {
124
+ return previous.view
125
+ }
126
+ const view = toAsyncData(state, options.gated)
127
+ MutableRef.set(projection, { state, view })
128
+ return view
129
+ }
130
+ return makeDerivedStore(project, options.stateStore.subscribe, options.scheduler)
131
+ }
132
+
133
+ interface ReaderStoreOptions<A, E> {
134
+ readonly store: Store<AnyAsyncData<A, E>>
135
+ readonly readers: MutableRef.MutableRef<number>
136
+ readonly onFirstReader: () => void
137
+ }
138
+
139
+ export const withReaderTracking = <A, E>(options: ReaderStoreOptions<A, E>): Store<AnyAsyncData<A, E>> => ({
140
+ ...options.store,
141
+ subscribe: (listener) => {
142
+ const before = MutableRef.get(options.readers)
143
+ MutableRef.set(options.readers, before + 1)
144
+ const off = options.store.subscribe(listener)
145
+ if (before === 0) {
146
+ options.onFirstReader()
147
+ }
148
+ return () => {
149
+ off()
150
+ MutableRef.update(options.readers, (count) => Math.max(0, count - 1))
151
+ }
152
+ },
153
+ })
@@ -0,0 +1,57 @@
1
+ import { Brand, Effect, Schema } from 'effect'
2
+ import type { AnyAsyncData } from '../calc/asyncData'
3
+ import type { InputsObject, InvalidateBy } from './sources'
4
+ import type { Store } from './store'
5
+ import type { AnySource } from '../state/token'
6
+ import type { AnySchema } from './variance'
7
+
8
+ export type GatedOf<AlwaysOn extends boolean> = AlwaysOn extends true ? false : true
9
+
10
+ export const gatedFlag = <AlwaysOn extends boolean>(alwaysOn: AlwaysOn | undefined): GatedOf<AlwaysOn> =>
11
+ // oxlint-disable-next-line reform-rules/no-type-assertion -- restate the runtime boolean as its conditional `GatedOf` literal; no guard maps a boolean to a conditional type
12
+ (alwaysOn !== true) as GatedOf<AlwaysOn>
13
+
14
+ export type Revision = number & Brand.Brand<'reform/Revision'>
15
+ export const Revision: Brand.Brand.Constructor<Revision> = Brand.nominal<Revision>()
16
+ export const RevisionSchema: Schema.Schema<Revision, number> = Schema.Number.pipe(
17
+ Schema.brand('reform/Revision'),
18
+ )
19
+ export const revisionZero: Revision = Revision(0)
20
+ export const bumpRevision = (revision: Revision): Revision => Revision(revision + 1)
21
+
22
+ export interface QueryDriverOptions<Inputs extends ReadonlyArray<AnySource>, A, E, R> {
23
+ readonly name: string
24
+ readonly label: string
25
+ readonly gated: boolean
26
+ readonly inputs: Inputs
27
+ readonly query: (inputs: InputsObject<Inputs>) => Effect.Effect<A, E, R>
28
+ // oxlint-disable-next-line reform-rules/no-optional-fields -- caller-omitted option built at the asyncCalc/remoteState construction site; Option<T> would change that call shape
29
+ readonly invalidateBy?: InvalidateBy<Inputs> | undefined
30
+ // oxlint-disable-next-line reform-rules/no-optional-fields -- caller-omitted option built at the asyncCalc/remoteState construction site; Option<T> would change that call shape
31
+ readonly disabled?: ((inputs: InputsObject<Inputs>) => boolean) | undefined
32
+ // oxlint-disable-next-line reform-rules/no-optional-fields -- caller-omitted option built at the asyncCalc/remoteState construction site; Option<T> would change that call shape
33
+ readonly coalesce?: 'switch' | 'trailing' | undefined
34
+ // oxlint-disable-next-line reform-rules/no-optional-fields -- caller-omitted option built at the asyncCalc/remoteState construction site; Option<T> would change that call shape
35
+ readonly reuse?: boolean | undefined
36
+ // oxlint-disable-next-line reform-rules/no-optional-fields -- caller-omitted option built at the asyncCalc/remoteState construction site; Option<T> would change that call shape
37
+ readonly extraKey?:
38
+ | {
39
+ readonly read: () => unknown
40
+ readonly subscribe: (listener: () => void) => () => void
41
+ }
42
+ | undefined
43
+ // oxlint-disable-next-line reform-rules/no-optional-fields -- caller-omitted option built at the asyncCalc/remoteState construction site; Option<T> would change that call shape
44
+ readonly persist?:
45
+ | {
46
+ readonly key: string | ((inputs: InputsObject<Inputs>) => string)
47
+ readonly schema: AnySchema<A>
48
+ }
49
+ | undefined
50
+ // oxlint-disable-next-line reform-rules/no-optional-fields -- caller-omitted option built at the asyncCalc/remoteState construction site; Option<T> would change that call shape
51
+ readonly onSettled?: ((generation: number) => void) | undefined
52
+ }
53
+
54
+ export interface QueryDriver<A, E> {
55
+ readonly store: Store<AnyAsyncData<A, E>>
56
+ readonly requested: () => number
57
+ }
@@ -1,17 +1,7 @@
1
1
  import { Context, Effect, Option } from 'effect'
2
2
 
3
- // `QueryEvents` is the optional host-signal seam invalidation *sources* subscribe
4
- // to — window focus/visibility and network reconnect. It carries no DOM itself:
5
- // the concrete `window.addEventListener` implementation lives in an edge package
6
- // (`@playfast/reform-query-browser`); core only declares the shape. Resolved via
7
- // `serviceOption` with a no-op fallback, so its absence (SSR, native, proofs) is
8
- // inert. The subscriptions return an unsubscribe thunk — the synchronous,
9
- // React-facing shape the rest of reform uses (see `Store.subscribe`).
10
-
11
3
  export interface QueryEventsApi {
12
- /** Fires when the window/tab regains focus or visibility. */
13
4
  readonly subscribeFocus: (listener: () => void) => () => void
14
- /** Fires when the network comes back online. */
15
5
  readonly subscribeOnline: (listener: () => void) => () => void
16
6
  }
17
7
 
@@ -21,13 +11,11 @@ export class QueryEvents extends QueryEventsBase {}
21
11
 
22
12
  const noop = (): (() => void) => () => {}
23
13
 
24
- /** A source that never fires — the fallback when no host provides `QueryEvents`. */
25
14
  export const noopQueryEvents: QueryEventsApi = {
26
15
  subscribeFocus: noop,
27
16
  subscribeOnline: noop,
28
17
  }
29
18
 
30
- /** Resolve the host's `QueryEvents`, or the no-op source when none is in context. */
31
19
  export const resolveQueryEvents: Effect.Effect<QueryEventsApi> = Effect.map(
32
20
  Effect.serviceOption(QueryEvents),
33
21
  Option.getOrElse(() => noopQueryEvents),
@@ -1,13 +1,5 @@
1
1
  import { Context, Effect, Option } from 'effect'
2
2
 
3
- // `QueryStore` is the optional persistence seam an `AsyncCalc` writes through and
4
- // hydrates from — a key→value cache provided by the host (localStorage,
5
- // IndexedDB, AsyncStorage, …). Like `Notifications`, it is resolved through
6
- // `serviceOption`, so a calc that opts into `persist` imposes NO hard requirement:
7
- // absent host ⇒ the no-op store ⇒ the feature is simply inert (SSR, native,
8
- // proofs). Values cross the boundary as `unknown` and are (de)serialized through
9
- // the calc's own `output` schema by the driver, never trusted structurally.
10
-
11
3
  export interface QueryStoreApi {
12
4
  readonly get: (key: string) => Effect.Effect<Option.Option<unknown>>
13
5
  readonly set: (key: string, value: unknown) => Effect.Effect<void>
@@ -18,18 +10,12 @@ const QueryStoreBase: Context.TagClass<QueryStore, 'reform/QueryStore', QuerySto
18
10
  Context.Tag('reform/QueryStore')<QueryStore, QueryStoreApi>()
19
11
  export class QueryStore extends QueryStoreBase {}
20
12
 
21
- /** A store that persists nothing — the fallback when no host provides `QueryStore`. */
22
13
  export const noopQueryStore: QueryStoreApi = {
23
14
  get: () => Effect.succeedNone,
24
15
  set: () => Effect.void,
25
16
  remove: () => Effect.void,
26
17
  }
27
18
 
28
- /**
29
- * Resolve the host's `QueryStore`, or the no-op store when none is in context.
30
- * `serviceOption` keeps it requirement-free — a calc wired as a sibling of the
31
- * runtime that provides no persistence simply gets the inert store.
32
- */
33
19
  export const resolveQueryStore: Effect.Effect<QueryStoreApi> = Effect.map(
34
20
  Effect.serviceOption(QueryStore),
35
21
  Option.getOrElse(() => noopQueryStore),
@@ -2,9 +2,6 @@ import { describe, expect, it } from '@effect/vitest'
2
2
  import { Data, Schema as S } from 'effect'
3
3
  import { reuse } from './reuse'
4
4
 
5
- // The structural-sharing pass behind `reuse: true`: identities move only where
6
- // values moved, and the result is always value-equal to the fresh output.
7
-
8
5
  describe('reuse', () => {
9
6
  it('returns the previous value when the next is deeply value-equal', () => {
10
7
  const prev = { a: [1, 2, { b: 'x' }], c: { d: true } }
@@ -54,9 +51,6 @@ describe('reuse', () => {
54
51
  const prev = new View({ title: 't', rows: [keep, new Row({ id: 2, name: 'b' })] })
55
52
  const next = new View({ title: 't', rows: [new Row({ id: 1, name: 'a' }), new Row({ id: 2, name: 'B' })] })
56
53
  const out = reuse(prev, next)
57
- // The container changed (row 2 renamed) so a fresh View comes back — but it
58
- // is still a View (prototype preserved) and the untouched row keeps its
59
- // exact previous identity.
60
54
  expect(out).not.toBe(prev)
61
55
  expect(out).toBeInstanceOf(View)
62
56
  expect(out.rows[0]).toBe(keep)
@@ -66,14 +60,10 @@ describe('reuse', () => {
66
60
  })
67
61
 
68
62
  it('compares Dates by instant (Effect Equal) and treats other class instances as opaque leaves', () => {
69
- // Effect's `Equal.equals` compares Dates structurally: same instant ⇒ the
70
- // previous identity is kept.
71
63
  const prev = { at: new Date(0) }
72
64
  const out = reuse(prev, { at: new Date(0) })
73
65
  expect(out.at).toBe(prev.at)
74
66
 
75
- // A class without Equal: never recursed into, the fresh leaf wins even
76
- // when its fields look identical.
77
67
  class Opaque {
78
68
  readonly v: number
79
69
  constructor(v: number) {
@@ -91,7 +81,6 @@ describe('reuse', () => {
91
81
  const prev = deep(20)
92
82
  const next = deep(20)
93
83
  const out = reuse(prev, next)
94
- // Value-equal throughout — shared down to the bound, fresh below it.
95
84
  expect(out).toEqual(next)
96
85
  })
97
86
 
@@ -1,14 +1,5 @@
1
1
  import { Equal, Record as Rec } from 'effect'
2
2
 
3
- // Structural sharing for recomputed calc outputs: reconcile a fresh output
4
- // against the previous one, substituting previous nodes wherever value equality
5
- // holds, so identities only move where values moved. Downstream memo boundaries
6
- // (React subtrees keyed on object identity) then skip everything that didn't
7
- // change. Referentially transparent: the result is value-equal to `next` — only
8
- // identities shift toward `previous` — and neither argument is mutated, so the
9
- // pass composes with the framework's Equal-based invalidation untouched.
10
-
11
- // Bounds the walk; calc outputs are shallow plain data by framework convention.
12
3
  const MAX_DEPTH = 16
13
4
 
14
5
  const isPlainRecord = (candidate: unknown): candidate is Record<string, unknown> =>
@@ -17,7 +8,6 @@ const isPlainRecord = (candidate: unknown): candidate is Record<string, unknown>
17
8
  (Object.getPrototypeOf(candidate) === Object.prototype ||
18
9
  Object.getPrototypeOf(candidate) === null)
19
10
 
20
- /** Own enumerable string-keyed fields of an object, as a plain record. */
21
11
  const ownRecord = (source: object): Record<string, unknown> =>
22
12
  Rec.fromEntries(
23
13
  Reflect.ownKeys(source)
@@ -34,7 +24,6 @@ interface ReconcileArgs {
34
24
  readonly depth: number
35
25
  }
36
26
 
37
- /** Reconcile two records field-by-field; reports whether every field stayed `prev`. */
38
27
  const reconcileRecord = (
39
28
  prev: Record<string, unknown>,
40
29
  next: Record<string, unknown>,
@@ -57,8 +46,6 @@ const reconcile = ({ prev, next, depth }: ReconcileArgs): unknown => {
57
46
  if (Object.is(prev, next)) {
58
47
  return prev
59
48
  }
60
- // Data/Schema classes implement Equal+Hash: substitute wholesale on value
61
- // equality. Plain objects/arrays fall through (their Equal is referential).
62
49
  if (Equal.equals(prev, next)) {
63
50
  return prev
64
51
  }
@@ -77,12 +64,6 @@ const reconcile = ({ prev, next, depth }: ReconcileArgs): unknown => {
77
64
  const { out, allPrev } = reconcileRecord(prev, next, depth)
78
65
  return allPrev ? prev : out
79
66
  }
80
- // Data/Schema class instances that DIFFER still get walked: their Equal is
81
- // fieldwise over own enumerable fields, so a reconstruction over reconciled
82
- // fields (same prototype; the constructor is bypassed, but every leaf is a
83
- // validated value out of `prev` or `next`) stays value-equal to `next` while
84
- // unchanged children — e.g. one untouched element inside a class-typed
85
- // container — keep their `prev` identity.
86
67
  if (
87
68
  Equal.isEqual(prev) &&
88
69
  Equal.isEqual(next) &&
@@ -93,18 +74,9 @@ const reconcile = ({ prev, next, depth }: ReconcileArgs): unknown => {
93
74
  ? prev
94
75
  : Object.create(Object.getPrototypeOf(next), Object.getOwnPropertyDescriptors(out))
95
76
  }
96
- // Class instances without Equal (Date, Map, …) are opaque leaves.
97
77
  return next
98
78
  }
99
79
 
100
- /**
101
- * Substitute `previous` nodes into `next` wherever they are value-equal. The
102
- * walker only ever returns `previous`, `next`, or a key/index-wise
103
- * reconstruction of `next` whose every leaf came from one of them, so the
104
- * result is value-equal to `next` and the type is preserved by construction —
105
- * the single cast below is that argument, in the style of `wireSources` /
106
- * `narrowStore`.
107
- */
108
80
  export const reuse = <A>(previous: A, next: A): A =>
109
81
  // 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
82
  reconcile({ prev: previous, next, depth: MAX_DEPTH }) as A
@@ -1,16 +1,5 @@
1
1
  import { Context, Effect, Layer, MutableRef, Option } from 'effect'
2
2
 
3
- /**
4
- * Coalesces store notifications into a single microtask flush so a batch of
5
- * writes wakes each subscriber at most once (RESEARCH #3). React owns frame
6
- * timing; we only collapse the notifications.
7
- *
8
- * One scheduler is shared by every store in a runtime, so writes across
9
- * different stores in the same tick batch into one flush. The instance is
10
- * resolved per store at creation: `Notifications` when a runtime provides it
11
- * (per-runtime isolation — see `resolveScheduler`), else the process-wide
12
- * `defaultScheduler`.
13
- */
14
3
  export interface Scheduler {
15
4
  readonly schedule: (listeners: Iterable<() => void>) => void
16
5
  }
@@ -19,9 +8,6 @@ export const makeScheduler = (): Scheduler => {
19
8
  const pending = new Set<() => void>()
20
9
  const armed = MutableRef.make(false)
21
10
 
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
11
  const runListener = (listener: () => void): void =>
26
12
  Effect.runSync(
27
13
  Effect.try({ try: listener, catch: (error) => error }).pipe(
@@ -36,12 +22,6 @@ export const makeScheduler = (): Scheduler => {
36
22
  ),
37
23
  )
38
24
 
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
25
  const drain = (): void => {
46
26
  if (pending.size === 0) {
47
27
  return
@@ -68,37 +48,14 @@ export const makeScheduler = (): Scheduler => {
68
48
  return { schedule }
69
49
  }
70
50
 
71
- /**
72
- * The process-wide scheduler, used by any store created outside a runtime that
73
- * provides its own `Notifications`. Fine for a single client app (microtask
74
- * ordering is global anyway); a runtime that needs isolation — concurrent SSR,
75
- * multiple mounted roots — provides `Notifications` upstream of its stores.
76
- */
77
51
  export const defaultScheduler: Scheduler = makeScheduler()
78
52
 
79
- /**
80
- * Optional per-runtime scheduler. When a runtime provides it upstream of its
81
- * state/calc layers, those stores coalesce on it instead of the global default,
82
- * isolating their notification timing from other runtimes in the same process.
83
- */
84
53
  const NotificationsBase: Context.TagClass<Notifications, 'reform/Notifications', Scheduler> =
85
54
  Context.Tag('reform/Notifications')<Notifications, Scheduler>()
86
55
  export class Notifications extends NotificationsBase {}
87
56
 
88
- /**
89
- * A fresh per-runtime scheduler. Merged into `Engine`, so a runtime that wires
90
- * its state/calc layers *downstream* of `Engine` gets isolated notification
91
- * timing; the common sibling wiring falls back to `defaultScheduler` (still
92
- * correct, just process-shared).
93
- */
94
57
  export const notificationsLayer: Layer.Layer<Notifications> = Layer.sync(Notifications, makeScheduler)
95
58
 
96
- /**
97
- * Resolve the scheduler a store should use: the runtime's `Notifications` if one
98
- * is in context, else the global default. Uses `serviceOption` so it imposes no
99
- * hard requirement — a store layer wired as a sibling of the runtime (the common
100
- * pattern) simply falls back to the default.
101
- */
102
59
  export const resolveScheduler: Effect.Effect<Scheduler> = Effect.map(
103
60
  Effect.serviceOption(Notifications),
104
61
  Option.getOrElse(() => defaultScheduler),
@@ -1,19 +1,5 @@
1
1
  import { FiberRef, GlobalValue } from 'effect'
2
2
 
3
- /**
4
- * The sanctioned tooling/test seam for seeding CLOSED scenes. A scene's layers
5
- * are pre-composed (each state group seeds its own live store), so an outer
6
- * layer cannot override an inner store — but a FiberRef set via
7
- * `Layer.locally(ref, value)(layer)` IS visible inside the construction effects
8
- * of nested layers. `State.live` consults this ref at store-construction time:
9
- * if the state's member name is present, the (schema-validated) value replaces
10
- * the authored seed; otherwise the authored seed wins.
11
- *
12
- * Empty by default — production wiring never touches it. The dev-tool inspector
13
- * (`Scene.seedScene`) and proofs are the only intended writers. `globalValue`
14
- * keeps a single ref instance even if the module is loaded twice (duplicated
15
- * bundles, HMR), so the writer and the reader always agree.
16
- */
17
3
  export const CurrentSeedOverrides: FiberRef.FiberRef<Readonly<Record<string, unknown>>> =
18
4
  GlobalValue.globalValue(Symbol.for('reform/CurrentSeedOverrides'), () =>
19
5
  FiberRef.unsafeMake<Readonly<Record<string, unknown>>>({}),
@@ -1,49 +1,24 @@
1
1
  import { type Context, Effect, Equal, Predicate } from 'effect'
2
2
  import { type AnySource, type SourceName, type SourceValue } from '../state/token'
3
3
  import { type Store } from './store'
4
+ import type { AnyValue } from './variance'
4
5
 
5
- // The reactive plumbing shared by `Calc` and `AsyncCalc`: read a list of input
6
- // `Source`s, snapshot them by name, project a comparable invalidation key, and
7
- // subscribe to changes. Both derived primitives sense their inputs the same way
8
- // and differ only in what they do with a change (recompute vs. re-run a query).
9
-
10
- /** The decoded inputs handed to a calc body, keyed by each source's name. */
11
6
  export type InputsObject<Inputs extends ReadonlyArray<AnySource>> = {
12
7
  readonly [S in Inputs[number] as SourceName<S>]: SourceValue<S>
13
8
  }
14
9
 
15
- /**
16
- * The store services a list of store-carriers requires, as a *union of distinct*
17
- * stores (each carrier has its own backing tag), not a single store of a union.
18
- * Structural over the `store` tag, so it serves both calc inputs (`Source`s) and
19
- * state-group members (`StateClass`es) — anything carrying a `store` tag.
20
- */
21
10
  export type StoresOf<Items extends ReadonlyArray<unknown>> = {
22
- [I in keyof Items]: Items[I] extends { readonly store: Context.Tag<infer Svc, any> } ? Svc : never
11
+ [I in keyof Items]: Items[I] extends { readonly store: Context.Tag<infer Svc, AnyValue> }
12
+ ? Svc
13
+ : never
23
14
  }[number]
24
15
 
25
- /** The store requirement a calc's inputs impose. */
26
16
  export type InputStores<Inputs extends ReadonlyArray<AnySource>> = StoresOf<Inputs>
27
17
 
28
- /** Project inputs into a comparable key; the calc reacts only when it moves. */
29
18
  export type InvalidateBy<Inputs extends ReadonlyArray<AnySource>> = (
30
19
  inputs: InputsObject<Inputs>,
31
20
  ) => ReadonlyArray<unknown>
32
21
 
33
- /**
34
- * The runtime key an input contributes to the inputs object. Prefer the
35
- * explicit `manifest.name` — the string handed to `make` — over the structural
36
- * `name`: a `Calc`/`AsyncCalc` *class* satisfies `Source` through its static
37
- * `name`, and a user subclass declaration (`class Feed extends Calc.make(…)`)
38
- * defines its OWN `name` from the class binding, shadowing the explicit one.
39
- * That binding is what minifiers rename, so keying the snapshot by `input.name`
40
- * made every subclassed calc input `undefined` in production builds (and would
41
- * silently mis-key in dev whenever the binding differs from the `make` name —
42
- * the type-level key, `SourceName<S>`, is always the `make` string). The
43
- * manifest is inherited through the static prototype chain and never shadowed.
44
- * `StateToken`s carry no manifest; their `name` is an instance field holding
45
- * the explicit member key, so the fallback is always the declared string.
46
- */
47
22
  const inputKey = (input: AnySource): string =>
48
23
  Predicate.hasProperty(input, 'manifest') &&
49
24
  Predicate.isRecord(input.manifest) &&
@@ -51,25 +26,15 @@ const inputKey = (input: AnySource): string =>
51
26
  ? input.manifest['name']
52
27
  : input.name
53
28
 
54
- /** Element-wise value equality of two invalidation keys. */
55
29
  export const sameKey = (left: ReadonlyArray<unknown>, right: ReadonlyArray<unknown>): boolean =>
56
30
  left.length === right.length && left.every((element, index) => Equal.equals(element, right[index]))
57
31
 
58
- /** The sensing surface both `Calc.live` and `AsyncCalc.live` build on. */
59
32
  export interface WiredSources<Inputs extends ReadonlyArray<AnySource>> {
60
- /** Snapshot every input by name — the decoded inputs object a calc body sees. */
61
33
  readonly snapshot: () => InputsObject<Inputs>
62
- /** The invalidation key for an inputs object: the projection, or all input values. */
63
34
  readonly keyOf: (inputs: InputsObject<Inputs>) => ReadonlyArray<unknown>
64
- /** Subscribe to every source at once; the returned thunk unsubscribes from all. */
65
35
  readonly subscribe: (listener: () => void) => () => void
66
36
  }
67
37
 
68
- /**
69
- * Read each input's backing store and return the shared sensing surface. The
70
- * caller owns lifecycle: register the `subscribe` result's unsubscribe as a
71
- * scope finalizer (both live bodies run under `Layer.scoped`).
72
- */
73
38
  export const wireSources = <Inputs extends ReadonlyArray<AnySource>>(
74
39
  inputs: Inputs,
75
40
  invalidateBy?: InvalidateBy<Inputs>,
@@ -84,8 +49,6 @@ export const wireSources = <Inputs extends ReadonlyArray<AnySource>>(
84
49
  const snapshot = (): InputsObject<Inputs> => {
85
50
  const args: Record<string, unknown> = {}
86
51
  inputs.forEach((input, index) => {
87
- // `noUncheckedIndexedAccess`: `sources` is built 1:1 with `inputs`, but
88
- // guard the index so the read is honestly total.
89
52
  const source = sources[index]
90
53
  if (source !== undefined) {
91
54
  args[inputKey(input)] = source.getSnapshot()
@@ -1,19 +1,5 @@
1
1
  import { Effect } from 'effect'
2
2
 
3
- /**
4
- * Module-load registry of the DI tag identifiers minted by `State.make` /
5
- * `StateFamily.make`. Two distinct definitions that claim one identifier (e.g.
6
- * `State.make('open')` in two different features) otherwise collapse silently —
7
- * `Layer.merge` resolves identical tags to the rightmost, with no error. Here we
8
- * surface the clash.
9
- *
10
- * Warned, not thrown — mirroring duplicate reducer/procedure names: a cross-
11
- * feature clash is a footgun, but a module may legitimately be re-evaluated under
12
- * the same identifier (HMR), so a hard throw would punish dev reloads. The
13
- * `warned` set keeps that to one warning per identifier regardless of reloads.
14
- * In-group clashes (where every member is addressable by name) are a harder error
15
- * — see `StateGroup.make`, which throws.
16
- */
17
3
  const claimed = new Set<string>()
18
4
  const warned = new Set<string>()
19
5
 
@@ -3,8 +3,6 @@ import { makeStore } from './store'
3
3
 
4
4
  const tick = () => new Promise<void>((resolve) => queueMicrotask(resolve))
5
5
 
6
- // A const counter we increment in place — subscriber callbacks need somewhere to
7
- // tally without a reassigned `let`.
8
6
  const counter = () => ({ n: 0 })
9
7
 
10
8
  test('subscribe observes changes; getSnapshot is always current', async () => {
@@ -15,7 +13,7 @@ test('subscribe observes changes; getSnapshot is always current', async () => {
15
13
  })
16
14
 
17
15
  store.set(1)
18
- expect(store.getSnapshot()).toBe(1) // snapshot is synchronous
16
+ expect(store.getSnapshot()).toBe(1)
19
17
  await tick()
20
18
  expect(notifications.n).toBe(1)
21
19
  })