@playfast/reform 1.0.1 → 1.2.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 (99) hide show
  1. package/README.md +103 -56
  2. package/package.json +17 -17
  3. package/src/boundary/boundary.test.ts +63 -49
  4. package/src/boundary/boundary.ts +144 -113
  5. package/src/calc/asyncCalc.invalidate.test.ts +518 -32
  6. package/src/calc/asyncCalc.test.ts +207 -213
  7. package/src/calc/asyncCalc.ts +131 -154
  8. package/src/calc/asyncData.ts +0 -37
  9. package/src/calc/calc.test.ts +7 -33
  10. package/src/calc/calc.ts +44 -58
  11. package/src/calc/calcFamily.test.ts +80 -34
  12. package/src/calc/calcFamily.ts +54 -65
  13. package/src/calc/compose.test.ts +1 -12
  14. package/src/calc/compose.ts +1 -36
  15. package/src/calc/queryState.ts +0 -23
  16. package/src/channel/channel.ts +126 -111
  17. package/src/compose/composition.test.ts +19 -0
  18. package/src/compose/composition.ts +351 -127
  19. package/src/compose/host.ts +0 -6
  20. package/src/compose/props.ts +13 -12
  21. package/src/compose/provide.ts +189 -62
  22. package/src/compose/slot.ts +158 -49
  23. package/src/compose/structure.test.ts +6 -9
  24. package/src/compose/structure.ts +108 -79
  25. package/src/compose/ui.test.ts +19 -7
  26. package/src/compose/ui.ts +155 -156
  27. package/src/compose/ui.typecheck.ts +40 -18
  28. package/src/definition/definition.ts +22 -41
  29. package/src/event/event.fromSource.test.ts +172 -0
  30. package/src/event/event.test.ts +10 -3
  31. package/src/event/event.ts +102 -27
  32. package/src/event/eventGroup.ts +0 -1
  33. package/src/feature/feature.mount.test.ts +122 -43
  34. package/src/feature/feature.test.ts +46 -21
  35. package/src/feature/feature.ts +1347 -256
  36. package/src/feature/feature.typecheck.ts +273 -68
  37. package/src/graph/closure.ts +403 -0
  38. package/src/index.ts +68 -126
  39. package/src/internal/bucketCache.ts +39 -0
  40. package/src/internal/capture.ts +9 -24
  41. package/src/internal/env.ts +7 -0
  42. package/src/internal/errors.test.ts +0 -6
  43. package/src/internal/errors.ts +37 -60
  44. package/src/internal/inspect.test.ts +0 -6
  45. package/src/internal/inspect.ts +0 -12
  46. package/src/internal/queryDriver.ts +105 -201
  47. package/src/internal/queryDriverStore.ts +156 -0
  48. package/src/internal/queryDriverTypes.ts +59 -0
  49. package/src/internal/queryEvents.ts +0 -12
  50. package/src/internal/queryStore.ts +0 -14
  51. package/src/internal/reuse.test.ts +10 -14
  52. package/src/internal/reuse.ts +5 -30
  53. package/src/internal/scheduler.ts +4 -44
  54. package/src/internal/seeds.ts +0 -14
  55. package/src/internal/sources.ts +26 -49
  56. package/src/internal/stateRegistry.ts +0 -14
  57. package/src/internal/store.test.ts +1 -3
  58. package/src/internal/store.ts +0 -37
  59. package/src/internal/track.ts +3 -20
  60. package/src/internal/variance.ts +5 -0
  61. package/src/internal.ts +222 -0
  62. package/src/namespace/namespace.test.ts +46 -0
  63. package/src/namespace/namespace.ts +106 -0
  64. package/src/procedure/procedure.ts +40 -35
  65. package/src/reducer/reducer.ts +78 -52
  66. package/src/remote/remoteState.test.ts +590 -397
  67. package/src/remote/remoteState.ts +425 -347
  68. package/src/remote/remoteState.typecheck.ts +47 -56
  69. package/src/runtime/appRuntime.activation.test.ts +100 -0
  70. package/src/runtime/appRuntime.test.ts +249 -0
  71. package/src/runtime/appRuntime.ts +415 -97
  72. package/src/runtime/bus.ts +2 -15
  73. package/src/runtime/eventBudget.test.ts +152 -0
  74. package/src/runtime/eventBudget.ts +120 -0
  75. package/src/runtime/hardening.test.ts +73 -13
  76. package/src/runtime/instrumentation.test.ts +55 -52
  77. package/src/runtime/instrumentation.ts +6 -60
  78. package/src/runtime/loop.test.ts +36 -17
  79. package/src/runtime/loop.ts +87 -88
  80. package/src/runtime/queries.ts +0 -17
  81. package/src/scene/featureScene.test.ts +61 -0
  82. package/src/scene/scene.ts +247 -104
  83. package/src/scene/seedScene.test.ts +42 -79
  84. package/src/state/state.nominal.typecheck.ts +68 -0
  85. package/src/state/state.test.ts +17 -0
  86. package/src/state/state.ts +80 -46
  87. package/src/state/stateFamily.test.ts +16 -11
  88. package/src/state/stateFamily.ts +55 -65
  89. package/src/state/stateGroup.ts +53 -64
  90. package/src/state/token.ts +40 -23
  91. package/src/synced/syncedStore.ts +46 -58
  92. package/src/testkit/flight.testkit.ts +75 -0
  93. package/src/ui/node.ts +0 -6
  94. package/src/ui/trigger.ts +0 -5
  95. package/src/wire/tree.test.ts +8 -7
  96. package/src/wire/tree.ts +0 -39
  97. package/src/wire/triggers.test.ts +6 -6
  98. package/src/wire/triggers.ts +14 -32
  99. package/src/internal/ctx.ts +0 -16
