@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
@@ -1,191 +1,222 @@
1
- import { Context, Data, Effect, Layer, MutableRef, Predicate } from 'effect'
1
+ import { Context, Data, Effect, Equal, Layer, MutableRef, Option, Predicate } from 'effect'
2
+ import type { Effect as EffectType } from 'effect/Effect'
2
3
  import { type Manifest, yieldableClass } from '../definition/definition'
3
4
  import { resolveScheduler } from '../internal/scheduler'
4
- import { type InputStores, sameKey, wireSources } from '../internal/sources'
5
+ import { type InputStores, wireSources } from '../internal/sources'
5
6
  import { makeDerivedStore, type Store } from '../internal/store'
6
7
  import { readTracked } from '../internal/track'
7
- import { type Source } from '../state/token'
8
-
9
- // Boundary: the Suspense-boundary analog, value-level. One derived store that
10
- // merges the lifecycles of several async sources into a single
11
- // Pending/Errored/Ready value, so ONE surface (a splash, a skeleton) covers a
12
- // whole subtree's first load instead of every query consumer painting its own.
13
- //
14
- // Reform can do this without React's throw-a-promise machinery because
15
- // fetching is layer-driven, not render-driven: an `AsyncCalc` runs because its
16
- // layer is built, never because something rendered. A boundary therefore only
17
- // *aggregates* lifecycles — it cannot create render-waterfalls, and content it
18
- // reveals already has its data.
19
- //
20
- // The arm mapping IS the semantics, and most of it falls out of `AsyncData`:
21
- // Loading → Pending (the arm only exists before the first value,
22
- // so a boundary is first-load-only by construction)
23
- // Success → Ready (even `refetching: true` — SWR refetches never
24
- // re-show the fallback; no transition machinery)
25
- // Idle → Ready (a gated query that is off is DELIBERATELY off —
26
- // a signed-out viewer must not wait on a query
27
- // that only runs signed-in)
28
- // Error → Errored (the error-boundary half, also value-level)
29
- //
30
- // Chained gates (B enables when A succeeds) cannot leak a Ready between hops:
31
- // the boundary's layer requires every covered store, so each driver subscribes
32
- // upstream BEFORE the boundary does, the scheduler flush runs listeners in
33
- // subscription order to a fixpoint within one microtask, and reads are
34
- // pull-fresh — by the time any subscriber reads, the next hop is already
35
- // Loading. `boundary.test.ts` pins this with a recorded-notification proof.
36
-
37
- /** Some covered source is still on its FIRST load — show the fallback. */
8
+ import { type AnySource, type SourceCapture, type SourceValue } from '../state/token'
9
+
38
10
  export interface BoundaryPending {
39
11
  readonly _tag: 'Pending'
40
12
  }
41
13
 
42
- /** Some covered source failed before its first value. */
43
14
  export interface BoundaryErrored {
44
15
  readonly _tag: 'Errored'
45
- /** The failures, in `over` order (heterogeneous — display via `String`). */
46
16
  readonly errors: ReadonlyArray<unknown>
47
17
  }
48
18
 
49
- /** Every covered source has settled (or is deliberately gated off). */
50
19
  export interface BoundaryReady {
51
20
  readonly _tag: 'Ready'
52
21
  }
53
22
 
54
23
  export type BoundaryState = BoundaryPending | BoundaryErrored | BoundaryReady
55
24
 
56
- // Stable arm references: repeated recomputes return the same object, so the
57
- // derived store's Equal gate (and `useSyncExternalStore`'s stable-snapshot
58
- // contract) hold without a wrapper. `Errored` carries data, so it is a `Data`
59
- // struct value-equal failures coalesce the same way.
25
+ export const BoundaryStoreTypeId: unique symbol = Symbol.for('reform/BoundaryStore')
26
+ export type BoundaryStoreTypeId = typeof BoundaryStoreTypeId
27
+
28
+ export interface BoundaryStore<N extends string> {
29
+ readonly [BoundaryStoreTypeId]: N
30
+ }
31
+
60
32
  const pendingArm: BoundaryState = { _tag: 'Pending' }
61
33
  const readyArm: BoundaryState = { _tag: 'Ready' }
62
34
  const erroredArm = (errors: ReadonlyArray<unknown>): BoundaryState =>
63
35
  Data.struct({ _tag: 'Errored' as const, errors: Data.array(errors) })
64
36
 
