@playfast/reform 0.0.10 → 0.1.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 (46) hide show
  1. package/README.md +11 -11
  2. package/package.json +1 -1
  3. package/src/boundary/boundary.ts +16 -7
  4. package/src/calc/asyncCalc.ts +84 -37
  5. package/src/calc/asyncData.ts +8 -4
  6. package/src/calc/calc.ts +7 -3
  7. package/src/calc/calcFamily.ts +24 -10
  8. package/src/calc/compose.ts +1 -1
  9. package/src/calc/queryState.ts +8 -8
  10. package/src/channel/channel.ts +63 -37
  11. package/src/compose/composition.ts +20 -1
  12. package/src/compose/provide.ts +5 -1
  13. package/src/compose/slot.ts +4 -2
  14. package/src/compose/structure.ts +43 -19
  15. package/src/compose/ui.ts +21 -2
  16. package/src/compose/ui.typecheck.ts +4 -4
  17. package/src/definition/definition.ts +20 -7
  18. package/src/feature/feature.test.ts +4 -4
  19. package/src/feature/feature.ts +30 -16
  20. package/src/feature/feature.typecheck.ts +2 -2
  21. package/src/internal/capture.ts +1 -0
  22. package/src/internal/errors.ts +9 -4
  23. package/src/internal/inspect.ts +4 -4
  24. package/src/internal/queryDriver.ts +102 -53
  25. package/src/internal/reuse.ts +67 -30
  26. package/src/internal/scheduler.ts +35 -23
  27. package/src/internal/sources.ts +14 -8
  28. package/src/internal/stateRegistry.ts +3 -1
  29. package/src/internal/store.ts +10 -8
  30. package/src/internal/track.ts +3 -1
  31. package/src/procedure/procedure.ts +2 -2
  32. package/src/reducer/reducer.ts +17 -9
  33. package/src/remote/remoteState.test.ts +188 -1
  34. package/src/remote/remoteState.ts +112 -51
  35. package/src/remote/remoteState.typecheck.ts +4 -1
  36. package/src/runtime/bus.ts +3 -1
  37. package/src/runtime/hardening.test.ts +1 -1
  38. package/src/runtime/loop.ts +67 -46
  39. package/src/runtime/queries.ts +3 -1
  40. package/src/scene/scene.ts +16 -8
  41. package/src/state/state.ts +11 -10
  42. package/src/state/stateFamily.ts +27 -11
  43. package/src/state/stateGroup.ts +22 -9
  44. package/src/synced/syncedStore.ts +15 -9
  45. package/src/wire/tree.ts +66 -30
  46. package/src/wire/triggers.ts +9 -8
@@ -1,4 +1,4 @@
1
- import { type Context, Effect, Layer, type Scope } from 'effect'
1
+ import { type Context, Effect, Layer, Option, type Scope } from 'effect'
2
2
  import { type Engine } from '../runtime/loop'
3
3
  import { type StoresOf } from '../internal/sources'
4
4
  import { FeatureLoadFailed } from '../internal/errors'
@@ -114,10 +114,10 @@ export interface FeatureManifest extends Manifest {
114
114
  readonly name: string
115
115
  readonly strategy: 'lazy' | 'default'
116
116
  readonly composition: Manifest & { readonly kind: 'Composition' }
117
- readonly placeholder?: {
117
+ readonly placeholder: Option.Option<{
118
118
  readonly loading: Manifest & { readonly kind: 'Composition' }
119
119
  readonly failed: Manifest & { readonly kind: 'Composition' }
120
- }
120
+ }>
121
121
  }
122
122
 
123
123
  /**
@@ -152,6 +152,7 @@ export interface FeatureBinding {
152
152
  readonly composition: CompositionClass<unknown>
153
153
  readonly strategy: 'lazy' | 'default'
154
154
  readonly load: Effect.Effect<LoadedModule, FeatureLoadFailed>
155
+ // oxlint-disable-next-line reform-rules/no-optional-fields -- read at the react host seam via `binding.placeholder === undefined`; an Option here would force a cross-package edit (react host) owned by another agent
155
156
  readonly placeholder?: {
156
157
  readonly loading: CompositionClass<unknown>
157
158
  readonly failed: CompositionClass<unknown>
@@ -160,8 +161,8 @@ export interface FeatureBinding {
160
161
  }
161
162
 
162
163
  /** Whether a slot's bound child is a lazy/eager feature (vs a plain composition). */
163
- export const isFeatureBinding = (u: unknown): u is FeatureBinding =>
164
- typeof u === 'object' && u !== null && FeatureBindingTypeId in u
164
+ export const isFeatureBinding = (candidate: unknown): candidate is FeatureBinding =>
165
+ typeof candidate === 'object' && candidate !== null && FeatureBindingTypeId in candidate
165
166
 
