@playfast/reform 1.1.1 → 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.
- package/README.md +85 -55
- package/package.json +17 -17
- package/src/boundary/boundary.test.ts +82 -33
- package/src/boundary/boundary.ts +141 -32
- package/src/calc/asyncCalc.invalidate.test.ts +733 -32
- package/src/calc/asyncCalc.test.ts +397 -225
- package/src/calc/asyncCalc.ts +115 -100
- package/src/calc/asyncCalcTypes.ts +147 -0
- package/src/calc/calc.test.ts +13 -16
- package/src/calc/calc.ts +44 -18
- package/src/calc/calcFamily.test.ts +90 -32
- package/src/calc/calcFamily.ts +56 -25
- package/src/calc/compose.ts +1 -14
- package/src/channel/channel.ts +25 -6
- package/src/channel/{procedureRegistry.ts → procedures.ts} +41 -23
- package/src/compose/composition.test.ts +19 -0
- package/src/compose/composition.ts +168 -81
- package/src/compose/compositionTypes.ts +249 -0
- package/src/compose/props.ts +13 -6
- package/src/compose/provide.ts +187 -46
- package/src/compose/slot.ts +156 -19
- package/src/compose/structure.test.ts +6 -3
- package/src/compose/structure.ts +106 -13
- package/src/compose/ui.test.ts +19 -3
- package/src/compose/ui.ts +147 -54
- package/src/compose/ui.typecheck.ts +40 -4
- package/src/definition/definition.ts +22 -9
- package/src/event/event.fromSource.test.ts +172 -0
- package/src/event/event.test.ts +10 -0
- package/src/event/event.ts +102 -18
- package/src/feature/feature.mount.test.ts +123 -56
- package/src/feature/feature.test.ts +67 -63
- package/src/feature/feature.ts +222 -262
- package/src/feature/feature.typecheck.ts +224 -91
- package/src/feature/featureBinding.ts +130 -20
- package/src/feature/featureClass.ts +162 -0
- package/src/feature/featureConfig.ts +192 -0
- package/src/feature/featureFactory.ts +163 -0
- package/src/feature/featureModule.ts +136 -0
- package/src/feature/featureModule.typecheck.ts +63 -0
- package/src/feature/featureNativeTree.ts +152 -0
- package/src/feature/featureRequirement.ts +82 -0
- package/src/feature/featureVariants.ts +234 -0
- package/src/feature/mountFeature.ts +54 -0
- package/src/graph/closure.ts +222 -0
- package/src/graph/services.ts +95 -0
- package/src/graph/summary.ts +134 -0
- package/src/index.ts +171 -245
- package/src/internal/bucketCache.ts +39 -0
- package/src/internal/capture.ts +9 -4
- package/src/internal/env.ts +1 -3
- package/src/internal/errors.ts +21 -10
- package/src/internal/queryDriver.ts +61 -25
- package/src/internal/queryDriverHydrate.ts +45 -0
- package/src/internal/queryDriverStore.ts +8 -5
- package/src/internal/queryDriverTypes.ts +11 -11
- package/src/internal/reuse.test.ts +10 -3
- package/src/internal/reuse.ts +5 -2
- package/src/internal/scheduler.ts +4 -1
- package/src/internal/sources.ts +25 -11
- package/src/internal/store.test.ts +27 -6
- package/src/internal/track.ts +3 -2
- package/src/internal.ts +222 -0
- package/src/namespace/namespace.ts +46 -25
- package/src/procedure/procedure.ts +38 -11
- package/src/reducer/reducer.ts +78 -34
- package/src/remote/pendingQueue.ts +145 -0
- package/src/remote/remoteState.test.ts +536 -354
- package/src/remote/remoteState.ts +145 -106
- package/src/remote/remoteState.typecheck.ts +47 -23
- package/src/remote/remoteStateMake.ts +97 -0
- package/src/remote/remoteStateSend.ts +95 -37
- package/src/remote/remoteStateTypes.ts +155 -0
- package/src/runtime/appRuntime.activation.test.ts +104 -0
- package/src/runtime/appRuntime.test.ts +187 -111
- package/src/runtime/appRuntime.ts +36 -130
- package/src/runtime/capturedAppRuntime.ts +274 -0
- package/src/runtime/eventBudget.test.ts +39 -12
- package/src/runtime/eventBudget.ts +9 -8
- package/src/runtime/featureMount.ts +94 -0
- package/src/runtime/hardening.test.ts +22 -13
- package/src/runtime/instrumentation.test.ts +213 -194
- package/src/runtime/instrumentation.ts +6 -11
- package/src/runtime/loop.test.ts +84 -7
- package/src/runtime/loop.ts +69 -40
- package/src/runtime/runtimeHandle.ts +124 -0
- package/src/scene/featureScene.test.ts +4 -2
- package/src/scene/scene.ts +234 -64
- package/src/scene/seedScene.test.ts +69 -86
- package/src/state/state.nominal.typecheck.ts +68 -0
- package/src/state/state.test.ts +17 -0
- package/src/state/state.ts +79 -26
- package/src/state/stateFamily.test.ts +39 -8
- package/src/state/stateFamily.ts +55 -22
- package/src/state/stateGroup.ts +53 -17
- package/src/state/token.ts +40 -10
- package/src/synced/syncedStore.ts +46 -23
- package/src/testkit/flight.testkit.ts +84 -0
- package/src/wire/tree.test.ts +8 -4
- package/src/wire/triggers.test.ts +6 -2
- package/src/wire/triggers.ts +14 -10
- package/src/calc/asyncCalcDefinitions.ts +0 -48
- package/src/internal/ctx.ts +0 -9
- package/src/remote/remoteStateDefinition.ts +0 -135
- package/src/remote/remoteStateLayers.ts +0 -118
- package/src/remote/remoteStateLiveTypes.ts +0 -59
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { Deferred, Duration, Effect, MutableRef, Option } from 'effect'
|
|
2
|
+
|
|
3
|
+
// Coalescing/cancellation semantics are only meaningful while a query is still
|
|
4
|
+
// in flight. Tests used to build that overlap by racing wall-clock — a 25ms
|
|
5
|
+
// query delay against 5ms gaps between input writes — which silently stops
|
|
6
|
+
// overlapping on a loaded machine: the runs serialize, every key gets its own
|
|
7
|
+
// flight, and the assertion fails reporting a coalescing bug that isn't there.
|
|
8
|
+
// A gate makes the overlap explicit: hold the flight open, land the burst,
|
|
9
|
+
// release.
|
|
10
|
+
|
|
11
|
+
export const tick = (ms = 10): Effect.Effect<void> => Effect.sleep(Duration.millis(ms))
|
|
12
|
+
|
|
13
|
+
// The runtime notifies on the microtask queue (scheduler, stateFamily, calcFamily),
|
|
14
|
+
// so draining turns is exact where a duration is a guess: a microtask cannot be
|
|
15
|
+
// starved by host load the way a 10ms budget can. Use this to let the graph
|
|
16
|
+
// quiesce before asserting that something did NOT happen.
|
|
17
|
+
export const flush = (turns = 3): Effect.Effect<void> =>
|
|
18
|
+
Effect.promise(() => new Promise<void>((resolve) => queueMicrotask(resolve))).pipe(
|
|
19
|
+
Effect.repeatN(Math.max(turns - 1, 0)),
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
export const until = <A>(
|
|
23
|
+
read: () => A,
|
|
24
|
+
predicate: (value: A) => boolean,
|
|
25
|
+
rounds = 200,
|
|
26
|
+
): Effect.Effect<A> =>
|
|
27
|
+
Effect.suspend(() => {
|
|
28
|
+
const value = read()
|
|
29
|
+
return predicate(value) || rounds <= 0
|
|
30
|
+
? Effect.succeed(value)
|
|
31
|
+
: Effect.flatMap(tick(10), () => until(read, predicate, rounds - 1))
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
export interface FlightGate {
|
|
35
|
+
/** Wrap a query body: it records its entry, then parks while the gate holds. */
|
|
36
|
+
readonly through: <A, E, R>(body: Effect.Effect<A, E, R>) => Effect.Effect<A, E, R>
|
|
37
|
+
/** Park every flight that reaches the gate from now on. */
|
|
38
|
+
readonly hold: Effect.Effect<void>
|
|
39
|
+
/** Let the parked flights settle; later flights pass straight through. */
|
|
40
|
+
readonly release: Effect.Effect<void>
|
|
41
|
+
/** Resolve once `count` flights have reached the gate. */
|
|
42
|
+
readonly awaitEntry: (count: number) => Effect.Effect<void>
|
|
43
|
+
/** How many flights have reached the gate. */
|
|
44
|
+
readonly entered: () => number
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export const makeFlightGate = (): FlightGate => {
|
|
48
|
+
const latch = MutableRef.make(Option.none<Deferred.Deferred<void>>())
|
|
49
|
+
const entered = MutableRef.make(0)
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
through: (body) =>
|
|
53
|
+
body.pipe(
|
|
54
|
+
Effect.tap(() =>
|
|
55
|
+
Effect.suspend(() => {
|
|
56
|
+
MutableRef.update(entered, (count) => count + 1)
|
|
57
|
+
return Option.match(MutableRef.get(latch), {
|
|
58
|
+
onNone: () => Effect.void,
|
|
59
|
+
onSome: (parked) => Deferred.await(parked),
|
|
60
|
+
})
|
|
61
|
+
}),
|
|
62
|
+
),
|
|
63
|
+
),
|
|
64
|
+
hold: Effect.flatMap(Deferred.make<void>(), (parked) =>
|
|
65
|
+
Effect.sync(() => MutableRef.set(latch, Option.some(parked))),
|
|
66
|
+
),
|
|
67
|
+
release: Effect.suspend(() => {
|
|
68
|
+
const parked = MutableRef.get(latch)
|
|
69
|
+
MutableRef.set(latch, Option.none())
|
|
70
|
+
return Option.match(parked, {
|
|
71
|
+
onNone: () => Effect.void,
|
|
72
|
+
onSome: (deferred) => Effect.asVoid(Deferred.succeed(deferred, undefined)),
|
|
73
|
+
})
|
|
74
|
+
}),
|
|
75
|
+
awaitEntry: (count) =>
|
|
76
|
+
Effect.asVoid(
|
|
77
|
+
until(
|
|
78
|
+
() => MutableRef.get(entered),
|
|
79
|
+
(reached) => reached >= count,
|
|
80
|
+
),
|
|
81
|
+
),
|
|
82
|
+
entered: () => MutableRef.get(entered),
|
|
83
|
+
}
|
|
84
|
+
}
|
package/src/wire/tree.test.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { expect, test } from 'vitest'
|
|
2
|
-
import
|
|
3
|
-
import type { WireNode, WireTree } from '
|
|
2
|
+
import * as Wire from './tree'
|
|
3
|
+
import type { WireNode, WireTree } from './tree'
|
|
4
4
|
|
|
5
5
|
const node = (id: string, over: Partial<WireNode> = {}): WireNode => ({
|
|
6
6
|
id,
|
|
@@ -71,8 +71,12 @@ test('roots and childrenOf order siblings by childIndex', () => {
|
|
|
71
71
|
})
|
|
72
72
|
|
|
73
73
|
test('nested deep prop changes are detected', () => {
|
|
74
|
-
const prev: WireTree = [
|
|
75
|
-
|
|
74
|
+
const prev: WireTree = [
|
|
75
|
+
node('a', { props: [{ _tag: 'Data', name: 'rows', value: [{ id: 1 }, { id: 2 }] }] }),
|
|
76
|
+
]
|
|
77
|
+
const next: WireTree = [
|
|
78
|
+
node('a', { props: [{ _tag: 'Data', name: 'rows', value: [{ id: 1 }, { id: 3 }] }] }),
|
|
79
|
+
]
|
|
76
80
|
expect(Wire.diff(prev, next)).toHaveLength(1)
|
|
77
81
|
expect(Wire.diff(prev, prev)).toHaveLength(0)
|
|
78
82
|
})
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { expect, test } from 'vitest'
|
|
2
2
|
import { Effect, Exit, Schema as S } from 'effect'
|
|
3
|
-
import
|
|
3
|
+
import * as Triggers from './triggers'
|
|
4
4
|
import type { Trigger } from '../index'
|
|
5
5
|
|
|
6
6
|
const ByPayload = S.Struct({ by: S.Number })
|
|
@@ -61,7 +61,11 @@ test('a revoked handle no longer fires', () => {
|
|
|
61
61
|
Effect.gen(function* () {
|
|
62
62
|
const registry = yield* Triggers.make
|
|
63
63
|
const calls: number[] = []
|
|
64
|
-
yield* registry.register(
|
|
64
|
+
yield* registry.register(
|
|
65
|
+
'0:bump',
|
|
66
|
+
((p: { by: number }) => void calls.push(p.by)) as Trigger<{ by: number }>,
|
|
67
|
+
ByPayload,
|
|
68
|
+
)
|
|
65
69
|
yield* registry.revoke('0:bump')
|
|
66
70
|
const exit = yield* Effect.exit(registry.invoke('0:bump', { by: 1 }))
|
|
67
71
|
return { calls, failed: Exit.isFailure(exit) }
|
package/src/wire/triggers.ts
CHANGED
|
@@ -1,12 +1,10 @@
|
|
|
1
1
|
import { type Cause, Context, Data, Effect, Layer, type ParseResult, Ref, Schema } from 'effect'
|
|
2
2
|
import type { Trigger } from '../ui/trigger'
|
|
3
|
-
import type { AnySchema, AnyValue } from '../internal/variance'
|
|
4
3
|
|
|
5
4
|
export type TriggerHandle = string
|
|
6
5
|
|
|
7
6
|
interface TriggerEntry {
|
|
8
|
-
readonly
|
|
9
|
-
readonly schema: Schema.Schema.AnyNoContext
|
|
7
|
+
readonly invoke: (encodedPayload: unknown) => Effect.Effect<void, ParseResult.ParseError, never>
|
|
10
8
|
}
|
|
11
9
|
|
|
12
10
|
const UnknownTriggerBase: new (args: { readonly handle: TriggerHandle }) => Cause.YieldableError & {
|
|
@@ -22,10 +20,10 @@ export class UnknownTrigger extends UnknownTriggerBase {
|
|
|
22
20
|
}
|
|
23
21
|
|
|
24
22
|
export interface TriggerRegistryApi {
|
|
25
|
-
readonly register: <P>(
|
|
23
|
+
readonly register: <P, Encoded>(
|
|
26
24
|
handle: TriggerHandle,
|
|
27
25
|
trigger: Trigger<P>,
|
|
28
|
-
schema:
|
|
26
|
+
schema: Schema.Schema<P, Encoded, never>,
|
|
29
27
|
) => Effect.Effect<void>
|
|
30
28
|
readonly invoke: (
|
|
31
29
|
handle: TriggerHandle,
|
|
@@ -45,12 +43,19 @@ export class TriggerRegistry extends TriggerRegistryBase {}
|
|
|
45
43
|
export const make: Effect.Effect<TriggerRegistryApi> = Effect.gen(function* () {
|
|
46
44
|
const entries = yield* Ref.make(new Map<TriggerHandle, TriggerEntry>())
|
|
47
45
|
|
|
48
|
-
const register = <P>(
|
|
46
|
+
const register = <P, Encoded>(
|
|
49
47
|
handle: TriggerHandle,
|
|
50
48
|
trigger: Trigger<P>,
|
|
51
|
-
schema:
|
|
49
|
+
schema: Schema.Schema<P, Encoded, never>,
|
|
52
50
|
): Effect.Effect<void> =>
|
|
53
|
-
Ref.update(entries, (map) =>
|
|
51
|
+
Ref.update(entries, (map) =>
|
|
52
|
+
new Map(map).set(handle, {
|
|
53
|
+
invoke: (encodedPayload) =>
|
|
54
|
+
Schema.decodeUnknown(schema)(encodedPayload).pipe(
|
|
55
|
+
Effect.map((payload) => trigger(payload)),
|
|
56
|
+
),
|
|
57
|
+
}),
|
|
58
|
+
)
|
|
54
59
|
|
|
55
60
|
const invoke = Effect.fn('invoke')(function* (
|
|
56
61
|
handle: TriggerHandle,
|
|
@@ -60,8 +65,7 @@ export const make: Effect.Effect<TriggerRegistryApi> = Effect.gen(function* () {
|
|
|
60
65
|
if (entry === undefined) {
|
|
61
66
|
return yield* Effect.fail(new UnknownTrigger({ handle }))
|
|
62
67
|
}
|
|
63
|
-
|
|
64
|
-
entry.trigger(payload)
|
|
68
|
+
yield* entry.invoke(encodedPayload)
|
|
65
69
|
})
|
|
66
70
|
|
|
67
71
|
const revoke = (handle: TriggerHandle): Effect.Effect<void> =>
|
|
@@ -1,48 +0,0 @@
|
|
|
1
|
-
import { Context, Effect } from 'effect'
|
|
2
|
-
import { yieldableClass } from '../definition/definition'
|
|
3
|
-
import { type GatedOf, gatedFlag } from '../internal/queryDriver'
|
|
4
|
-
import type { Store } from '../internal/store'
|
|
5
|
-
import { readTracked } from '../internal/track'
|
|
6
|
-
import { Queries } from '../runtime/queries'
|
|
7
|
-
import type { AnySource } from '../state/token'
|
|
8
|
-
import type { AsyncData } from './asyncData'
|
|
9
|
-
import type { AsyncCalcClass, AsyncCalcConfig, AsyncCalcManifest } from './asyncCalc'
|
|
10
|
-
|
|
11
|
-
export const make = <
|
|
12
|
-
const N extends string,
|
|
13
|
-
const Inputs extends ReadonlyArray<AnySource>,
|
|
14
|
-
A,
|
|
15
|
-
E = never,
|
|
16
|
-
const AlwaysOn extends boolean = false,
|
|
17
|
-
>(
|
|
18
|
-
name: N,
|
|
19
|
-
config: AsyncCalcConfig<Inputs, A, E, AlwaysOn>,
|
|
20
|
-
): AsyncCalcClass<N, Inputs, A, E, GatedOf<AlwaysOn>> => {
|
|
21
|
-
const store = Context.GenericTag<Store<AsyncData<A, E, GatedOf<AlwaysOn>>>>(
|
|
22
|
-
`reform/asyncCalc/${name}`,
|
|
23
|
-
)
|
|
24
|
-
const gated = gatedFlag(config.alwaysOn)
|
|
25
|
-
const manifest: AsyncCalcManifest<N, A, E> = {
|
|
26
|
-
kind: 'AsyncCalc',
|
|
27
|
-
name,
|
|
28
|
-
output: config.output,
|
|
29
|
-
gated,
|
|
30
|
-
...(config.error !== undefined ? { error: config.error } : {}),
|
|
31
|
-
}
|
|
32
|
-
const read = Effect.flatMap(store, readTracked)
|
|
33
|
-
return yieldableClass(read, { manifest, store, name, inputs: config.inputs, gated })
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
export interface NamedCalc {
|
|
37
|
-
readonly name: string
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
export const invalidate = (calc: NamedCalc): Effect.Effect<void, never, Queries> =>
|
|
41
|
-
Effect.flatMap(Queries, (queries) =>
|
|
42
|
-
Effect.sync(() => queries.byName.get(calc.name)?.invalidate()),
|
|
43
|
-
)
|
|
44
|
-
|
|
45
|
-
export const refetch = (calc: NamedCalc): Effect.Effect<void, never, Queries> =>
|
|
46
|
-
Effect.flatMap(Queries, (queries) =>
|
|
47
|
-
Effect.sync(() => queries.byName.get(calc.name)?.refetch()),
|
|
48
|
-
)
|
package/src/internal/ctx.ts
DELETED
|
@@ -1,9 +0,0 @@
|
|
|
1
|
-
import type { Effect } from 'effect'
|
|
2
|
-
import type { YieldWrap } from 'effect/Utils'
|
|
3
|
-
import type { AnyValue } from './variance'
|
|
4
|
-
|
|
5
|
-
export type Ctx<Eff> = [Eff] extends [never]
|
|
6
|
-
? never
|
|
7
|
-
: [Eff] extends [YieldWrap<Effect.Effect<AnyValue, AnyValue, infer R>>]
|
|
8
|
-
? R
|
|
9
|
-
: never
|
|
@@ -1,135 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,118 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,59 +0,0 @@
|
|
|
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
|
-
}
|