@playfast/reform 1.0.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 (99) hide show
  1. package/README.md +103 -56
  2. package/package.json +17 -17
  3. package/src/boundary/boundary.test.ts +63 -49
  4. package/src/boundary/boundary.ts +144 -113
  5. package/src/calc/asyncCalc.invalidate.test.ts +518 -32
  6. package/src/calc/asyncCalc.test.ts +207 -213
  7. package/src/calc/asyncCalc.ts +131 -154
  8. package/src/calc/asyncData.ts +0 -37
  9. package/src/calc/calc.test.ts +7 -33
  10. package/src/calc/calc.ts +44 -58
  11. package/src/calc/calcFamily.test.ts +80 -34
  12. package/src/calc/calcFamily.ts +54 -65
  13. package/src/calc/compose.test.ts +1 -12
  14. package/src/calc/compose.ts +1 -36
  15. package/src/calc/queryState.ts +0 -23
  16. package/src/channel/channel.ts +126 -111
  17. package/src/compose/composition.test.ts +19 -0
  18. package/src/compose/composition.ts +351 -127
  19. package/src/compose/host.ts +0 -6
  20. package/src/compose/props.ts +13 -12
  21. package/src/compose/provide.ts +189 -62
  22. package/src/compose/slot.ts +158 -49
  23. package/src/compose/structure.test.ts +6 -9
  24. package/src/compose/structure.ts +108 -79
  25. package/src/compose/ui.test.ts +19 -7
  26. package/src/compose/ui.ts +155 -156
  27. package/src/compose/ui.typecheck.ts +40 -18
  28. package/src/definition/definition.ts +22 -41
  29. package/src/event/event.fromSource.test.ts +172 -0
  30. package/src/event/event.test.ts +10 -3
  31. package/src/event/event.ts +102 -27
  32. package/src/event/eventGroup.ts +0 -1
  33. package/src/feature/feature.mount.test.ts +122 -43
  34. package/src/feature/feature.test.ts +46 -21
  35. package/src/feature/feature.ts +1347 -256
  36. package/src/feature/feature.typecheck.ts +273 -68
  37. package/src/graph/closure.ts +403 -0
  38. package/src/index.ts +68 -126
  39. package/src/internal/bucketCache.ts +39 -0
  40. package/src/internal/capture.ts +9 -24
  41. package/src/internal/env.ts +7 -0
  42. package/src/internal/errors.test.ts +0 -6
  43. package/src/internal/errors.ts +37 -60
  44. package/src/internal/inspect.test.ts +0 -6
  45. package/src/internal/inspect.ts +0 -12
  46. package/src/internal/queryDriver.ts +105 -201
  47. package/src/internal/queryDriverStore.ts +156 -0
  48. package/src/internal/queryDriverTypes.ts +59 -0
  49. package/src/internal/queryEvents.ts +0 -12
  50. package/src/internal/queryStore.ts +0 -14
  51. package/src/internal/reuse.test.ts +10 -14
  52. package/src/internal/reuse.ts +5 -30
  53. package/src/internal/scheduler.ts +4 -44
  54. package/src/internal/seeds.ts +0 -14
  55. package/src/internal/sources.ts +26 -49
  56. package/src/internal/stateRegistry.ts +0 -14
  57. package/src/internal/store.test.ts +1 -3
  58. package/src/internal/store.ts +0 -37
  59. package/src/internal/track.ts +3 -20
  60. package/src/internal/variance.ts +5 -0
  61. package/src/internal.ts +222 -0
  62. package/src/namespace/namespace.test.ts +46 -0
  63. package/src/namespace/namespace.ts +106 -0
  64. package/src/procedure/procedure.ts +40 -35
  65. package/src/reducer/reducer.ts +78 -52
  66. package/src/remote/remoteState.test.ts +590 -397
  67. package/src/remote/remoteState.ts +425 -347
  68. package/src/remote/remoteState.typecheck.ts +47 -56
  69. package/src/runtime/appRuntime.activation.test.ts +100 -0
  70. package/src/runtime/appRuntime.test.ts +249 -0
  71. package/src/runtime/appRuntime.ts +415 -97
  72. package/src/runtime/bus.ts +2 -15
  73. package/src/runtime/eventBudget.test.ts +152 -0
  74. package/src/runtime/eventBudget.ts +120 -0
  75. package/src/runtime/hardening.test.ts +73 -13
  76. package/src/runtime/instrumentation.test.ts +55 -52
  77. package/src/runtime/instrumentation.ts +6 -60
  78. package/src/runtime/loop.test.ts +36 -17
  79. package/src/runtime/loop.ts +87 -88
  80. package/src/runtime/queries.ts +0 -17
  81. package/src/scene/featureScene.test.ts +61 -0
  82. package/src/scene/scene.ts +247 -104
  83. package/src/scene/seedScene.test.ts +42 -79
  84. package/src/state/state.nominal.typecheck.ts +68 -0
  85. package/src/state/state.test.ts +17 -0
  86. package/src/state/state.ts +80 -46
  87. package/src/state/stateFamily.test.ts +16 -11
  88. package/src/state/stateFamily.ts +55 -65
  89. package/src/state/stateGroup.ts +53 -64
  90. package/src/state/token.ts +40 -23
  91. package/src/synced/syncedStore.ts +46 -58
  92. package/src/testkit/flight.testkit.ts +75 -0
  93. package/src/ui/node.ts +0 -6
  94. package/src/ui/trigger.ts +0 -5
  95. package/src/wire/tree.test.ts +8 -7
  96. package/src/wire/tree.ts +0 -39
  97. package/src/wire/triggers.test.ts +6 -6
  98. package/src/wire/triggers.ts +14 -32
  99. package/src/internal/ctx.ts +0 -16