65
- /**
66
- * Anything a boundary can cover: a `Source` whose value is an async lifecycle —
67
- * an `AsyncCalc`, or anything lifecycle-valued built over one (e.g. a
68
- * `RemoteState` overlay). The constraint is the same erased `Source` every calc
69
- * input uses (store invariance rules out a fixed lifecycle value type — each
70
- * definition's union carries its own `Gated`/`E` arms); the captured `const`
71
- * tuple keeps each member's precise store requirement, and the runtime merge
72
- * reads arms through structural guards, so a non-lifecycle source simply
73
- * counts as settled.
74
- */
75
- export type AnyLifecycleSource = Source<string, any>
37
+ export type AnyLifecycleSource = AnySource
38
+
39
+ declare const BoundaryLifecycleMismatchTypeId: unique symbol
40
+
41
+ interface BoundaryLifecycleMismatch<Value> {
42
+ readonly [BoundaryLifecycleMismatchTypeId]: Value
43
+ }
44
+
45
+ type CheckedLifecycleValue<Value> = Value extends { readonly _tag: 'Idle' | 'Loading' }
46
+ ? Value
47
+ : Value extends {
48
+ readonly _tag: 'Success'
49
+ readonly value: infer _Success
50
+ readonly refetching: boolean
51
+ }
52
+ ? Value
53
+ : Value extends {
54
+ readonly _tag: 'Error'
55
+ readonly error: infer _Failure
56
+ readonly refetching: boolean
57
+ }
58
+ ? Value
59
+ : BoundaryLifecycleMismatch<Value>
60
+
61
+ type CheckedLifecycleSource<Source extends AnySource> = [SourceValue<Source>] extends [never]
62
+ ? BoundaryLifecycleMismatch<Source>
63
+ : SourceValue<Source> extends CheckedLifecycleValue<SourceValue<Source>>
64
+ ? Source
65
+ : BoundaryLifecycleMismatch<SourceValue<Source>>
66
+
67
+ type CheckedLifecycleSources<Over extends ReadonlyArray<AnyLifecycleSource>> = {
68
+ readonly [Index in keyof Over]: Over[Index] extends AnySource
69
+ ? CheckedLifecycleSource<Over[Index]>
70
+ : BoundaryLifecycleMismatch<Over[Index]>
71
+ }
76
72
 
77
73
  export interface BoundaryManifest<N extends string> extends Manifest {
78
74
  readonly kind: 'Boundary'
79
75
  readonly name: N
80
76
  }
81
77
 
