@playfast/reform 0.1.0 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/README.md +18 -1
  2. package/package.json +1 -1
  3. package/src/boundary/boundary.test.ts +1 -33
  4. package/src/boundary/boundary.ts +4 -82
  5. package/src/calc/asyncCalc.invalidate.test.ts +2 -14
  6. package/src/calc/asyncCalc.test.ts +0 -38
  7. package/src/calc/asyncCalc.ts +11 -163
  8. package/src/calc/asyncCalcDefinitions.ts +48 -0
  9. package/src/calc/asyncData.ts +0 -37
  10. package/src/calc/calc.test.ts +4 -28
  11. package/src/calc/calc.ts +16 -44
  12. package/src/calc/calcFamily.test.ts +0 -12
  13. package/src/calc/calcFamily.ts +12 -49
  14. package/src/calc/compose.test.ts +1 -12
  15. package/src/calc/compose.ts +0 -22
  16. package/src/calc/queryState.ts +0 -23
  17. package/src/channel/channel.ts +78 -173
  18. package/src/channel/procedureRegistry.ts +90 -0
  19. package/src/compose/composition.ts +7 -67
  20. package/src/compose/host.ts +0 -6
  21. package/src/compose/props.ts +3 -9
  22. package/src/compose/provide.ts +19 -33
  23. package/src/compose/slot.ts +2 -30
  24. package/src/compose/structure.test.ts +0 -6
  25. package/src/compose/structure.ts +3 -67
  26. package/src/compose/ui.test.ts +0 -4
  27. package/src/compose/ui.ts +17 -111
  28. package/src/compose/ui.typecheck.ts +0 -14
  29. package/src/definition/definition.ts +0 -32
  30. package/src/event/event.test.ts +0 -3
  31. package/src/event/event.ts +5 -14
  32. package/src/event/eventGroup.ts +0 -1
  33. package/src/feature/feature.mount.test.ts +66 -54
  34. package/src/feature/feature.test.ts +67 -46
  35. package/src/feature/feature.ts +185 -214
  36. package/src/feature/feature.typecheck.ts +29 -16
  37. package/src/feature/featureBinding.ts +48 -0
  38. package/src/index.ts +245 -202
  39. package/src/internal/capture.ts +0 -20
  40. package/src/internal/ctx.ts +2 -9
  41. package/src/internal/env.ts +9 -0
  42. package/src/internal/errors.test.ts +0 -6
  43. package/src/internal/errors.ts +14 -48
  44. package/src/internal/inspect.test.ts +0 -6
  45. package/src/internal/inspect.ts +0 -12
  46. package/src/internal/queryDriver.ts +48 -243
  47. package/src/internal/queryDriverStore.ts +153 -0
  48. package/src/internal/queryDriverTypes.ts +57 -0
  49. package/src/internal/queryEvents.ts +0 -12
  50. package/src/internal/queryStore.ts +0 -14
  51. package/src/internal/reuse.test.ts +0 -11
  52. package/src/internal/reuse.ts +0 -28
  53. package/src/internal/scheduler.ts +0 -43
  54. package/src/internal/seeds.ts +0 -14
  55. package/src/internal/sources.ts +4 -41
  56. package/src/internal/stateRegistry.ts +0 -14
  57. package/src/internal/store.test.ts +1 -3
  58. package/src/internal/store.ts +10 -37
  59. package/src/internal/track.ts +0 -18
  60. package/src/internal/variance.ts +5 -0
  61. package/src/namespace/namespace.test.ts +46 -0
  62. package/src/namespace/namespace.ts +85 -0
  63. package/src/procedure/procedure.ts +2 -24
  64. package/src/reducer/reducer.ts +3 -21
  65. package/src/remote/remoteState.test.ts +92 -75
  66. package/src/remote/remoteState.ts +99 -486
  67. package/src/remote/remoteState.typecheck.ts +0 -33
  68. package/src/remote/remoteStateDefinition.ts +135 -0
  69. package/src/remote/remoteStateLayers.ts +118 -0
  70. package/src/remote/remoteStateLiveTypes.ts +59 -0
  71. package/src/remote/remoteStateSend.ts +64 -0
  72. package/src/runtime/appRuntime.test.ts +197 -0
  73. package/src/runtime/appRuntime.ts +131 -0
  74. package/src/runtime/bus.ts +2 -15
  75. package/src/runtime/eventBudget.test.ts +155 -0
  76. package/src/runtime/eventBudget.ts +119 -0
  77. package/src/runtime/hardening.test.ts +74 -9
  78. package/src/runtime/instrumentation.test.ts +324 -0
  79. package/src/runtime/instrumentation.ts +44 -0
  80. package/src/runtime/loop.test.ts +0 -17
  81. package/src/runtime/loop.ts +41 -58
  82. package/src/runtime/queries.ts +0 -17
  83. package/src/scene/featureScene.test.ts +59 -0
  84. package/src/scene/scene.ts +51 -44
  85. package/src/scene/seedScene.test.ts +96 -116
  86. package/src/state/state.ts +12 -28
  87. package/src/state/stateFamily.test.ts +2 -11
  88. package/src/state/stateFamily.ts +20 -54
  89. package/src/state/stateGroup.ts +2 -49
  90. package/src/state/token.ts +4 -17
  91. package/src/synced/syncedStore.ts +7 -42
  92. package/src/ui/node.ts +0 -6
  93. package/src/ui/trigger.ts +0 -5
  94. package/src/wire/tree.test.ts +0 -3
  95. package/src/wire/tree.ts +0 -39
  96. package/src/wire/triggers.test.ts +0 -4
  97. package/src/wire/triggers.ts +5 -27
