@playfast/reform 1.1.1 → 1.2.0

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 (85) hide show
  1. package/README.md +85 -55
  2. package/package.json +17 -17
  3. package/src/boundary/boundary.test.ts +62 -16
  4. package/src/boundary/boundary.ts +141 -32
  5. package/src/calc/asyncCalc.invalidate.test.ts +516 -18
  6. package/src/calc/asyncCalc.test.ts +207 -175
  7. package/src/calc/asyncCalc.ts +168 -39
  8. package/src/calc/calc.test.ts +3 -5
  9. package/src/calc/calc.ts +44 -18
  10. package/src/calc/calcFamily.test.ts +80 -22
  11. package/src/calc/calcFamily.ts +56 -25
  12. package/src/calc/compose.ts +1 -14
  13. package/src/channel/channel.ts +128 -11
  14. package/src/compose/composition.test.ts +19 -0
  15. package/src/compose/composition.ts +346 -62
  16. package/src/compose/props.ts +13 -6
  17. package/src/compose/provide.ts +187 -46
  18. package/src/compose/slot.ts +156 -19
  19. package/src/compose/structure.test.ts +6 -3
  20. package/src/compose/structure.ts +106 -13
  21. package/src/compose/ui.test.ts +19 -3
  22. package/src/compose/ui.ts +147 -54
  23. package/src/compose/ui.typecheck.ts +40 -4
  24. package/src/definition/definition.ts +22 -9
  25. package/src/event/event.fromSource.test.ts +172 -0
  26. package/src/event/event.test.ts +10 -0
  27. package/src/event/event.ts +102 -18
  28. package/src/feature/feature.mount.test.ts +123 -56
  29. package/src/feature/feature.test.ts +67 -63
  30. package/src/feature/feature.ts +1317 -197
  31. package/src/feature/feature.typecheck.ts +253 -61
  32. package/src/graph/closure.ts +403 -0
  33. package/src/index.ts +171 -245
  34. package/src/internal/bucketCache.ts +39 -0
  35. package/src/internal/capture.ts +9 -4
  36. package/src/internal/env.ts +1 -3
  37. package/src/internal/errors.ts +21 -10
  38. package/src/internal/queryDriver.ts +120 -15
  39. package/src/internal/queryDriverStore.ts +8 -5
  40. package/src/internal/queryDriverTypes.ts +3 -1
  41. package/src/internal/reuse.test.ts +10 -3
  42. package/src/internal/reuse.ts +5 -2
  43. package/src/internal/scheduler.ts +4 -1
  44. package/src/internal/sources.ts +25 -11
  45. package/src/internal/track.ts +3 -2
  46. package/src/internal.ts +222 -0
  47. package/src/namespace/namespace.ts +46 -25
  48. package/src/procedure/procedure.ts +38 -11
  49. package/src/reducer/reducer.ts +78 -34
  50. package/src/remote/remoteState.test.ts +515 -339
  51. package/src/remote/remoteState.ts +572 -107
  52. package/src/remote/remoteState.typecheck.ts +47 -23
  53. package/src/runtime/appRuntime.activation.test.ts +100 -0
  54. package/src/runtime/appRuntime.test.ts +157 -105
  55. package/src/runtime/appRuntime.ts +394 -33
  56. package/src/runtime/eventBudget.test.ts +1 -4
  57. package/src/runtime/eventBudget.ts +9 -8
  58. package/src/runtime/hardening.test.ts +4 -9
  59. package/src/runtime/instrumentation.test.ts +174 -192
  60. package/src/runtime/instrumentation.ts +6 -11
  61. package/src/runtime/loop.test.ts +36 -0
  62. package/src/runtime/loop.ts +69 -40
  63. package/src/scene/featureScene.test.ts +4 -2
  64. package/src/scene/scene.ts +234 -64
  65. package/src/scene/seedScene.test.ts +69 -86
  66. package/src/state/state.nominal.typecheck.ts +68 -0
  67. package/src/state/state.test.ts +17 -0
  68. package/src/state/state.ts +79 -26
  69. package/src/state/stateFamily.test.ts +14 -0
  70. package/src/state/stateFamily.ts +55 -22
  71. package/src/state/stateGroup.ts +53 -17
  72. package/src/state/token.ts +40 -10
  73. package/src/synced/syncedStore.ts +46 -23
  74. package/src/testkit/flight.testkit.ts +75 -0
  75. package/src/wire/tree.test.ts +8 -4
  76. package/src/wire/triggers.test.ts +6 -2
  77. package/src/wire/triggers.ts +14 -10
  78. package/src/calc/asyncCalcDefinitions.ts +0 -48
  79. package/src/channel/procedureRegistry.ts +0 -90
  80. package/src/feature/featureBinding.ts +0 -48
  81. package/src/internal/ctx.ts +0 -9
  82. package/src/remote/remoteStateDefinition.ts +0 -135
  83. package/src/remote/remoteStateLayers.ts +0 -118
  84. package/src/remote/remoteStateLiveTypes.ts +0 -59
  85. package/src/remote/remoteStateSend.ts +0 -64
