@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
package/src/index.ts CHANGED
@@ -1,28 +1,35 @@
1
- // Reform core — the renderer-neutral primitives and runtime.
2
- // Definitions (`X.make`) are separate from implementations (`.live`)
3
- // throughout, and nothing self-registers on import (principle #1, #6).
4
-
5
1
  export * as State from './state/state'
2
+ export type { StateClass, StateStore, StateValue } from './state/state'
6
3
  export * as StateGroup from './state/stateGroup'
4
+ export type { SeedsOf } from './state/stateGroup'
7
5
  export * as StateFamily from './state/stateFamily'
8
- export { StateToken } from './state/token'
9
- export type { AnySource, Source } from './state/token'
6
+ export type {
7
+ FamilyKey,
8
+ FamilyValue,
9
+ StateFamilyClass,
10
+ StateFamilyStore,
11
+ } from './state/stateFamily'
10
12
 
11
13
  export * as Event from './event/event'
12
- // Direct type re-export alongside the namespace (TS2742 nameability): inferred
13
- // types of layers built over intent/event unions reference `EventOf`, and the
14
- // declaration emitter can only name it through a plain export.
15
- export type { EventOf } from './event/event'
14
+ // TS2742: direct type exports allow declaration emit to name event-derived types.
15
+ export type {
16
+ EventClass,
17
+ EventOf,
18
+ EventPayload,
19
+ SourceChange,
20
+ SourceEventConfig,
21
+ SourceEventConfigExternalApi,
22
+ } from './event/event'
16
23
  export * as EventGroup from './event/eventGroup'
17
24
 
18
25
  export * as Reducer from './reducer/reducer'
19
26
  export * as Calc from './calc/calc'
27
+ export type { CalcClass, CalcStore } from './calc/calc'
20
28
  export * as CalcFamily from './calc/calcFamily'
29
+ export type { CalcFamilyClass, CalcFamilyStore } from './calc/calcFamily'
21
30
  export * as AsyncCalc from './calc/asyncCalc'
31
+ export type { AsyncCalcClass, AsyncCalcStore } from './calc/asyncCalc'
22
32
  export { composeCalcs } from './calc/compose'
23
- // The inputs-object/key/store types appearing in `AsyncCalc.live` /
24
- // `RemoteState.live` signatures, exported so app code can annotate
25
- // query-swapping helpers and layer types without re-spelling them.
26
33
  export type { InputsObject, InputStores, InvalidateBy } from './internal/sources'