166
167
  /**
167
168
  * A feature *definition*: a reflectable manifest (`kind: 'Feature'`) that carries
@@ -195,11 +196,14 @@ export interface FeatureClass<
195
196
  export type AnyFeature = FeatureClass<any, any, any, ReadonlyArray<RequireCarrier>>
196
197
 
197
198
  /** Whether a value is a feature definition (its target shape in `provide(slot, Feature)`). */
198
- export const isFeature = (u: unknown): u is AnyFeature =>
199
- (typeof u === 'function' || typeof u === 'object') &&
200
- u !== null &&
201
- 'manifest' in u &&
202
- (u as { readonly manifest: { readonly kind?: unknown } }).manifest?.kind === 'Feature'
199
+ export const isFeature = (candidate: unknown): candidate is AnyFeature =>
200
+ (typeof candidate === 'function' || typeof candidate === 'object') &&
201
+ candidate !== null &&
202
+ 'manifest' in candidate &&
203
+ typeof candidate.manifest === 'object' &&
204
+ candidate.manifest !== null &&
205
+ 'kind' in candidate.manifest &&
206
+ candidate.manifest.kind === 'Feature'
203
207
 
204
208
  /**
205
209
  * The placeholders a lazy feature must ship — both eager compositions (their
@@ -213,7 +217,12 @@ export interface Placeholders {
213
217
  readonly failed: CompositionClass<any, UiContract>
214
218
  }
215
219
 
216
- type FeatureConfig<P, C extends UiContract, ROut, Requires extends ReadonlyArray<RequireCarrier>> = {
220
+ type FeatureConfigExternalApi<
221
+ P,
222
+ C extends UiContract,
223
+ ROut,
224
+ Requires extends ReadonlyArray<RequireCarrier>,
225
+ > = {
217
226
  readonly composition: CompositionClass<P, C>
218
227
  readonly boot?: ReadonlyArray<Tagged>
219
228
  } & (
@@ -240,7 +249,7 @@ type FeatureConfig<P, C extends UiContract, ROut, Requires extends ReadonlyArray
240
249
  */
241
250
  export const make = <P, C extends UiContract, ROut, Requires extends ReadonlyArray<RequireCarrier>>(
242
251
  name: string,
243
- config: FeatureConfig<P, C, ROut, Requires>,
252
+ config: FeatureConfigExternalApi<P, C, ROut, Requires>,
244
253
  ): FeatureClass<P, C, ROut, Requires> => {
245
254
  const strategy: 'lazy' | 'default' = config.loadingStrategy ?? 'default'
246
255
  const load: Effect.Effect<FeatureModule<ROut, Requires>, FeatureLoadFailed> =
@@ -253,9 +262,12 @@ export const make = <P, C extends UiContract, ROut, Requires extends ReadonlyArr
253
262
  name,
254
263
  strategy,
255
264
  composition: config.composition.manifest,
256
- ...(placeholder
257
- ? { placeholder: { loading: placeholder.loading.manifest, failed: placeholder.failed.manifest } }
258
- : {}),
265
+ placeholder: Option.fromNullable(placeholder).pipe(
266
+ Option.map((placeholders) => ({
267
+ loading: placeholders.loading.manifest,
268
+ failed: placeholders.failed.manifest,
269
+ })),
270
+ ),
259
271
  }
260
272
 
261
273
  const binding: FeatureBinding = {
@@ -267,7 +279,7 @@ export const make = <P, C extends UiContract, ROut, Requires extends ReadonlyArr
267
279
  // assignment to the erased `LoadedModule` needs no cast — see `LoadedModule`.
268
280
  load,
269
281
  ...(placeholder ? { placeholder: { loading: placeholder.loading, failed: placeholder.failed } } : {}),
270
- boot: config.boot ?? [],
282
+ boot: Option.getOrElse(Option.fromNullable(config.boot), () => []),
271
283
  }
272
284
 
273
285
  return definitionClass<FeatureClass<P, C, ROut, Requires>>({ manifest, load, binding, eagerModule })
@@ -289,12 +301,14 @@ export const make = <P, C extends UiContract, ROut, Requires extends ReadonlyArr
289
301
  * we re-attach the concrete `engineContext` it builds against. `R` is the engine
290
302
  * context's service set, supplied by the host.
291
303
  */
304
+ // oxlint-disable-next-line reform-rules/prefer-effect-fn -- generic export: Effect.fn's inferred type isn't portable under isolatedDeclarations
292
305
  export const mountFeature = <R>(
293
306
  binding: FeatureBinding,
294
307
  engineContext: Context.Context<R>,
295
308
  ): Effect.Effect<Context.Context<unknown>, FeatureLoadFailed, Scope.Scope> =>