@@ -1,57 +1,417 @@
1
1
  import {
2
2
  Context,
3
3
  Effect,
4
+ Either,
5
+ Equal,
6
+ Iterable,
4
7
  Layer,
5
8
  MutableRef,
6
9
  Option,
7
10
  Runtime,
8
11
  Schema,
9
12
  } from 'effect'
10
- import { type AsyncData, narrowStore, widenStore } from '../calc/asyncData'
13
+ import type { Effect as EffectType } from 'effect/Effect'
14
+ import {
15
+ type AnyAsyncData,
16
+ AsyncData,
17
+ type AsyncError,
18
+ type AsyncSuccess,
19
+ narrowStore,
20
+ widenStore,
21
+ } from '../calc/asyncData'
11
22
  import * as Channel from '../channel/channel'
23
+ import { type Manifest, yieldableClass } from '../definition/definition'
12
24
  import * as Event from '../event/event'
13
25
  import {
14
26
  bumpRevision,
27
+ type GatedOf,
28
+ gatedFlag,
15
29
  makeQueryDriver,
30
+ type QueryCodec,
16
31
  RevisionSchema,
17
32
  revisionZero,
18
33
  } from '../internal/queryDriver'
19
- import { resolveScheduler } from '../internal/scheduler'
20
- import { type InputStores } from '../internal/sources'
21
- import { makeStore, type Store } from '../internal/store'
34
+ import { inspectable } from '../internal/inspect'
35
+ import { reuse as shareStructure } from '../internal/reuse'
36
+ import { resolveScheduler, type Scheduler } from '../internal/scheduler'
37
+ import {
38
+ type InputsObject,
39
+ type InputStores,
40
+ type InvalidateBy,
41
+ sameKey,
42
+ } from '../internal/sources'
43
+ import { makeDerivedStore, type Store } from '../internal/store'
44
+ import { readTracked } from '../internal/track'
22
45
  import * as Reducer from '../reducer/reducer'
23
- import { Bus, publish } from '../runtime/bus'
24
- import { Reducers } from '../runtime/loop'
46
+ import { Bus, narrowHandled, publish } from '../runtime/bus'
47
+ import { type ReducerEntry, Reducers } from '../runtime/loop'
25
48
  import * as State from '../state/state'
