@playfast/reform 1.0.1 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/README.md +18 -1
  2. package/package.json +1 -1
  3. package/src/boundary/boundary.test.ts +1 -33
  4. package/src/boundary/boundary.ts +4 -82
  5. package/src/calc/asyncCalc.invalidate.test.ts +2 -14
  6. package/src/calc/asyncCalc.test.ts +0 -38
  7. package/src/calc/asyncCalc.ts +11 -163
  8. package/src/calc/asyncCalcDefinitions.ts +48 -0
  9. package/src/calc/asyncData.ts +0 -37
  10. package/src/calc/calc.test.ts +4 -28
  11. package/src/calc/calc.ts +6 -46
  12. package/src/calc/calcFamily.test.ts +0 -12
  13. package/src/calc/calcFamily.ts +6 -48
  14. package/src/calc/compose.test.ts +1 -12
  15. package/src/calc/compose.ts +0 -22
  16. package/src/calc/queryState.ts +0 -23
  17. package/src/channel/channel.ts +72 -174
  18. package/src/channel/procedureRegistry.ts +90 -0
  19. package/src/compose/composition.ts +7 -67
  20. package/src/compose/host.ts +0 -6
  21. package/src/compose/props.ts +3 -9
  22. package/src/compose/provide.ts +19 -33
  23. package/src/compose/slot.ts +2 -30
  24. package/src/compose/structure.test.ts +0 -6
  25. package/src/compose/structure.ts +3 -67
  26. package/src/compose/ui.test.ts +0 -4
  27. package/src/compose/ui.ts +17 -111
  28. package/src/compose/ui.typecheck.ts +0 -14
  29. package/src/definition/definition.ts +0 -32
  30. package/src/event/event.test.ts +0 -3
  31. package/src/event/event.ts +5 -14
  32. package/src/event/eventGroup.ts +0 -1
  33. package/src/feature/feature.mount.test.ts +66 -54
  34. package/src/feature/feature.test.ts +67 -46
  35. package/src/feature/feature.ts +185 -214
  36. package/src/feature/feature.typecheck.ts +29 -16
  37. package/src/feature/featureBinding.ts +48 -0
  38. package/src/index.ts +245 -229
  39. package/src/internal/capture.ts +0 -20
  40. package/src/internal/ctx.ts +2 -9
  41. package/src/internal/env.ts +9 -0
  42. package/src/internal/errors.test.ts +0 -6
  43. package/src/internal/errors.ts +14 -48
  44. package/src/internal/inspect.test.ts +0 -6
  45. package/src/internal/inspect.ts +0 -12
  46. package/src/internal/queryDriver.ts +42 -243
  47. package/src/internal/queryDriverStore.ts +153 -0
  48. package/src/internal/queryDriverTypes.ts +57 -0
  49. package/src/internal/queryEvents.ts +0 -12
  50. package/src/internal/queryStore.ts +0 -14
  51. package/src/internal/reuse.test.ts +0 -11
  52. package/src/internal/reuse.ts +0 -28
  53. package/src/internal/scheduler.ts +0 -43
  54. package/src/internal/seeds.ts +0 -14
  55. package/src/internal/sources.ts +4 -41
  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 +0 -18
  60. package/src/internal/variance.ts +5 -0
  61. package/src/namespace/namespace.test.ts +46 -0
  62. package/src/namespace/namespace.ts +85 -0
  63. package/src/procedure/procedure.ts +2 -24
  64. package/src/reducer/reducer.ts +3 -21
  65. package/src/remote/remoteState.test.ts +92 -75
  66. package/src/remote/remoteState.ts +99 -486
  67. package/src/remote/remoteState.typecheck.ts +0 -33
  68. package/src/remote/remoteStateDefinition.ts +135 -0
  69. package/src/remote/remoteStateLayers.ts +118 -0
  70. package/src/remote/remoteStateLiveTypes.ts +59 -0
  71. package/src/remote/remoteStateSend.ts +64 -0
  72. package/src/runtime/appRuntime.test.ts +197 -0
  73. package/src/runtime/appRuntime.ts +40 -83
  74. package/src/runtime/bus.ts +2 -15
  75. package/src/runtime/eventBudget.test.ts +155 -0
  76. package/src/runtime/eventBudget.ts +119 -0
  77. package/src/runtime/hardening.test.ts +74 -9
  78. package/src/runtime/instrumentation.test.ts +213 -192
  79. package/src/runtime/instrumentation.ts +0 -49
  80. package/src/runtime/loop.test.ts +0 -17
  81. package/src/runtime/loop.ts +25 -55
  82. package/src/runtime/queries.ts +0 -17
  83. package/src/scene/featureScene.test.ts +59 -0
  84. package/src/scene/scene.ts +32 -59
  85. package/src/scene/seedScene.test.ts +96 -116
  86. package/src/state/state.ts +7 -26
  87. package/src/state/stateFamily.test.ts +2 -11
  88. package/src/state/stateFamily.ts +9 -52
  89. package/src/state/stateGroup.ts +2 -49
  90. package/src/state/token.ts +4 -17
  91. package/src/synced/syncedStore.ts +7 -42
  92. package/src/ui/node.ts +0 -6
  93. package/src/ui/trigger.ts +0 -5
  94. package/src/wire/tree.test.ts +0 -3
  95. package/src/wire/tree.ts +0 -39
  96. package/src/wire/triggers.test.ts +0 -4
  97. package/src/wire/triggers.ts +5 -27
