@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.
Files changed (106) hide show
  1. package/README.md +85 -55
  2. package/package.json +17 -17
  3. package/src/boundary/boundary.test.ts +82 -33
  4. package/src/boundary/boundary.ts +141 -32
  5. package/src/calc/asyncCalc.invalidate.test.ts +733 -32
  6. package/src/calc/asyncCalc.test.ts +397 -225
  7. package/src/calc/asyncCalc.ts +115 -100
  8. package/src/calc/asyncCalcTypes.ts +147 -0
  9. package/src/calc/calc.test.ts +13 -16
  10. package/src/calc/calc.ts +44 -18
  11. package/src/calc/calcFamily.test.ts +90 -32
  12. package/src/calc/calcFamily.ts +56 -25
  13. package/src/calc/compose.ts +1 -14
  14. package/src/channel/channel.ts +25 -6
  15. package/src/channel/{procedureRegistry.ts → procedures.ts} +41 -23
  16. package/src/compose/composition.test.ts +19 -0
  17. package/src/compose/composition.ts +168 -81
  18. package/src/compose/compositionTypes.ts +249 -0
  19. package/src/compose/props.ts +13 -6
  20. package/src/compose/provide.ts +187 -46
  21. package/src/compose/slot.ts +156 -19
  22. package/src/compose/structure.test.ts +6 -3
  23. package/src/compose/structure.ts +106 -13
  24. package/src/compose/ui.test.ts +19 -3
  25. package/src/compose/ui.ts +147 -54
  26. package/src/compose/ui.typecheck.ts +40 -4
  27. package/src/definition/definition.ts +22 -9
  28. package/src/event/event.fromSource.test.ts +172 -0
  29. package/src/event/event.test.ts +10 -0
  30. package/src/event/event.ts +102 -18
  31. package/src/feature/feature.mount.test.ts +123 -56
  32. package/src/feature/feature.test.ts +67 -63
  33. package/src/feature/feature.ts +222 -262
  34. package/src/feature/feature.typecheck.ts +224 -91
  35. package/src/feature/featureBinding.ts +130 -20
  36. package/src/feature/featureClass.ts +162 -0
  37. package/src/feature/featureConfig.ts +192 -0
  38. package/src/feature/featureFactory.ts +163 -0
  39. package/src/feature/featureModule.ts +136 -0
  40. package/src/feature/featureModule.typecheck.ts +63 -0
  41. package/src/feature/featureNativeTree.ts +152 -0
  42. package/src/feature/featureRequirement.ts +82 -0
  43. package/src/feature/featureVariants.ts +234 -0
  44. package/src/feature/mountFeature.ts +54 -0
  45. package/src/graph/closure.ts +222 -0
  46. package/src/graph/services.ts +95 -0
  47. package/src/graph/summary.ts +134 -0
  48. package/src/index.ts +171 -245
  49. package/src/internal/bucketCache.ts +39 -0
  50. package/src/internal/capture.ts +9 -4
  51. package/src/internal/env.ts +1 -3
  52. package/src/internal/errors.ts +21 -10
  53. package/src/internal/queryDriver.ts +61 -25
  54. package/src/internal/queryDriverHydrate.ts +45 -0
  55. package/src/internal/queryDriverStore.ts +8 -5
  56. package/src/internal/queryDriverTypes.ts +11 -11
  57. package/src/internal/reuse.test.ts +10 -3
  58. package/src/internal/reuse.ts +5 -2
  59. package/src/internal/scheduler.ts +4 -1
  60. package/src/internal/sources.ts +25 -11
  61. package/src/internal/store.test.ts +27 -6
  62. package/src/internal/track.ts +3 -2
  63. package/src/internal.ts +222 -0
  64. package/src/namespace/namespace.ts +46 -25
  65. package/src/procedure/procedure.ts +38 -11
  66. package/src/reducer/reducer.ts +78 -34
  67. package/src/remote/pendingQueue.ts +145 -0
  68. package/src/remote/remoteState.test.ts +536 -354
  69. package/src/remote/remoteState.ts +145 -106
  70. package/src/remote/remoteState.typecheck.ts +47 -23
  71. package/src/remote/remoteStateMake.ts +97 -0
  72. package/src/remote/remoteStateSend.ts +95 -37
  73. package/src/remote/remoteStateTypes.ts +155 -0
  74. package/src/runtime/appRuntime.activation.test.ts +104 -0
  75. package/src/runtime/appRuntime.test.ts +187 -111
  76. package/src/runtime/appRuntime.ts +36 -130
  77. package/src/runtime/capturedAppRuntime.ts +274 -0
  78. package/src/runtime/eventBudget.test.ts +39 -12
  79. package/src/runtime/eventBudget.ts +9 -8
  80. package/src/runtime/featureMount.ts +94 -0
  81. package/src/runtime/hardening.test.ts +22 -13
  82. package/src/runtime/instrumentation.test.ts +213 -194
  83. package/src/runtime/instrumentation.ts +6 -11
  84. package/src/runtime/loop.test.ts +84 -7
  85. package/src/runtime/loop.ts +69 -40
  86. package/src/runtime/runtimeHandle.ts +124 -0
  87. package/src/scene/featureScene.test.ts +4 -2
  88. package/src/scene/scene.ts +234 -64
  89. package/src/scene/seedScene.test.ts +69 -86
  90. package/src/state/state.nominal.typecheck.ts +68 -0
  91. package/src/state/state.test.ts +17 -0
  92. package/src/state/state.ts +79 -26
  93. package/src/state/stateFamily.test.ts +39 -8
  94. package/src/state/stateFamily.ts +55 -22
  95. package/src/state/stateGroup.ts +53 -17
  96. package/src/state/token.ts +40 -10
  97. package/src/synced/syncedStore.ts +46 -23
  98. package/src/testkit/flight.testkit.ts +84 -0
  99. package/src/wire/tree.test.ts +8 -4
  100. package/src/wire/triggers.test.ts +6 -2
  101. package/src/wire/triggers.ts +14 -10
  102. package/src/calc/asyncCalcDefinitions.ts +0 -48
  103. package/src/internal/ctx.ts +0 -9
  104. package/src/remote/remoteStateDefinition.ts +0 -135
  105. package/src/remote/remoteStateLayers.ts +0 -118
  106. 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
+ }
@@ -1,6 +1,6 @@
1
1
  import { expect, test } from 'vitest'
2
- import { Wire } from '../index'
3
- import type { WireNode, WireTree } from '../index'
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 = [node('a', { props: [{ _tag: 'Data', name: 'rows', value: [{ id: 1 }, { id: 2 }] }] })]
75
- const next: WireTree = [node('a', { props: [{ _tag: 'Data', name: 'rows', value: [{ id: 1 }, { id: 3 }] }] })]
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 { Triggers } from '../index'
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('0:bump', ((p: { by: number }) => void calls.push(p.by)) as Trigger<{ by: number }>, ByPayload)
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) }
@@ -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 trigger: Trigger<AnyValue>
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: AnySchema<P>,
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: AnySchema<P>,
49
+ schema: Schema.Schema<P, Encoded, never>,
52
50
  ): Effect.Effect<void> =>
53
- Ref.update(entries, (map) => new Map(map).set(handle, { trigger, schema }))
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
- const payload = yield* Schema.decodeUnknown(entry.schema)(encodedPayload)
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
- )
@@ -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
- }