26
- import { type AnySource } from '../state/token'
27
- import {
28
- looseSchema,
29
- type PendingIntent,
30
- type RemoteStateClass,
31
- type RemoteStateSettled,
32
- } from './remoteStateDefinition'
33
- import type {
34
- RemoteStateLiveExternalApi,
35
- RemoteStateLiveRuntimeExternalApi,
36
- SettleLink,
37
- } from './remoteStateLiveTypes'
38
- import { makeOverlayLayer, makeQueueReducerLayer } from './remoteStateLayers'
39
- import { makeSendProcedureLayer } from './remoteStateSend'
40
-
41
- // Generation rule: intent acked at gen g settles only on Success with gen > g (no flicker).
42
- // apply must be idempotent: between refetch landing and settle, intent is applied over truth that already has it.
43
-
44
- export {
45
- type FailedIntent,
46
- make,
47
- type PendingIntent,
48
- type RemoteStateClass,
49
- type RemoteStateConfigExternalApi,
50
- type RemoteStateManifestExternalApi,
51
- type RemoteStateSettled,
52
- } from './remoteStateDefinition'
53
-
54
- export type { RemoteStateLiveExternalApi } from './remoteStateLiveTypes'
49
+ import { type AnySource, type SourceCapture, StateToken } from '../state/token'
50
+
51
+ // An intent acked at generation g settles only on Success with generation > g.
52
+ // `apply` must be idempotent while refetched truth and the pending intent overlap.
53
+
54
+ export interface PendingIntent<I> {
55
+ readonly opId: string
56
+ readonly intent: I
57
+ readonly status: 'sending' | 'confirmed'
58
+ }
59
+
60
+ export interface FailedIntent<I> {
61
+ readonly intent: I
62
+ readonly error: unknown
63
+ }
64
+
65
+ export type RemoteStateSettled<A, E> = AsyncSuccess<A> | AsyncError<E>
66
+
67
+ export const RemoteStateVisibleStoreTypeId: unique symbol = Symbol.for(
68
+ 'reform/RemoteStateVisibleStore',
69
+ )
70
+ export type RemoteStateVisibleStoreTypeId = typeof RemoteStateVisibleStoreTypeId
71
+
72
+ export interface RemoteStateVisibleStore<N extends string> {
73
+ readonly [RemoteStateVisibleStoreTypeId]: N
74
+ }
75
+
76
+ export const RemoteStateTruthStoreTypeId: unique symbol = Symbol.for('reform/RemoteStateTruthStore')
77
+ export type RemoteStateTruthStoreTypeId = typeof RemoteStateTruthStoreTypeId
78
+
79
+ export interface RemoteStateTruthStore<N extends string> {
80
+ readonly [RemoteStateTruthStoreTypeId]: N
81
+ }
82
+
83
+ export const RemoteStatePendingStoreTypeId: unique symbol = Symbol.for(
84
+ 'reform/RemoteStatePendingStore',
85
+ )
86
+ export type RemoteStatePendingStoreTypeId = typeof RemoteStatePendingStoreTypeId
87
+
88
+ export interface RemoteStatePendingStore<N extends string> {
89
+ readonly [RemoteStatePendingStoreTypeId]: N
90
+ }
91
+
92
+ type EventPayload<Ev extends Event.AnyEvent> =
93
+ Ev extends Event.EventClass<string, infer P> ? P : never
94
+
95
+ // ExternalApi: optional `intent` only on Queued envelopes.
96
+ interface QueueFoldEventExternalApi<I> {
97
+ readonly _tag: string
98
+ readonly opId: string
99
+ readonly intent?: I
100
+ }
101
+
102
+ export interface RemoteStateSchemaReflection {
103
+ readonly ast: Schema.Schema<unknown, unknown, unknown>['ast']
104
+ }
105
+
106
+ export interface RemoteStateManifestExternalApi<N extends string> extends Manifest {
107
+ readonly kind: 'RemoteState'
108
+ readonly name: N
109
+ readonly output: RemoteStateSchemaReflection
110
+ readonly error?: RemoteStateSchemaReflection
111
+ readonly gated: boolean
112
+ readonly intents: ReadonlyArray<string>
113
+ }
114
+
115
+ export interface RemoteStateClass<
116
+ N extends string,
117
+ out Inputs extends ReadonlyArray<AnySource>,
118
+ in out Intents extends ReadonlyArray<Event.AnyEvent>,
119
+ in out A,
120
+ in out E,
121
+ in out Gated extends boolean,
122
+ > extends EffectType<AsyncData<A, E, Gated>, never, RemoteStateVisibleStore<N>> {
123
+ new (): {}
124
+ readonly manifest: RemoteStateManifestExternalApi<N>
125
+ readonly codec: QueryCodec<A>
126
+ readonly store: Context.Tag<RemoteStateVisibleStore<N>, Store<AsyncData<A, E, Gated>>>
127
+ readonly name: N
128
+ readonly inputs: Inputs
129
+ readonly intents: Intents
130
+ readonly gated: Gated
131
+ readonly capture: <Result>(visit: SourceCapture<Result>) => Result
132
+ readonly truth: StateToken<`${N}/truth`, AsyncData<A, E, Gated>, RemoteStateTruthStore<N>>
133
+ // pending is a Source, not a StateClass, so user reducers cannot target it.
134
+ readonly pending: StateToken<
135
+ `${N}/pending`,
136
+ ReadonlyArray<PendingIntent<Event.EventType<Intents[number]>>>,
137
+ RemoteStatePendingStore<N>
138
+ >
139
+ readonly Failed: Event.EventClass<`${N}/Failed`, FailedIntent<Event.EventType<Intents[number]>>>
140
+ }
141
+
142
+ export interface RemoteStateConfigExternalApi<
143
+ Inputs extends ReadonlyArray<AnySource>,
144
+ Intents extends ReadonlyArray<Event.AnyEvent>,
145
+ OutputSchema extends Schema.Schema.AnyNoContext,
146
+ ErrorSchema extends Schema.Schema.AnyNoContext | undefined,
147
+ AlwaysOn extends boolean,
148
+ > {
149
+ readonly inputs: Inputs
150
+ readonly output: OutputSchema
151
+ readonly error?: ErrorSchema
152
+ readonly alwaysOn?: AlwaysOn
153
+ readonly intents: Intents
154
+ }
155
+
156
+ // Schema.Unknown is structural because Intents is open at make-time; the declaration preserves its authored payload type.
157
+ const looseSchema = <P>(schema: Schema.Schema.AnyNoContext): Schema.Schema<P, P, never> =>
158
+ Schema.declare((input): input is P => Schema.is(schema)(input))
159
+
160
+ export const make = <
161
+ const N extends string,
162
+ const Inputs extends ReadonlyArray<AnySource>,
163
+ const Intents extends ReadonlyArray<Event.AnyEvent>,
164
+ OutputSchema extends Schema.Schema.AnyNoContext,
165
+ ErrorSchema extends Schema.Schema.AnyNoContext | undefined = undefined,
166
+ const AlwaysOn extends boolean = false,
167
+ >(
168
+ name: N,
169
+ config: RemoteStateConfigExternalApi<Inputs, Intents, OutputSchema, ErrorSchema, AlwaysOn>,
170
+ ): RemoteStateClass<
171
+ N,
172
+ Inputs,
173
+ Intents,
174
+ Schema.Schema.Type<OutputSchema>,
175
+ ErrorSchema extends Schema.Schema.AnyNoContext ? Schema.Schema.Type<ErrorSchema> : never,
176
+ GatedOf<AlwaysOn>
177
+ > => {
178
+ type A = Schema.Schema.Type<OutputSchema>
179
+ type E = ErrorSchema extends Schema.Schema.AnyNoContext ? Schema.Schema.Type<ErrorSchema> : never
180
+ type I = Event.EventType<Intents[number]>
181
+ const store = Context.GenericTag<
182
+ RemoteStateVisibleStore<N>,
183
+ Store<AsyncData<A, E, GatedOf<AlwaysOn>>>
184
+ >(`reform/remoteState/${name}`)
185
+ const truthTag = Context.GenericTag<
186
+ RemoteStateTruthStore<N>,
187
+ Store<AsyncData<A, E, GatedOf<AlwaysOn>>>
188
+ >(`reform/remoteState/${name}/truth`)
189
+ const pendingTag = Context.GenericTag<
190
+ RemoteStatePendingStore<N>,
191
+ Store<ReadonlyArray<PendingIntent<I>>>
192
+ >(`reform/remoteState/${name}/pending`)
193
+ const gated = gatedFlag(config.alwaysOn)
194
+ const manifest: RemoteStateManifestExternalApi<N> = {
195
+ kind: 'RemoteState',
196
+ name,
197
+ output: config.output,
198
+ gated,
199
+ intents: config.intents.map((event) => event.tag),
200
+ ...(config.error !== undefined ? { error: config.error } : {}),
201
+ }
202
+ const codec: QueryCodec<A> = {
203
+ encode: Schema.encode(config.output),
204
+ decode: Schema.decodeUnknown(config.output),
205
+ }
206
+ // Annotations preserve template-literal types for the public statics.
207
+ const truthName: `${N}/truth` = `${name}/truth`
208
+ const pendingName: `${N}/pending` = `${name}/pending`
209
+ const failedName: `${N}/Failed` = `${name}/Failed`
210
+ const read = Effect.flatMap(store, readTracked)
211
+ const remote: RemoteStateClass<N, Inputs, Intents, A, E, GatedOf<AlwaysOn>> = yieldableClass(
212
+ read,
213
+ {
214
+ manifest,
215
+ codec,
216
+ store,
217
+ name,
218
+ inputs: config.inputs,
219
+ intents: config.intents,
220
+ gated,
221
+ capture: <Result>(visit: SourceCapture<Result>): Result => visit(remote),
222
+ truth: new StateToken(truthName, truthTag),
223
+ pending: new StateToken(pendingName, pendingTag),
224
+ Failed: Event.make(
225
+ failedName,
226
+ looseSchema<FailedIntent<I>>(
227
+ Schema.Struct({ intent: Schema.Unknown, error: Schema.Unknown }),
228
+ ),
229
+ ),
230
+ },
231
+ )
232
+ return remote
233
+ }
234
+
235
+ export type RemoteStateLiveExternalApi<
236
+ Inputs extends ReadonlyArray<AnySource>,
237
+ Intents extends ReadonlyArray<Event.AnyEvent>,
238
+ A,
239
+ E,
240
+ Gated extends boolean,
241
+ R,
242
+ R2,
243
+ SettledEvent extends Event.AnyEvent,
244
+ > = {
245
+ readonly query: (inputs: InputsObject<Inputs>) => Effect.Effect<A, E, R>
246
+ // Any failure settles and emits Failed; truth comes only from the invalidated query.
247
+ readonly send: (intent: Event.EventType<Intents[number]>) => Effect.Effect<unknown, unknown, R2>
248
+ readonly apply: (value: A, intent: Event.EventType<Intents[number]>) => A
249
+ readonly channel?: Channel.ChannelClass
250
+ readonly invalidateBy?: InvalidateBy<Inputs>
251
+ readonly invalidateOn?: ReadonlyArray<Event.AnyEvent>
252
+ readonly coalesce?: 'switch' | 'trailing'
253
+ readonly reuse?: boolean
254
+ readonly settled?: {
255
+ readonly event: SettledEvent
256
+ readonly payload: (result: RemoteStateSettled<A, E>) => EventPayload<SettledEvent>
257
+ }
258
+ // Only settled server truth is persisted; the optimistic overlay is a separate store.
259
+ readonly persist?:
260
+ | boolean
261
+ | { readonly key?: string | ((inputs: InputsObject<Inputs>) => string) }
262
+ } & (Gated extends true
263
+ ? { readonly disabled?: (inputs: InputsObject<Inputs>) => boolean }
264
+ : { readonly disabled?: never })
265
+
266
+ interface SettleLink {
267
+ readonly requested: () => number
268
+ readonly register: (opId: string, waitFor: number) => void
269
+ }
270
+
271
+ interface WaiterRegistration {
272
+ readonly opId: string
273
+ readonly waitFor: number
274
+ }
275
+
276
+ interface PendingNode<I> {
277
+ readonly entry: MutableRef.MutableRef<PendingIntent<I>>
278
+ }
279
+
280
+ // Duplicate opIds retain every node so indexed updates preserve the old map semantics.
281
+ interface PendingOpEntry<I> {
282
+ readonly nodes: Set<PendingNode<I>>
283
+ }
284
+
285
+ interface PendingQueue<I> {
286
+ readonly store: Store<ReadonlyArray<PendingIntent<I>>>
287
+ readonly revision: () => number
288
+ readonly fold: <B>(initial: B, combine: (accumulator: B, entry: PendingIntent<I>) => B) => B
289
+ readonly append: (entry: PendingIntent<I>) => void
290
+ readonly ack: (opId: string) => void
291
+ readonly settle: (opId: string) => void
292
+ }
293
+
294
+ const makePendingQueue = <I>(scheduler: Scheduler): PendingQueue<I> => {
295
+ // Set insertion order preserves dispatch/apply order while allowing O(1) updates.
296
+ const ordered = new Set<PendingNode<I>>()
297
+ const byOpId = new Map<string, PendingOpEntry<I>>()
298
+ const listeners = new Set<() => void>()
299
+ const revision = MutableRef.make(0)
300
+ const initial: ReadonlyArray<PendingIntent<I>> = []
301
+ const snapshot = MutableRef.make<Option.Option<ReadonlyArray<PendingIntent<I>>>>(
302
+ Option.some(initial),
303
+ )
304
+
305
+ const materialize = (): ReadonlyArray<PendingIntent<I>> => {
306
+ const cached = MutableRef.get(snapshot)
307
+ if (Option.isSome(cached)) {
308
+ return cached.value
309
+ }
310
+ const current = Array.from(ordered, (node) => MutableRef.get(node.entry))
311
+ MutableRef.set(snapshot, Option.some(current))
312
+ return current
313
+ }
314
+
315
+ const announce = (next: Option.Option<ReadonlyArray<PendingIntent<I>>>): void => {
316
+ MutableRef.set(snapshot, next)
317
+ MutableRef.update(revision, (current) => current + 1)
318
+ scheduler.schedule(listeners)
319
+ }
320
+
321
+ const link = (entry: PendingIntent<I>): void => {
322
+ const node: PendingNode<I> = {
323
+ entry: MutableRef.make(entry),
324
+ }
325
+ ordered.add(node)
326
+ const indexed = byOpId.get(entry.opId)
327
+ if (indexed === undefined) {
328
+ byOpId.set(entry.opId, { nodes: new Set([node]) })
329
+ } else {
330
+ indexed.nodes.add(node)
331
+ }
332
+ }
333
+
334
+ const clear = (): void => {
335
+ ordered.clear()
336
+ byOpId.clear()
337
+ }
338
+
339
+ const fold = <B>(initialValue: B, combine: (accumulator: B, entry: PendingIntent<I>) => B): B => {
340
+ const folded = MutableRef.make(initialValue)
341
+ ordered.forEach((node) => {
342
+ MutableRef.update(folded, (accumulator) => combine(accumulator, MutableRef.get(node.entry)))
343
+ })
344
+ return MutableRef.get(folded)
345
+ }
346
+
347
+ const append = (entry: PendingIntent<I>): void => {
348
+ link(entry)
349
+ announce(Option.none())
350
+ }
351
+
352
+ const ack = (opId: string): void => {
353
+ const indexed = byOpId.get(opId)
354
+ if (indexed === undefined) {
355
+ return
356
+ }
357
+ const stillSending = MutableRef.make(false)
358
+ indexed.nodes.forEach((node) => {
359
+ if (MutableRef.get(node.entry).status === 'sending') {
360
+ MutableRef.set(stillSending, true)
361
+ }
362
+ })
363
+ if (!MutableRef.get(stillSending)) {
364
+ return
365
+ }
366
+ // Rebuild every duplicate once one is sending, matching the former array map.
367
+ indexed.nodes.forEach((node) => {
368
+ MutableRef.update(node.entry, (entry) => ({ ...entry, status: 'confirmed' as const }))
369
+ })
370
+ announce(Option.none())
371
+ }
372
+
373
+ const settle = (opId: string): void => {
374
+ const indexed = byOpId.get(opId)
375
+ if (indexed === undefined) {
376
+ return
377
+ }
378
+ indexed.nodes.forEach((node) => {
379
+ ordered.delete(node)
380
+ })
381
+ byOpId.delete(opId)
382
+ announce(Option.none())
383
+ }
384
+
385
+ const store: Store<ReadonlyArray<PendingIntent<I>>> = {
386
+ get: materialize,
387
+ getSnapshot: materialize,
388
+ getVersion: () => MutableRef.get(revision),
389
+ set: (next) => {
390
+ if (Equal.equals(materialize(), next)) {
391
+ return
392
+ }
393
+ clear()
394
+ next.forEach(link)
395
+ announce(Option.some(next))
396
+ },
397
+ subscribe: (listener) => {
398
+ listeners.add(listener)
399
+ return () => {
400
+ listeners.delete(listener)
401
+ }
402
+ },
403
+ ...inspectable(() => ({ _id: 'reform/Store', value: materialize() })),
404
+ }
405
+
406
+ return {
407
+ store,
408
+ revision: () => MutableRef.get(revision),
409
+ fold,
410
+ append,
411
+ ack,
412
+ settle,
413
+ }
414
+ }
55
415
 