@@ -1,7 +1,3 @@
1
- // Type-level proofs enforced by `tsc --noEmit` over `src` (not a vitest file).
2
- // These guard the cast-free seam: if a guarantee regresses, a `@ts-expect-error`
3
- // goes unused or an assignment fails, and `tsc` breaks.
4
-
5
1
  import { Context, Effect, Layer, Match, Schema as S } from 'effect'
6
2
  import * as Boundary from '../boundary/boundary'
7
3
  import type { AsyncData } from '../calc/asyncData'
@@ -25,8 +21,6 @@ const RenameIntentBase: Event.EventClass<
25
21
  > = Event.make('TRenameIntent', S.Struct({ id: S.String, name: S.String }))
26
22
  class RenameIntent extends RenameIntentBase {}
27
23
  type Intent = Event.EventType<typeof AddIntent> | Event.EventType<typeof RenameIntent>
28
- // An undeclared intent tag, named so the negative `apply` below avoids an inline
29
- // object-type parameter.
30
24
  type DeleteIntentTag = { readonly _tag: 'TDeleteIntent' }
31
25
 
32
26
  const PageBase: State.StateClass<'tcPage', number> = State.make('tcPage', S.Number)
@@ -63,10 +57,6 @@ const ItemsBase: RemoteState.RemoteStateClass<
63
57
  })
64
58
  class Items extends ItemsBase {}
65
59
 
66
- // --- intent-union inference: apply/send receive exactly the declared union ---
67
-
68
- // POSITIVE: a Match.exhaustive fold over the declared intents compiles, `send`'s
69
- // R2 flows from its body, and gated `disabled` is accepted.
70
60
  export const okLive: Layer.Layer<
71
61
  | Store<AsyncData<ReadonlyArray<Row>, string, true>>
72
62
  | Store<ReadonlyArray<RemoteState.PendingIntent<Intent>>>,
@@ -87,7 +77,6 @@ export const okLive: Layer.Layer<
87
77
  disabled: ({ tcPage }) => tcPage < 0,
88
78
  })
89
79
 
90
- // NEGATIVE: a handler typed for an undeclared tag does not fit the union.
91
80
  export const badIntent: Layer.Layer<
92
81
  | Store<AsyncData<ReadonlyArray<Row>, string, true>>
93
82
  | Store<ReadonlyArray<RemoteState.PendingIntent<Intent>>>,
@@ -100,18 +89,12 @@ export const badIntent: Layer.Layer<
100
89
  apply: (rows: ReadonlyArray<Row>, intent: DeleteIntentTag) => rows,
101
90
  })
102
91
 
103
- // --- layer requirement honesty: RIn is complete and exact ---
104
-
105
- // POSITIVE: the layer's RIn is exactly the input stores, the query/send context,
106
- // and the engine registries — and its ROut names the overlay + pending stores.
107
92
  export const okLayer: Layer.Layer<
108
93
  Store<AsyncData<ReadonlyArray<Row>, string, true>> | Store<ReadonlyArray<RemoteState.PendingIntent<Intent>>>,
109
94
  never,
110
95
  Store<number> | SendDep | Reducers | Procedures | Channels | Bus
111
96
  > = okLive
112
97
 
113
- // NEGATIVE: dropping the send dependency (R2) from the annotation fails —
114
- // requirements cannot be silently widened away.
115
98
  // @ts-expect-error the send body's R2 must appear in the layer's requirements
116
99
  export const badLayer: Layer.Layer<
117
100
  Store<AsyncData<ReadonlyArray<Row>, string, true>> | Store<ReadonlyArray<RemoteState.PendingIntent<Intent>>>,
@@ -119,8 +102,6 @@ export const badLayer: Layer.Layer<
119
102
  Store<number> | Reducers | Procedures | Channels | Bus
120
103
  > = okLive
121
104
 
122
- // --- alwaysOn / error narrowing: the read carries exactly the possible arms ---
123
-
124
105
  const AlwaysItemsBase: RemoteState.RemoteStateClass<
125
106
  'TAlwaysItems',
126
107
  readonly [typeof page],
@@ -136,7 +117,6 @@ const AlwaysItemsBase: RemoteState.RemoteStateClass<
136
117
  })
137
118
  class AlwaysItems extends AlwaysItemsBase {}
138
119
 
139
- // NEGATIVE: an alwaysOn remote state rejects `disabled` (erased to never).
140
120
  export const badDisabled: Layer.Layer<
141
121
  | Store<AsyncData<ReadonlyArray<Row>, never, false>>
142
122
  | Store<ReadonlyArray<RemoteState.PendingIntent<Event.EventType<typeof AddIntent>>>>,
