@playfast/reform 1.2.0 → 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/package.json +1 -1
  2. package/src/boundary/boundary.test.ts +26 -23
  3. package/src/calc/asyncCalc.invalidate.test.ts +258 -55
  4. package/src/calc/asyncCalc.test.ts +225 -85
  5. package/src/calc/asyncCalc.ts +27 -141
  6. package/src/calc/asyncCalcTypes.ts +147 -0
  7. package/src/calc/calc.test.ts +10 -11
  8. package/src/calc/calcFamily.test.ts +12 -12
  9. package/src/channel/channel.ts +8 -106
  10. package/src/channel/procedures.ts +108 -0
  11. package/src/compose/composition.ts +49 -246
  12. package/src/compose/compositionTypes.ts +249 -0
  13. package/src/event/event.fromSource.test.ts +12 -12
  14. package/src/feature/feature.ts +50 -1210
  15. package/src/feature/feature.typecheck.ts +2 -61
  16. package/src/feature/featureBinding.ts +158 -0
  17. package/src/feature/featureClass.ts +162 -0
  18. package/src/feature/featureConfig.ts +192 -0
  19. package/src/feature/featureFactory.ts +163 -0
  20. package/src/feature/featureModule.ts +136 -0
  21. package/src/feature/featureModule.typecheck.ts +63 -0
  22. package/src/feature/featureNativeTree.ts +152 -0
  23. package/src/feature/featureRequirement.ts +82 -0
  24. package/src/feature/featureVariants.ts +234 -0
  25. package/src/feature/mountFeature.ts +54 -0
  26. package/src/graph/closure.ts +45 -226
  27. package/src/graph/services.ts +95 -0
  28. package/src/graph/summary.ts +134 -0
  29. package/src/internal/queryDriver.ts +20 -89
  30. package/src/internal/queryDriverHydrate.ts +45 -0
  31. package/src/internal/queryDriverTypes.ts +8 -10
  32. package/src/internal/store.test.ts +27 -6
  33. package/src/remote/pendingQueue.ts +145 -0
  34. package/src/remote/remoteState.test.ts +28 -22
  35. package/src/remote/remoteState.ts +63 -489
  36. package/src/remote/remoteStateMake.ts +97 -0
  37. package/src/remote/remoteStateSend.ts +122 -0
  38. package/src/remote/remoteStateTypes.ts +155 -0
  39. package/src/runtime/appRuntime.activation.test.ts +9 -5
  40. package/src/runtime/appRuntime.test.ts +40 -16
  41. package/src/runtime/appRuntime.ts +17 -472
  42. package/src/runtime/capturedAppRuntime.ts +274 -0
  43. package/src/runtime/eventBudget.test.ts +38 -8
  44. package/src/runtime/featureMount.ts +94 -0
  45. package/src/runtime/hardening.test.ts +18 -4
  46. package/src/runtime/instrumentation.test.ts +49 -12
  47. package/src/runtime/loop.test.ts +51 -10
  48. package/src/runtime/runtimeHandle.ts +124 -0
  49. package/src/state/stateFamily.test.ts +25 -8
  50. package/src/testkit/flight.testkit.ts +9 -0
@@ -1,476 +1,21 @@
1
- import { Cause, Context, Effect, Exit, Fiber, Layer, ManagedRuntime, Option, Scope } from 'effect'
2
- import type {
3
- CompositionActivation,
4
- CompositionId,
5
- RequiredSlotIdentities,
6
- } from '../compose/composition'
1
+ import type { CompositionId, RequiredSlotIdentities } from '../compose/composition'
7
2
  import type { UiContract } from '../compose/ui'