@@ -1,28 +1,14 @@
1
1
  import { Cause, Data, Option, Runtime } from 'effect'
2
2
 
3
- /**
4
- * Tagged errors for the framework's synchronous boundaries. Even when thrown
5
- * (React render, layer build, reflection), an error carries a `_tag` and its
6
- * structured fields — never a bare `Error` — so callers and tooling can match on
7
- * it. `Data.TaggedError` subclasses are real `Error`s, so `throw` still works.
8
- */
9
-
10
- /**
11
- * The constructor shape `Data.TaggedError(tag)<A>` produces, named so the
12
- * generated `.d.ts` can describe the `extends` base under `isolatedDeclarations`
13
- * (which forbids an inferred expression in an extends clause).
14
- */
15
3
  type TaggedErrorClass<Tag extends string, A extends Record<string, unknown>> = new (
16
4
  args: A,
17
5
  ) => Cause.YieldableError & { readonly _tag: Tag } & Readonly<A>
18
6
 
19
- /** The fieldless variant — a `Data.TaggedError(tag)<{}>` constructor. */
20
7
  type EmptyTaggedErrorClass<Tag extends string> = new () => Cause.YieldableError & { readonly _tag: Tag }
21
8
 
22
9
  const InvalidProvideTargetBase: EmptyTaggedErrorClass<'reform/InvalidProvideTarget'> =
23
10
  Data.TaggedError('reform/InvalidProvideTarget')<{}>
24
11
 