@@ -0,0 +1,59 @@
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>(
11
+ alwaysOn: AlwaysOn | undefined,
12
+ ): GatedOf<AlwaysOn> =>
13
+ // 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
14
+ (alwaysOn !== true) as GatedOf<AlwaysOn>
15
+
16
+ export type Revision = number & Brand.Brand<'reform/Revision'>
17
+ export const Revision: Brand.Brand.Constructor<Revision> = Brand.nominal<Revision>()
18
+ export const RevisionSchema: Schema.Schema<Revision, number> = Schema.Number.pipe(
19
+ Schema.brand('reform/Revision'),
20
+ )
21
+ export const revisionZero: Revision = Revision(0)
22
+ export const bumpRevision = (revision: Revision): Revision => Revision(revision + 1)
23
+
24
+ export interface QueryDriverOptions<Inputs extends ReadonlyArray<AnySource>, A, E, R> {
25
+ readonly name: string
26
+ readonly label: string
27
+ readonly gated: boolean
28
+ readonly inputs: Inputs
29
+ readonly query: (inputs: InputsObject<Inputs>) => Effect.Effect<A, E, R>
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 invalidateBy?: InvalidateBy<Inputs> | 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 disabled?: ((inputs: InputsObject<Inputs>) => boolean) | 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 coalesce?: 'switch' | 'trailing' | 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 reuse?: boolean | undefined
38
+ // 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
39
+ readonly extraKey?:
40
+ | {
41
+ readonly read: () => unknown
42
+ readonly subscribe: (listener: () => void) => () => void
43
+ }
44
+ | undefined
45
+ // 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
46
+ readonly persist?:
47
+ | {
48
+ readonly key: string | ((inputs: InputsObject<Inputs>) => string)
49
+ readonly schema: AnySchema<A>
50
+ }
51
+ | undefined
52
+ // 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
53
+ readonly onSettled?: ((generation: number) => void) | undefined
54
+ }
55
+
56
+ export interface QueryDriver<A, E> {
57
+ readonly store: Store<AnyAsyncData<A, E>>
58
+ readonly requested: () => number
59
+ }
@@ -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 } }
@@ -16,7 +13,10 @@ describe('reuse', () => {
16
13
  const e0 = { id: 1, name: 'a' }
17
14
  const e1 = { id: 2, name: 'b' }
18
15
  const prev = [e0, e1]
19
- const next = [{ id: 1, name: 'a' }, { id: 2, name: 'B' }]
16
+ const next = [
17
+ { id: 1, name: 'a' },
18
+ { id: 2, name: 'B' },
19
+ ]
20
20
  const out = reuse(prev, next)
21
21
  expect(out).not.toBe(prev)
22
22
  expect(out[0]).toBe(e0)
@@ -52,11 +52,11 @@ describe('reuse', () => {
52
52
  class View extends S.Class<View>('View')({ title: S.String, rows: S.Array(Row) }) {}
53
53
  const keep = new Row({ id: 1, name: 'a' })
54
54
  const prev = new View({ title: 't', rows: [keep, new Row({ id: 2, name: 'b' })] })
55
- const next = new View({ title: 't', rows: [new Row({ id: 1, name: 'a' }), new Row({ id: 2, name: 'B' })] })
55
+ const next = new View({
56
+ title: 't',
57
+ rows: [new Row({ id: 1, name: 'a' }), new Row({ id: 2, name: 'B' })],
58
+ })
56
59
  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
60
  expect(out).not.toBe(prev)
61
61
  expect(out).toBeInstanceOf(View)
62
62
  expect(out.rows[0]).toBe(keep)
@@ -66,14 +66,10 @@ describe('reuse', () => {
66
66
  })
67
67
 
68
68
  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
69
  const prev = { at: new Date(0) }
72
70
  const out = reuse(prev, { at: new Date(0) })
73
71
  expect(out.at).toBe(prev.at)
74
72
 
75
- // A class without Equal: never recursed into, the fresh leaf wins even
76
- // when its fields look identical.
77
73
  class Opaque {
78
74
  readonly v: number
79
75
  constructor(v: number) {
@@ -87,11 +83,11 @@ describe('reuse', () => {
87
83
 
88
84
  it('returns next below the depth bound', () => {
89
85
  type Nest = { readonly n?: Nest; readonly v: number }
90
- const deep = (depth: number): Nest => (depth === 0 ? { v: 0 } : { v: depth, n: deep(depth - 1) })
86
+ const deep = (depth: number): Nest =>
87
+ depth === 0 ? { v: 0 } : { v: depth, n: deep(depth - 1) }
91
88
  const prev = deep(20)
92
89
  const next = deep(20)
93
90
  const out = reuse(prev, next)
94
- // Value-equal throughout — shared down to the bound, fresh below it.
95
91
  expect(out).toEqual(next)
96
92
  })
97
93
 
@@ -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
  }
@@ -67,22 +54,19 @@ const reconcile = ({ prev, next, depth }: ReconcileArgs): unknown => {
67
54
  }
68
55
  if (Array.isArray(prev) && Array.isArray(next)) {
69
56
  const reconciled = next.map((element, index) =>
70
- index < prev.length ? reconcile({ prev: prev[index], next: element, depth: depth - 1 }) : element,
57
+ index < prev.length
58
+ ? reconcile({ prev: prev[index], next: element, depth: depth - 1 })
59
+ : element,
71
60
  )
72
61
  const unchanged =
73
- prev.length === next.length && reconciled.every((entry, index) => Object.is(entry, prev[index]))
62
+ prev.length === next.length &&
63
+ reconciled.every((entry, index) => Object.is(entry, prev[index]))
74
64
  return unchanged ? prev : reconciled
75
65
  }
76
66
  if (isPlainRecord(prev) && isPlainRecord(next)) {
77
67
  const { out, allPrev } = reconcileRecord(prev, next, depth)
78
68
  return allPrev ? prev : out
79
69
  }
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
70
  if (
87
71
  Equal.isEqual(prev) &&
88
72
  Equal.isEqual(next) &&
@@ -93,18 +77,9 @@ const reconcile = ({ prev, next, depth }: ReconcileArgs): unknown => {
93
77
  ? prev
94
78
  : Object.create(Object.getPrototypeOf(next), Object.getOwnPropertyDescriptors(out))
95
79
  }
96
- // Class instances without Equal (Date, Map, …) are opaque leaves.
97
80
  return next
98
81
  }
99
82
 
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
83
  export const reuse = <A>(previous: A, next: A): A =>
109
84
  // 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
85
  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,17 @@ 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
- export const notificationsLayer: Layer.Layer<Notifications> = Layer.sync(Notifications, makeScheduler)
57
+ export const notificationsLayer: Layer.Layer<Notifications> = Layer.sync(
58
+ Notifications,
59
+ makeScheduler,
60
+ )
95
61
 
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
62
  export const resolveScheduler: Effect.Effect<Scheduler> = Effect.map(
103
63
  Effect.serviceOption(Notifications),
104
64
  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,29 @@
1
- import { type Context, Effect, Equal, Predicate } from 'effect'
2
- import { type AnySource, type SourceName, type SourceValue } from '../state/token'
3
- import { type Store } from './store'
1
+ import { Context, Effect, Equal, Predicate } from 'effect'
2
+ import {
3
+ type AnySource,
4
+ type SourceIdentifier,
5
+ type SourceName,
6
+ type SourceValue,
7
+ } from '../state/token'
4
8
 
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
9
  export type InputsObject<Inputs extends ReadonlyArray<AnySource>> = {
12
10
  readonly [S in Inputs[number] as SourceName<S>]: SourceValue<S>
13
11
  }
14
12
 
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
13
  export type StoresOf<Items extends ReadonlyArray<unknown>> = {
22
- [I in keyof Items]: Items[I] extends { readonly store: Context.Tag<infer Svc, any> } ? Svc : never
14
+ [I in keyof Items]: Items[I] extends {
15
+ readonly store: Context.Tag<infer Service, infer _Store>
16
+ }
17
+ ? Service
18
+ : never
23
19
  }[number]
24
20
 
25
- /** The store requirement a calc's inputs impose. */
26
21
  export type InputStores<Inputs extends ReadonlyArray<AnySource>> = StoresOf<Inputs>
27
22
 
28
- /** Project inputs into a comparable key; the calc reacts only when it moves. */
29
23
  export type InvalidateBy<Inputs extends ReadonlyArray<AnySource>> = (
30
24
  inputs: InputsObject<Inputs>,
31
25
  ) => ReadonlyArray<unknown>
32
26
 
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
27
  const inputKey = (input: AnySource): string =>
48
28
  Predicate.hasProperty(input, 'manifest') &&
49
29
  Predicate.isRecord(input.manifest) &&
@@ -51,41 +31,38 @@ const inputKey = (input: AnySource): string =>
51
31
  ? input.manifest['name']
52
32
  : input.name
53
33
 
54
- /** Element-wise value equality of two invalidation keys. */
34
+ // Capture an invariant source tag through a covariant read-only surface.
35
+ interface ReadableStore<out A> {
36
+ readonly getSnapshot: () => A
37
+ readonly subscribe: (listener: () => void) => () => void
38
+ }
39
+
40
+ const readStore = <Source extends AnySource>(
41
+ source: Source,
42
+ ): Effect.Effect<ReadableStore<SourceValue<Source>>, never, SourceIdentifier<Source>> =>
43
+ Context.GenericTag<SourceIdentifier<Source>, ReadableStore<SourceValue<Source>>>(source.store.key)
44
+
55
45
  export const sameKey = (left: ReadonlyArray<unknown>, right: ReadonlyArray<unknown>): boolean =>
56
- left.length === right.length && left.every((element, index) => Equal.equals(element, right[index]))
46
+ left.length === right.length &&
47
+ left.every((element, index) => Equal.equals(element, right[index]))
57
48
 
58
- /** The sensing surface both `Calc.live` and `AsyncCalc.live` build on. */
59
49
  export interface WiredSources<Inputs extends ReadonlyArray<AnySource>> {
60
- /** Snapshot every input by name — the decoded inputs object a calc body sees. */
61
50
  readonly snapshot: () => InputsObject<Inputs>
62
- /** The invalidation key for an inputs object: the projection, or all input values. */
63
51
  readonly keyOf: (inputs: InputsObject<Inputs>) => ReadonlyArray<unknown>
64
- /** Subscribe to every source at once; the returned thunk unsubscribes from all. */
65
52
  readonly subscribe: (listener: () => void) => () => void
66
53
  }
67
54
 
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
55
  export const wireSources = <Inputs extends ReadonlyArray<AnySource>>(
74
56
  inputs: Inputs,
75
57
  invalidateBy?: InvalidateBy<Inputs>,
76
58
  ): Effect.Effect<WiredSources<Inputs>, never, InputStores<Inputs>> =>
77
59
  // 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)
78
60
  Effect.gen(function* () {
79
- const sources: ReadonlyArray<Store<unknown>> = yield* Effect.forEach(
80
- inputs,
81
- (input) => input.store,
82
- )
61
+ const sources = yield* Effect.forEach(inputs, readStore)
83
62
 
84
63
  const snapshot = (): InputsObject<Inputs> => {
85
64
  const args: Record<string, unknown> = {}
86
65
  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
66
  const source = sources[index]
90
67
  if (source !== undefined) {
91
68
  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
  })
@@ -2,26 +2,6 @@ import { Equal, MutableRef } from 'effect'
2
2
  import { type Inspectable, inspectable } from './inspect'
3
3
  import { defaultScheduler, type Scheduler } from './scheduler'
4
4
 
5
- /**
6
- * A reactive cell living outside Effect. The single source of truth for one
7
- * slice of state. `set` is internal: only the reduce loop ever holds a Store
8
- * (reducers return values, the loop writes them), which is how "state changes
9
- * only in the reduce loop" is enforced structurally — there is no public writer.
10
- *
11
- * This is the synchronous, React-facing analog of Effect's `SubscriptionRef`
12
- * (a `Ref` + `PubSub` + a `changes` `Stream`). We don't use `SubscriptionRef`
13
- * directly because `useSyncExternalStore` requires a *synchronous* `getSnapshot`
14
- * and a *synchronous* `subscribe` returning an unsubscribe thunk, whereas
15
- * `SubscriptionRef.get` is an `Effect` and its `changes` is a `Stream` (a fiber
16
- * per subscription). Mutable state is held in a `MutableRef` (Effect's mutable
17
- * cell), so there is no reassigned binding.
18
- *
19
- * `getSnapshot` + `subscribe` are the stable pair the React bridge binds to;
20
- * `getSnapshot` returns the same reference until the value actually changes.
21
- * `getVersion` returns a monotonic counter bumped on every real change — the
22
- * host sums it across a render's tracked stores for a `useSyncExternalStore`
23
- * snapshot, which closes the tear/dropped-update window a value snapshot can't.
24
- */
25
5
  export interface Store<in out A> extends Inspectable {
26
6
  get(): A
27
7
  set(value: A): void
@@ -30,7 +10,6 @@ export interface Store<in out A> extends Inspectable {
30
10
  subscribe(listener: () => void): () => void
31
11
  }
32
12
 
33
- /** Default change hook — a shared no-op so unprofiled stores pay one skipped call. */
34
13
  const noChange = (): void => {}
35
14
 
36
15
  export const makeStore = <A>(
@@ -38,8 +17,6 @@ export const makeStore = <A>(
38
17
  scheduler: Scheduler = defaultScheduler,
39
18
  onChange: () => void = noChange,
40
19
  ): Store<A> => {
41
- // The store is a mutable reactive slot by design, so its value lives in a
42
- // `MutableRef` we update in place (no reassigned binding).
43
20
  const current = MutableRef.make(initial)
44
21
  const version = MutableRef.make(0)
45
22
  const listeners = new Set<() => void>()
@@ -66,18 +43,6 @@ export const makeStore = <A>(
66
43
  }
67
44
  }
68
45
 
69
- /**
70
- * A read-only `Store` whose value is computed from upstream sources.
71
- *
72
- * Reads are pull-fresh: `get`/`getSnapshot` call `compute` directly, so a
73
- * headless snapshot always reflects the current source state without waiting for
74
- * a notification (`compute` must memoize so an unchanged read returns a stable
75
- * reference — `useSyncExternalStore` requires it). *Notifications*, by contrast,
76
- * run through the same coalescing scheduler as `makeStore`: an upstream change
77
- * recomputes once and wakes subscribers only when the output actually moves (by
78
- * `Equal.equals`), so every store kind shares one notification timing. `set` is
79
- * a no-op — the only writer is the upstream subscription.
80
- */
81
46
  export const makeDerivedStore = <A>(
82
47
  compute: () => A,
83
48
  subscribeUpstream: (onChange: () => void) => () => void,
@@ -86,8 +51,6 @@ export const makeDerivedStore = <A>(
86
51
  ): { readonly store: Store<A>; readonly unsubscribe: () => void } => {
87
52
  const listeners = new Set<() => void>()
88
53
  const version = MutableRef.make(0)
89
- // The last output we notified against — tracked only here, never touched by
90
- // reads, so a `get` racing ahead of `onChange` can't suppress a notification.
91
54
  const seen = MutableRef.make<{ readonly value: A } | undefined>({ value: compute() })
92
55
  const onChange = () => {
93
56
  const previous = MutableRef.get(seen)
@@ -1,37 +1,20 @@
1
1
  import { Context, Effect, Option } from 'effect'
2
2
  import type { Store } from './store'
3
3
 
4
- /**
5
- * Anything a render can depend on: a source it can subscribe to for changes and
6
- * snapshot a monotonic version from. `Store` (and the calc/family stores)
7
- * structurally satisfy this. The host sums `getVersion()` across a render's
8
- * tracked sources for its `useSyncExternalStore` snapshot — a value that moves
9
- * whenever any dependency changes, which is what closes the tear/dropped-update
10
- * window a per-store value snapshot cannot.
11
- */
12
4
  export interface Subscribable {
13
5
  subscribe(listener: () => void): () => void
14
6
  getVersion(): number
15
7
  }
16
8
 
17
- /** A per-render dependency sink. The renderer provides one; reads record into it. */
18
9
  export interface Tracker {
19
10
  add(source: Subscribable): void
20
11
  }
21
12
 
22
- /**
23
- * The active render's dependency tracker (D5). Present only while a composition
24
- * is rendering under `@reform/react`; absent in procedures and headless reads.
25
- */
26
- const CurrentTrackerBase: Context.TagClass<CurrentTracker, 'reform/Tracker', Tracker> =
27
- Context.Tag('reform/Tracker')<CurrentTracker, Tracker>()
13
+ const CurrentTrackerBase: Context.TagClass<CurrentTracker, 'reform/Tracker', Tracker> = Context.Tag(
14
+ 'reform/Tracker',
15
+ )<CurrentTracker, Tracker>()
28
16
  export class CurrentTracker extends CurrentTrackerBase {}
29
17
 
30
- /**
31
- * Read a store's current snapshot and, if a tracker is active, record the store
32
- * as a dependency of the current render. One read API, two behaviors by context
33
- * (D5): inside a render it subscribes the slice; elsewhere it just snapshots.
34
- */
35
18
  export const readTracked = <A>(store: Store<A>): Effect.Effect<A> =>
36
19
  Effect.map(Effect.serviceOption(CurrentTracker), (tracker) => {
37
20
  if (Option.isSome(tracker)) {
@@ -0,0 +1,5 @@
1
+ import type { Schema } from 'effect'
2
+
3
+ export type AnyValue = Schema.Schema.Type<Schema.Schema.AnyNoContext>
4
+
5
+ export type AnySchema<A> = Schema.Schema<A, Schema.Schema.Encoded<Schema.Schema.AnyNoContext>>