8
- import {
9
- type AnyFeatureBinding,
10
- type CapturedFeatureBinding,
11
- type FeatureBinding,
12
- FeatureInput,
13
- type FeatureRequirement,
14
- } from '../feature/feature'
15
- import type {
16
- AnyFeatureGraphSummary,
17
- FeatureSummaryCompositionIdentity,
18
- FeatureSummaryContract,
19
- FeatureSummaryInput,
20
- FeatureSummaryMountProps,
21
- FeatureSummaryName,
22
- FeatureSummaryOpenServices,
23
- FeatureSummaryUsesInput,
24
- } from '../graph/closure'
25
- import { FeatureLoadFailed, forceSync } from '../internal/errors'
26
- import { type Node } from '../ui/node'
27
- import { type AnyScene, type CapturedScene, type Scene, sceneInstrumentation } from '../scene/scene'
28
- import { Bus, publish, type Priority, type Tagged } from './bus'
29
- import { CurrentEventBudget, type ReformOptions, resolveEventBudget } from './eventBudget'
30
- import { type Instrumentation } from './instrumentation'
31
- import { EngineRequirements, type EngineServices } from './loop'
32
-
33
- export const UnspecifiedRuntimeServicesTypeId: unique symbol = Symbol.for(
34
- 'reform/UnspecifiedRuntimeServices',
35
- )
36
- export type UnspecifiedRuntimeServicesTypeId = typeof UnspecifiedRuntimeServicesTypeId
37
- export interface UnspecifiedRuntimeServices {
38
- readonly [UnspecifiedRuntimeServicesTypeId]: UnspecifiedRuntimeServicesTypeId
39
- }
40
-
41
- export type FeatureMountHandlersExternalApi<UsesInput extends boolean, Input, Services> = {
42
- readonly onLive: (runtime: RuntimeHandle<Services>) => void
43
- readonly onFailed: (error: FeatureLoadFailed) => void
44
- } & (UsesInput extends true ? { readonly props: Input } : { readonly props?: never })
45
-
46
- export type FeatureMountHandlers<
47
- UsesInput extends boolean,
48
- Input,
49
- Services,
50
- > = FeatureMountHandlersExternalApi<UsesInput, Input, Services>
51
-
52
- export type RuntimeFeatureAvailability<Services, OpenServices> = [
53
- EngineServices | OpenServices,
54
- ] extends [Services]
55
- ? object
56
- : {
57
- readonly missingFeatureServices: Exclude<EngineServices | OpenServices, Services>
58
- }
59
-
60
- export interface AnyFeatureMountHandlers {
61
- readonly props: unknown
62
- readonly onLive: <Services>(runtime: RuntimeHandle<Services>) => void
63
- readonly onFailed: (error: FeatureLoadFailed) => void
64
- }
65
-
66
- export interface CapturedFeatureMountHandlers<
67
- Props,
68
- HostServices,
69
- FeatureServices,
70
- C extends UiContract,
71
- N extends string,
72
- Identity,
73
- > {
74
- readonly props: Props
75
- readonly onLive: (
76
- runtime: RuntimeHandle<
77
- HostServices | FeatureServices | CompositionId<N, Identity, RequiredSlotIdentities<C>>
78
- >,
79
- ) => void
80
- readonly onFailed: (error: FeatureLoadFailed) => void
81
- }
82
-
83
- const isObjectLike = (candidate: unknown): candidate is object =>
84
- (typeof candidate === 'object' || typeof candidate === 'function') && candidate !== null
85
-
86
- const isContextTag = (candidate: unknown): candidate is Context.Tag<unknown, unknown> =>
87
- isObjectLike(candidate) &&
88
- Context.TagTypeId in candidate &&
89
- 'key' in candidate &&
90
- typeof candidate.key === 'string'
91
-
92
- const isUnknownArray = (candidate: unknown): candidate is ReadonlyArray<unknown> =>
93
- Array.isArray(candidate)
94
-
95
- const requirementAvailable = <Services>(
96
- context: Context.Context<Services>,
97
- requirement: unknown,
98
- ): boolean => {
99
- if (isContextTag(requirement)) {
100
- return Option.isSome(Context.getOption(context, requirement))
101
- }
102
- if (!isObjectLike(requirement)) {
103
- return false
104
- }
105
- if ('store' in requirement) {
106
- return requirementAvailable(context, requirement.store)
107
- }
108
- if ('members' in requirement && isUnknownArray(requirement.members)) {
109
- return requirement.members.every((member) => requirementAvailable(context, member))
110
- }
111
- return false
112
- }
113
-
114
- const hasFeatureRequirements = <
115
- Services,
116
- OpenServices,
117
- const DirectRequires extends ReadonlyArray<FeatureRequirement>,
118
- >(
119
- context: Context.Context<Services>,
120
- requires: DirectRequires,
121
- ): context is Context.Context<Services | EngineServices | OpenServices> =>
122
- EngineRequirements.every((tag) => requirementAvailable(context, tag)) &&
123
- requires.every((requirement) => requirementAvailable(context, requirement))
124
-
125
- const featureMountFailure = (message: string): FeatureLoadFailed =>
126
- new FeatureLoadFailed({ cause: message })
127
-
128
- export interface RuntimeHandle<Services = UnspecifiedRuntimeServices> {
129
- read<Identifier extends Services, Service>(tag: Context.Tag<Identifier, Service>): Service
130
- readOption<Identifier, Service>(tag: Context.Tag<Identifier, Service>): Option.Option<Service>
131
- runRender(effect: Effect.Effect<Node, never, never>): Node
132
- dispatch(priority: Priority, event: Tagged): void
133
- mountScoped(activation: CompositionActivation): () => void
134
- mountFeature<
135
- Summary extends AnyFeatureGraphSummary,
136
- ROut,
137
- const DirectRequires extends ReadonlyArray<FeatureRequirement>,
138
- >(
139
- binding: [EngineServices | FeatureSummaryOpenServices<Summary>] extends [Services]
140
- ? FeatureBinding<Summary, ROut, DirectRequires>
141
- : never,
142
- handlers: FeatureMountHandlers<
143
- FeatureSummaryUsesInput<Summary>,
144
- FeatureSummaryInput<Summary>,
145
- Services | ROut
146
- >,
147
- ): () => void
148
- mountCapturedFeature<
149
- Summary extends AnyFeatureGraphSummary,
150
- ROut,
151
- const DirectRequires extends ReadonlyArray<FeatureRequirement>,
152
- >(
153
- binding: CapturedFeatureBinding<Summary, ROut, DirectRequires> &
154
- RuntimeFeatureAvailability<Services, FeatureSummaryOpenServices<Summary>>,
155
- handlers: CapturedFeatureMountHandlers<
156
- FeatureSummaryMountProps<Summary>,
157
- Services,
158
- ROut,
159
- FeatureSummaryContract<Summary>,
160
- FeatureSummaryName<Summary>,
161
- FeatureSummaryCompositionIdentity<Summary>
162
- >,
163
- ): () => void
164
- mountAnyFeature(binding: AnyFeatureBinding, handlers: AnyFeatureMountHandlers): () => void
165
- readonly instrumentation: Instrumentation
166
- }
167
-
168
- export interface AppRuntime<Services = UnspecifiedRuntimeServices> extends RuntimeHandle<Services> {
169
- boot(): void
170
- dispose(): void
171
- isDisposed(): boolean
172
- }
173
-
174
- const makeCapturedAppRuntime = <
175
- C extends UiContract,
176
- S extends ReadonlyArray<unknown>,
177
- Services,
178
- P,
179
- N extends string,
180
- Identity,
181
- >(
182
- scene: CapturedScene<C, S, Services, P, N, Identity>,
183
- options: ReformOptions = {},
184
- ): AppRuntime<Services | CompositionId<N, Identity, RequiredSlotIdentities<C>>> => {
185
- const instrumentation = sceneInstrumentation(scene)
186
- const provide = scene.provide.map(Layer.locally(CurrentEventBudget, resolveEventBudget(options)))
187
- const layer = provide.reduce((accLayer, nextLayer) => Layer.merge(accLayer, nextLayer))
188
- const runtime = ManagedRuntime.make(layer)
189
- const capturedContext = forceSync(() => runtime.runSync(Effect.context<Services>()))
190
- const rootCompositionService = Option.getOrThrow(
191
- Context.getOption(capturedContext, scene.composition.tag),
192
- )
193
- // Re-add the exact tag so the captured context retains the root's nominal identity.
194
- const rootContext = Context.add(capturedContext, scene.composition.tag, rootCompositionService)
195
- const rootBus = Context.getOption(rootContext, Bus)
196
-
197
- // Hosts can still render tearing-down trees after the managed runtime is disposed.
198
- const status = { disposed: false, booted: false }
199
- const activationDisposers = new Set<() => void>()
200
-
201
- // A closed render Effect remains safe on the default runtime after disposal.
202
- const runRender = (effect: Effect.Effect<Node, never, never>): Node =>
203
- status.disposed ? Effect.runSync(effect) : runtime.runSync(effect)
204
-
205
- const dispatch = (priority: Priority, event: Tagged): void => {
206
- if (status.disposed || Option.isNone(rootBus)) {
207
- return
208
- }
209
- runtime.runFork(publish(priority, event).pipe(Effect.provideService(Bus, rootBus.value)))
210
- }
211
-
212
- const mountScoped = (activation: CompositionActivation): (() => void) => {
213
- if (status.disposed) {
214
- return () => {}
215
- }
216
- const scope = Effect.runSync(Scope.make())
217
- const liveness = { active: true }
218
- const fiber = runtime.runFork(
219
- Layer.build(activation).pipe(Effect.provideService(Scope.Scope, scope)),
220
- )
221
- const dispose = (): void => {
222
- if (!liveness.active) {
223
- return
224
- }
225
- liveness.active = false
226
- fiber.unsafeInterruptAsFork(fiber.id())
227
- const close = Fiber.await(fiber).pipe(
228
- Effect.zipRight(Scope.close(scope, Exit.succeed(undefined))),
229
- Effect.ensuring(Effect.sync(() => activationDisposers.delete(dispose))),
230
- )
231
- Effect.runFork(close)
232
- }
233
- activationDisposers.add(dispose)
234
- return dispose
235
- }
236
-
237
- const makeChildRuntime = <ChildServices>(
238
- context: Context.Context<ChildServices>,
239
- ): RuntimeHandle<ChildServices> => ({
240
- read: <Identifier extends ChildServices, Service>(
241
- tag: Context.Tag<Identifier, Service>,
242
- ): Service => Context.get(context, tag),
243
- readOption: <Identifier, Service>(
244
- tag: Context.Tag<Identifier, Service>,
245
- ): Option.Option<Service> => Context.getOption(context, tag),
246
- runRender,
247
- dispatch,
248
- mountScoped,
249
- mountFeature: (binding, handlers) => mountFeatureOnto(context, binding, handlers),
250
- mountCapturedFeature: (binding, handlers) =>
251
- mountCapturedFeatureOnto(context, binding, handlers),
252
- mountAnyFeature: (binding, handlers) => mountAnyFeatureOnto(context, binding, handlers),
253
- instrumentation,
254
- })
255
-
256
- const mountFeatureOnto = <
257
- HostServices,
258
- Summary extends AnyFeatureGraphSummary,
259
- ROut,
260
- const DirectRequires extends ReadonlyArray<FeatureRequirement>,
261
- >(
262
- hostContext: Context.Context<HostServices>,
263
- binding: FeatureBinding<Summary, ROut, DirectRequires>,
264
- handlers: FeatureMountHandlers<
265
- FeatureSummaryUsesInput<Summary>,
266
- FeatureSummaryInput<Summary>,
267
- HostServices | ROut
268
- >,
269
- ): (() => void) => {
270
- if (status.disposed) {
271
- return () => {}
272
- }
273
- const scope = Effect.runSync(Scope.make())
274
- const mount = Effect.flatMap(binding.load, (loaded) => {
275
- if (
276
- !hasFeatureRequirements<HostServices, FeatureSummaryOpenServices<Summary>, DirectRequires>(
277
- hostContext,
278
- loaded.requires,
279
- )
280
- ) {
281
- return Effect.fail(
282
- featureMountFailure(
283
- `Feature ${binding.manifest.name} requires services missing from the host runtime`,
284
- ),
285
- )
286
- }
287
- return Effect.gen(function* () {
288
- const bus = Context.get(hostContext, Bus)
289
- const featureContext = Context.add(hostContext, FeatureInput, handlers.props)
290
- const context = yield* Layer.build(
291
- Layer.provide(loaded.layer, Layer.succeedContext(featureContext)),
292
- )
293
- yield* Effect.forEach(binding.boot, (event) => publish('High', event), {
294
- discard: true,
295
- }).pipe(Effect.provideService(Bus, bus))
296
- const merged = Context.merge(hostContext, context)
297
- yield* Effect.sync(() => handlers.onLive(makeChildRuntime<HostServices | ROut>(merged)))
298
- })
299
- })
300
- const fiber = runtime.runFork(
301
- mount.pipe(
302
- Effect.provideService(Scope.Scope, scope),
303
- Effect.matchCause({
304
- onFailure: (cause) => {
305
- // Disposer interruption is cancellation, not a failed feature.
306
- if (!Cause.isInterruptedOnly(cause)) {
307
- handlers.onFailed(
308
- Option.getOrElse(
309
- Cause.failureOption(cause),
310
- () => new FeatureLoadFailed({ cause }),
311
- ),
312
- )
313
- }
314
- },
315
- onSuccess: () => undefined,
316
- }),
317
- ),
318
- )
319
- return () => {
320
- // Interrupt first, then await finalizer registration before closing the scope.
321
- fiber.unsafeInterruptAsFork(fiber.id())
322
- const close = Fiber.await(fiber).pipe(
323
- Effect.zipRight(Scope.close(scope, Exit.succeed(undefined))),
324
- )
325
- if (status.disposed) {
326
- Effect.runFork(close)
327
- } else {
328
- runtime.runFork(close)
329
- }
330
- }
331
- }
332
-
333
- const mountCapturedFeatureOnto = <
334
- HostServices,
335
- Summary extends AnyFeatureGraphSummary,
336
- ROut,
337
- const DirectRequires extends ReadonlyArray<FeatureRequirement>,
338
- >(
339
- hostContext: Context.Context<HostServices>,
340
- binding: CapturedFeatureBinding<Summary, ROut, DirectRequires>,
341
- handlers: CapturedFeatureMountHandlers<
342
- FeatureSummaryMountProps<Summary>,
343
- HostServices,
344
- ROut,
345
- FeatureSummaryContract<Summary>,
346
- FeatureSummaryName<Summary>,
347
- FeatureSummaryCompositionIdentity<Summary>
348
- >,
349
- ): (() => void) => {
350
- if (status.disposed) {
351
- return () => {}
352
- }
353
- const scope = Effect.runSync(Scope.make())
354
- const mount = Effect.flatMap(binding.load, (loaded) => {
355
- if (
356
- !hasFeatureRequirements<HostServices, FeatureSummaryOpenServices<Summary>, DirectRequires>(
357
- hostContext,
358
- loaded.requires,
359
- )
360
- ) {
361
- return Effect.fail(
362
- featureMountFailure(
363
- `Feature ${binding.manifest.name} requires services missing from the host runtime`,
364
- ),
365
- )
366
- }
367
- const input = loaded.usesInput ? binding.parseInput(handlers.props) : Option.some(undefined)
368
- return Option.match(input, {
369
- onNone: () =>
370
- Effect.fail(
371
- featureMountFailure(`Feature ${binding.manifest.name} received invalid props`),
372
- ),
373
- onSome: (props) =>
374
- Effect.gen(function* () {
375
- const bus = Context.get(hostContext, Bus)
376
- const featureContext = Context.add(hostContext, FeatureInput, props)
377
- const context = yield* Layer.build(
378
- Layer.provide(loaded.layer, Layer.succeedContext(featureContext)),
379
- )
380
- yield* Effect.forEach(binding.boot, (event) => publish('High', event), {
381
- discard: true,
382
- }).pipe(Effect.provideService(Bus, bus))
383
- const merged = Context.merge(hostContext, context)
384
- const compositionService = Option.getOrThrow(
385
- Context.getOption(merged, binding.composition.tag),
386
- )
387
- const rooted = Context.add(merged, binding.composition.tag, compositionService)
388
- yield* Effect.sync(() => handlers.onLive(makeChildRuntime(rooted)))
389
- }),
390
- })
391
- })
392
- const fiber = runtime.runFork(
393
- mount.pipe(
394
- Effect.provideService(Scope.Scope, scope),
395
- Effect.matchCause({
396
- onFailure: (cause) => {
397
- if (!Cause.isInterruptedOnly(cause)) {
398
- handlers.onFailed(
399
- Option.getOrElse(
400
- Cause.failureOption(cause),
401
- () => new FeatureLoadFailed({ cause }),
402
- ),
403
- )
404
- }
405
- },
406
- onSuccess: () => undefined,
407
- }),
408
- ),
409
- )
410
- return () => {
411
- fiber.unsafeInterruptAsFork(fiber.id())
412
- const close = Fiber.await(fiber).pipe(
413
- Effect.zipRight(Scope.close(scope, Exit.succeed(undefined))),
414
- )
415
- if (status.disposed) {
416
- Effect.runFork(close)
417
- } else {
418
- runtime.runFork(close)
419
- }
420
- }
421
- }
422
-
423
- const mountAnyFeatureOnto = <HostServices>(
424
- hostContext: Context.Context<HostServices>,
425
- binding: AnyFeatureBinding,
426
- handlers: AnyFeatureMountHandlers,
427
- ): (() => void) =>
428
- binding.capture((exactBinding) =>
429
- mountCapturedFeatureOnto(hostContext, exactBinding, {
430
- props: handlers.props,
431
- onLive: (childRuntime) => handlers.onLive(childRuntime),
432
- onFailed: handlers.onFailed,
433
- }),
434
- )
435
-
436
- type RootServices = Services | CompositionId<N, Identity, RequiredSlotIdentities<C>>
437
- const app: AppRuntime<RootServices> = {
438
- // Captured reads stay available while the managed runtime is tearing down.
439
- read: <Identifier extends RootServices, Service>(
440
- tag: Context.Tag<Identifier, Service>,
441
- ): Service => Context.get(rootContext, tag),
442
- readOption: <Identifier, Service>(
443
- tag: Context.Tag<Identifier, Service>,
444
- ): Option.Option<Service> => Context.getOption(rootContext, tag),
445
- runRender,
446
- dispatch,
447
- mountScoped,
448
- mountFeature: (binding, handlers) => mountFeatureOnto(rootContext, binding, handlers),
449
- mountCapturedFeature: (binding, handlers) =>
450
- mountCapturedFeatureOnto(rootContext, binding, handlers),
451
- mountAnyFeature: (binding, handlers) => mountAnyFeatureOnto(rootContext, binding, handlers),
452
- boot: () => {
453
- if (status.disposed || status.booted) {
454
- return
455
- }
456
- status.booted = true
457
- Option.fromNullable(scene.boot)
458
- .pipe(Option.getOrElse((): ReadonlyArray<Tagged> => []))
459
- .forEach((event) => dispatch('High', event))
460
- },
461
- dispose: () => {
462
- if (status.disposed) {
463
- return
464
- }
465
- status.disposed = true
466
- activationDisposers.forEach((dispose) => dispose())
467
- void runtime.dispose()
468
- },
469
- isDisposed: () => status.disposed,
470
- instrumentation,
471
- }
472
- return app
473
- }
3
+ import type { AnyScene, CapturedScene, Scene } from '../scene/scene'
4
+ import { makeCapturedAppRuntime } from './capturedAppRuntime'
5
+ import type { ReformOptions } from './eventBudget'
6
+ import type { AppRuntime, UnspecifiedRuntimeServices } from './runtimeHandle'
7
+
8
+ export { UnspecifiedRuntimeServicesTypeId } from './runtimeHandle'
9
+ export type {
10
+ AnyFeatureMountHandlers,
11
+ AppRuntime,
12
+ CapturedFeatureMountHandlers,
13
+ FeatureMountHandlers,
14
+ FeatureMountHandlersExternalApi,
15
+ RuntimeFeatureAvailability,
16
+ RuntimeHandle,
17
+ UnspecifiedRuntimeServices,
18
+ } from './runtimeHandle'
474
19
 
475
20
  export function makeAppRuntime<
476
21
  C extends UiContract,