@@ -1,21 +1,28 @@
1
1
  import {
2
- Brand,
3
2
  Cause,
4
3
  Chunk,
5
4
  Effect,
6
5
  MutableRef,
7
6
  Option,
7
+ type ParseResult,
8
8
  Queue,
9
- Schema,
9
+ Runtime,
10
10
  type Scope,
11
11
  Stream,
12
12
  } from 'effect'
13
13
  import { type AnyAsyncData } from '../calc/asyncData'
14
- import { empty, type QueryState, toAsyncData } from '../calc/queryState'
14
+ import { empty, type QueryState } from '../calc/queryState'
15
15
  import { Queries, type QueryHandle } from '../runtime/queries'
16
16
  import { reuse } from './reuse'
17
17
  import { resolveInstrumentation } from '../runtime/instrumentation'
18
18
  import { resolveQueryStore } from './queryStore'
19
+ import {
20
+ incrementGeneration,
21
+ makeQueryProjection,
22
+ subscribeQuerySources,
23
+ settleQueryError,
24
+ withReaderTracking,
25
+ } from './queryDriverStore'
19
26
  import { resolveScheduler } from './scheduler'
20
27
  import {
21
28
  type InputsObject,
@@ -24,116 +31,54 @@ import {
24
31
  sameKey,
25
32
  wireSources,
26
33
  } from './sources'
27
- import { makeDerivedStore, makeStore, type Store } from './store'
34
+ import { makeStore, type Store } from './store'
28
35
  import { type AnySource } from '../state/token'
29
36
 
30
- // The query-lifecycle driver shared by `AsyncCalc.live` and `RemoteState.live`:
31
- // sense the inputs, run the query latest-wins (or trailing-conflated), and fold
32
- // the result into a flat `QueryState` cell (data/error/isFetching/isStale). The
33
- // public store is the `AnyAsyncData` *projection* of that cell, so existing
34
- // arm-matching consumers and `RemoteState` are unchanged. `invalidate` (flip
35
- // `isStale`) and `refetch` (force a run) are exposed as separate operations and
36
- // registered into the `Queries` registry by name. The asyncCalc test suite is the
37
- // driver's regression suite.
38
-
39
- /** `Gated` (whether the `Idle` arm exists) is the inverse of `alwaysOn`. */
40
- export type GatedOf<AlwaysOn extends boolean> = AlwaysOn extends true ? false : true
37
+ export {
38
+ bumpRevision,
39
+ gatedFlag,
40
+ type GatedOf,
41
+ Revision,
42
+ RevisionSchema,
43
+ revisionZero,
44
+ } from './queryDriverTypes'
41
45
 
42
- /** The runtime gated flag for an `alwaysOn` config, typed as its `Gated` literal. */
43
- export const gatedFlag = <AlwaysOn extends boolean>(alwaysOn: AlwaysOn | undefined): GatedOf<AlwaysOn> =>
44
- // oxlint-disable-next-line reform-rules/no-type-assertion -- restate the runtime boolean as its conditional `GatedOf` literal; no guard maps a boolean to a conditional type
45
- (alwaysOn !== true) as GatedOf<AlwaysOn>
46
-
47
- // The hidden revision behind `invalidateOn` — a module-local *branded* number.
48
- // Reform store services are type-identified (`Context.GenericTag<Store<A>>`), so
49
- // the hidden state's value type must be un-collidable: `Layer.provide` erases
50
- // exactly `Store<Revision>` from the returned layer and can never type-level
51
- // erase a user input that happens to be a number state. Never exported from the
52
- // package index, so the editor catalog (which only indexes module exports)
53
- // never sees it.
54
- export type Revision = number & Brand.Brand<'reform/Revision'>
55
- export const Revision: Brand.Brand.Constructor<Revision> = Brand.nominal<Revision>()
56
- export const RevisionSchema: Schema.Schema<Revision, number> = Schema.Number.pipe(
57
- Schema.brand('reform/Revision'),
58
- )
59
- export const revisionZero: Revision = Revision(0)
60
- export const bumpRevision = (revision: Revision): Revision => Revision(revision + 1)
46
+ // Keep schema invariance behind a covariant persistence boundary.
47
+ export interface QueryCodec<A> {
48
+ readonly encode: (value: A) => Effect.Effect<unknown, ParseResult.ParseError, never>
49
+ readonly decode: (input: unknown) => Effect.Effect<A, ParseResult.ParseError, never>
50
+ }
61
51
 
62
52
  export interface QueryDriverOptions<Inputs extends ReadonlyArray<AnySource>, A, E, R> {
63
- /** The owning definition's name — the defect log line and the `Queries` key. */
64
53
  readonly name: string
65
- /** The owning primitive's kind label — only for the defect log line. */
66
54
  readonly label: string
67
- /** Whether `disabled` is honored / the `Idle` arm can occur. */
68
55
  readonly gated: boolean
69
56
  readonly inputs: Inputs
70
57
  readonly query: (inputs: InputsObject<Inputs>) => Effect.Effect<A, E, R>
71
- // oxlint-disable-next-line reform-rules/no-optional-fields -- caller-omitted option built at the asyncCalc/remoteState construction site; Option<T> would change that call shape
72
58
  readonly invalidateBy?: InvalidateBy<Inputs> | undefined
73
- // oxlint-disable-next-line reform-rules/no-optional-fields -- caller-omitted option built at the asyncCalc/remoteState construction site; Option<T> would change that call shape
74
59
  readonly disabled?: ((inputs: InputsObject<Inputs>) => boolean) | undefined
75
- // oxlint-disable-next-line reform-rules/no-optional-fields -- caller-omitted option built at the asyncCalc/remoteState construction site; Option<T> would change that call shape
76
60
  readonly coalesce?: 'switch' | 'trailing' | undefined
77
- // oxlint-disable-next-line reform-rules/no-optional-fields -- caller-omitted option built at the asyncCalc/remoteState construction site; Option<T> would change that call shape
78
61
  readonly reuse?: boolean | undefined
79
- /**
80
- * An extra key segment + wake source beyond the declared inputs — the hidden
81
- * `invalidateOn` revision. Kept out of `wireSources` so the user's `query`
82
- * and `invalidateBy` see exactly the declared inputs.
83
- */
84
- // oxlint-disable-next-line reform-rules/no-optional-fields -- caller-omitted option built at the asyncCalc/remoteState construction site; Option<T> would change that call shape
85
62
  readonly extraKey?:
86
63
  | {
87
64
  readonly read: () => unknown
88
65
  readonly subscribe: (listener: () => void) => () => void
89
66
  }
90
67
  | undefined
91
- /**
92
- * Persist the `Success` value through the optional `QueryStore`: hydrate the
93
- * cell from `key` before the first run (seeded `isStale: true`, so it shows
94
- * instantly and still refetches) and write through on each settle. Values
95
- * (de)serialize through `schema`, never trusted structurally.
96
- */
97
- // oxlint-disable-next-line reform-rules/no-optional-fields -- caller-omitted option built at the asyncCalc/remoteState construction site; Option<T> would change that call shape
98
68
  readonly persist?:
99
69
  | {
100
- // A static string keys a singleton read; a function of the inputs keys a
101
- // family per-entity, so reactive-input keyed reads never collide on one slot.
102
70
  readonly key: string | ((inputs: InputsObject<Inputs>) => string)
103
- readonly schema: Schema.Schema<A, any>
71
+ readonly codec: QueryCodec<A>
104
72
  }
105
73
  | undefined
106
- /**
107
- * Fired when a run's `Success` value has landed in the store (same scheduler
108
- * flush — never before the converged value is readable), with the run's
109
- * generation. Not fired on `Error`, interrupt, or while disabled. The seam
110
- * `RemoteState` settles pending intents through.
111
- */
112
- // oxlint-disable-next-line reform-rules/no-optional-fields -- caller-omitted option built at the asyncCalc/remoteState construction site; Option<T> would change that call shape
113
74
  readonly onSettled?: ((generation: number) => void) | undefined
114
75
  }
115
76
 
116
77
  export interface QueryDriver<A, E> {
117
- /** The lifecycle store (the `AnyAsyncData` projection — callers narrow to their arms). */
118
78
  readonly store: Store<AnyAsyncData<A, E>>
119
- /**
120
- * The highest generation requested so far (0 before the first kick).
121
- * Generations are assigned when a run is REQUESTED (enqueued), so a waiter
122
- * registered as `requested() + 1` is satisfied only by a run that began
123
- * after registration — the settle-ordering rule `RemoteState` builds on.
124
- */
125
79
  readonly requested: () => number
126
80
  }
127
81
 
128
- /**
129
- * Build the driver: state cell + projection + subscription + request queue + run
130
- * fiber, all owned by the ambient scope (callers run this under `Layer.scoped`). A
131
- * change to an input re-runs the query latest-wins (a new run cancels the in-flight
132
- * one; or, with `coalesce: 'trailing'`, lets it finish and runs one trailing
133
- * refetch); while a re-run is in flight the last `data`/`error` is kept with
134
- * `isFetching: true`. `invalidate` flips `isStale`; a stale query with active
135
- * readers auto-refetches (the same rule that drives refetch-on-resubscribe).
136
- */
137
82
  // oxlint-disable-next-line reform-rules/prefer-effect-fn -- generic export: Effect.fn's inferred type isn't portable under isolatedDeclarations
138
83
  export const makeQueryDriver = <Inputs extends ReadonlyArray<AnySource>, A, E, R>(
139
84
  options: QueryDriverOptions<Inputs, A, E, R>,
@@ -148,44 +93,30 @@ export const makeQueryDriver = <Inputs extends ReadonlyArray<AnySource>, A, E, R
148
93
 
149
94
  const keyOf = (args: InputsObject<Inputs>): ReadonlyArray<unknown> =>
150
95
  extraKey === undefined ? sources.keyOf(args) : [...sources.keyOf(args), extraKey.read()]
151
- // The persist storage key at the current inputs: a static string for a
152
- // singleton, or the per-input function for a keyed family (no DOM/extraKey
153
- // churn — purely the user-declared key, so it stays stable across refetches).
154
- const persistKey = (key: string | ((inputs: InputsObject<Inputs>) => string), args: InputsObject<Inputs>): string =>
155
- typeof key === 'function' ? key(args) : key
156
- const subscribeAll = (listener: () => void): (() => void) => {
157
- const offSources = sources.subscribe(listener)
158
- const offExtra = extraKey?.subscribe(listener)
159
- return () => {
160
- offSources()
161
- offExtra?.()
162
- }
163
- }
96
+ const persistKey = (
97
+ key: string | ((inputs: InputsObject<Inputs>) => string),
98
+ args: InputsObject<Inputs>,
99
+ ): string => (typeof key === 'function' ? key(args) : key)
164
100
  const disabledNow = (args: InputsObject<Inputs> = sources.snapshot()): boolean =>
165
101
  options.gated && options.disabled !== undefined ? options.disabled(args) : false
166
102
 
167
- // The flat source-of-truth cell. `idle` is a shared reference so re-disabling
168
- // an already-idle query is a no-op write (the store's Equal gate suppresses it).
169
103
  const idle: QueryState<A, E> = empty(false)
170
104
  const stateStore = makeStore<QueryState<A, E>>(disabledNow() ? idle : empty(true), scheduler)
171
105
 
172
- // Active-reader count of the PROJECTION (React components, the RemoteState
173
- // overlay). Drives the "stale + active ⇒ refetch" policy and refetch-on-
174
- // resubscribe (a 0→1 transition).
175
106
  const readers = MutableRef.make(0)
176
107
 
177
- // The last requested key, so a change that doesn't move it (or only churns an
178
- // input `invalidateBy` ignores) doesn't re-fetch.
179
108
  const lastKey = MutableRef.make<ReadonlyArray<unknown> | undefined>(undefined)
109
+ // Monotonic source epoch for the one startup hydration attempt. Comparing
110
+ // only the current key is insufficient: A → B → A would otherwise let the
111
+ // original A cache land over the later A request. Same-key refetches do not
112
+ // move this epoch, so cache may still paint during an in-flight revalidation.
113
+ const sourceEpoch = MutableRef.make(0)
180
114
  const generation = MutableRef.make(0)
181
- const nextGeneration = (): number => {
182
- MutableRef.update(generation, (current) => current + 1)
183
- return MutableRef.get(generation)
184
- }
185
-
186
- // ── State writers, each touching only the axes it owns. The untouched axes
187
- // keep their field references, so a pure `isStale` flip leaves the projection
188
- // reference unmoved (see `project`). ──────────────────────────────────────
115
+ const nextGeneration = (): number => incrementGeneration(generation)
116
+ // A stale cause needs a strictly newer requested generation. Hydration
117
+ // captures this before the startup kick, while invalidation captures it when
118
+ // the value is marked stale.
119
+ const staleAtGeneration = MutableRef.make(0)
189
120
  const markFetching = () => {
190
121
  const state = stateStore.get()
191
122
  if (!state.isFetching) {
@@ -206,11 +137,10 @@ export const makeQueryDriver = <Inputs extends ReadonlyArray<AnySource>, A, E, R
206
137
  })
207
138
  return shared
208
139
  }
209
- const settleError = (error: E) => {
210
- const prev = stateStore.get()
211
- // Keep `data` underneath: `error` wins in the projection (the `Error` arm),
212
- // but a consumer reading `QueryState` still has the last good value.
213
- stateStore.set({ ...prev, error: Option.some(error), isFetching: false })
140
+ const settleError = (error: E, failedGeneration: number) => {
141
+ // Preserve the stale cause after failure without duplicating a newer request.
142
+ MutableRef.update(staleAtGeneration, (current) => Math.max(current, failedGeneration))
143
+ settleQueryError(stateStore, error)
214
144
  }
215
145
 
216
146
  const persistWriteTo = (
@@ -218,39 +148,15 @@ export const makeQueryDriver = <Inputs extends ReadonlyArray<AnySource>, A, E, R
218
148
  produced: A,
219
149
  args: InputsObject<Inputs>,
220
150
  ): Effect.Effect<void> =>
221
- Schema.encode(target.schema)(produced).pipe(
151
+ target.codec.encode(produced).pipe(
222
152
  Effect.flatMap((encoded) => queryStore.set(persistKey(target.key, args), encoded)),
223
- // Persistence is best-effort: a serialization or storage failure must
224
- // never fail the run nor block the visible write.
153
+ // Persistence is best-effort and must not block the visible write.
225
154
  Effect.ignore,
226
155
  )
227
156
  const persistWrite = (produced: A, args: InputsObject<Inputs>): Effect.Effect<void> =>
228
157
  persist === undefined ? Effect.void : persistWriteTo(persist, produced, args)
229
158
 
230
- // The projection the public store exposes: `AnyAsyncData`, memoized so a pure
231
- // `isStale` change (which the projection ignores) returns the SAME reference
232
- // and wakes no `AnyAsyncData` subscriber.
233
- const projection = MutableRef.make<
234
- { readonly state: QueryState<A, E>; readonly view: AnyAsyncData<A, E> } | undefined
235
- >(undefined)
236
- const project = (): AnyAsyncData<A, E> => {
237
- const state = stateStore.get()
238
- const prev = MutableRef.get(projection)
239
- // The projection depends only on data/error/isFetching; writers preserve the
240
- // refs of untouched axes, so reference identity on those three is exact.
241
- if (
242
- prev !== undefined &&
243
- prev.state.data === state.data &&
244
- prev.state.error === state.error &&
245
- prev.state.isFetching === state.isFetching
246
- ) {
247
- return prev.view
248
- }
249
- const view = toAsyncData(state, options.gated)
250
- MutableRef.set(projection, { state, view })
251
- return view
252
- }
253
- const derived = makeDerivedStore(project, stateStore.subscribe, scheduler)
159
+ const derived = makeQueryProjection({ gated: options.gated, scheduler, stateStore })
254
160
  yield* Effect.addFinalizer(() => Effect.sync(derived.unsubscribe))
255
161
 
256
162
  interface Request {
@@ -258,8 +164,6 @@ export const makeQueryDriver = <Inputs extends ReadonlyArray<AnySource>, A, E, R
258
164
  readonly generation: number
259
165
  }
260
166
 
261
- // Commit a `Success` value: write the cell, report the generation, then persist
262
- // (best-effort, trailing). A no-op when the query was disabled meanwhile.
263
167
  const commitSuccess = (produced: A, request: Request): Effect.Effect<void> => {
264
168
  if (disabledNow()) {
265
169
  return Effect.void
@@ -271,11 +175,6 @@ export const makeQueryDriver = <Inputs extends ReadonlyArray<AnySource>, A, E, R
271
175
  })
272
176
  }
273
177
 
274
- // One run of the query, folded into the cell. A failure becomes `Error`; an
275
- // interrupt (latest-wins cancel) leaves the state untouched; a defect (a bug in
276
- // the body) is logged and isolated. A `Success` reports its generation through
277
- // `onSettled` after the write, then persists (best-effort, trailing). The
278
- // `queryRun` span covers start → settle/cancel (`ensuring` fires on all three).
279
178
  const runQuery = (request: Request): Effect.Effect<void, never, R> =>
280
179
  Effect.sync(() => instrumentation.queryRun(options.name)).pipe(
281
180
  Effect.flatMap((endSpan) =>
@@ -289,7 +188,7 @@ export const makeQueryDriver = <Inputs extends ReadonlyArray<AnySource>, A, E, R
289
188
  onFailure: (cause): Option.Option<A> => {
290
189
  const failure = Cause.failureOption(cause)
291
190
  if (Option.isSome(failure) && !disabledNow()) {
292
- settleError(failure.value)
191
+ settleError(failure.value, request.generation)
293
192
  }
294
193
  return Option.none()
295
194
  },
@@ -302,11 +201,6 @@ export const makeQueryDriver = <Inputs extends ReadonlyArray<AnySource>, A, E, R
302
201
  ),
303
202
  )
304
203
 
305
- // The driver. `'switch'` (default): each new request cancels the in-flight run
306
- // — the same latest-wins semantics a `latest` channel gives procedures.
307
- // `'trailing'`: a strictly sequential consumer that, on wake, drains every
308
- // request that piled up during the flight and runs the LATEST one — "exactly
309
- // one trailing run after settle" by construction.
310
204
  const trailing = options.coalesce === 'trailing'
311
205
  const requests = yield* Queue.unbounded<Request>()
312
206
  const trailingConsumer = Effect.forever(
@@ -329,12 +223,12 @@ export const makeQueryDriver = <Inputs extends ReadonlyArray<AnySource>, A, E, R
329
223
  )
330
224
  yield* Effect.forkScoped(trailing ? trailingConsumer : switchConsumer)
331
225
 
332
- // Enqueue a run of the current key, marking the flight. `force` skips the
333
- // no-op-key guard (the refetch path); otherwise an unchanged key is ignored.
334
226
  const enqueue = (force: boolean) => {
335
227
  const args = sources.snapshot()
336
228
  if (disabledNow(args)) {
337
- // Switched off: show Idle and forget the key so re-enabling always re-runs.
229
+ if (MutableRef.get(lastKey) !== undefined) {
230
+ MutableRef.update(sourceEpoch, (current) => current + 1)
231
+ }
338
232
  MutableRef.set(lastKey, undefined)
339
233
  stateStore.set(idle)
340
234
  return
@@ -344,6 +238,9 @@ export const makeQueryDriver = <Inputs extends ReadonlyArray<AnySource>, A, E, R
344
238
  if (!force && previous !== undefined && sameKey(key, previous)) {
345
239
  return
346
240
  }
241
+ if (previous !== undefined && !sameKey(key, previous)) {
242
+ MutableRef.update(sourceEpoch, (current) => current + 1)
243
+ }
347
244
  MutableRef.set(lastKey, key)
348
245
  markFetching()
349
246
  Queue.unsafeOffer(requests, { args, generation: nextGeneration() })
@@ -351,10 +248,9 @@ export const makeQueryDriver = <Inputs extends ReadonlyArray<AnySource>, A, E, R
351
248
  const trigger = () => enqueue(false)
352
249
  const refetch = () => enqueue(true)
353
250
 
354
- // `invalidate`: flip `isStale` only. A stale query with active readers then
355
- // auto-refetches — the one place invalidation indirectly fetches.
356
251
  const maybeAutoRefetch = () => {
357
- if (stateStore.get().isStale && MutableRef.get(readers) > 0) {
252
+ const hasCoveringRequest = MutableRef.get(generation) > MutableRef.get(staleAtGeneration)
253
+ if (stateStore.get().isStale && MutableRef.get(readers) > 0 && !hasCoveringRequest) {
358
254
  refetch()
359
255
  }
360
256
  }
@@ -363,38 +259,56 @@ export const makeQueryDriver = <Inputs extends ReadonlyArray<AnySource>, A, E, R
363
259
  if (state.isStale) {
364
260
  return
365
261
  }
262
+ MutableRef.set(staleAtGeneration, MutableRef.get(generation))
366
263
  stateStore.set({ ...state, isStale: true })
367
264
  maybeAutoRefetch()
368
265
  }
369
266
 
370
- const unsubscribe = subscribeAll(trigger)
267
+ const unsubscribe = subscribeQuerySources(sources, extraKey, trigger)
371
268
  yield* Effect.addFinalizer(() => Effect.sync(unsubscribe))
372
269
 
373
- // Hydrate from the persisted value before the first kick: show it instantly,
374
- // marked stale, so the kick refetches over it (stale-while-revalidate).
270
+ // Fork hydration so an asynchronous store never blocks layer acquisition.
271
+ // Epoch, key, and settled-state guards prevent a late cache from replacing newer truth.
375
272
  if (persist !== undefined && !disabledNow()) {
376
273
  const hydrateArgs = sources.snapshot()
377
- const cached = yield* queryStore.get(persistKey(persist.key, hydrateArgs))
378
- yield* Option.match(cached, {
379
- onNone: () => Effect.void,
380
- onSome: (raw) =>
381
- Schema.decodeUnknown(persist.schema)(raw).pipe(
382
- Effect.match({
383
- // Corrupt/incompatible cache: ignore and fetch fresh.
384
- onFailure: () => undefined,
385
- onSuccess: (decoded) =>
386
- stateStore.set({
387
- data: Option.some(decoded),
388
- error: Option.none(),
389
- isFetching: false,
390
- isStale: true,
391
- }),
392
- }),
393
- ),
394
- })
274
+ const hydrateSourceKey = keyOf(hydrateArgs)
275
+ const hydrateSourceEpoch = MutableRef.get(sourceEpoch)
276
+ const hydrateStaleAtGeneration = MutableRef.get(generation)
277
+ const hydrate = queryStore.get(persistKey(persist.key, hydrateArgs)).pipe(
278
+ Effect.flatMap((cached) =>
279
+ Option.match(cached, {
280
+ onNone: () => Effect.void,
281
+ onSome: (raw) =>
282
+ persist.codec.decode(raw).pipe(
283
+ Effect.tap((decoded) =>
284
+ Effect.sync(() => {
285
+ const state = stateStore.get()
286
+ if (
287
+ disabledNow() ||
288
+ hydrateSourceEpoch !== MutableRef.get(sourceEpoch) ||
289
+ !sameKey(hydrateSourceKey, keyOf(sources.snapshot())) ||
290
+ Option.isSome(state.data) ||
291
+ Option.isSome(state.error)
292
+ ) {
293
+ return
294
+ }
295
+ MutableRef.update(staleAtGeneration, (current) =>
296
+ Math.max(current, hydrateStaleAtGeneration),
297
+ )
298
+ stateStore.set({ ...state, data: Option.some(decoded), isStale: true })
299
+ }),
300
+ ),
301
+ ),
302
+ }),
303
+ ),
304
+ // Corrupt/incompatible cache: ignore and fetch fresh.
305
+ Effect.ignore,
306
+ )
307
+ const runtime = yield* Effect.runtime<never>()
308
+ const scope = yield* Effect.scope
309
+ Runtime.runFork(runtime)(hydrate, { immediate: true, scope })
395
310
  }
396
311
 
397
- // Kick off the first fetch unless the query starts disabled.
398
312
  const initial = sources.snapshot()
399
313
  if (!disabledNow(initial)) {
400
314
  MutableRef.set(lastKey, keyOf(initial))
@@ -402,27 +316,12 @@ export const makeQueryDriver = <Inputs extends ReadonlyArray<AnySource>, A, E, R
402
316
  Queue.unsafeOffer(requests, { args: initial, generation: nextGeneration() })
403
317
  }
404
318
 
405
- // The public store: the projection, with reader-count tracking wrapped around
406
- // `subscribe`, so a 0→1 transition can auto-refetch a stale query.
407
- const store: Store<AnyAsyncData<A, E>> = {
408
- ...derived.store,
409
- subscribe: (listener) => {
410
- const before = MutableRef.get(readers)
411
- MutableRef.set(readers, before + 1)
412
- const off = derived.store.subscribe(listener)
413
- if (before === 0) {
414
- maybeAutoRefetch()
415
- }
416
- return () => {
417
- off()
418
- MutableRef.update(readers, (count) => Math.max(0, count - 1))
419
- }
420
- },
421
- }
319
+ const store = withReaderTracking({
320
+ store: derived.store,
321
+ readers,
322
+ onFirstReader: maybeAutoRefetch,
323
+ })
422
324
 
423
- // Register the handle so `AsyncCalc.invalidate`/`.refetch` and provider layers
424
- // can act on this query by name. Optional (`serviceOption`): an absent registry
425
- // (a calc wired with no `Engine` in context) just means no imperative control.
426
325
  const handle: QueryHandle = {
427
326
  name: options.name,
428
327
  invalidate,
@@ -432,6 +331,11 @@ export const makeQueryDriver = <Inputs extends ReadonlyArray<AnySource>, A, E, R
432
331
  }
433
332
  const queries = Option.getOrUndefined(yield* Effect.serviceOption(Queries))
434
333
  if (queries !== undefined) {
334
+ if (queries.byName.has(handle.name)) {
335
+ yield* Effect.logWarning(
336
+ `reform: duplicate query name '${handle.name}' registered; previous registration is being replaced`,
337
+ )
338
+ }
435
339
  queries.register(handle)
436
340
  yield* Effect.addFinalizer(() => Effect.sync(() => queries.unregister(handle)))
437
341
  }
@@ -0,0 +1,156 @@
1
+ import { Effect, MutableRef, Option, Schema } from 'effect'
2
+ import type { AnyAsyncData } from '../calc/asyncData'
3
+ import { type QueryState, toAsyncData } from '../calc/queryState'
4
+ import type { QueryStoreApi } from './queryStore'
5
+ import type { Scheduler } from './scheduler'
6
+ import { makeDerivedStore, type Store } from './store'
7
+ import type { AnySchema } from './variance'
8
+ import type { InputsObject } from './sources'
9
+ import type { AnySource } from '../state/token'
10
+
11
+ interface HydrateQueryOptions<A, E> {
12
+ readonly cacheKey: string
13
+ readonly queryStore: QueryStoreApi
14
+ readonly schema: AnySchema<A>
15
+ readonly stateStore: Store<QueryState<A, E>>
16
+ }
17
+
18
+ export const hydrateQuery = <A, E>(options: HydrateQueryOptions<A, E>): Effect.Effect<void> =>
19
+ Effect.flatMap(options.queryStore.get(options.cacheKey), (cached) =>
20
+ Option.match(cached, {
21
+ onNone: () => Effect.void,
22
+ onSome: (raw) =>
23
+ Schema.decodeUnknown(options.schema)(raw).pipe(
24
+ Effect.match({
25
+ onFailure: () => undefined,
26
+ onSuccess: (decoded) =>
27
+ options.stateStore.set({
28
+ data: Option.some(decoded),
29
+ error: Option.none(),
30
+ isFetching: false,
31
+ isStale: true,
32
+ }),
33
+ }),
34
+ ),
35
+ }),
36
+ )
37
+
38
+ interface PersistQueryValueOptions<A> {
39
+ readonly cacheKey: string
40
+ readonly produced: A
41
+ readonly queryStore: QueryStoreApi
42
+ readonly schema: AnySchema<A>
43
+ }
44
+
45
+ export const persistQueryValue = <A>(options: PersistQueryValueOptions<A>): Effect.Effect<void> =>
46
+ Schema.encode(options.schema)(options.produced).pipe(
47
+ Effect.flatMap((encoded) => options.queryStore.set(options.cacheKey, encoded)),
48
+ Effect.ignore,
49
+ )
50
+
51
+ interface PersistWriterOptions<Inputs extends ReadonlyArray<AnySource>, A> {
52
+ readonly persist:
53
+ | {
54
+ readonly key: string | ((inputs: InputsObject<Inputs>) => string)
55
+ readonly schema: AnySchema<A>
56
+ }
57
+ | undefined
58
+ readonly queryStore: QueryStoreApi
59
+ }
60
+
61
+ export const makePersistWriter = <Inputs extends ReadonlyArray<AnySource>, A>(
62
+ options: PersistWriterOptions<Inputs, A>,
63
+ ): ((produced: A, inputs: InputsObject<Inputs>) => Effect.Effect<void>) => {
64
+ if (options.persist === undefined) {
65
+ return () => Effect.void
66
+ }
67
+ const persist = options.persist
68
+ return (produced, inputs) =>
69
+ persistQueryValue({
70
+ cacheKey: typeof persist.key === 'function' ? persist.key(inputs) : persist.key,
71
+ produced,
72
+ queryStore: options.queryStore,
73
+ schema: persist.schema,
74
+ })
75
+ }
76
+
77
+ interface SubscribableSource {
78
+ readonly subscribe: (listener: () => void) => () => void
79
+ }
80
+
81
+ export const subscribeQuerySources = (
82
+ sources: SubscribableSource,
83
+ extraKey: SubscribableSource | undefined,
84
+ listener: () => void,
85
+ ): (() => void) => {
86
+ const offSources = sources.subscribe(listener)
87
+ const offExtra = extraKey?.subscribe(listener)
88
+ return () => {
89
+ offSources()
90
+ offExtra?.()
91
+ }
92
+ }
93
+
94
+ export const settleQueryError = <A, E>(stateStore: Store<QueryState<A, E>>, error: E): void => {
95
+ const previous = stateStore.get()
96
+ stateStore.set({ ...previous, error: Option.some(error), isFetching: false })
97
+ }
98
+
99
+ export const incrementGeneration = (generation: MutableRef.MutableRef<number>): number => {
100
+ MutableRef.update(generation, (current) => current + 1)
101
+ return MutableRef.get(generation)
102
+ }
103
+
104
+ interface QueryProjectionOptions<A, E> {
105
+ readonly gated: boolean
106
+ readonly scheduler: Scheduler
107
+ readonly stateStore: Store<QueryState<A, E>>
108
+ }
109
+
110
+ export const makeQueryProjection = <A, E>(
111
+ options: QueryProjectionOptions<A, E>,
112
+ ): { readonly store: Store<AnyAsyncData<A, E>>; readonly unsubscribe: () => void } => {
113
+ const projection = MutableRef.make<
114
+ { readonly state: QueryState<A, E>; readonly view: AnyAsyncData<A, E> } | undefined
115
+ >(undefined)
116
+ const project = (): AnyAsyncData<A, E> => {
117
+ const state = options.stateStore.get()
118
+ const previous = MutableRef.get(projection)
119
+ if (
120
+ previous !== undefined &&
121
+ previous.state.data === state.data &&
122
+ previous.state.error === state.error &&
123
+ previous.state.isFetching === state.isFetching
124
+ ) {
125
+ return previous.view
126
+ }
127
+ const view = toAsyncData(state, options.gated)
128
+ MutableRef.set(projection, { state, view })
129
+ return view
130
+ }
131
+ return makeDerivedStore(project, options.stateStore.subscribe, options.scheduler)
132
+ }
133
+
134
+ interface ReaderStoreOptions<A, E> {
135
+ readonly store: Store<AnyAsyncData<A, E>>
136
+ readonly readers: MutableRef.MutableRef<number>
137
+ readonly onFirstReader: () => void
138
+ }
139
+
140
+ export const withReaderTracking = <A, E>(
141
+ options: ReaderStoreOptions<A, E>,
142
+ ): Store<AnyAsyncData<A, E>> => ({
143
+ ...options.store,
144
+ subscribe: (listener) => {
145
+ const before = MutableRef.get(options.readers)
146
+ MutableRef.set(options.readers, before + 1)
147
+ const off = options.store.subscribe(listener)
148
+ if (before === 0) {
149
+ options.onFirstReader()
150
+ }
151
+ return () => {
152
+ off()
153
+ MutableRef.update(options.readers, (count) => Math.max(0, count - 1))
154
+ }
155
+ },
156
+ })