25
- /** `provide(target, …)` was handed something that is neither a ui contract nor a slot. */
26
12
  export class InvalidProvideTarget extends InvalidProvideTargetBase {
27
13
  override get message(): string {
28
14
  return 'reform: provide target is neither a ui contract nor a slot'
@@ -32,7 +18,6 @@ export class InvalidProvideTarget extends InvalidProvideTargetBase {
32
18
  const UnknownGroupStateBase: TaggedErrorClass<'reform/UnknownGroupState', { readonly name: string }> =
33
19
  Data.TaggedError('reform/UnknownGroupState')<{ readonly name: string }>
34
20
 
35
- /** `StateGroup.select(group, name)` named a state the group does not contain. */
36
21
  export class UnknownGroupState extends UnknownGroupStateBase {
37
22
  override get message(): string {
38
23
  return `reform: unknown state '${this.name}' in group`
@@ -42,12 +27,6 @@ export class UnknownGroupState extends UnknownGroupStateBase {
42
27
  const FeatureLoadFailedBase: TaggedErrorClass<'reform/FeatureLoadFailed', { readonly cause: unknown }> =
43
28
  Data.TaggedError('reform/FeatureLoadFailed')<{ readonly cause: unknown }>
44
29
 
45
- /**
46
- * A lazy `Feature`'s `load` Effect failed — the dynamic `import()` rejected (a
47
- * missing chunk, a network failure, a module-eval throw). Surfaced as a typed
48
- * failure the host hands to the feature's `placeholder.failed`, never a bare
49
- * rejection. `cause` is the original rejection value.
50
- */
51
30
  export class FeatureLoadFailed extends FeatureLoadFailedBase {
52
31
  override get message(): string {
53
32
  return `reform: a feature failed to load (${String(this.cause)})`
@@ -57,7 +36,6 @@ export class FeatureLoadFailed extends FeatureLoadFailedBase {
57
36
  const AsyncReducerBase: EmptyTaggedErrorClass<'reform/AsyncReducer'> =
58
37
  Data.TaggedError('reform/AsyncReducer')<{}>
59
38
 
60
- /** A reducer fold returned a thenable — folds must be pure synchronous writes. */
61
39
  export class AsyncReducer extends AsyncReducerBase {
62
40
  override get message(): string {
63
41
  return 'reform: a reducer fold must be synchronous (it returned a Promise)'
@@ -67,14 +45,6 @@ export class AsyncReducer extends AsyncReducerBase {
67
45
  const AsyncSceneLayerBase: EmptyTaggedErrorClass<'reform/AsyncSceneLayer'> =
68
46
  Data.TaggedError('reform/AsyncSceneLayer')<{}>
69
47
 
70
- /**
71
- * A scene layer performed async work during reform's synchronous layer build.
72
- * Reform materialises a scene's layer graph with `runSync` so the first frame
73
- * renders synchronously (D2); a `Layer.effect`/`scoped` whose acquire suspends
74
- * (HTTP, opening a DB, `Effect.sleep`) makes that `runSync` throw Effect's opaque
75
- * `AsyncFiberException`. `forceSync` catches it and rethrows this, which names the
76
- * cause and the sanctioned fix instead of pointing at Effect internals.
77
- */
78
48
  export class AsyncSceneLayer extends AsyncSceneLayerBase {
79
49
  override get message(): string {
80
50
  return (
@@ -87,12 +57,6 @@ export class AsyncSceneLayer extends AsyncSceneLayerBase {
87
57
  }
88
58
  }
89
59
 
90
- /**
91
- * Whether a thrown value is Effect's "ran async under runSync" defect. `runSync`
92
- * throws the bare `AsyncFiberException`; a `ManagedRuntime.runSync` whose layer
93
- * build suspends instead throws a `FiberFailure` carrying it as a `Die`, so both
94
- * shapes are checked.
95
- */
96
60
  const isAsyncBuildDefect = (error: unknown): boolean => {
97
61
  if (Runtime.isAsyncFiberException(error)) {
98
62
  return true
@@ -104,12 +68,6 @@ const isAsyncBuildDefect = (error: unknown): boolean => {
104
68
  return Option.isSome(die) && Runtime.isAsyncFiberException(die.value)
105
69
  }
106
70
 
107
- /**
108
- * Run a synchronous runtime read (the scene layer build / context capture) and
109
- * translate Effect's async-fiber defect into `AsyncSceneLayer`; any other throw
110
- * passes through unchanged. The single home for the sync-build boundary, shared by
111
- * the React host and the remote server.
112
- */
113
71
  export const forceSync = <A>(thunk: () => A): A => {
114
72
  // 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
115
73
  try {
@@ -125,14 +83,22 @@ const DuplicateRegistrationBase: TaggedErrorClass<
125
83
  { readonly kind: string; readonly name: string }
126
84
  > = Data.TaggedError('reform/DuplicateRegistration')<{ readonly kind: string; readonly name: string }>
127
85
 
128
- /**
129
- * Two distinct primitives were registered under one name into the same runtime —
130
- * a silent footgun, since names key DI tags and channel lanes. Thrown for a
131
- * channel name reused with a different policy (the second would otherwise be
132
- * dropped). Reducer/procedure name clashes are warned (logged), not thrown.
133
- */
134
86
  export class DuplicateRegistration extends DuplicateRegistrationBase {
135
87
  override get message(): string {
136
88
  return `reform: duplicate ${this.kind} registered under name '${this.name}'`
137
89
  }
138
90
  }
91
+
92
+ const EventLoopOverflowBase: TaggedErrorClass<
93
+ 'reform/EventLoopOverflow',
94
+ { readonly count: number; readonly limit: number }
95
+ > = Data.TaggedError('reform/EventLoopOverflow')<{ readonly count: number; readonly limit: number }>
96
+
97
+ export class EventLoopOverflow extends EventLoopOverflowBase {
98
+ override get message(): string {
99
+ return (
100
+ `reform: ${this.count} events processed without yielding to the event loop ` +
101
+ `(limit ${this.limit}) — likely an infinite event feedback loop; the runtime has been halted`
102
+ )
103
+ }
104
+ }
@@ -3,24 +3,18 @@ import { Schema as S } from 'effect'
3
3
  import { State } from '../index'
4
4
  import { makeStore } from './store'
5
5
 
6
- // Every reform value should print like an Effect value (an `_id`-tagged JSON),
7
- // not `[object Object]` — via `toJSON`/`toString`/the Node inspect symbol.
8
-
9
6
  describe('Inspectable', () => {
10
7
  it('a Store reports its current value as tagged JSON', () => {
11
8
  const store = makeStore(41)
12
9
  expect(store.toJSON()).toEqual({ _id: 'reform/Store', value: 41 })
13
10
  store.set(42)
14
11
  expect(store.toJSON()).toEqual({ _id: 'reform/Store', value: 42 })
15
- // toString and JSON.stringify route through the same toJSON.
16
12
  expect(JSON.parse(JSON.stringify(store))).toEqual({ _id: 'reform/Store', value: 42 })
17
13
  expect(store.toString()).toContain('reform/Store')
18
14
  })
19
15
 
20
16
  it('a definition reports its manifest', () => {
21
17
  class Count extends State.make('count', S.Number) {}
22
- // Inspectable is attached at runtime (definitions are reflected via their
23
- // manifest); reach the method through a typed view.
24
18
  const inspectable = Count as unknown as { toJSON(): unknown }
25
19
  expect(inspectable.toJSON()).toBe(Count.manifest)
26
20
  expect(String(Count)).toContain('"kind": "State"')
@@ -2,24 +2,12 @@ import { format, type Inspectable, NodeInspectSymbol } from 'effect/Inspectable'
2
2
 
3
3
  export { type Inspectable, NodeInspectSymbol }
4
4
 
5
- /**
6
- * The `Inspectable` trio built around a `toJSON`, so every reform value prints
7
- * like an Effect value — `_id`-tagged JSON under `console.log`, Node's
8
- * inspector, `JSON.stringify`, and `String(...)` — instead of `[object Object]`.
9
- * Mirrors Effect's `BaseProto` / `Inspectable.Class` (`Inspectable.ts:170`).
10
- */
11
5
  export const inspectable = (toJSON: () => unknown): Inspectable => ({
12
6
  toJSON,
13
7
  toString: () => format(toJSON()),
14
8
  [NodeInspectSymbol]: () => toJSON(),
15
9
  })
16
10
 
17
- /**
18
- * Attach the `Inspectable` trio to an existing object as *non-enumerable* own
19
- * properties (so they don't show up in `Object.keys`/`for…in` over a definition
20
- * class). Used where the value is built by other means — the definition classes,
21
- * whose statics are merged in separately.
22
- */
23
11
  export const attachInspectable = <T extends object>(target: T, toJSON: () => unknown): T => {
24
12
  const trio = inspectable(toJSON)
25
13
  Reflect.ownKeys(trio).forEach((key) => {
@@ -1,139 +1,49 @@
1
1
  import {
2
- Brand,
3
2
  Cause,
4
3
  Chunk,
5
4
  Effect,
6
5
  MutableRef,
7
6
  Option,
8
7
  Queue,
9
- Schema,
10
8
  type Scope,
11
9
  Stream,
12
10
  } from 'effect'
13
- import { type AnyAsyncData } from '../calc/asyncData'
14
- import { empty, type QueryState, toAsyncData } from '../calc/queryState'
11
+ import { empty, type QueryState } from '../calc/queryState'
15
12
  import { Queries, type QueryHandle } from '../runtime/queries'
16
13
  import { reuse } from './reuse'
17
14
  import { resolveInstrumentation } from '../runtime/instrumentation'
18
15
  import { resolveQueryStore } from './queryStore'
16
+ import type { QueryDriver, QueryDriverOptions } from './queryDriverTypes'
17
+ import {
18
+ hydrateQuery,
19
+ incrementGeneration,
20
+ makePersistWriter,
21
+ makeQueryProjection,
22
+ subscribeQuerySources,
23
+ settleQueryError,
24
+ withReaderTracking,
25
+ } from './queryDriverStore'
19
26
  import { resolveScheduler } from './scheduler'
20
27
  import {
21
28
  type InputsObject,
22
29
  type InputStores,
23
- type InvalidateBy,
24
30
  sameKey,
25
31
  wireSources,
26
32
  } from './sources'
27
- import { makeDerivedStore, makeStore, type Store } from './store'
33
+ import { makeStore } from './store'
28
34
  import { type AnySource } from '../state/token'
29
35
 
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
41
-
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)
61
-
62
- 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
- readonly name: string
65
- /** The owning primitive's kind label — only for the defect log line. */
66
- readonly label: string
67
- /** Whether `disabled` is honored / the `Idle` arm can occur. */
68
- readonly gated: boolean
69
- readonly inputs: Inputs
70
- 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
- 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
- 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
- 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
- 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
- readonly extraKey?:
86
- | {
87
- readonly read: () => unknown
88
- readonly subscribe: (listener: () => void) => () => void
89
- }
90
- | 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
- readonly persist?:
99
- | {
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
- readonly key: string | ((inputs: InputsObject<Inputs>) => string)
103
- readonly schema: Schema.Schema<A, any>
104
- }
105
- | 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
- readonly onSettled?: ((generation: number) => void) | undefined
114
- }
115
-
116
- export interface QueryDriver<A, E> {
117
- /** The lifecycle store (the `AnyAsyncData` projection — callers narrow to their arms). */
118
- 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
- readonly requested: () => number
126
- }
36
+ export {
37
+ bumpRevision,
38
+ gatedFlag,
39
+ type GatedOf,
40
+ type QueryDriver,
41
+ type QueryDriverOptions,
42
+ Revision,
43
+ RevisionSchema,
44
+ revisionZero,
45
+ } from './queryDriverTypes'
127
46
 
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
47
  // oxlint-disable-next-line reform-rules/prefer-effect-fn -- generic export: Effect.fn's inferred type isn't portable under isolatedDeclarations
138
48
  export const makeQueryDriver = <Inputs extends ReadonlyArray<AnySource>, A, E, R>(
139
49
  options: QueryDriverOptions<Inputs, A, E, R>,
@@ -148,44 +58,17 @@ export const makeQueryDriver = <Inputs extends ReadonlyArray<AnySource>, A, E, R
148
58
 
149
59
  const keyOf = (args: InputsObject<Inputs>): ReadonlyArray<unknown> =>
150
60
  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
- }
164
61
  const disabledNow = (args: InputsObject<Inputs> = sources.snapshot()): boolean =>
165
62
  options.gated && options.disabled !== undefined ? options.disabled(args) : false
166
63
 
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
64
  const idle: QueryState<A, E> = empty(false)
170
65
  const stateStore = makeStore<QueryState<A, E>>(disabledNow() ? idle : empty(true), scheduler)
171
66
 
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
67
  const readers = MutableRef.make(0)
176
68
 
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
69
  const lastKey = MutableRef.make<ReadonlyArray<unknown> | undefined>(undefined)
180
70
  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`). ──────────────────────────────────────
71
+ const nextGeneration = (): number => incrementGeneration(generation)
189
72
  const markFetching = () => {
190
73
  const state = stateStore.get()
191
74
  if (!state.isFetching) {
@@ -206,51 +89,9 @@ export const makeQueryDriver = <Inputs extends ReadonlyArray<AnySource>, A, E, R
206
89
  })
207
90
  return shared
208
91
  }
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 })
214
- }
215
-
216
- const persistWriteTo = (
217
- target: NonNullable<typeof persist>,
218
- produced: A,
219
- args: InputsObject<Inputs>,
220
- ): Effect.Effect<void> =>
221
- Schema.encode(target.schema)(produced).pipe(
222
- 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.
225
- Effect.ignore,
226
- )
227
- const persistWrite = (produced: A, args: InputsObject<Inputs>): Effect.Effect<void> =>
228
- persist === undefined ? Effect.void : persistWriteTo(persist, produced, args)
92
+ const persistWrite = makePersistWriter<Inputs, A>({ persist, queryStore })
229
93
 
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)
94
+ const derived = makeQueryProjection({ gated: options.gated, scheduler, stateStore })
254
95
  yield* Effect.addFinalizer(() => Effect.sync(derived.unsubscribe))
255
96
 
256
97
  interface Request {
@@ -258,8 +99,6 @@ export const makeQueryDriver = <Inputs extends ReadonlyArray<AnySource>, A, E, R
258
99
  readonly generation: number
259
100
  }
260
101
 
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
102
  const commitSuccess = (produced: A, request: Request): Effect.Effect<void> => {
264
103
  if (disabledNow()) {
265
104
  return Effect.void
@@ -271,11 +110,6 @@ export const makeQueryDriver = <Inputs extends ReadonlyArray<AnySource>, A, E, R
271
110
  })
272
111
  }
273
112
 
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
113
  const runQuery = (request: Request): Effect.Effect<void, never, R> =>
280
114
  Effect.sync(() => instrumentation.queryRun(options.name)).pipe(
281
115
  Effect.flatMap((endSpan) =>
@@ -289,7 +123,7 @@ export const makeQueryDriver = <Inputs extends ReadonlyArray<AnySource>, A, E, R
289
123
  onFailure: (cause): Option.Option<A> => {
290
124
  const failure = Cause.failureOption(cause)
291
125
  if (Option.isSome(failure) && !disabledNow()) {
292
- settleError(failure.value)
126
+ settleQueryError(stateStore, failure.value)
293
127
  }
294
128
  return Option.none()
295
129
  },
@@ -302,11 +136,6 @@ export const makeQueryDriver = <Inputs extends ReadonlyArray<AnySource>, A, E, R
302
136
  ),
303
137
  )
304
138
 
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
139
  const trailing = options.coalesce === 'trailing'
311
140
  const requests = yield* Queue.unbounded<Request>()
312
141
  const trailingConsumer = Effect.forever(
@@ -329,12 +158,9 @@ export const makeQueryDriver = <Inputs extends ReadonlyArray<AnySource>, A, E, R
329
158
  )
330
159
  yield* Effect.forkScoped(trailing ? trailingConsumer : switchConsumer)
331
160
 
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
161
  const enqueue = (force: boolean) => {
335
162
  const args = sources.snapshot()
336
163
  if (disabledNow(args)) {
337
- // Switched off: show Idle and forget the key so re-enabling always re-runs.
338
164
  MutableRef.set(lastKey, undefined)
339
165
  stateStore.set(idle)
340
166
  return
@@ -351,8 +177,6 @@ export const makeQueryDriver = <Inputs extends ReadonlyArray<AnySource>, A, E, R
351
177
  const trigger = () => enqueue(false)
352
178
  const refetch = () => enqueue(true)
353
179
 
354
- // `invalidate`: flip `isStale` only. A stale query with active readers then
355
- // auto-refetches — the one place invalidation indirectly fetches.
356
180
  const maybeAutoRefetch = () => {
357
181
  if (stateStore.get().isStale && MutableRef.get(readers) > 0) {
358
182
  refetch()
@@ -367,34 +191,19 @@ export const makeQueryDriver = <Inputs extends ReadonlyArray<AnySource>, A, E, R
367
191
  maybeAutoRefetch()
368
192
  }
369
193
 
370
- const unsubscribe = subscribeAll(trigger)
194
+ const unsubscribe = subscribeQuerySources(sources, extraKey, trigger)
371
195
  yield* Effect.addFinalizer(() => Effect.sync(unsubscribe))
372
196
 
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).
375
197
  if (persist !== undefined && !disabledNow()) {
376
198
  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
- ),
199
+ yield* hydrateQuery({
200
+ cacheKey: typeof persist.key === 'function' ? persist.key(hydrateArgs) : persist.key,
201
+ queryStore,
202
+ schema: persist.schema,
203
+ stateStore,
394
204
  })
395
205
  }
396
206
 
397
- // Kick off the first fetch unless the query starts disabled.
398
207
  const initial = sources.snapshot()
399
208
  if (!disabledNow(initial)) {
400
209
  MutableRef.set(lastKey, keyOf(initial))
@@ -402,27 +211,12 @@ export const makeQueryDriver = <Inputs extends ReadonlyArray<AnySource>, A, E, R
402
211
  Queue.unsafeOffer(requests, { args: initial, generation: nextGeneration() })
403
212
  }
404
213
 
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
- }
214
+ const store = withReaderTracking({
215
+ store: derived.store,
216
+ readers,
217
+ onFirstReader: maybeAutoRefetch,
218
+ })
422
219
 
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
220
  const handle: QueryHandle = {
427
221
  name: options.name,
428
222
  invalidate,
@@ -432,6 +226,11 @@ export const makeQueryDriver = <Inputs extends ReadonlyArray<AnySource>, A, E, R
432
226
  }
433
227
  const queries = Option.getOrUndefined(yield* Effect.serviceOption(Queries))
434
228
  if (queries !== undefined) {
229
+ if (queries.byName.has(handle.name)) {
230
+ yield* Effect.logWarning(
231
+ `reform: duplicate query name '${handle.name}' registered; previous registration is being replaced`,
232
+ )
233
+ }
435
234
  queries.register(handle)
436
235
  yield* Effect.addFinalizer(() => Effect.sync(() => queries.unregister(handle)))
437
236
  }