@@ -156,40 +136,27 @@ declare const idleArm: 'Idle'
156
136
  declare const errorArm: 'Error'
157
137
  declare const fourArms: 'Idle' | 'Loading' | 'Success' | 'Error'
158
138
 
159
- // POSITIVE: alwaysOn + no `error` schema ⇒ at most Loading | Success…
160
139
  export const alwaysExact: 'Loading' | 'Success' = alwaysArms
161
- // …and gated + `error` schema ⇒ exactly all four arms (both directions).
162
140
  export const gatedSubset: 'Idle' | 'Loading' | 'Success' | 'Error' = gatedArms
163
141
  export const gatedAll: Effect.Effect.Success<typeof Items>['_tag'] = fourArms
164
142
 
165
- // NEGATIVE: the excluded arms really are gone from the read type.
166
143
  // @ts-expect-error an alwaysOn remote state has no Idle arm
167
144
  export const noIdle: Effect.Effect.Success<typeof AlwaysItems>['_tag'] = idleArm
168
145
  // @ts-expect-error no `error` schema ⇒ no Error arm
169
146
  export const noError: Effect.Effect.Success<typeof AlwaysItems>['_tag'] = errorArm
170
147
 
171
- // --- surface shape: Sources where expected, never a reducer target ---
172
-
173
- // POSITIVE: the class itself is a `Source` (a calc input / boundary coverage),
174
- // valued at the overlaid lifecycle.
175
148
  export const classAsSource: Source<'TItems', AsyncData<ReadonlyArray<Row>, string, true>> = Items
176
149
  export const classAsAnySource: AnySource = Items
177
150
 
178
- // POSITIVE: `pending` is an ordinary read-only `Source` (badges, "saving…" chrome).
179
151
  export const pendingAsSource: Source<'TItems/pending', ReadonlyArray<RemoteState.PendingIntent<Intent>>> =
180
152
  Items.pending
181
153
 
182
- // POSITIVE: `truth` is the un-overlaid lifecycle under the same arms.
183
154
  export const truthAsSource: Source<'TItems/truth', AsyncData<ReadonlyArray<Row>, string, true>> =
184
155
  Items.truth
185
156
 
186
- // POSITIVE: a boundary covers the remote state like any lifecycle source.
187
157
  export const covered: Boundary.BoundaryClass<'TItemsBoundary', readonly [typeof Items]> =
188
158
  Boundary.make('TItemsBoundary', { over: [Items] })
189
159
 
190
- // NEGATIVE: the pending queue is NOT user state — `Reducer.make` cannot target
191
- // it (it is a `StateToken`, not a `StateClass`; the hidden queue reducer is the
192
- // sole writer).
193
160
  export const badReducerTarget: Reducer.StateReducerClass<State.AnyState, readonly [typeof AddIntent]> =
194
161
  // @ts-expect-error the pending queue is not a reducible state
