@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.
- package/README.md +18 -1
- package/package.json +1 -1
- package/src/boundary/boundary.test.ts +1 -33
- package/src/boundary/boundary.ts +4 -82
- package/src/calc/asyncCalc.invalidate.test.ts +2 -14
- package/src/calc/asyncCalc.test.ts +0 -38
- package/src/calc/asyncCalc.ts +11 -163
- package/src/calc/asyncCalcDefinitions.ts +48 -0
- package/src/calc/asyncData.ts +0 -37
- package/src/calc/calc.test.ts +4 -28
- package/src/calc/calc.ts +16 -44
- package/src/calc/calcFamily.test.ts +0 -12
- package/src/calc/calcFamily.ts +12 -49
- package/src/calc/compose.test.ts +1 -12
- package/src/calc/compose.ts +0 -22
- package/src/calc/queryState.ts +0 -23
- package/src/channel/channel.ts +78 -173
- package/src/channel/procedureRegistry.ts +90 -0
- package/src/compose/composition.ts +7 -67
- package/src/compose/host.ts +0 -6
- package/src/compose/props.ts +3 -9
- package/src/compose/provide.ts +19 -33
- package/src/compose/slot.ts +2 -30
- package/src/compose/structure.test.ts +0 -6
- package/src/compose/structure.ts +3 -67
- package/src/compose/ui.test.ts +0 -4
- package/src/compose/ui.ts +17 -111
- package/src/compose/ui.typecheck.ts +0 -14
- package/src/definition/definition.ts +0 -32
- package/src/event/event.test.ts +0 -3
- package/src/event/event.ts +5 -14
- package/src/event/eventGroup.ts +0 -1
- package/src/feature/feature.mount.test.ts +66 -54
- package/src/feature/feature.test.ts +67 -46
- package/src/feature/feature.ts +185 -214
- package/src/feature/feature.typecheck.ts +29 -16
- package/src/feature/featureBinding.ts +48 -0
- package/src/index.ts +245 -202
- package/src/internal/capture.ts +0 -20
- package/src/internal/ctx.ts +2 -9
- package/src/internal/env.ts +9 -0
- package/src/internal/errors.test.ts +0 -6
- package/src/internal/errors.ts +14 -48
- package/src/internal/inspect.test.ts +0 -6
- package/src/internal/inspect.ts +0 -12
- package/src/internal/queryDriver.ts +48 -243
- package/src/internal/queryDriverStore.ts +153 -0
- package/src/internal/queryDriverTypes.ts +57 -0
- package/src/internal/queryEvents.ts +0 -12
- package/src/internal/queryStore.ts +0 -14
- package/src/internal/reuse.test.ts +0 -11
- package/src/internal/reuse.ts +0 -28
- package/src/internal/scheduler.ts +0 -43
- package/src/internal/seeds.ts +0 -14
- package/src/internal/sources.ts +4 -41
- package/src/internal/stateRegistry.ts +0 -14
- package/src/internal/store.test.ts +1 -3
- package/src/internal/store.ts +10 -37
- package/src/internal/track.ts +0 -18
- package/src/internal/variance.ts +5 -0
- package/src/namespace/namespace.test.ts +46 -0
- package/src/namespace/namespace.ts +85 -0
- package/src/procedure/procedure.ts +2 -24
- package/src/reducer/reducer.ts +3 -21
- package/src/remote/remoteState.test.ts +92 -75
- package/src/remote/remoteState.ts +99 -486
- package/src/remote/remoteState.typecheck.ts +0 -33
- package/src/remote/remoteStateDefinition.ts +135 -0
- package/src/remote/remoteStateLayers.ts +118 -0
- package/src/remote/remoteStateLiveTypes.ts +59 -0
- package/src/remote/remoteStateSend.ts +64 -0
- package/src/runtime/appRuntime.test.ts +197 -0
- package/src/runtime/appRuntime.ts +131 -0
- package/src/runtime/bus.ts +2 -15
- package/src/runtime/eventBudget.test.ts +155 -0
- package/src/runtime/eventBudget.ts +119 -0
- package/src/runtime/hardening.test.ts +74 -9
- package/src/runtime/instrumentation.test.ts +324 -0
- package/src/runtime/instrumentation.ts +44 -0
- package/src/runtime/loop.test.ts +0 -17
- package/src/runtime/loop.ts +41 -58
- package/src/runtime/queries.ts +0 -17
- package/src/scene/featureScene.test.ts +59 -0
- package/src/scene/scene.ts +51 -44
- package/src/scene/seedScene.test.ts +96 -116
- package/src/state/state.ts +12 -28
- package/src/state/stateFamily.test.ts +2 -11
- package/src/state/stateFamily.ts +20 -54
- package/src/state/stateGroup.ts +2 -49
- package/src/state/token.ts +4 -17
- package/src/synced/syncedStore.ts +7 -42
- package/src/ui/node.ts +0 -6
- package/src/ui/trigger.ts +0 -5
- package/src/wire/tree.test.ts +0 -3
- package/src/wire/tree.ts +0 -39
- package/src/wire/triggers.test.ts +0 -4
- 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
|
+
);
|