27
34
  export {
28
35
  AsyncData,
@@ -31,138 +38,118 @@ export {
31
38
  type AsyncLoading,
32
39
  type AsyncSuccess,
33
40
  } from './calc/asyncData'
34
- // The flat, data-oriented query value (`data`/`error`/`isFetching`/`isStale`) an
35
- // `AsyncCalc` is driven by; `toAsyncData` projects it onto the matchable arms.
36
41
  export { isLoading, type QueryState, toAsyncData } from './calc/queryState'
37
- // Optional host seams an `AsyncCalc` consults — persistence and lifecycle signals.
38
- // Concrete implementations live in edge packages (e.g. `@playfast/reform-query-browser`).
39
- export {
40
- noopQueryStore,
41
- QueryStore,
42
- type QueryStoreApi,
43
- resolveQueryStore,
44
- } from './internal/queryStore'
45
- export {
46
- noopQueryEvents,
47
- QueryEvents,
48
- type QueryEventsApi,
49
- resolveQueryEvents,
50
- } from './internal/queryEvents'
42
+
51
43
  export * as RemoteState from './remote/remoteState'
52
- // Direct type re-exports alongside the namespace (TS2742 nameability): an app
53
- // layer's inferred type references these (`Store<ReadonlyArray<PendingIntent<…>>>`),
54
- // and the declaration emitter can only name them through a plain export.
55
- export type { FailedIntent, PendingIntent, RemoteStateClass } from './remote/remoteState'
44
+ // TS2742: direct exports keep pending and failed state types nameable.
45
+ export type {
46
+ FailedIntent,
47
+ PendingIntent,
48
+ RemoteStateClass,
49
+ RemoteStatePendingStore,
50
+ RemoteStateSettled,
51
+ RemoteStateTruthStore,
52
+ RemoteStateVisibleStore,
53
+ } from './remote/remoteState'
56
54
  export * as SyncedStore from './synced/syncedStore'
57
- // Direct type re-export alongside the namespace (TS2742 nameability): adapters
58
- // (e.g. `@playfast/reform-db`) annotate the source handed to `SyncedStore.live`.
59
- export type { SyncedSource, SyncedStoreClass } from './synced/syncedStore'
55
+ // TS2742: adapters need a directly nameable synchronized source type.
56
+ export type { SyncedSource, SyncedStoreClass, SyncedStoreIdentifier } from './synced/syncedStore'
60
57
  export * as Boundary from './boundary/boundary'
61
- // Direct type re-exports alongside the namespace (same TS2742 nameability
62
- // reason as the RemoteState types above): app code annotates props with these.
58
+ // TS2742: direct exports keep boundary state types nameable.
63
59
  export type {
64
60
  BoundaryErrored,
65
61
  BoundaryPending,
66
62
  BoundaryReady,
67
63
  BoundaryState,
64
+ BoundaryStore,
68
65
  } from './boundary/boundary'
66
+
69
67
  export * as Procedure from './procedure/procedure'
68
+ export type { ProcedureClass, ProcedureConfig } from './procedure/procedure'
70
69
  export * as Channel from './channel/channel'
70
+ export type { ChannelClass, ChannelPolicy } from './channel/channel'
71
+ export * as Namespace from './namespace/namespace'
72
+ export type { FeatureNamespace } from './namespace/namespace'
71
73
 
72
74
  export * as Composition from './compose/composition'
75
+ export { liveScoped } from './compose/composition'
73
76
  export type {
77
+ CompositionActivation,
74
78
  CompositionClass,
79
+ CompositionId,
75
80
  CompositionService,
76
81
  Frame,
77
82
  RenderEnv,
83
+ ScopedCompositionLiveConfig,
78
84
  } from './compose/composition'
79
85
  export {
80
86
  absent,
87
+ type AbsentFill,
81
88
  each,
82
89
  type EachFill,
83
90
  isStructure,
84
91
  mount,
85
92
  one,
86
93
  type OneFill,
87
- type AbsentFill,
88
94
  type SlotFill,
89
95
  type Structure,
90
96
  structureEquals,
91
97
  type StructureSlots,
92
- StructureTypeId,
93
98
  when,
94
99
  } from './compose/structure'
95
100
  export { Props } from './compose/props'
96
101
  export {
102
+ type CompositionOfSlotTarget,
103
+ type ContractOfSlotTarget,
104
+ type FeatureSlotTarget,
97
105
  isSlot,
98
106
  type Node,
107
+ type PropsOfSlotTarget,
99
108
  slot,
100
109
  type SlotClass,
110
+ type SlotComposition,
111
+ type SlotContract,
101
112
  type SlotInstance,
102
113
  type SlotProps,
103
- SlotTypeId,
114
+ type SlotService,
115
+ type SlotTarget,
116
+ type TargetOfSlot,
104
117
  } from './compose/slot'
105
- export { type SlotRenderer } from './compose/host'
106
118
  export * as Ui from './compose/ui'
107
119
  export {
120
+ type CallableView,
108
121
  type DerivedContract,
109
122
  isUi,
110
123
  type MadeView,
111
124
  ui,
112
125
  type UiClass,
113
126
  type UiContract,
114
- type UiManifest,
115
- UiTypeId,
127
+ type UiService,
116
128
  UiViewContract,
117
129
  type ViewImpl,
118
- type WireSchemas,
119
- type WiredUi,
120
- type WiredUiManifest,
121
130
  } from './compose/ui'
122
- export { provide } from './compose/provide'
131
+ export { provide, provideExisting, provideMergeExact } from './compose/provide'
123
132
  export type { Trigger } from './ui/trigger'
124
133
 
125
- // Wire: the serializable rendered-tree model + pure diff/apply the remote
126
- // transport sends over the wire and the client folds back. See REMOTE_UI.md §3.
127
- export * as Wire from './wire/tree'
128
- export type { WireNode, WirePatch, WireProp, WireTree } from './wire/tree'
129
-
130
- // Triggers: the server-side handle registry bridging client trigger invocations
131
- // back to live triggers, decoding payloads via the event schema. See REMOTE_UI.md §4.
132
- export * as Triggers from './wire/triggers'
133
- export {
134
- TriggerRegistry,
135
- type TriggerHandle,
136
- type TriggerRegistryApi,
137
- UnknownTrigger,
138
- } from './wire/triggers'
139
-
140
- // Feature: the lazy/eager code-split unit. Definitions stay eager and reflectable;
141
- // the heavy `.live` layer loads on demand (an Effect, never a Promise). See LAZY.md.
142
134
  export * as Feature from './feature/feature'
143
135
  export {
144
- type EngineServices,
136
+ type EagerFeatureClass,
145
137
  type FailedProps,
146
- type FeatureBinding,
147
- FeatureBindingTypeId,
148
138
  type FeatureClass,
149
- type FeatureManifest,
150
139
  featureModule,
151
140
  type FeatureModule,
141
+ type FeatureModuleInput,
152
142
  isFeature,
153
143
  isFeatureBinding,
154
144
  lazyImport,
155
- type LoadedModule,
156
- mountFeature,
145
+ type LazyFeatureClass,
157
146
  type Placeholders,
158
- type ProvidedBy,
159
- type RequireCarrier,
160
147
  } from './feature/feature'
161
- export { type SlotChild } from './compose/slot'
162
148
 
163
- // A scene bundles a composition with the closed wiring that runs it — the single
164
- // handle the react host, proofs, and the dev tool all consume.
165
149
  export {
150
+ type CapturedScene,
151
+ featureScene,
152
+ type FeatureSceneConfig,
166
153
  isScene,
167
154
  type MountedServices,
168
155
  profiledScene,
@@ -171,69 +158,24 @@ export {
171
158
  sceneInstrumentation,
172
159
  seedScene,
173
160
  } from './scene/scene'
174
- export type { SeedsOf } from './state/stateGroup'
175
161
 
176
- // Runtime surface for hosts (`@reform/react`) and headless tests.
177
- export { Bus, busLayer, type Envelope, type Priority, publish, type Tagged } from './runtime/bus'
178
- // The renderer-neutral scene runtime: hosts layer their lifecycle on top;
179
- // headless tests/proofs/drive scripts consume it directly.
162
+ export { type Priority, publish } from './runtime/bus'
180
163
  export {
181
164
  type AppRuntime,
182
165
  type FeatureMountHandlers,
183
166
  makeAppRuntime,
167
+ type RuntimeFeatureAvailability,
184
168
  type RuntimeHandle,
185
169
  } from './runtime/appRuntime'
186
- export { Engine, type ReducerEntry, Reducers, reducersLayer } from './runtime/loop'
187
- // The profiler SPI: synchronous hooks the engine calls on its hot paths, plus
188
- // the FiberRef seam `@playfast/reform-profiler` writes through (`profiledScene`).
189
- export {
190
- CurrentInstrumentation,
191
- type Instrumentation,
192
- noopInstrumentation,
193
- resolveInstrumentation,
194
- type SpanEnd,
195
- } from './runtime/instrumentation'
196
- // The runtime-wide registry of live query handles. `AsyncCalc.invalidate` /
197
- // `AsyncCalc.refetch` and provider layers act on a query by name through it.
198
- export { Queries, queriesLayer, type QueryHandle, type QueryRegistry } from './runtime/queries'
199
- export {
200
- type ChannelClass,
201
- type ChannelPolicy,
202
- type ChannelRuntime,
203
- Channels,
204
- channelsLayer,
205
- type ProcedureEntry,
206
- Procedures,
207
- proceduresLayer,
208
- } from './channel/channel'
209
- export { CurrentTracker, type Subscribable, type Tracker } from './internal/track'
210
- export { CaptureSink, type CaptureSinkApi, type UiCapture } from './internal/capture'
170
+ export { Engine } from './runtime/loop'
171
+ export type { EventBudget, ReformOptions } from './runtime/eventBudget'
211
172
  export {
212
173
  AsyncReducer,
213
174
  AsyncSceneLayer,
214
175
  DuplicateRegistration,
176
+ EventLoopOverflow,
215
177
  FeatureLoadFailed,
216
178
  forceSync,
217
179
  InvalidProvideTarget,
218
180
  UnknownGroupState,
219
181
  } from './internal/errors'
220
- // Notification scheduler: hosts that need per-runtime isolation (concurrent SSR,
221
- // multiple mounted roots) provide `Notifications` upstream of their state layers;
222
- // everything else coalesces on the process-wide default.
223
- export {
224
- defaultScheduler,
225
- makeScheduler,
226
- Notifications,
227
- notificationsLayer,
228
- resolveScheduler,
229
- type Scheduler,
230
- } from './internal/scheduler'
231
- export { makeStore, type Store } from './internal/store'
232
- export type { FamilyStore } from './state/stateFamily'
233
- // Primitive-authoring toolkit — exported for first-party companion packages
234
- // (e.g. `@playfast/reform-db`, `@playfast/reform-resource`) that build their own
235
- // first-class `Source`s which must participate in dependency tracking and
236
- // reflection exactly like a `Calc`.
237
- export { type Kind, type Manifest, yieldableClass } from './definition/definition'
238
- export { readTracked } from './internal/track'
239
- export { sameKey, wireSources, type WiredSources } from './internal/sources'
@@ -0,0 +1,39 @@
1
+ import { Option } from 'effect'
2
+
3
+ /**
4
+ * A read-through snapshot cache over a tag-indexed `Map<string, Set<T>>`.
5
+ *
6
+ * The registries keep mutable `Set` buckets so register/unregister stay O(1),
7
+ * but the dispatch loop must iterate a *snapshot*: an entry registered while a
8
+ * frame is folding must not join the fold already in flight (`Set` iteration is
9
+ * live, an array copy is not). Copying per event would pay that snapshot on
10
+ * every dispatch, so the copy is cached and dropped only when the bucket
11
+ * actually changes — registration is rare, dispatch is not.
12
+ *
13
+ * A tag with no bucket returns a shared empty array and is never cached, so
14
+ * events nothing handles allocate nothing and cannot grow the cache.
15
+ */
16
+ export interface BucketCache<T> {
17
+ readonly read: (tag: string) => ReadonlyArray<T>
18
+ readonly invalidate: (tag: string) => void
19
+ }
20
+
21
+ export const makeBucketCache = <T>(source: Map<string, Set<T>>): BucketCache<T> => {
22
+ const empty: ReadonlyArray<T> = []
23
+ const cache = new Map<string, ReadonlyArray<T>>()
24
+ const build = (tag: string): ReadonlyArray<T> =>
25
+ Option.match(Option.fromNullable(source.get(tag)), {
26
+ onNone: (): ReadonlyArray<T> => empty,
27
+ onSome: (bucket) => {
28
+ const current: ReadonlyArray<T> = Array.from(bucket)
29
+ cache.set(tag, current)
30
+ return current
31
+ },
32
+ })
33
+ return {
34
+ read: (tag) => Option.getOrElse(Option.fromNullable(cache.get(tag)), () => build(tag)),
35
+ invalidate: (tag) => {
36
+ cache.delete(tag)
37
+ },
38
+ }
39
+ }
@@ -1,39 +1,24 @@
1
1
  import { Context } from 'effect'
2
2
  import type { Trigger } from '../ui/trigger'
3
3
 
4
- /**
5
- * A single rendered UI node's observable surface: which contract rendered, the
6
- * props it received, and the event triggers it was handed. This is exactly what
7
- * a headless inspector (the proof facade, the dev tool) needs — no DOM, no JSX.
8
- */
9
- export interface UiCapture {
4
+ export interface CapturedRender {
10
5
  readonly name: string
11
6
  readonly props: unknown
12
7
  readonly events: Record<string, Trigger<unknown>>
13
- /**
14
- * The stable slot-fill key this render was mounted under (an `each` item's
15
- * `key`, or a synthesized singleton key for a `one`). Present only on the
16
- * structure path — the legacy Node path defers slot expansion to host
17
- * components and has no fill key, so it is omitted there. Lets the proof
18
- * facade select a child by key (`byKey`) instead of by render index.
19
- */
20
8
  // oxlint-disable-next-line reform-rules/no-optional-fields -- omitted (not Option-none) on the legacy Node path; built in compose/structure.ts and read by the proof package, both outside this batch
21
9
  readonly key?: string
22
10
  }
23
11
 
24
- /** Where captured renders are reported, when a capturing host is present. */
12
+ export type UiCapture = CapturedRender
13
+
25
14
  export interface CaptureSinkApi {
26
- record(capture: UiCapture): void
15
+ record(capture: CapturedRender): void
27
16
  }
28
17
 
29
- /**
30
- * An OPTIONAL service. Core's `ui` read consults it via `serviceOption`: when a
31
- * capturing host provides it (proofs, the dev tool), every resolved view reports
32
- * its `(props, events)` before rendering, giving a faithful tree without
33
- * replacing any presentation. Production never provides it, so nothing is
34
- * recorded and there is no cost beyond one `serviceOption` lookup per render.
35
- */
36
- const CaptureSinkBase: Context.TagClass<CaptureSink, 'reform/internal/CaptureSink', CaptureSinkApi> =
37
- Context.Tag('reform/internal/CaptureSink')<CaptureSink, CaptureSinkApi>()
18
+ const CaptureSinkBase: Context.TagClass<
19
+ CaptureSink,
20
+ 'reform/internal/CaptureSink',
21
+ CaptureSinkApi
22
+ > = Context.Tag('reform/internal/CaptureSink')<CaptureSink, CaptureSinkApi>()
38
23
 
39
24
  export class CaptureSink extends CaptureSinkBase {}
@@ -0,0 +1,7 @@
1
+ declare const process: { readonly env: { readonly NODE_ENV: string | undefined } } | undefined
2
+
3
+ export const isDev: boolean =
4
+ typeof process === 'undefined' ||
5
+ typeof process.env === 'undefined' ||
6
+ // oxlint-disable-next-line reform-rules/no-process-env -- must stay a static member expression so bundlers substitute it and DCE dev-only branches; Config resolves at runtime and cannot
7
+ process.env.NODE_ENV !== 'production'
@@ -2,15 +2,9 @@ import { expect, it } from '@effect/vitest'
2
2
  import { Context, Effect, Layer, ManagedRuntime } from 'effect'
3
3
  import { AsyncSceneLayer, forceSync } from '../index'
4
4
 
5
- // `forceSync` is the sync-build boundary: reform materialises a scene's layer graph
6
- // with `runSync` so the first frame renders synchronously (D2). A layer whose acquire
7
- // suspends makes that `runSync` throw Effect's opaque `AsyncFiberException`; the
8
- // boundary translates it into `AsyncSceneLayer`, which names the cause and the fix.
9
-
10
5
  class Thing extends Context.Tag('test/Thing')<Thing, number>() {}
11
6
 
12
7
  it('translates an async layer build into AsyncSceneLayer', () => {
13
- // A layer whose acquire suspends — exactly what a consumer trips on.
14
8
  const asyncLayer = Layer.effect(Thing, Effect.as(Effect.sleep('1 millis'), 1))
15
9
  const runtime = ManagedRuntime.make(asyncLayer)
16
10
  expect(() => forceSync(() => runtime.runSync(Effect.context<Thing>()))).toThrow(AsyncSceneLayer)
@@ -1,80 +1,58 @@
1
1
  import { Cause, Data, Option, Runtime } from 'effect'
2
2
 
3
- /**
4
- * Tagged errors for the framework's synchronous boundaries. Even when thrown
5
- * (React render, layer build, reflection), an error carries a `_tag` and its
6
- * structured fields — never a bare `Error` — so callers and tooling can match on
7
- * it. `Data.TaggedError` subclasses are real `Error`s, so `throw` still works.
8
- */
9
-
10
- /**
11
- * The constructor shape `Data.TaggedError(tag)<A>` produces, named so the
12
- * generated `.d.ts` can describe the `extends` base under `isolatedDeclarations`
13
- * (which forbids an inferred expression in an extends clause).
14
- */
15
3
  type TaggedErrorClass<Tag extends string, A extends Record<string, unknown>> = new (
16
4
  args: A,
17
5
  ) => Cause.YieldableError & { readonly _tag: Tag } & Readonly<A>
18
6
 
19
- /** The fieldless variant a `Data.TaggedError(tag)<{}>` constructor. */
20
- type EmptyTaggedErrorClass<Tag extends string> = new () => Cause.YieldableError & { readonly _tag: Tag }
7
+ type EmptyTaggedErrorClass<Tag extends string> = new () => Cause.YieldableError & {
8
+ readonly _tag: Tag
9
+ }
21
10
 
22
11
  const InvalidProvideTargetBase: EmptyTaggedErrorClass<'reform/InvalidProvideTarget'> =
23
12
  Data.TaggedError('reform/InvalidProvideTarget')<{}>
24
13
 
25
- /** `provide(target, …)` was handed something that is neither a ui contract nor a slot. */
26
14
  export class InvalidProvideTarget extends InvalidProvideTargetBase {
27
15
  override get message(): string {
28
16
  return 'reform: provide target is neither a ui contract nor a slot'
29
17
  }
30
18
  }
31
19
 
32
- const UnknownGroupStateBase: TaggedErrorClass<'reform/UnknownGroupState', { readonly name: string }> =
33
- Data.TaggedError('reform/UnknownGroupState')<{ readonly name: string }>
20
+ const UnknownGroupStateBase: TaggedErrorClass<
21
+ 'reform/UnknownGroupState',
22
+ { readonly name: string }
23
+ > = Data.TaggedError('reform/UnknownGroupState')<{ readonly name: string }>
34
24
 
35
- /** `StateGroup.select(group, name)` named a state the group does not contain. */
36
25
  export class UnknownGroupState extends UnknownGroupStateBase {
37
26
  override get message(): string {
38
27
  return `reform: unknown state '${this.name}' in group`
39
28
  }
40
29
  }
41
30
 
42
- const FeatureLoadFailedBase: TaggedErrorClass<'reform/FeatureLoadFailed', { readonly cause: unknown }> =
43
- Data.TaggedError('reform/FeatureLoadFailed')<{ readonly cause: unknown }>
31
+ const FeatureLoadFailedBase: TaggedErrorClass<
32
+ 'reform/FeatureLoadFailed',
33
+ { readonly cause: unknown }
34
+ > = Data.TaggedError('reform/FeatureLoadFailed')<{ readonly cause: unknown }>
44
35
 
45
- /**
46
- * A lazy `Feature`'s `load` Effect failed — the dynamic `import()` rejected (a
47
- * missing chunk, a network failure, a module-eval throw). Surfaced as a typed
48
- * failure the host hands to the feature's `placeholder.failed`, never a bare
49
- * rejection. `cause` is the original rejection value.
50
- */
51
36
  export class FeatureLoadFailed extends FeatureLoadFailedBase {
52
37
  override get message(): string {
53
38
  return `reform: a feature failed to load (${String(this.cause)})`
54
39
  }
55
40
  }
56
41
 
57
- const AsyncReducerBase: EmptyTaggedErrorClass<'reform/AsyncReducer'> =
58
- Data.TaggedError('reform/AsyncReducer')<{}>
42
+ const AsyncReducerBase: EmptyTaggedErrorClass<'reform/AsyncReducer'> = Data.TaggedError(
43
+ 'reform/AsyncReducer',
44
+ )<{}>
59
45
 
60
- /** A reducer fold returned a thenable — folds must be pure synchronous writes. */
61
46
  export class AsyncReducer extends AsyncReducerBase {
62
47
  override get message(): string {
63
48
  return 'reform: a reducer fold must be synchronous (it returned a Promise)'
64
49
  }
65
50
  }
66
51
 
67
- const AsyncSceneLayerBase: EmptyTaggedErrorClass<'reform/AsyncSceneLayer'> =
68
- Data.TaggedError('reform/AsyncSceneLayer')<{}>
69
-
70
- /**
71
- * A scene layer performed async work during reform's synchronous layer build.
72
- * Reform materialises a scene's layer graph with `runSync` so the first frame
73
- * renders synchronously (D2); a `Layer.effect`/`scoped` whose acquire suspends
74
- * (HTTP, opening a DB, `Effect.sleep`) makes that `runSync` throw Effect's opaque
75
- * `AsyncFiberException`. `forceSync` catches it and rethrows this, which names the
76
- * cause and the sanctioned fix instead of pointing at Effect internals.
77
- */
52
+ const AsyncSceneLayerBase: EmptyTaggedErrorClass<'reform/AsyncSceneLayer'> = Data.TaggedError(
53
+ 'reform/AsyncSceneLayer',
54
+ )<{}>
55
+
78
56
  export class AsyncSceneLayer extends AsyncSceneLayerBase {
79
57
  override get message(): string {
80
58
  return (
@@ -87,12 +65,6 @@ export class AsyncSceneLayer extends AsyncSceneLayerBase {
87
65
  }
88
66
  }
89
67
 
90
- /**
91
- * Whether a thrown value is Effect's "ran async under runSync" defect. `runSync`
92
- * throws the bare `AsyncFiberException`; a `ManagedRuntime.runSync` whose layer
93
- * build suspends instead throws a `FiberFailure` carrying it as a `Die`, so both
94
- * shapes are checked.
95
- */
96
68
  const isAsyncBuildDefect = (error: unknown): boolean => {
97
69
  if (Runtime.isAsyncFiberException(error)) {
98
70
  return true
@@ -104,12 +76,6 @@ const isAsyncBuildDefect = (error: unknown): boolean => {
104
76
  return Option.isSome(die) && Runtime.isAsyncFiberException(die.value)
105
77
  }
106
78
 
107
- /**
108
- * Run a synchronous runtime read (the scene layer build / context capture) and
109
- * translate Effect's async-fiber defect into `AsyncSceneLayer`; any other throw
110
- * passes through unchanged. The single home for the sync-build boundary, shared by
111
- * the React host and the remote server.
112
- */
113
79
  export const forceSync = <A>(thunk: () => A): A => {
114
80
  // oxlint-disable-next-line reform-rules/no-try-catch -- sync-build boundary must throw the exact value, which Effect.runSync would re-wrap in a FiberFailure
115
81
  try {
@@ -123,16 +89,27 @@ export const forceSync = <A>(thunk: () => A): A => {
123
89
  const DuplicateRegistrationBase: TaggedErrorClass<
124
90
  'reform/DuplicateRegistration',
125
91
  { readonly kind: string; readonly name: string }
126
- > = Data.TaggedError('reform/DuplicateRegistration')<{ readonly kind: string; readonly name: string }>
127
-
128
- /**
129
- * Two distinct primitives were registered under one name into the same runtime —
130
- * a silent footgun, since names key DI tags and channel lanes. Thrown for a
131
- * channel name reused with a different policy (the second would otherwise be
132
- * dropped). Reducer/procedure name clashes are warned (logged), not thrown.
133
- */
92
+ > = Data.TaggedError('reform/DuplicateRegistration')<{
93
+ readonly kind: string
94
+ readonly name: string
95
+ }>
96
+
134
97
  export class DuplicateRegistration extends DuplicateRegistrationBase {
135
98
  override get message(): string {
136
99
  return `reform: duplicate ${this.kind} registered under name '${this.name}'`
137
100
  }
138
101
  }
102
+
103
+ const EventLoopOverflowBase: TaggedErrorClass<
104
+ 'reform/EventLoopOverflow',
105
+ { readonly count: number; readonly limit: number }
106
+ > = Data.TaggedError('reform/EventLoopOverflow')<{ readonly count: number; readonly limit: number }>
107
+
108
+ export class EventLoopOverflow extends EventLoopOverflowBase {
109
+ override get message(): string {
110
+ return (
111
+ `reform: ${this.count} events processed without yielding to the event loop ` +
112
+ `(limit ${this.limit}) — likely an infinite event feedback loop; the runtime has been halted`
113
+ )
114
+ }
115
+ }
@@ -3,24 +3,18 @@ import { Schema as S } from 'effect'
3
3
  import { State } from '../index'
4
4
  import { makeStore } from './store'
5
5
 
6
- // Every reform value should print like an Effect value (an `_id`-tagged JSON),
7
- // not `[object Object]` — via `toJSON`/`toString`/the Node inspect symbol.
8
-
9
6
  describe('Inspectable', () => {
10
7
  it('a Store reports its current value as tagged JSON', () => {
11
8
  const store = makeStore(41)
12
9
  expect(store.toJSON()).toEqual({ _id: 'reform/Store', value: 41 })
13
10
  store.set(42)
14
11
  expect(store.toJSON()).toEqual({ _id: 'reform/Store', value: 42 })
15
- // toString and JSON.stringify route through the same toJSON.
16
12
  expect(JSON.parse(JSON.stringify(store))).toEqual({ _id: 'reform/Store', value: 42 })
17
13
  expect(store.toString()).toContain('reform/Store')
18
14
  })
19
15
 
20
16
  it('a definition reports its manifest', () => {
21
17
  class Count extends State.make('count', S.Number) {}
22
- // Inspectable is attached at runtime (definitions are reflected via their
23
- // manifest); reach the method through a typed view.
24
18
  const inspectable = Count as unknown as { toJSON(): unknown }
25
19
  expect(inspectable.toJSON()).toBe(Count.manifest)
26
20
  expect(String(Count)).toContain('"kind": "State"')
@@ -2,24 +2,12 @@ import { format, type Inspectable, NodeInspectSymbol } from 'effect/Inspectable'
2
2
 
3
3
  export { type Inspectable, NodeInspectSymbol }
4
4
 
5
- /**
6
- * The `Inspectable` trio built around a `toJSON`, so every reform value prints
7
- * like an Effect value — `_id`-tagged JSON under `console.log`, Node's
8
- * inspector, `JSON.stringify`, and `String(...)` — instead of `[object Object]`.
9
- * Mirrors Effect's `BaseProto` / `Inspectable.Class` (`Inspectable.ts:170`).
10
- */
11
5
  export const inspectable = (toJSON: () => unknown): Inspectable => ({
12
6
  toJSON,
13
7
  toString: () => format(toJSON()),
14
8
  [NodeInspectSymbol]: () => toJSON(),
15
9
  })
16
10
 
17
- /**
18
- * Attach the `Inspectable` trio to an existing object as *non-enumerable* own
19
- * properties (so they don't show up in `Object.keys`/`for…in` over a definition
20
- * class). Used where the value is built by other means — the definition classes,
21
- * whose statics are merged in separately.
22
- */
23
11
  export const attachInspectable = <T extends object>(target: T, toJSON: () => unknown): T => {
24
12
  const trio = inspectable(toJSON)
25
13
  Reflect.ownKeys(trio).forEach((key) => {