56
416
  export const live = <
57
417
  N extends string,
@@ -67,21 +427,17 @@ export const live = <
67
427
  remote: RemoteStateClass<N, Inputs, Intents, A, E, Gated>,
68
428
  config: RemoteStateLiveExternalApi<Inputs, Intents, A, E, Gated, R, R2, SettledEvent>,
69
429
  ): Layer.Layer<
70
- | Store<AsyncData<A, E, Gated>>
71
- | Store<ReadonlyArray<PendingIntent<Event.EventType<Intents[number]>>>>,
430
+ RemoteStateVisibleStore<N> | RemoteStateTruthStore<N> | RemoteStatePendingStore<N>,
72
431
  never,
73
- | InputStores<Inputs>
74
- | R
75
- | R2
76
- | Reducers
77
- | Channel.Procedures
78
- | Channel.Channels
79
- | Bus
432
+ InputStores<Inputs> | R | R2 | Reducers | Channel.Procedures | Channel.Channels | Bus
80
433
  > => {
81
434
  type I = Event.EventType<Intents[number]>
82
435
  const name = remote.manifest.name
83
436
  const truthTag = remote.truth.store
84
437
  const pendingTag = remote.pending.store
438
+ const pendingQueueTag = Context.GenericTag<PendingQueue<I>>(
439
+ `reform/remoteState/${name}/pendingQueue`,
440
+ )
85
441
 
86
442
  const Queued = Event.make(
87
443
  `${name}/Queued`,
@@ -107,53 +463,57 @@ export const live = <
107
463
 
108
464
  const driverLayer = Layer.scopedContext(
109
465
  Effect.gen(function* () {
110
- // oxlint-disable-next-line reform-rules/no-type-assertion -- type-level erasure seam: `disabled` is `never` for non-gated definitions; read it back through the runtime view
111
- const cfg = config as RemoteStateLiveRuntimeExternalApi<Inputs, A, E, R>
112
466
  const revision = Option.getOrUndefined(yield* Effect.serviceOption(revisionState.store))
113
467
  const runtime = yield* Effect.runtime<Bus>()
114
468
 
115
- // Registry (not per-intent fibers): latest/exclusive cancel must not orphan waiters
116
- const waiters = MutableRef.make<
117
- ReadonlyArray<{ readonly opId: string; readonly waitFor: number }>
118
- >([])
469
+ // Buckets, not cancellable fibers, keep waiters alive under latest/exclusive policies.
470
+ // Monotonic waitFor values make insertion order match generation order.
471
+ const waiters = new Map<number, Set<{ readonly opId: string }>>()
119
472
  const onSettled = (generation: number) => {
120
- const due = MutableRef.get(waiters).filter((waiter) => waiter.waitFor <= generation)
121
- if (due.length === 0) {
122
- return
123
- }
124
- MutableRef.update(waiters, (all) => all.filter((waiter) => waiter.waitFor > generation))
125
- // runSync: settle enqueued in same flush as converged value write
126
- due.forEach((waiter) =>
127
- Runtime.runSync(runtime)(
128
- publish('Normal', Event.construct(Settled, { opId: waiter.opId })),
129
- ),
473
+ Iterable.forEach(
474
+ Iterable.takeWhile(waiters, ([waitFor]) => waitFor <= generation),
475
+ ([waitFor, generationWaiters]) => {
476
+ waiters.delete(waitFor)
477
+ // runSync enqueues settles in the same flush as converged truth.
478
+ generationWaiters.forEach(({ opId }) =>
479
+ Runtime.runSync(runtime)(publish('Normal', Event.construct(Settled, { opId }))),
480
+ )
481
+ },
130
482
  )
131
483
  }
484
+ const registerWaiter = ({ opId, waitFor }: WaiterRegistration): void => {
485
+ const generationWaiters = waiters.get(waitFor)
486
+ if (generationWaiters === undefined) {
487
+ waiters.set(waitFor, new Set([{ opId }]))
488
+ } else {
489
+ generationWaiters.add({ opId })
490
+ }
491
+ }
132
492
 
133
- // Persist only settled server truth (overlay is a separate store the driver never sees)
134
- const persistOption = cfg.persist
493
+ const persistOption = config.persist
135
494
  const persistKey =
136
495
  persistOption === true || persistOption === undefined || persistOption === false
137
496
  ? name
138
- : persistOption.key ?? name
497
+ : (persistOption.key ?? name)
139
498
  const persist =
140
499
  persistOption === undefined || persistOption === false
141
500
  ? undefined
142
- : { key: persistKey, schema: remote.manifest.output }
143
- const extraKey =
144
- revision === undefined
145
- ? undefined
146
- : { read: () => revision.getSnapshot(), subscribe: (listener: () => void) => revision.subscribe(listener) }
501
+ : { key: persistKey, codec: remote.codec }
502
+ const revisionKey = (source: NonNullable<typeof revision>) => ({
503
+ read: () => source.getSnapshot(),
504
+ subscribe: (listener: () => void) => source.subscribe(listener),
505
+ })
506
+ const extraKey = revision === undefined ? undefined : revisionKey(revision)
147
507
  const driver = yield* makeQueryDriver({
148
508
  name,
149
509
  label: 'RemoteState',
150
510
  gated: remote.gated,
151
511
  inputs: remote.inputs,
152
- query: cfg.query,
153
- invalidateBy: cfg.invalidateBy,
154
- disabled: cfg.disabled,
155
- coalesce: cfg.coalesce,
156
- reuse: cfg.reuse,
512
+ query: config.query,
513
+ invalidateBy: config.invalidateBy,
514
+ disabled: config.disabled,
515
+ coalesce: config.coalesce,
516
+ reuse: config.reuse,
157
517
  persist,
158
518
  extraKey,
159
519
  onSettled,
@@ -173,7 +533,7 @@ export const live = <
173
533
  Runtime.runSync(runtime)(
174
534
  publish(
175
535
  'Normal',
176
- Event.construct(settledConfig.event, settledConfig.payload(settledTruth)),
536
+ settledConfig.event.buildUnknown(settledConfig.payload(settledTruth)),
177
537
  ),
178
538
  )
179
539
  }
@@ -185,8 +545,7 @@ export const live = <
185
545
  }
186
546
  const link: SettleLink = {
187
547
  requested: driver.requested,
188
- register: (opId, waitFor) =>
189
- MutableRef.update(waiters, (all) => [...all, { opId, waitFor }]),
548
+ register: (opId, waitFor) => registerWaiter({ opId, waitFor }),
190
549
  }
191
550
  return Context.make(truthTag, narrowStore<A, E, Gated>(driver.store)).pipe(
192
551
  Context.add(linkTag, link),
@@ -194,42 +553,148 @@ export const live = <
194
553
  }),
195
554
  )
196
555
 
197
- const overlayLayer = makeOverlayLayer<I, A, E, Gated>({
198
- apply: config.apply,
199
- pendingTag,
200
- reuse: config.reuse,
201
- storeTag: remote.store,
202
- truthTag,
203
- })
556
+ const overlayLayer = Layer.scoped(
557
+ remote.store,
558
+ Effect.gen(function* () {
559
+ const scheduler = yield* resolveScheduler
560
+ const feedStore = widenStore<A, E, Gated>(yield* truthTag)
561
+ const queue = yield* pendingQueueTag
562
+ const memo = MutableRef.make<
563
+ { readonly key: ReadonlyArray<unknown>; readonly output: AnyAsyncData<A, E> } | undefined
564
+ >(undefined)
565
+ const recompute = (): AnyAsyncData<A, E> => {
566
+ const feed = feedStore.getSnapshot()
567
+ const key = [feed, queue.revision()]
568
+ const prev = MutableRef.get(memo)
569
+ if (prev !== undefined && sameKey(key, prev.key)) {
570
+ return prev.output
571
+ }
572
+ // Full replay preserves observable value identity when apply allocates and reuse is off.
573
+ const applyPending = (base: A): A =>
574
+ queue.fold(base, (applied, entry) => config.apply(applied, entry.intent))
575
+ const overlaid: AnyAsyncData<A, E> =
576
+ feed._tag === 'Success'
577
+ ? AsyncData.success(applyPending(feed.value), feed.refetching)
578
+ : feed
579
+ const reusable =
580
+ config.reuse === true &&
581
+ prev !== undefined &&
582
+ prev.output._tag === 'Success' &&
583
+ overlaid._tag === 'Success'
584
+ ? shareStructure(prev.output.value, overlaid.value)
585
+ : undefined
586
+ const output: AnyAsyncData<A, E> =
587
+ reusable === undefined || overlaid._tag !== 'Success'
588
+ ? overlaid
589
+ : AsyncData.success(reusable, overlaid.refetching)
590
+ MutableRef.set(memo, { key, output })
591
+ return output
592
+ }
593
+ const subscribe = (listener: () => void): (() => void) => {
594
+ const offFeed = feedStore.subscribe(listener)
595
+ const offQueue = queue.store.subscribe(listener)
596
+ return () => {
597
+ offFeed()
598
+ offQueue()
599
+ }
600
+ }
601
+ const derived = makeDerivedStore(recompute, subscribe, scheduler)
602
+ yield* Effect.addFinalizer(() => Effect.sync(derived.unsubscribe))
603
+ return narrowStore<A, E, Gated>(derived.store)
604
+ }),
605
+ )
204
606
 
205
- const queueReducerLayer = makeQueueReducerLayer<I>({
206
- ackedTag: Acked.tag,
207
- name: `${name}/pending`,
208
- pendingTag,
209
- queuedTag: Queued.tag,
210
- settledTag: Settled.tag,
211
- })
607
+ // Direct registry assembly avoids deferred conditional parameters while Intents is generic.
608
+ const queueReducerName = `${name}/pending`
609
+ const queueHandles: ReadonlySet<string> = new Set([Queued.tag, Acked.tag, Settled.tag])
610
+ const queueReducerLayer = Layer.scopedDiscard(
611
+ Effect.gen(function* () {
612
+ const reducers = yield* Reducers
613
+ if (reducers.entries.some((entry) => entry.name === queueReducerName)) {
614
+ yield* Effect.logWarning(`reform: duplicate reducer name '${queueReducerName}' registered`)
615
+ }
616
+ const queue = yield* pendingQueueTag
617
+ const entry: ReducerEntry = {
618
+ name: queueReducerName,
619
+ handles: queueHandles,
620
+ apply: (event) => {
621
+ const handled = narrowHandled<QueueFoldEventExternalApi<I>>(event)
622
+ if (handled._tag === Queued.tag) {
623
+ if (handled.intent !== undefined) {
624
+ queue.append({
625
+ opId: handled.opId,
626
+ intent: handled.intent,
627
+ status: 'sending',
628
+ })
629
+ }
630
+ return
631
+ }
632
+ if (handled._tag === Acked.tag) {
633
+ queue.ack(handled.opId)
634
+ return
635
+ }
636
+ queue.settle(handled.opId)
637
+ },
638
+ }
639
+ yield* Effect.acquireRelease(
640
+ Effect.sync(() => reducers.register(entry)),
641
+ () => Effect.sync(() => reducers.unregister(entry)),
642
+ )
643
+ }),
644
+ )
212
645
  const channel = config.channel ?? Channel.make(`${name}/sends`, { policy: { _tag: 'merge' } })
213
646
  const procedureName = `${name}/send`
214
647
  const sendHandles: ReadonlySet<string> = new Set(remote.intents.map((event) => event.tag))
215
- const sendProcedureLayer = makeSendProcedureLayer<I, R2>({
216
- acked: Acked,
217
- channel,
218
- failed: remote.Failed,
219
- handles: sendHandles,
220
- invalidated: Invalidated,
221
- linkTag,
222
- name: procedureName,
223
- queued: Queued,
224
- send: config.send,
225
- settled: Settled,
226
- })
648
+ const sendProcedureLayer = Layer.scopedDiscard(
649
+ Effect.gen(function* () {
650
+ const procedures = yield* Channel.Procedures
651
+ if (procedures.entries.some((entry) => entry.name === procedureName)) {
652
+ yield* Effect.logWarning(`reform: duplicate procedure name '${procedureName}' registered`)
653
+ }
654
+ const link = yield* linkTag
655
+ const runtime = yield* Effect.runtime<Bus | R2>()
656
+ const deliver = Effect.fn('deliver')(function* (
657
+ intent: I,
658
+ ): Effect.fn.Return<void, never, Bus | R2> {
659
+ const opId = yield* Effect.sync(() => crypto.randomUUID())
660
+ yield* Event.dispatch(Queued, { opId, intent })
661
+ const outcome = yield* config.send(intent).pipe(
662
+ Effect.catchAllDefect((defect) => Effect.fail(defect)),
663
+ Effect.either,
664
+ Effect.onInterrupt(() => Event.dispatch(Settled, { opId })),
665
+ )
666
+ yield* Either.match(outcome, {
667
+ onLeft: (error) =>
668
+ Effect.zipRight(
669
+ Event.dispatch(remote.Failed, { intent, error }),
670
+ Event.dispatch(Settled, { opId }),
671
+ ),
672
+ onRight: () =>
673
+ // Register before invalidation so a pre-ack refetch cannot settle this intent.
674
+ Effect.sync(() => link.register(opId, link.requested() + 1)).pipe(
675
+ Effect.zipRight(Event.dispatch(Acked, { opId })),
676
+ Effect.zipRight(Event.dispatch(Invalidated, {})),
677
+ ),
678
+ })
679
+ })
680
+ const entry: Channel.ProcedureEntry = {
681
+ name: procedureName,
682
+ channelName: channel.manifest.name,
683
+ handles: sendHandles,
684
+ run: (event) => Effect.provide(deliver(narrowHandled<I>(event)), runtime),
685
+ }
686
+ yield* Effect.acquireRelease(
687
+ Effect.sync(() => procedures.register(entry)),
688
+ () => Effect.sync(() => procedures.unregister(entry)),
689
+ )
690
+ }),
691
+ )
227
692
 
228
- const pendingStoreLayer = Layer.effect(
229
- pendingTag,
230
- Effect.map(resolveScheduler, (scheduler) =>
231
- makeStore<ReadonlyArray<PendingIntent<I>>>([], scheduler),
232
- ),
693
+ const pendingStoreLayer = Layer.scopedContext(
694
+ Effect.map(resolveScheduler, (scheduler) => {
695
+ const queue = makePendingQueue<I>(scheduler)
696
+ return Context.make(pendingTag, queue.store).pipe(Context.add(pendingQueueTag, queue))
697
+ }),
233
698
  )
234
699
  return Layer.mergeAll(
235
700
  overlayLayer,