296
309
  Effect.gen(function* () {
297
310
  const { layer } = yield* binding.load
311
+ // oxlint-disable-next-line reform-rules/no-type-assertion -- documented erasure boundary: re-attach the concrete engine context to the type-erased `LoadedModule` layer (mirrors `buildRuntime`); RIn was verified cast-free at `provide`/scene wiring
298
312
  const featureLayer = layer as Layer.Layer<unknown, never, R>
299
313
  const context = yield* Layer.build(
300
314
  Layer.provideMerge(featureLayer, Layer.succeedContext(engineContext)),
@@ -65,7 +65,7 @@ const AppFeatureBase: Feature.FeatureClass<
65
65
  > = Feature.make('appFeature', {
66
66
  loadingStrategy: 'lazy',
67
67
  composition: AppComp,
68
- load: lazyImport(() => Promise.resolve({ default: featureModule([], Layer.empty) })),
68
+ load: lazyImport(async () => ({ default: featureModule([], Layer.empty) })),
69
69
  placeholder: { loading: AppComp, failed: AppComp },
70
70
  })
71
71
  class AppFeature extends AppFeatureBase {}
@@ -95,7 +95,7 @@ const OtherFeatureBase: Feature.FeatureClass<
95
95
  > = Feature.make('otherFeature', {
96
96
  loadingStrategy: 'lazy',
97
97
  composition: OtherComp,
98
- load: lazyImport(() => Promise.resolve({ default: featureModule([], Layer.empty) })),
98
+ load: lazyImport(async () => ({ default: featureModule([], Layer.empty) })),
99
99
  placeholder: { loading: OtherComp, failed: OtherComp },
100
100
  })
101
101
  class OtherFeature extends OtherFeatureBase {}
@@ -17,6 +17,7 @@ export interface UiCapture {
17
17
  * components and has no fill key, so it is omitted there. Lets the proof
18
18
  * facade select a child by key (`byKey`) instead of by render index.
19
19
  */
20
+ // oxlint-disable-next-line reform-rules/no-optional-fields -- omitted (not Option-none) on the legacy Node path; built in compose/structure.ts and read by the proof package, both outside this batch
20
21
  readonly key?: string
21
22
  }
22
23
 
@@ -94,8 +94,12 @@ export class AsyncSceneLayer extends AsyncSceneLayerBase {
94
94
  * shapes are checked.
95
95
  */
96
96
  const isAsyncBuildDefect = (error: unknown): boolean => {
97
- if (Runtime.isAsyncFiberException(error)) return true
98
- if (!Runtime.isFiberFailure(error)) return false
97
+ if (Runtime.isAsyncFiberException(error)) {
98
+ return true
99
+ }
100
+ if (!Runtime.isFiberFailure(error)) {
101
+ return false
102
+ }
99
103
  const die = Cause.dieOption(error[Runtime.FiberFailureCauseId])
100
104
  return Option.isSome(die) && Runtime.isAsyncFiberException(die.value)
101
105
  }
@@ -107,11 +111,12 @@ const isAsyncBuildDefect = (error: unknown): boolean => {
107
111
  * the React host and the remote server.
108
112
  */
109
113
  export const forceSync = <A>(thunk: () => A): A => {
114
+ // oxlint-disable-next-line reform-rules/no-try-catch -- sync-build boundary must throw the exact value, which Effect.runSync would re-wrap in a FiberFailure
110
115
  try {
111
116
  return thunk()
112
117
  } catch (error) {
113
- if (isAsyncBuildDefect(error)) throw new AsyncSceneLayer()
114
- throw error
118
+ // oxlint-disable-next-line reform-rules/no-throw -- rethrow the exact value (translated for the async-build defect) so callers can match on it
119
+ throw isAsyncBuildDefect(error) ? new AsyncSceneLayer() : error
115
120
  }
116
121
  }
117
122
 
@@ -21,14 +21,14 @@ export const inspectable = (toJSON: () => unknown): Inspectable => ({
21
21
  * whose statics are merged in separately.
22
22
  */
23
23
  export const attachInspectable = <T extends object>(target: T, toJSON: () => unknown): T => {
24
- const trio = inspectable(toJSON) as unknown as Record<PropertyKey, unknown>
25
- for (const key of Reflect.ownKeys(trio)) {
24
+ const trio = inspectable(toJSON)
25
+ Reflect.ownKeys(trio).forEach((key) => {
26
26
  Object.defineProperty(target, key, {
27
- value: trio[key],
27
+ value: Reflect.get(trio, key),
28
28
  enumerable: false,
29
29
  writable: true,
30
30
  configurable: true,
31
31
  })
32
- }
32
+ })
33
33
  return target
34
34
  }
@@ -40,6 +40,7 @@ export type GatedOf<AlwaysOn extends boolean> = AlwaysOn extends true ? false :
40
40
 
41
41
  /** The runtime gated flag for an `alwaysOn` config, typed as its `Gated` literal. */
42
42
  export const gatedFlag = <AlwaysOn extends boolean>(alwaysOn: AlwaysOn | undefined): GatedOf<AlwaysOn> =>
43
+ // 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
43
44
  (alwaysOn !== true) as GatedOf<AlwaysOn>
44
45
 
45
46
  // The hidden revision behind `invalidateOn` — a module-local *branded* number.
@@ -55,7 +56,7 @@ export const RevisionSchema: Schema.Schema<Revision, number> = Schema.Number.pip
55
56
  Schema.brand('reform/Revision'),
56
57
  )
57
58
  export const revisionZero: Revision = Revision(0)
58
- export const bumpRevision = (r: Revision): Revision => Revision(r + 1)
59
+ export const bumpRevision = (revision: Revision): Revision => Revision(revision + 1)
59
60
 
60
61
  export interface QueryDriverOptions<Inputs extends ReadonlyArray<AnySource>, A, E, R> {
61
62
  /** The owning definition's name — the defect log line and the `Queries` key. */
@@ -66,15 +67,20 @@ export interface QueryDriverOptions<Inputs extends ReadonlyArray<AnySource>, A,
66
67
  readonly gated: boolean
67
68
  readonly inputs: Inputs
68
69
  readonly query: (inputs: InputsObject<Inputs>) => Effect.Effect<A, E, R>
70
+ // 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
69
71
  readonly invalidateBy?: InvalidateBy<Inputs> | undefined
72
+ // 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
70
73
  readonly disabled?: ((inputs: InputsObject<Inputs>) => boolean) | undefined
74
+ // 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
71
75
  readonly coalesce?: 'switch' | 'trailing' | undefined
76
+ // 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
77
  readonly reuse?: boolean | undefined
73
78
  /**
74
79
  * An extra key segment + wake source beyond the declared inputs — the hidden
75
80
  * `invalidateOn` revision. Kept out of `wireSources` so the user's `query`
76
81
  * and `invalidateBy` see exactly the declared inputs.
77
82
  */
83
+ // 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
84
  readonly extraKey?:
79
85
  | {
80
86
  readonly read: () => unknown
@@ -87,13 +93,22 @@ export interface QueryDriverOptions<Inputs extends ReadonlyArray<AnySource>, A,
87
93
  * instantly and still refetches) and write through on each settle. Values
88
94
  * (de)serialize through `schema`, never trusted structurally.
89
95
  */
90
- readonly persist?: { readonly key: string; readonly schema: Schema.Schema<A, any> } | undefined
96
+ // 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
97
+ readonly persist?:
98
+ | {
99
+ // A static string keys a singleton read; a function of the inputs keys a
100
+ // family per-entity, so reactive-input keyed reads never collide on one slot.
101
+ readonly key: string | ((inputs: InputsObject<Inputs>) => string)
102
+ readonly schema: Schema.Schema<A, any>
103
+ }
104
+ | undefined
91
105
  /**
92
106
  * Fired when a run's `Success` value has landed in the store (same scheduler
93
107
  * flush — never before the converged value is readable), with the run's
94
108
  * generation. Not fired on `Error`, interrupt, or while disabled. The seam
95
109
  * `RemoteState` settles pending intents through.
96
110
  */
111
+ // 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
97
112
  readonly onSettled?: ((generation: number) => void) | undefined
98
113
  }
99
114
 
@@ -118,6 +133,7 @@ export interface QueryDriver<A, E> {
118
133
  * `isFetching: true`. `invalidate` flips `isStale`; a stale query with active
119
134
  * readers auto-refetches (the same rule that drives refetch-on-resubscribe).
120
135
  */
136
+ // oxlint-disable-next-line reform-rules/prefer-effect-fn -- generic export: Effect.fn's inferred type isn't portable under isolatedDeclarations
121
137
  export const makeQueryDriver = <Inputs extends ReadonlyArray<AnySource>, A, E, R>(
122
138
  options: QueryDriverOptions<Inputs, A, E, R>,
123
139
  ): Effect.Effect<QueryDriver<A, E>, never, InputStores<Inputs> | R | Scope.Scope> =>
@@ -130,6 +146,11 @@ export const makeQueryDriver = <Inputs extends ReadonlyArray<AnySource>, A, E, R
130
146
 
131
147
  const keyOf = (args: InputsObject<Inputs>): ReadonlyArray<unknown> =>
132
148
  extraKey === undefined ? sources.keyOf(args) : [...sources.keyOf(args), extraKey.read()]
149
+ // The persist storage key at the current inputs: a static string for a
150
+ // singleton, or the per-input function for a keyed family (no DOM/extraKey
151
+ // churn — purely the user-declared key, so it stays stable across refetches).
152
+ const persistKey = (key: string | ((inputs: InputsObject<Inputs>) => string), args: InputsObject<Inputs>): string =>
153
+ typeof key === 'function' ? key(args) : key
133
154
  const subscribeAll = (listener: () => void): (() => void) => {
134
155
  const offSources = sources.subscribe(listener)
135
156
  const offExtra = extraKey?.subscribe(listener)
@@ -156,7 +177,7 @@ export const makeQueryDriver = <Inputs extends ReadonlyArray<AnySource>, A, E, R
156
177
  const lastKey = MutableRef.make<ReadonlyArray<unknown> | undefined>(undefined)
157
178
  const generation = MutableRef.make(0)
158
179
  const nextGeneration = (): number => {
159
- MutableRef.update(generation, (n) => n + 1)
180
+ MutableRef.update(generation, (current) => current + 1)
160
181
  return MutableRef.get(generation)
161
182
  }
162
183
 
@@ -164,13 +185,17 @@ export const makeQueryDriver = <Inputs extends ReadonlyArray<AnySource>, A, E, R
164
185
  // keep their field references, so a pure `isStale` flip leaves the projection
165
186
  // reference unmoved (see `project`). ──────────────────────────────────────
166
187
  const markFetching = () => {
167
- const s = stateStore.get()
168
- if (!s.isFetching) stateStore.set({ ...s, isFetching: true })
188
+ const state = stateStore.get()
189
+ if (!state.isFetching) {
190
+ stateStore.set({ ...state, isFetching: true })
191
+ }
169
192
  }
170
- const settleSuccess = (value: A): A => {
193
+ const settleSuccess = (produced: A): A => {
171
194
  const prev = stateStore.get()
172
195
  const shared =
173
- options.reuse === true && Option.isSome(prev.data) ? reuse(prev.data.value, value) : value
196
+ options.reuse === true && Option.isSome(prev.data)
197
+ ? reuse(prev.data.value, produced)
198
+ : produced
174
199
  stateStore.set({
175
200
  data: Option.some(shared),
176
201
  error: Option.none(),
@@ -186,15 +211,19 @@ export const makeQueryDriver = <Inputs extends ReadonlyArray<AnySource>, A, E, R
186
211
  stateStore.set({ ...prev, error: Option.some(error), isFetching: false })
187
212
  }
188
213
 
189
- const persistWrite = (value: A): Effect.Effect<void> =>
190
- persist === undefined
191
- ? Effect.void
192
- : Schema.encode(persist.schema)(value).pipe(
193
- Effect.flatMap((encoded) => queryStore.set(persist.key, encoded)),
194
- // Persistence is best-effort: a serialization or storage failure must
195
- // never fail the run nor block the visible write.
196
- Effect.ignore,
197
- )
214
+ const persistWriteTo = (
215
+ target: NonNullable<typeof persist>,
216
+ produced: A,
217
+ args: InputsObject<Inputs>,
218
+ ): Effect.Effect<void> =>
219
+ Schema.encode(target.schema)(produced).pipe(
220
+ Effect.flatMap((encoded) => queryStore.set(persistKey(target.key, args), encoded)),
221
+ // Persistence is best-effort: a serialization or storage failure must
222
+ // never fail the run nor block the visible write.
223
+ Effect.ignore,
224
+ )
225
+ const persistWrite = (produced: A, args: InputsObject<Inputs>): Effect.Effect<void> =>
226
+ persist === undefined ? Effect.void : persistWriteTo(persist, produced, args)
198
227
 
199
228
  // The projection the public store exposes: `AnyAsyncData`, memoized so a pure
200
229
  // `isStale` change (which the projection ignores) returns the SAME reference
@@ -212,8 +241,9 @@ export const makeQueryDriver = <Inputs extends ReadonlyArray<AnySource>, A, E, R
212
241
  prev.state.data === state.data &&
213
242
  prev.state.error === state.error &&
214
243
  prev.state.isFetching === state.isFetching
215
- )
244
+ ) {
216
245
  return prev.view
246
+ }
217
247
  const view = toAsyncData(state, options.gated)
218
248
  MutableRef.set(projection, { state, view })
219
249
  return view
@@ -226,6 +256,19 @@ export const makeQueryDriver = <Inputs extends ReadonlyArray<AnySource>, A, E, R
226
256
  readonly generation: number
227
257
  }
228
258
 
259
+ // Commit a `Success` value: write the cell, report the generation, then persist
260
+ // (best-effort, trailing). A no-op when the query was disabled meanwhile.
261
+ const commitSuccess = (produced: A, request: Request): Effect.Effect<void> => {
262
+ if (disabledNow()) {
263
+ return Effect.void
264
+ }
265
+ return Effect.suspend(() => {
266
+ const shared = settleSuccess(produced)
267
+ options.onSettled?.(request.generation)
268
+ return persistWrite(shared, request.args)
269
+ })
270
+ }
271
+
229
272
  // One run of the query, folded into the cell. A failure becomes `Error`; an
230
273
  // interrupt (latest-wins cancel) leaves the state untouched; a defect (a bug in
231
274
  // the body) is logged and isolated. A `Success` reports its generation through
@@ -236,24 +279,19 @@ export const makeQueryDriver = <Inputs extends ReadonlyArray<AnySource>, A, E, R
236
279
  Effect.logError(`reform: ${options.label} '${options.name}' query defect`, defect),
237
280
  ),
238
281
  Effect.matchCause({
239
- onSuccess: (value): Option.Option<A> => Option.some(value),
282
+ onSuccess: (produced): Option.Option<A> => Option.some(produced),
240
283
  onFailure: (cause): Option.Option<A> => {
241
284
  const failure = Cause.failureOption(cause)
242
- if (Option.isSome(failure) && !disabledNow()) settleError(failure.value)
285
+ if (Option.isSome(failure) && !disabledNow()) {
286
+ settleError(failure.value)
287
+ }
243
288
  return Option.none()
244
289
  },
245
290
  }),
246
291
  Effect.flatMap((ok) =>
247
292
  Option.match(ok, {
248
293
  onNone: () => Effect.void,
249
- onSome: (value) =>
250
- disabledNow()
251
- ? Effect.void
252
- : Effect.suspend(() => {
253
- const shared = settleSuccess(value)
254
- options.onSettled?.(request.generation)
255
- return persistWrite(shared)
256
- }),
294
+ onSome: (produced) => commitSuccess(produced, request),
257
295
  }),
258
296
  ),
259
297
  )
@@ -265,23 +303,25 @@ export const makeQueryDriver = <Inputs extends ReadonlyArray<AnySource>, A, E, R
265
303
  // one trailing run after settle" by construction.
266
304
  const trailing = options.coalesce === 'trailing'
267
305
  const requests = yield* Queue.unbounded<Request>()
268
- yield* Effect.forkScoped(
269
- trailing
270
- ? Effect.forever(
271
- Effect.gen(function* () {
272
- const first = yield* Queue.take(requests)
273
- const queued = yield* Queue.takeAll(requests)
274
- const request = Option.getOrElse(Chunk.last(queued), () => first)
275
- if (!disabledNow()) yield* runQuery(request)
276
- const pending = yield* Queue.size(requests)
277
- if (pending > 0 && !disabledNow()) yield* Effect.sync(markFetching)
278
- }),
279
- )
280
- : Stream.fromQueue(requests).pipe(
281
- Stream.flatMap((request) => Stream.fromEffect(runQuery(request)), { switch: true }),
282
- Stream.runDrain,
283
- ),
306
+ const trailingConsumer = Effect.forever(
307
+ Effect.gen(function* () {
308
+ const first = yield* Queue.take(requests)
309
+ const queued = yield* Queue.takeAll(requests)
310
+ const request = Option.getOrElse(Chunk.last(queued), () => first)
311
+ if (!disabledNow()) {
312
+ yield* runQuery(request)
313
+ }
314
+ const pending = yield* Queue.size(requests)
315
+ if (pending > 0 && !disabledNow()) {
316
+ yield* Effect.sync(markFetching)
317
+ }
318
+ }),
319
+ )
320
+ const switchConsumer = Stream.fromQueue(requests).pipe(
321
+ Stream.flatMap((request) => Stream.fromEffect(runQuery(request)), { switch: true }),
322
+ Stream.runDrain,
284
323
  )
324
+ yield* Effect.forkScoped(trailing ? trailingConsumer : switchConsumer)
285
325
 
286
326
  // Enqueue a run of the current key, marking the flight. `force` skips the
287
327
  // no-op-key guard (the refetch path); otherwise an unchanged key is ignored.
@@ -295,7 +335,9 @@ export const makeQueryDriver = <Inputs extends ReadonlyArray<AnySource>, A, E, R
295
335
  }
296
336
  const key = keyOf(args)
297
337
  const previous = MutableRef.get(lastKey)
298
- if (!force && previous !== undefined && sameKey(key, previous)) return
338
+ if (!force && previous !== undefined && sameKey(key, previous)) {
339
+ return
340
+ }
299
341
  MutableRef.set(lastKey, key)
300
342
  markFetching()
301
343
  Queue.unsafeOffer(requests, { args, generation: nextGeneration() })
@@ -306,12 +348,16 @@ export const makeQueryDriver = <Inputs extends ReadonlyArray<AnySource>, A, E, R
306
348
  // `invalidate`: flip `isStale` only. A stale query with active readers then
307
349
  // auto-refetches — the one place invalidation indirectly fetches.
308
350
  const maybeAutoRefetch = () => {
309
- if (stateStore.get().isStale && MutableRef.get(readers) > 0) refetch()
351
+ if (stateStore.get().isStale && MutableRef.get(readers) > 0) {
352
+ refetch()
353
+ }
310
354
  }
311
355
  const invalidate = () => {
312
- const s = stateStore.get()
313
- if (s.isStale) return
314
- stateStore.set({ ...s, isStale: true })
356
+ const state = stateStore.get()
357
+ if (state.isStale) {
358
+ return
359
+ }
360
+ stateStore.set({ ...state, isStale: true })
315
361
  maybeAutoRefetch()
316
362
  }
317
363
 
@@ -321,7 +367,8 @@ export const makeQueryDriver = <Inputs extends ReadonlyArray<AnySource>, A, E, R
321
367
  // Hydrate from the persisted value before the first kick: show it instantly,
322
368
  // marked stale, so the kick refetches over it (stale-while-revalidate).
323
369
  if (persist !== undefined && !disabledNow()) {
324
- const cached = yield* queryStore.get(persist.key)
370
+ const hydrateArgs = sources.snapshot()
371
+ const cached = yield* queryStore.get(persistKey(persist.key, hydrateArgs))
325
372
  yield* Option.match(cached, {
326
373
  onNone: () => Effect.void,
327
374
  onSome: (raw) =>
@@ -329,9 +376,9 @@ export const makeQueryDriver = <Inputs extends ReadonlyArray<AnySource>, A, E, R
329
376
  Effect.match({
330
377
  // Corrupt/incompatible cache: ignore and fetch fresh.
331
378
  onFailure: () => undefined,
332
- onSuccess: (value) =>
379
+ onSuccess: (decoded) =>
333
380
  stateStore.set({
334
- data: Option.some(value),
381
+ data: Option.some(decoded),
335
382
  error: Option.none(),
336
383
  isFetching: false,
337
384
  isStale: true,
@@ -357,10 +404,12 @@ export const makeQueryDriver = <Inputs extends ReadonlyArray<AnySource>, A, E, R
357
404
  const before = MutableRef.get(readers)
358
405
  MutableRef.set(readers, before + 1)
359
406
  const off = derived.store.subscribe(listener)
360
- if (before === 0) maybeAutoRefetch()
407
+ if (before === 0) {
408
+ maybeAutoRefetch()
409
+ }
361
410
  return () => {
362
411
  off()
363
- MutableRef.update(readers, (n) => Math.max(0, n - 1))
412
+ MutableRef.update(readers, (count) => Math.max(0, count - 1))
364
413
  }
365
414
  },
366
415
  }
@@ -1,4 +1,4 @@
1
- import { Equal } from 'effect'
1
+ import { Equal, Record as Rec } from 'effect'
2
2
 
3
3
  // Structural sharing for recomputed calc outputs: reconcile a fresh output
4
4
  // against the previous one, substituting previous nodes wherever value equality
@@ -9,32 +9,72 @@ import { Equal } from 'effect'
9
9
  // pass composes with the framework's Equal-based invalidation untouched.
10
10
 
11
11
  // Bounds the walk; calc outputs are shallow plain data by framework convention.
12
- const maxDepth = 16
12
+ const MAX_DEPTH = 16
13
13
 
14
- const isPlainRecord = (v: unknown): v is Record<string, unknown> =>
15
- typeof v === 'object' &&
16
- v !== null &&
17
- (Object.getPrototypeOf(v) === Object.prototype || Object.getPrototypeOf(v) === null)
14
+ const isPlainRecord = (candidate: unknown): candidate is Record<string, unknown> =>
15
+ typeof candidate === 'object' &&
16
+ candidate !== null &&
17
+ (Object.getPrototypeOf(candidate) === Object.prototype ||
18
+ Object.getPrototypeOf(candidate) === null)
18
19
 
19
- const go = (prev: unknown, next: unknown, depth: number): unknown => {
20
- if (Object.is(prev, next)) return prev
20
+ /** Own enumerable string-keyed fields of an object, as a plain record. */
21
+ const ownRecord = (source: object): Record<string, unknown> =>
22
+ Rec.fromEntries(
23
+ Reflect.ownKeys(source)
24
+ .filter(
25
+ (key): key is string =>
26
+ typeof key === 'string' && Object.prototype.propertyIsEnumerable.call(source, key),
27
+ )
28
+ .map((key): readonly [string, unknown] => [key, Reflect.get(source, key)]),
29
+ )
30
+
31
+ interface ReconcileArgs {
32
+ readonly prev: unknown
33
+ readonly next: unknown
34
+ readonly depth: number
35
+ }
36
+
37
+ /** Reconcile two records field-by-field; reports whether every field stayed `prev`. */
38
+ const reconcileRecord = (
39
+ prev: Record<string, unknown>,
40
+ next: Record<string, unknown>,
41
+ depth: number,
42
+ ): { readonly out: Record<string, unknown>; readonly allPrev: boolean } => {
43
+ const keys = Rec.keys(next)
44
+ const out = Rec.fromEntries(
45
+ keys.map((key): readonly [string, unknown] => [
46
+ key,
47
+ key in prev ? reconcile({ prev: prev[key], next: next[key], depth: depth - 1 }) : next[key],
48
+ ]),
49
+ )
50
+ const allPrev =
51
+ keys.length === Rec.keys(prev).length &&
52
+ keys.every((key) => key in prev && Object.is(out[key], prev[key]))
53
+ return { out, allPrev }
54
+ }
55
+
56
+ const reconcile = ({ prev, next, depth }: ReconcileArgs): unknown => {
57
+ if (Object.is(prev, next)) {
58
+ return prev
59
+ }
21
60
  // Data/Schema classes implement Equal+Hash: substitute wholesale on value
22
61
  // equality. Plain objects/arrays fall through (their Equal is referential).
23
- if (Equal.equals(prev, next)) return prev
24
- if (depth <= 0) return next
62
+ if (Equal.equals(prev, next)) {
63
+ return prev
64
+ }
65
+ if (depth <= 0) {
66
+ return next
67
+ }
25
68
  if (Array.isArray(prev) && Array.isArray(next)) {
26
- const out = next.map((item, i) => (i < prev.length ? go(prev[i], item, depth - 1) : item))
27
- return prev.length === next.length && out.every((v, i) => Object.is(v, prev[i]))
28
- ? prev
29
- : out
69
+ const reconciled = next.map((element, index) =>
70
+ index < prev.length ? reconcile({ prev: prev[index], next: element, depth: depth - 1 }) : element,
71
+ )
72
+ const unchanged =
73
+ prev.length === next.length && reconciled.every((entry, index) => Object.is(entry, prev[index]))
74
+ return unchanged ? prev : reconciled
30
75
  }
31
76
  if (isPlainRecord(prev) && isPlainRecord(next)) {
32
- const keys = Object.keys(next)
33
- const out: Record<string, unknown> = {}
34
- for (const key of keys) out[key] = key in prev ? go(prev[key], next[key], depth - 1) : next[key]
35
- const allPrev =
36
- keys.length === Object.keys(prev).length &&
37
- keys.every((key) => key in prev && Object.is(out[key], prev[key]))
77
+ const { out, allPrev } = reconcileRecord(prev, next, depth)
38
78
  return allPrev ? prev : out
39
79
  }
40
80
  // Data/Schema class instances that DIFFER still get walked: their Equal is
@@ -48,15 +88,10 @@ const go = (prev: unknown, next: unknown, depth: number): unknown => {
48
88
  Equal.isEqual(next) &&
49
89
  Object.getPrototypeOf(prev) === Object.getPrototypeOf(next)
50
90
  ) {
51
- const prevFields = Object.fromEntries(Object.entries(prev))
52
- const nextFields = Object.entries(next)
53
- const out: Record<string, unknown> = {}
54
- for (const [key, value] of nextFields)
55
- out[key] = key in prevFields ? go(prevFields[key], value, depth - 1) : value
56
- const allPrev =
57
- nextFields.length === Object.keys(prevFields).length &&
58
- nextFields.every(([key]) => key in prevFields && Object.is(out[key], prevFields[key]))
59
- return allPrev ? prev : Object.assign(Object.create(Object.getPrototypeOf(next)), out)
91
+ const { out, allPrev } = reconcileRecord(ownRecord(prev), ownRecord(next), depth)
92
+ return allPrev
93
+ ? prev
94
+ : Object.create(Object.getPrototypeOf(next), Object.getOwnPropertyDescriptors(out))
60
95
  }
61
96
  // Class instances without Equal (Date, Map, …) are opaque leaves.
62
97
  return next
@@ -70,4 +105,6 @@ const go = (prev: unknown, next: unknown, depth: number): unknown => {
70
105
  * the single cast below is that argument, in the style of `wireSources` /
71
106
  * `narrowStore`.
72
107
  */
73
- export const reuse = <A>(previous: A, next: A): A => go(previous, next, maxDepth) as A
108
+ export const reuse = <A>(previous: A, next: A): A =>
109
+ // oxlint-disable-next-line reform-rules/no-type-assertion -- the walker returns a value-equal reconstruction of `next`, type-preserved by construction (the single seam this cast lives at)
110
+ reconcile({ prev: previous, next, depth: MAX_DEPTH }) as A