82
- export interface BoundaryClass<out N extends string, out Over extends ReadonlyArray<AnyLifecycleSource>>
83
- extends Effect.Effect<BoundaryState, never, Store<BoundaryState>> {
78
+ export interface BoundaryClass<
79
+ N extends string,
80
+ out Over extends ReadonlyArray<AnyLifecycleSource>,
81
+ > extends EffectType<BoundaryState, never, BoundaryStore<N>> {
84
82
  new (): {}
85
83
  readonly manifest: BoundaryManifest<N>
86
- readonly store: Context.Tag<Store<BoundaryState>, Store<BoundaryState>>
87
- /** The boundary's name, so it doubles as a `Source` input to a calc. */
84
+ readonly store: Context.Tag<BoundaryStore<N>, Store<BoundaryState>>
88
85
  readonly name: N
89
- /** The covered lifecycle sources — read by `live` and the editor's graph. */
90
86
  readonly over: Over
87
+ readonly capture: <Result>(visit: SourceCapture<Result>) => Result
91
88
  }
92
89
 
93
90
  export interface BoundaryConfig<Over extends ReadonlyArray<AnyLifecycleSource>> {
94
- readonly over: Over
91
+ readonly over: Over & CheckedLifecycleSources<Over>
95
92
  }
96
93
 
97
94
  export interface BoundaryOptions {
98
- /**
99
- * Latch: once Ready, stay Ready. A BOOT boundary wants this — a covered
100
- * route-gated query that first enables on a later navigation (Idle →
101
- * Loading) must not re-splash the whole shell; its own screen owns that
102
- * loading state. Without the latch the boundary re-pends on any covered
103
- * first load, which is what a per-screen boundary wants.
104
- */
105
95
  // oxlint-disable-next-line reform-rules/no-optional-fields -- public options-bag input; omission is the documented default (no latch) and external callers pass a plain `{ once: true }` literal
106
96
  readonly once?: boolean
107
97
  }
108
98
 
109
- /**
110
- * Define a boundary over async sources. `yield* MyBoundary` reads the merged
111
- * `Pending | Errored | Ready` lifecycle; the consuming view renders ONE
112
- * fallback on Pending and its content slot on Ready — content reveals
113
- * together, with data already present (fetching never waited on rendering).
114
- */
115
99
  export const make = <const N extends string, const Over extends ReadonlyArray<AnyLifecycleSource>>(
116
100
  name: N,
117
101
  config: BoundaryConfig<Over>,
118
102
  ): BoundaryClass<N, Over> => {
119
- const store = Context.GenericTag<Store<BoundaryState>, Store<BoundaryState>>(
103
+ const store = Context.GenericTag<BoundaryStore<N>, Store<BoundaryState>>(
120
104
  `reform/boundary/${name}`,
121
105
  )
122
106
  const manifest: BoundaryManifest<N> = { kind: 'Boundary', name }
123
107
  const read = Effect.flatMap(store, readTracked)
124
- return yieldableClass(read, { manifest, store, name, over: config.over })
108
+ const boundary: BoundaryClass<N, Over> = yieldableClass(read, {
109
+ manifest,
110
+ store,
111
+ name,
112
+ over: config.over,
113
+ capture: <Result>(visit: SourceCapture<Result>): Result => visit(boundary),
114
+ })
115
+ return boundary
125
116
  }
126
117
 
127
- // The snapshot values arrive through `wireSources`' erased key projection
128
- // (`ReadonlyArray<unknown>`), so read the two facts the merge needs through
129
- // honest structural guards — no cast back to `AnyAsyncData`.
130
118
  const tagOf = (arm: unknown): string =>
131
119
  Predicate.hasProperty(arm, '_tag') && Predicate.isString(arm._tag) ? arm._tag : ''
132
- const errorOf = (arm: unknown): ReadonlyArray<unknown> =>
133
- Predicate.hasProperty(arm, 'error') && tagOf(arm) === 'Error' ? [arm.error] : []
134
-
135
- /**
136
- * Wire the merge. A derived store over the covered stores (sensed through the
137
- * same `wireSources` plumbing every calc uses): recomputes are memoized on the
138
- * input snapshots and the arms are stable references, so input churn that does
139
- * not move the merged arm wakes nobody.
140
- */
120
+ const errorOf = (arm: unknown): Option.Option<unknown> =>
121
+ Predicate.hasProperty(arm, 'error') && tagOf(arm) === 'Error'
122
+ ? Option.some(arm.error)
123
+ : Option.none()
124
+
125
+ interface SlotUpdate {
126
+ readonly slot: number
127
+ readonly arm: unknown
128
+ }
129
+
141
130
  export const live = <N extends string, Over extends ReadonlyArray<AnyLifecycleSource>>(
142
131
  boundary: BoundaryClass<N, Over>,
143
132
  options: BoundaryOptions = {},
144
- ): Layer.Layer<Store<BoundaryState>, never, InputStores<Over>> =>
145
- // Scoped so the source subscriptions are released with the layer's scope.
133
+ ): Layer.Layer<BoundaryStore<N>, never, InputStores<Over>> =>
146
134
  Layer.scoped(
147
135
  boundary.store,
148
136
  Effect.gen(function* () {
149
137
  const scheduler = yield* resolveScheduler
150
- const sources = yield* wireSources(boundary.over)
138
+ const sourceWires = yield* Effect.forEach(boundary.over, (source) => wireSources([source]))
139
+ const readSource = (source: (typeof sourceWires)[number]): unknown =>
140
+ source.keyOf(source.snapshot())[0]
141
+ const arms = sourceWires.map((source) => MutableRef.make(readSource(source)))
142
+ const loadingCount = MutableRef.make(
143
+ arms.reduce((count, arm) => count + (tagOf(MutableRef.get(arm)) === 'Loading' ? 1 : 0), 0),
144
+ )
145
+ const errorCount = MutableRef.make(
146
+ arms.reduce(
147
+ (count, arm) => count + (Option.isSome(errorOf(MutableRef.get(arm))) ? 1 : 0),
148
+ 0,
149
+ ),
150
+ )
151
+ const collectErrors = (): ReadonlyArray<unknown> =>
152
+ arms.flatMap((arm) => Option.toArray(errorOf(MutableRef.get(arm))))
153
+ const errorArm = MutableRef.make(erroredArm(collectErrors()))
154
+ const updateSlot = (update: SlotUpdate): void => {
155
+ const armRef = arms[update.slot]
156
+ if (armRef === undefined) {
157
+ return
158
+ }
159
+ const previous = MutableRef.get(armRef)
160
+ if (Equal.equals(previous, update.arm)) {
161
+ return
162
+ }
163
+ MutableRef.set(armRef, update.arm)
164
+ MutableRef.update(
165
+ loadingCount,
166
+ (count) =>
167
+ count +
168
+ (tagOf(update.arm) === 'Loading' ? 1 : 0) -
169
+ (tagOf(previous) === 'Loading' ? 1 : 0),
170
+ )
171
+ const previousError = errorOf(previous)
172
+ const nextError = errorOf(update.arm)
173
+ if (!Equal.equals(previousError, nextError)) {
174
+ MutableRef.update(
175
+ errorCount,
176
+ (count) =>
177
+ count + (Option.isSome(nextError) ? 1 : 0) - (Option.isSome(previousError) ? 1 : 0),
178
+ )
179
+ MutableRef.set(errorArm, erroredArm(collectErrors()))
180
+ }
181
+ }
182
+ const synchronize = (): void =>
183
+ sourceWires.forEach((source, slot) => updateSlot({ slot, arm: readSource(source) }))
151
184
 
152
185
  const latched = MutableRef.make(false)
153
- const memo = MutableRef.make<
154
- { readonly key: ReadonlyArray<unknown>; readonly value: BoundaryState } | undefined
155
- >(undefined)
186
+ const incremental = MutableRef.make(false)
156
187
  const recompute = (): BoundaryState => {
157
188
  if (options.once === true && MutableRef.get(latched)) {
158
189
  return readyArm
159
190
  }
160
- const arms = sources.keyOf(sources.snapshot())
161
- const prev = MutableRef.get(memo)
162
- if (prev !== undefined && sameKey(arms, prev.key)) {
163
- return prev.value
191
+ if (!MutableRef.get(incremental)) {
192
+ synchronize()
164
193
  }
165
- const errors = arms.flatMap(errorOf)
166
- const mergedArm =
167
- errors.length > 0
168
- ? erroredArm(errors)
169
- : arms.some((arm) => tagOf(arm) === 'Loading')
170
- ? pendingArm
171
- : readyArm
172
- MutableRef.set(memo, { key: arms, value: mergedArm })
173
- return mergedArm
194
+ return MutableRef.get(errorCount) > 0
195
+ ? MutableRef.get(errorArm)
196
+ : MutableRef.get(loadingCount) > 0
197
+ ? pendingArm
198
+ : readyArm
174
199
  }
175
200
 
176
- const derived = makeDerivedStore(recompute, sources.subscribe, scheduler)
201
+ const subscribe = (onChange: () => void): (() => void) => {
202
+ const publish = (): void => {
203
+ MutableRef.set(incremental, true)
204
+ onChange()
205
+ MutableRef.set(incremental, false)
206
+ }
207
+ const settle = (): void => scheduler.schedule([publish])
208
+ const unsubscribes = sourceWires.map((source, sourceIndex) =>
209
+ source.subscribe(() => {
210
+ updateSlot({ slot: sourceIndex, arm: readSource(source) })
211
+ scheduler.schedule([settle])
212
+ }),
213
+ )
214
+ return () => unsubscribes.forEach((unsubscribe) => unsubscribe())
215
+ }
216
+ const derived = makeDerivedStore(recompute, subscribe, scheduler)
177
217
  yield* Effect.addFinalizer(() => Effect.sync(derived.unsubscribe))
178
218
 
179
219
  if (options.once === true) {
180
- // Latch on CONVERGED values only — at build and on (coalesced,
181
- // post-fixpoint) notifications — never inside `recompute`. A chained
182
- // gate with a plain Calc hop between the async stores (bootstrap →
183
- // current-workspace → boards) propagates over TWO flush rounds, so a
184
- // mid-flush recompute can observe the frame where the downstream
185
- // driver has not yet flipped its query to Loading. That intermediate
186
- // Ready self-corrects within the flush for readers, but a latch taken
187
- // there would freeze it; a subscriber runs only after the fixpoint,
188
- // where the value is converged.
189
220
  if (recompute()._tag === 'Ready') {
190
221
  MutableRef.set(latched, true)
191
222
  }