195
162
  Reducer.make('TBadReducer', {
@@ -0,0 +1,135 @@
1
+ import { Context, Effect, Schema } from 'effect'
2
+ import type { Effect as EffectType } from 'effect/Effect'
3
+ import { type Manifest, yieldableClass } from '../definition/definition'
4
+ import * as Event from '../event/event'
5
+ import { readTracked } from '../internal/track'
6
+ import type { AnySchema } from '../internal/variance'
7
+ import type { GatedOf } from '../internal/queryDriver'
8
+ import { gatedFlag } from '../internal/queryDriver'
9
+ import type { AnySource } from '../state/token'
10
+ import { StateToken } from '../state/token'
11
+ import type { Store } from '../internal/store'
12
+ import type { AsyncData, AsyncError, AsyncSuccess } from '../calc/asyncData'
13
+
14
+ export interface PendingIntent<I> {
15
+ readonly opId: string
16
+ readonly intent: I
17
+ readonly status: 'sending' | 'confirmed'
18
+ }
19
+
20
+ export interface FailedIntent<I> {
21
+ readonly intent: I
22
+ readonly error: unknown
23
+ }
24
+
25
+ export type RemoteStateSettled<A, E> = AsyncSuccess<A> | AsyncError<E>
26
+
27
+ // ExternalApi: optional `intent` only on Queued envelopes
28
+ export interface QueueFoldEventExternalApi<I> {
29
+ readonly _tag: string
30
+ readonly opId: string
31
+ readonly intent?: I
32
+ }
33
+
34
+ export interface RemoteStateManifestExternalApi<N extends string, A, E> extends Manifest {
35
+ readonly kind: 'RemoteState'
36
+ readonly name: N
37
+ readonly output: AnySchema<A>
38
+ readonly error?: AnySchema<E>
39
+ readonly gated: boolean
40
+ readonly intents: ReadonlyArray<string>
41
+ }
42
+
43
+ export interface RemoteStateClass<
44
+ out N extends string,
45
+ out Inputs extends ReadonlyArray<AnySource>,
46
+ in out Intents extends ReadonlyArray<Event.AnyEvent>,
47
+ in out A,
48
+ in out E,
49
+ in out Gated extends boolean,
50
+ > extends EffectType<AsyncData<A, E, Gated>, never, Store<AsyncData<A, E, Gated>>> {
51
+ new (): {}
52
+ readonly manifest: RemoteStateManifestExternalApi<N, A, E>
53
+ readonly store: Context.Tag<Store<AsyncData<A, E, Gated>>, Store<AsyncData<A, E, Gated>>>
54
+ readonly name: N
55
+ readonly inputs: Inputs
56
+ readonly intents: Intents
57
+ readonly gated: Gated
58
+ readonly truth: StateToken<`${N}/truth`, AsyncData<A, E, Gated>>
59
+ // pending is a Source (not StateClass) so user reducers cannot target it
60
+ readonly pending: StateToken<
61
+ `${N}/pending`,
62
+ ReadonlyArray<PendingIntent<Event.EventType<Intents[number]>>>
63
+ >
64
+ readonly Failed: Event.EventClass<`${N}/Failed`, FailedIntent<Event.EventType<Intents[number]>>>
65
+ }
66
+
67
+ export interface RemoteStateConfigExternalApi<
68
+ Inputs extends ReadonlyArray<AnySource>,
69
+ Intents extends ReadonlyArray<Event.AnyEvent>,
70
+ A,
71
+ E,
72
+ AlwaysOn extends boolean,
73
+ > {
74
+ readonly inputs: Inputs
75
+ readonly output: AnySchema<A>
76
+ readonly error?: AnySchema<E>
77
+ readonly alwaysOn?: AlwaysOn
78
+ readonly intents: Intents
79
+ }
80
+
81
+ // Schema.Unknown in intent slot: Intents is open at make-time; event schemas are reflective only
82
+ export const looseSchema = <P>(schema: Schema.Schema.AnyNoContext): AnySchema<P> => schema
83
+
84
+ export const make = <
85
+ const N extends string,
86
+ const Inputs extends ReadonlyArray<AnySource>,
87
+ const Intents extends ReadonlyArray<Event.AnyEvent>,
88
+ A,
89
+ E = never,
90
+ const AlwaysOn extends boolean = false,
91
+ >(
92
+ name: N,
93
+ config: RemoteStateConfigExternalApi<Inputs, Intents, A, E, AlwaysOn>,
94
+ ): RemoteStateClass<N, Inputs, Intents, A, E, GatedOf<AlwaysOn>> => {
95
+ type I = Event.EventType<Intents[number]>
96
+ const store = Context.GenericTag<Store<AsyncData<A, E, GatedOf<AlwaysOn>>>>(
97
+ `reform/remoteState/${name}`,
98
+ )
99
+ const truthTag = Context.GenericTag<Store<AsyncData<A, E, GatedOf<AlwaysOn>>>>(
100
+ `reform/remoteState/${name}/truth`,
101
+ )
102
+ const pendingTag = Context.GenericTag<Store<ReadonlyArray<PendingIntent<I>>>>(
103
+ `reform/remoteState/${name}/pending`,
104
+ )
105
+ const gated = gatedFlag(config.alwaysOn)
106
+ const manifest: RemoteStateManifestExternalApi<N, A, E> = {
107
+ kind: 'RemoteState',
108
+ name,
109
+ output: config.output,
110
+ gated,
111
+ intents: config.intents.map((event) => event.tag),
112
+ ...(config.error !== undefined ? { error: config.error } : {}),
113
+ }
114
+ // Annotated consts preserve template-literal types for interface matching
115
+ const truthName: `${N}/truth` = `${name}/truth`
116
+ const pendingName: `${N}/pending` = `${name}/pending`
117
+ const failedName: `${N}/Failed` = `${name}/Failed`
118
+ const read = Effect.flatMap(store, readTracked)
119
+ return yieldableClass(read, {
120
+ manifest,
121
+ store,
122
+ name,
123
+ inputs: config.inputs,
124
+ intents: config.intents,
125
+ gated,
126
+ truth: new StateToken(truthName, truthTag),
127
+ pending: new StateToken(pendingName, pendingTag),
128
+ Failed: Event.make(
129
+ failedName,
130
+ looseSchema<FailedIntent<I>>(
131
+ Schema.Struct({ intent: Schema.Unknown, error: Schema.Unknown }),
132
+ ),
133
+ ),
134
+ })
135
+ }
@@ -0,0 +1,118 @@
1
+ import { Context, Effect, Layer, MutableRef } from 'effect'
2
+ import { type AnyAsyncData, AsyncData, narrowStore, widenStore } from '../calc/asyncData'
3
+ import { reuse as shareStructure } from '../internal/reuse'
4
+ import { resolveScheduler } from '../internal/scheduler'
5
+ import { sameKey } from '../internal/sources'
6
+ import { makeDerivedStore, type Store } from '../internal/store'
7
+ import { narrowHandled } from '../runtime/bus'
8
+ import { type ReducerEntry, Reducers } from '../runtime/loop'
9
+ import type { PendingIntent, QueueFoldEventExternalApi } from './remoteStateDefinition'
10
+
11
+ interface OverlayLayerOptions<I, A, E, Gated extends boolean> {
12
+ readonly apply: (current: A, intent: I) => A
13
+ readonly pendingTag: Context.Tag<Store<ReadonlyArray<PendingIntent<I>>>, Store<ReadonlyArray<PendingIntent<I>>>>
14
+ readonly reuse: boolean | undefined
15
+ readonly storeTag: Context.Tag<Store<AsyncData<A, E, Gated>>, Store<AsyncData<A, E, Gated>>>
16
+ readonly truthTag: Context.Tag<Store<AsyncData<A, E, Gated>>, Store<AsyncData<A, E, Gated>>>
17
+ }
18
+
19
+ export const makeOverlayLayer = <I, A, E, Gated extends boolean>(
20
+ options: OverlayLayerOptions<I, A, E, Gated>,
21
+ ): Layer.Layer<Store<AsyncData<A, E, Gated>>, never, Store<AsyncData<A, E, Gated>> | Store<ReadonlyArray<PendingIntent<I>>>> =>
22
+ Layer.scoped(
23
+ options.storeTag,
24
+ Effect.gen(function* () {
25
+ const scheduler = yield* resolveScheduler
26
+ const feedStore = widenStore<A, E, Gated>(yield* options.truthTag)
27
+ const queueStore = yield* options.pendingTag
28
+ const memo = MutableRef.make<
29
+ { readonly key: ReadonlyArray<unknown>; readonly output: AnyAsyncData<A, E> } | undefined
30
+ >(undefined)
31
+ const recompute = (): AnyAsyncData<A, E> => {
32
+ const feed = feedStore.getSnapshot()
33
+ const pending = queueStore.getSnapshot()
34
+ const key = [feed, pending]
35
+ const previous = MutableRef.get(memo)
36
+ if (previous !== undefined && sameKey(key, previous.key)) {
37
+ return previous.output
38
+ }
39
+ const applyPending = (base: A): A =>
40
+ pending.reduce((applied, entry) => options.apply(applied, entry.intent), base)
41
+ const overlaid: AnyAsyncData<A, E> =
42
+ feed._tag === 'Success' ? AsyncData.success(applyPending(feed.value), feed.refetching) : feed
43
+ const output =
44
+ options.reuse === true &&
45
+ previous !== undefined &&
46
+ previous.output._tag === 'Success' &&
47
+ overlaid._tag === 'Success'
48
+ ? AsyncData.success(shareStructure(previous.output.value, overlaid.value), overlaid.refetching)
49
+ : overlaid
50
+ MutableRef.set(memo, { key, output })
51
+ return output
52
+ }
53
+ const subscribe = (listener: () => void): (() => void) => {
54
+ const offFeed = feedStore.subscribe(listener)
55
+ const offQueue = queueStore.subscribe(listener)
56
+ return () => {
57
+ offFeed()
58
+ offQueue()
59
+ }
60
+ }
61
+ const derived = makeDerivedStore(recompute, subscribe, scheduler)
62
+ yield* Effect.addFinalizer(() => Effect.sync(derived.unsubscribe))
63
+ return narrowStore<A, E, Gated>(derived.store)
64
+ }),
65
+ )
66
+
67
+ interface QueueReducerOptions<I> {
68
+ readonly ackedTag: string
69
+ readonly name: string
70
+ readonly pendingTag: Context.Tag<Store<ReadonlyArray<PendingIntent<I>>>, Store<ReadonlyArray<PendingIntent<I>>>>
71
+ readonly queuedTag: string
72
+ readonly settledTag: string
73
+ }
74
+
75
+ export const makeQueueReducerLayer = <I>(
76
+ options: QueueReducerOptions<I>,
77
+ ): Layer.Layer<never, never, Reducers | Store<ReadonlyArray<PendingIntent<I>>>> => {
78
+ const handles: ReadonlySet<string> = new Set([options.queuedTag, options.ackedTag, options.settledTag])
79
+ const fold = (
80
+ queue: ReadonlyArray<PendingIntent<I>>,
81
+ event: QueueFoldEventExternalApi<I>,
82
+ ): ReadonlyArray<PendingIntent<I>> => {
83
+ if (event._tag === options.queuedTag) {
84
+ return event.intent === undefined
85
+ ? queue
86
+ : [...queue, { opId: event.opId, intent: event.intent, status: 'sending' }]
87
+ }
88
+ if (event._tag === options.ackedTag) {
89
+ const sending = queue.some((entry) => entry.opId === event.opId && entry.status === 'sending')
90
+ if (!sending) {
91
+ return queue
92
+ }
93
+ return queue.map((entry) =>
94
+ entry.opId === event.opId ? { ...entry, status: 'confirmed' as const } : entry,
95
+ )
96
+ }
97
+ return queue.some((entry) => entry.opId === event.opId)
98
+ ? queue.filter((entry) => entry.opId !== event.opId)
99
+ : queue
100
+ }
101
+ return Layer.scopedDiscard(
102
+ Effect.gen(function* () {
103
+ const reducers = yield* Reducers
104
+ if (reducers.entries.some((entry) => entry.name === options.name)) {
105
+ yield* Effect.logWarning(`reform: duplicate reducer name '${options.name}' registered`)
106
+ }
107
+ const store = yield* options.pendingTag
108
+ const entry: ReducerEntry = {
109
+ name: options.name,
110
+ handles,
111
+ apply: (event) => store.set(fold(store.get(), narrowHandled(event))),
112
+ }
113
+ yield* Effect.acquireRelease(Effect.sync(() => reducers.register(entry)), () =>
114
+ Effect.sync(() => reducers.unregister(entry)),
115
+ )
116
+ }),
117
+ )
118
+ }
@@ -0,0 +1,59 @@
1
+ import { Effect } from 'effect'
2
+ import * as Event from '../event/event'
3
+ import * as Channel from '../channel/channel'
4
+ import type { InputsObject, InvalidateBy } from '../internal/sources'
5
+ import type { AnySource } from '../state/token'
6
+ import type { RemoteStateSettled } from './remoteStateDefinition'
7
+
8
+ type EventPayload<Ev extends Event.AnyEvent> = Ev extends Event.EventClass<string, infer P>
9
+ ? P
10
+ : never
11
+
12
+ export type RemoteStateLiveExternalApi<
13
+ Inputs extends ReadonlyArray<AnySource>,
14
+ Intents extends ReadonlyArray<Event.AnyEvent>,
15
+ A,
16
+ E,
17
+ Gated extends boolean,
18
+ R,
19
+ R2,
20
+ SettledEvent extends Event.AnyEvent = never,
21
+ > = {
22
+ readonly query: (inputs: InputsObject<Inputs>) => Effect.Effect<A, E, R>
23
+ // Any failure settles + Failed; response value ignored — truth via invalidated query
24
+ readonly send: (intent: Event.EventType<Intents[number]>) => Effect.Effect<unknown, unknown, R2>
25
+ // Must be idempotent against truth that already carries the intent
26
+ readonly apply: (value: A, intent: Event.EventType<Intents[number]>) => A
27
+ readonly channel?: Channel.ChannelClass
28
+ readonly invalidateBy?: InvalidateBy<Inputs>
29
+ readonly invalidateOn?: ReadonlyArray<Event.AnyEvent>
30
+ readonly coalesce?: 'switch' | 'trailing'
31
+ readonly reuse?: boolean
32
+ readonly settled?: {
33
+ readonly event: SettledEvent
34
+ readonly payload: (result: RemoteStateSettled<A, E>) => EventPayload<SettledEvent>
35
+ }
36
+ // Only settled server truth is persisted (overlay is a separate store)
37
+ readonly persist?:
38
+ | boolean
39
+ | { readonly key?: string | ((inputs: InputsObject<Inputs>) => string) }
40
+ } & (Gated extends true
41
+ ? { readonly disabled?: (inputs: InputsObject<Inputs>) => boolean }
42
+ : { readonly disabled?: never })
43
+
44
+ // Runtime view: `disabled` is `never` at type level for non-gated definitions
45
+ export type RemoteStateLiveRuntimeExternalApi<Inputs extends ReadonlyArray<AnySource>, A, E, R> = {
46
+ readonly query: (inputs: InputsObject<Inputs>) => Effect.Effect<A, E, R>
47
+ readonly invalidateBy?: InvalidateBy<Inputs>
48
+ readonly disabled?: (inputs: InputsObject<Inputs>) => boolean
49
+ readonly coalesce?: 'switch' | 'trailing'
50
+ readonly reuse?: boolean
51
+ readonly persist?:
52
+ | boolean
53
+ | { readonly key?: string | ((inputs: InputsObject<Inputs>) => string) }
54
+ }
55
+
56
+ export interface SettleLink {
57
+ readonly requested: () => number
58
+ readonly register: (opId: string, waitFor: number) => void
59
+ }
@@ -0,0 +1,64 @@
1
+ import { Context, Effect, Either, Layer } from 'effect'
2
+ import * as Channel from '../channel/channel'
3
+ import * as Event from '../event/event'
4
+ import { Bus, narrowHandled } from '../runtime/bus'
5
+ import type { SettleLink } from './remoteStateLiveTypes'
6
+
7
+ interface SendProcedureOptions<I, R> {
8
+ readonly acked: Event.EventClass<string, { readonly opId: string }>
9
+ readonly channel: Channel.ChannelClass
10
+ readonly failed: Event.EventClass<string, { readonly intent: I; readonly error: unknown }>
11
+ readonly handles: ReadonlySet<string>
12
+ readonly invalidated: Event.EventClass<string, {}>
13
+ readonly linkTag: Context.Tag<SettleLink, SettleLink>
14
+ readonly name: string
15
+ readonly queued: Event.EventClass<string, { readonly opId: string; readonly intent: I }>
16
+ readonly send: (intent: I) => Effect.Effect<unknown, unknown, R>
17
+ readonly settled: Event.EventClass<string, { readonly opId: string }>
18
+ }
19
+
20
+ export const makeSendProcedureLayer = <I, R>(
21
+ options: SendProcedureOptions<I, R>,
22
+ ): Layer.Layer<never, never, Channel.Procedures | SettleLink | Bus | R> =>
23
+ Layer.scopedDiscard(
24
+ Effect.gen(function* () {
25
+ const procedures = yield* Channel.Procedures
26
+ if (procedures.entries.some((entry) => entry.name === options.name)) {
27
+ yield* Effect.logWarning(`reform: duplicate procedure name '${options.name}' registered`)
28
+ }
29
+ const link = yield* options.linkTag
30
+ const runtime = yield* Effect.runtime<Bus | R>()
31
+ const deliver = Effect.fn('deliver')(
32
+ function* (intent: I): Effect.fn.Return<void, never, Bus | R> {
33
+ const opId = yield* Effect.sync(() => crypto.randomUUID())
34
+ yield* Event.dispatch(options.queued, { opId, intent })
35
+ const outcome = yield* options.send(intent).pipe(
36
+ Effect.catchAllDefect((defect) => Effect.fail(defect)),
37
+ Effect.either,
38
+ Effect.onInterrupt(() => Event.dispatch(options.settled, { opId })),
39
+ )
40
+ yield* Either.match(outcome, {
41
+ onLeft: (error) =>
42
+ Effect.zipRight(
43
+ Event.dispatch(options.failed, { intent, error }),
44
+ Event.dispatch(options.settled, { opId }),
45
+ ),
46
+ onRight: () =>
47
+ Effect.sync(() => link.register(opId, link.requested() + 1)).pipe(
48
+ Effect.zipRight(Event.dispatch(options.acked, { opId })),
49
+ Effect.zipRight(Event.dispatch(options.invalidated, {})),
50
+ ),
51
+ })
52
+ },
53
+ )
54
+ const entry: Channel.ProcedureEntry = {
55
+ name: options.name,
56
+ channelName: options.channel.manifest.name,
57
+ handles: options.handles,
58
+ run: (event) => Effect.provide(deliver(narrowHandled<I>(event)), runtime),
59
+ }
60
+ yield* Effect.acquireRelease(Effect.sync(() => procedures.register(entry)), () =>
61
+ Effect.sync(() => procedures.unregister(entry)),
62
+ )
63
+ }),
64
+ )
@@ -0,0 +1,197 @@
1
+ import { Context, Duration, Effect, Layer, Schema as S } from "effect";
2
+ import { expect, it } from "@effect/vitest";
3
+ import * as Composition from "../compose/composition";
4
+ import { provide } from "../compose/provide";
5
+ import { mount } from "../compose/structure";
6
+ import * as Ui from "../compose/ui";
7
+ import { ui } from "../compose/ui";
8
+ import * as Event from "../event/event";
9
+ import * as Feature from "../feature/feature";
10
+ import { featureModule } from "../feature/feature";
11
+ import * as Reducer from "../reducer/reducer";
12
+ import { scene } from "../scene/scene";
13
+ import * as State from "../state/state";
14
+ import { Engine, Reducers } from "./loop";
15
+ import { makeAppRuntime } from "./appRuntime";
16
+
17
+ // The host-level mount lifecycle: `RuntimeHandle.mountFeature` forks the mount,
18
+ // and its disposer must CANCEL a mount whose fiber has not run yet (StrictMode
19
+ // cleanup/setup, fast remounts) — a scope close alone does not interrupt, so a
20
+ // stale mount would still register reducers and dispatch `binding.boot` onto the
21
+ // shared bus.
22
+
23
+ class Tick extends Event.make("appRuntime.Tick", S.Struct({})) {}
24
+ class Count extends State.make("appRuntime.count", S.Number) {}
25
+ class CountReducer extends Reducer.make("appRuntime.CountReducer", {
26
+ states: [Count],
27
+ events: [Tick],
28
+ }) {}
29
+
30
+ class CtrUi extends ui("appRuntime.CtrUi")<{ props: {} }>() {}
31
+ class CtrComp extends Composition.make("appRuntime.CtrComp", {
32
+ title: "Counter",
33
+ ui: CtrUi,
34
+ }) {}
35
+
36
+ class CounterFeature extends Feature.make("appRuntime.counter", {
37
+ composition: CtrComp,
38
+ module: featureModule(
39
+ [],
40
+ Reducer.live(CountReducer, (n) => n + 1).pipe(
41
+ Layer.provideMerge(State.live(Count, 0)),
42
+ ),
43
+ ),
44
+ boot: [Event.construct(Tick, {})],
45
+ }) {}
46
+
47
+ class RootUi extends ui("appRuntime.RootUi")<{ props: {} }>() {}
48
+ class RootComp extends Composition.make("appRuntime.RootComp", {
49
+ title: "Root",
50
+ ui: RootUi,
51
+ }) {}
52
+ const RootLive = Composition.live(RootComp, function* () {
53
+ return mount({ props: {}, slots: {}, events: {} });
54
+ });
55
+
56
+ const rootScene = () =>
57
+ scene(RootComp, {
58
+ provide: [
59
+ RootLive.pipe(
60
+ Layer.provideMerge(
61
+ provide(
62
+ RootUi,
63
+ Ui.make(RootUi, () => null),
64
+ ),
65
+ ),
66
+ Layer.provideMerge(Engine),
67
+ ),
68
+ ],
69
+ });
70
+
71
+ const settle = Effect.sleep(Duration.millis(50));
72
+ const appRuntime = Effect.acquireRelease(
73
+ Effect.sync(() => makeAppRuntime(rootScene())),
74
+ (app) => Effect.sync(() => app.dispose()),
75
+ );
76
+
77
+ interface ParentValueService {
78
+ readonly value: string;
79
+ }
80
+ const ParentValueBase: Context.TagClass<
81
+ ParentValue,
82
+ "appRuntime.ParentValue",
83
+ ParentValueService
84
+ > = Context.Tag("appRuntime.ParentValue")<ParentValue, ParentValueService>();
85
+ class ParentValue extends ParentValueBase {}
86
+
87
+ interface ChildValueService {
88
+ readonly value: string;
89
+ }
90
+ const ChildValueBase: Context.TagClass<
91
+ ChildValue,
92
+ "appRuntime.ChildValue",
93
+ ChildValueService
94
+ > = Context.Tag("appRuntime.ChildValue")<ChildValue, ChildValueService>();
95
+ class ChildValue extends ChildValueBase {}
96
+
97
+ class ParentFeature extends Feature.make("appRuntime.parent", {
98
+ composition: CtrComp,
99
+ module: featureModule(
100
+ [],
101
+ Layer.succeed(ParentValue, { value: "from-parent" }),
102
+ ),
103
+ }) {}
104
+
105
+ class ChildFeature extends Feature.make("appRuntime.child", {
106
+ composition: CtrComp,
107
+ module: featureModule(
108
+ [ParentValue],
109
+ Layer.effect(
110
+ ChildValue,
111
+ Effect.map(ParentValue, ({ value }) => ({ value })),
112
+ ),
113
+ ),
114
+ }) {}
115
+
116
+ it.scopedLive(
117
+ "disposing a mount before its fiber runs cancels it — no registration, no boot, no handler calls",
118
+ () =>
119
+ Effect.gen(function* () {
120
+ const app = yield* appRuntime;
121
+ const baseline = app.read(Reducers).entries.length;
122
+ const calls = { live: 0, failed: 0 };
123
+ const dispose = app.mountFeature(CounterFeature.binding, {
124
+ onLive: () => {
125
+ calls.live += 1;
126
+ },
127
+ onFailed: () => {
128
+ calls.failed += 1;
129
+ },
130
+ });
131
+ // Same-tick dispose (the StrictMode cleanup shape): the fork has not run yet.
132
+ dispose();
133
+ yield* settle;
134
+ expect(calls).toEqual({ live: 0, failed: 0 });
135
+ expect(app.read(Reducers).entries.length).toBe(baseline);
136
+ expect(app.read(Reducers).byTag.get("appRuntime.Tick")).toBeUndefined();
137
+ }),
138
+ );
139
+
140
+ it.scopedLive(
141
+ "an undisposed mount still goes live, and disposing it after live reclaims the registration",
142
+ () =>
143
+ Effect.gen(function* () {
144
+ const app = yield* appRuntime;
145
+ const baseline = app.read(Reducers).entries.length;
146
+ const calls = { live: 0, failed: 0 };
147
+ const dispose = app.mountFeature(CounterFeature.binding, {
148
+ onLive: () => {
149
+ calls.live += 1;
150
+ },
151
+ onFailed: () => {
152
+ calls.failed += 1;
153
+ },
154
+ });
155
+ yield* Effect.sleep(Duration.millis(1)).pipe(
156
+ Effect.repeat({ until: () => calls.live === 1 }),
157
+ Effect.timeout(Duration.seconds(2)),
158
+ );
159
+ expect(calls).toEqual({ live: 1, failed: 0 });
160
+ expect(app.read(Reducers).entries.length).toBe(baseline + 1);
161
+ dispose();
162
+ yield* settle;
163
+ expect(app.read(Reducers).entries.length).toBe(baseline);
164
+ }),
165
+ );
166
+
167
+ it.scopedLive(
168
+ "a nested Feature mounts against its immediate parent context",
169
+ () =>
170
+ Effect.gen(function* () {
171
+ const app = yield* appRuntime;
172
+ const observed: Array<string> = [];
173
+ const disposers: Array<() => void> = [];
174
+ yield* Effect.addFinalizer(() =>
175
+ Effect.sync(() => disposers.reverse().forEach((dispose) => dispose())),
176
+ );
177
+ disposers.push(
178
+ app.mountFeature(ParentFeature.binding, {
179
+ onFailed: () => observed.push("parent-failed"),
180
+ onLive: (parent) => {
181
+ disposers.push(
182
+ parent.mountFeature(ChildFeature.binding, {
183
+ onFailed: () => observed.push("child-failed"),
184
+ onLive: (child) =>
185
+ observed.push(child.read(ChildValue).value),
186
+ }),
187
+ );
188
+ },
189
+ }),
190
+ );
191
+ yield* Effect.sleep(Duration.millis(1)).pipe(
192
+ Effect.repeat({ until: () => observed.length > 0 }),
193
+ Effect.timeout(Duration.seconds(2)),
194
+ );
195
+ expect(observed).toEqual(["from-parent"]);
196
+ }),
197
+ );