@playfast/reform 0.1.0 → 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 +16 -44
  12. package/src/calc/calcFamily.test.ts +0 -12
  13. package/src/calc/calcFamily.ts +12 -49
  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 +78 -173
  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 -202
  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 +48 -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 +10 -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 +131 -0
  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 +324 -0
  79. package/src/runtime/instrumentation.ts +44 -0
  80. package/src/runtime/loop.test.ts +0 -17
  81. package/src/runtime/loop.ts +41 -58
  82. package/src/runtime/queries.ts +0 -17
  83. package/src/scene/featureScene.test.ts +59 -0
  84. package/src/scene/scene.ts +51 -44
  85. package/src/scene/seedScene.test.ts +96 -116
  86. package/src/state/state.ts +12 -28
  87. package/src/state/stateFamily.test.ts +2 -11
  88. package/src/state/stateFamily.ts +20 -54
  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,144 +1,56 @@
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'
14
+ import { resolveInstrumentation } from '../runtime/instrumentation'
17
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'
18
26
  import { resolveScheduler } from './scheduler'
19
27
  import {
20
28
  type InputsObject,
21
29
  type InputStores,
22
- type InvalidateBy,
23
30
  sameKey,
24
31
  wireSources,
25
32
  } from './sources'
26
- import { makeDerivedStore, makeStore, type Store } from './store'
33
+ import { makeStore } from './store'
27
34
  import { type AnySource } from '../state/token'
28
35
 
29
- // The query-lifecycle driver shared by `AsyncCalc.live` and `RemoteState.live`:
30
- // sense the inputs, run the query latest-wins (or trailing-conflated), and fold
31
- // the result into a flat `QueryState` cell (data/error/isFetching/isStale). The
32
- // public store is the `AnyAsyncData` *projection* of that cell, so existing
33
- // arm-matching consumers and `RemoteState` are unchanged. `invalidate` (flip
34
- // `isStale`) and `refetch` (force a run) are exposed as separate operations and
35
- // registered into the `Queries` registry by name. The asyncCalc test suite is the
36
- // driver's regression suite.
37
-
38
- /** `Gated` (whether the `Idle` arm exists) is the inverse of `alwaysOn`. */
39
- export type GatedOf<AlwaysOn extends boolean> = AlwaysOn extends true ? false : true
40
-
41
- /** The runtime gated flag for an `alwaysOn` config, typed as its `Gated` literal. */
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
44
- (alwaysOn !== true) as GatedOf<AlwaysOn>
45
-
46
- // The hidden revision behind `invalidateOn` — a module-local *branded* number.
47
- // Reform store services are type-identified (`Context.GenericTag<Store<A>>`), so
48
- // the hidden state's value type must be un-collidable: `Layer.provide` erases
49
- // exactly `Store<Revision>` from the returned layer and can never type-level
50
- // erase a user input that happens to be a number state. Never exported from the
51
- // package index, so the editor catalog (which only indexes module exports)
52
- // never sees it.
53
- export type Revision = number & Brand.Brand<'reform/Revision'>
54
- export const Revision: Brand.Brand.Constructor<Revision> = Brand.nominal<Revision>()
55
- export const RevisionSchema: Schema.Schema<Revision, number> = Schema.Number.pipe(
56
- Schema.brand('reform/Revision'),
57
- )
58
- export const revisionZero: Revision = Revision(0)
59
- export const bumpRevision = (revision: Revision): Revision => Revision(revision + 1)
60
-
61
- export interface QueryDriverOptions<Inputs extends ReadonlyArray<AnySource>, A, E, R> {
62
- /** The owning definition's name — the defect log line and the `Queries` key. */
63
- readonly name: string
64
- /** The owning primitive's kind label — only for the defect log line. */
65
- readonly label: string
66
- /** Whether `disabled` is honored / the `Idle` arm can occur. */
67
- readonly gated: boolean
68
- readonly inputs: Inputs
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
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
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
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
77
- readonly reuse?: boolean | undefined
78
- /**
79
- * An extra key segment + wake source beyond the declared inputs — the hidden
80
- * `invalidateOn` revision. Kept out of `wireSources` so the user's `query`
81
- * and `invalidateBy` see exactly the declared inputs.
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
84
- readonly extraKey?:
85
- | {
86
- readonly read: () => unknown
87
- readonly subscribe: (listener: () => void) => () => void
88
- }
89
- | undefined
90
- /**
91
- * Persist the `Success` value through the optional `QueryStore`: hydrate the
92
- * cell from `key` before the first run (seeded `isStale: true`, so it shows
93
- * instantly and still refetches) and write through on each settle. Values
94
- * (de)serialize through `schema`, never trusted structurally.
95
- */
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
105
- /**
106
- * Fired when a run's `Success` value has landed in the store (same scheduler
107
- * flush — never before the converged value is readable), with the run's
108
- * generation. Not fired on `Error`, interrupt, or while disabled. The seam
109
- * `RemoteState` settles pending intents through.
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
112
- readonly onSettled?: ((generation: number) => void) | undefined
113
- }
114
-
115
- export interface QueryDriver<A, E> {
116
- /** The lifecycle store (the `AnyAsyncData` projection — callers narrow to their arms). */
117
- readonly store: Store<AnyAsyncData<A, E>>
118
- /**
119
- * The highest generation requested so far (0 before the first kick).
120
- * Generations are assigned when a run is REQUESTED (enqueued), so a waiter
121
- * registered as `requested() + 1` is satisfied only by a run that began
122
- * after registration — the settle-ordering rule `RemoteState` builds on.
123
- */
124
- readonly requested: () => number
125
- }
36
+ export {
37
+ bumpRevision,
38
+ gatedFlag,
39
+ type GatedOf,
40
+ type QueryDriver,
41
+ type QueryDriverOptions,
42
+ Revision,
43
+ RevisionSchema,
44
+ revisionZero,
45
+ } from './queryDriverTypes'
126
46
 
127
- /**
128
- * Build the driver: state cell + projection + subscription + request queue + run
129
- * fiber, all owned by the ambient scope (callers run this under `Layer.scoped`). A
130
- * change to an input re-runs the query latest-wins (a new run cancels the in-flight
131
- * one; or, with `coalesce: 'trailing'`, lets it finish and runs one trailing
132
- * refetch); while a re-run is in flight the last `data`/`error` is kept with
133
- * `isFetching: true`. `invalidate` flips `isStale`; a stale query with active
134
- * readers auto-refetches (the same rule that drives refetch-on-resubscribe).
135
- */
136
47
  // oxlint-disable-next-line reform-rules/prefer-effect-fn -- generic export: Effect.fn's inferred type isn't portable under isolatedDeclarations
137
48
  export const makeQueryDriver = <Inputs extends ReadonlyArray<AnySource>, A, E, R>(
138
49
  options: QueryDriverOptions<Inputs, A, E, R>,
139
50
  ): Effect.Effect<QueryDriver<A, E>, never, InputStores<Inputs> | R | Scope.Scope> =>
140
51
  Effect.gen(function* () {
141
52
  const scheduler = yield* resolveScheduler
53
+ const instrumentation = yield* resolveInstrumentation
142
54
  const sources = yield* wireSources(options.inputs, options.invalidateBy)
143
55
  const extraKey = options.extraKey
144
56
  const persist = options.persist
@@ -146,44 +58,17 @@ export const makeQueryDriver = <Inputs extends ReadonlyArray<AnySource>, A, E, R
146
58
 
147
59
  const keyOf = (args: InputsObject<Inputs>): ReadonlyArray<unknown> =>
148
60
  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
154
- const subscribeAll = (listener: () => void): (() => void) => {
155
- const offSources = sources.subscribe(listener)
156
- const offExtra = extraKey?.subscribe(listener)
157
- return () => {
158
- offSources()
159
- offExtra?.()
160
- }
161
- }
162
61
  const disabledNow = (args: InputsObject<Inputs> = sources.snapshot()): boolean =>
163
62
  options.gated && options.disabled !== undefined ? options.disabled(args) : false
164
63
 
165
- // The flat source-of-truth cell. `idle` is a shared reference so re-disabling
166
- // an already-idle query is a no-op write (the store's Equal gate suppresses it).
167
64
  const idle: QueryState<A, E> = empty(false)
168
65
  const stateStore = makeStore<QueryState<A, E>>(disabledNow() ? idle : empty(true), scheduler)
169
66
 
170
- // Active-reader count of the PROJECTION (React components, the RemoteState
171
- // overlay). Drives the "stale + active ⇒ refetch" policy and refetch-on-
172
- // resubscribe (a 0→1 transition).
173
67
  const readers = MutableRef.make(0)
174
68
 
175
- // The last requested key, so a change that doesn't move it (or only churns an
176
- // input `invalidateBy` ignores) doesn't re-fetch.
177
69
  const lastKey = MutableRef.make<ReadonlyArray<unknown> | undefined>(undefined)
178
70
  const generation = MutableRef.make(0)
179
- const nextGeneration = (): number => {
180
- MutableRef.update(generation, (current) => current + 1)
181
- return MutableRef.get(generation)
182
- }
183
-
184
- // ── State writers, each touching only the axes it owns. The untouched axes
185
- // keep their field references, so a pure `isStale` flip leaves the projection
186
- // reference unmoved (see `project`). ──────────────────────────────────────
71
+ const nextGeneration = (): number => incrementGeneration(generation)
187
72
  const markFetching = () => {
188
73
  const state = stateStore.get()
189
74
  if (!state.isFetching) {
@@ -204,51 +89,9 @@ export const makeQueryDriver = <Inputs extends ReadonlyArray<AnySource>, A, E, R
204
89
  })
205
90
  return shared
206
91
  }
207
- const settleError = (error: E) => {
208
- const prev = stateStore.get()
209
- // Keep `data` underneath: `error` wins in the projection (the `Error` arm),
210
- // but a consumer reading `QueryState` still has the last good value.
211
- stateStore.set({ ...prev, error: Option.some(error), isFetching: false })
212
- }
213
-
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)
92
+ const persistWrite = makePersistWriter<Inputs, A>({ persist, queryStore })
227
93
 
228
- // The projection the public store exposes: `AnyAsyncData`, memoized so a pure
229
- // `isStale` change (which the projection ignores) returns the SAME reference
230
- // and wakes no `AnyAsyncData` subscriber.
231
- const projection = MutableRef.make<
232
- { readonly state: QueryState<A, E>; readonly view: AnyAsyncData<A, E> } | undefined
233
- >(undefined)
234
- const project = (): AnyAsyncData<A, E> => {
235
- const state = stateStore.get()
236
- const prev = MutableRef.get(projection)
237
- // The projection depends only on data/error/isFetching; writers preserve the
238
- // refs of untouched axes, so reference identity on those three is exact.
239
- if (
240
- prev !== undefined &&
241
- prev.state.data === state.data &&
242
- prev.state.error === state.error &&
243
- prev.state.isFetching === state.isFetching
244
- ) {
245
- return prev.view
246
- }
247
- const view = toAsyncData(state, options.gated)
248
- MutableRef.set(projection, { state, view })
249
- return view
250
- }
251
- const derived = makeDerivedStore(project, stateStore.subscribe, scheduler)
94
+ const derived = makeQueryProjection({ gated: options.gated, scheduler, stateStore })
252
95
  yield* Effect.addFinalizer(() => Effect.sync(derived.unsubscribe))
253
96
 
254
97
  interface Request {
@@ -256,8 +99,6 @@ export const makeQueryDriver = <Inputs extends ReadonlyArray<AnySource>, A, E, R
256
99
  readonly generation: number
257
100
  }
258
101
 
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
102
  const commitSuccess = (produced: A, request: Request): Effect.Effect<void> => {
262
103
  if (disabledNow()) {
263
104
  return Effect.void
@@ -269,12 +110,11 @@ export const makeQueryDriver = <Inputs extends ReadonlyArray<AnySource>, A, E, R
269
110
  })
270
111
  }
271
112
 
272
- // One run of the query, folded into the cell. A failure becomes `Error`; an
273
- // interrupt (latest-wins cancel) leaves the state untouched; a defect (a bug in
274
- // the body) is logged and isolated. A `Success` reports its generation through
275
- // `onSettled` after the write, then persists (best-effort, trailing).
276
113
  const runQuery = (request: Request): Effect.Effect<void, never, R> =>
277
- options.query(request.args).pipe(
114
+ Effect.sync(() => instrumentation.queryRun(options.name)).pipe(
115
+ Effect.flatMap((endSpan) =>
116
+ options.query(request.args).pipe(Effect.ensuring(Effect.sync(endSpan))),
117
+ ),
278
118
  Effect.tapDefect((defect) =>
279
119
  Effect.logError(`reform: ${options.label} '${options.name}' query defect`, defect),
280
120
  ),
@@ -283,7 +123,7 @@ export const makeQueryDriver = <Inputs extends ReadonlyArray<AnySource>, A, E, R
283
123
  onFailure: (cause): Option.Option<A> => {
284
124
  const failure = Cause.failureOption(cause)
285
125
  if (Option.isSome(failure) && !disabledNow()) {
286
- settleError(failure.value)
126
+ settleQueryError(stateStore, failure.value)
287
127
  }
288
128
  return Option.none()
289
129
  },
@@ -296,11 +136,6 @@ export const makeQueryDriver = <Inputs extends ReadonlyArray<AnySource>, A, E, R
296
136
  ),
297
137
  )
298
138
 
299
- // The driver. `'switch'` (default): each new request cancels the in-flight run
300
- // — the same latest-wins semantics a `latest` channel gives procedures.
301
- // `'trailing'`: a strictly sequential consumer that, on wake, drains every
302
- // request that piled up during the flight and runs the LATEST one — "exactly
303
- // one trailing run after settle" by construction.
304
139
  const trailing = options.coalesce === 'trailing'
305
140
  const requests = yield* Queue.unbounded<Request>()
306
141
  const trailingConsumer = Effect.forever(
@@ -323,12 +158,9 @@ export const makeQueryDriver = <Inputs extends ReadonlyArray<AnySource>, A, E, R
323
158
  )
324
159
  yield* Effect.forkScoped(trailing ? trailingConsumer : switchConsumer)
325
160
 
326
- // Enqueue a run of the current key, marking the flight. `force` skips the
327
- // no-op-key guard (the refetch path); otherwise an unchanged key is ignored.
328
161
  const enqueue = (force: boolean) => {
329
162
  const args = sources.snapshot()
330
163
  if (disabledNow(args)) {
331
- // Switched off: show Idle and forget the key so re-enabling always re-runs.
332
164
  MutableRef.set(lastKey, undefined)
333
165
  stateStore.set(idle)
334
166
  return
@@ -345,8 +177,6 @@ export const makeQueryDriver = <Inputs extends ReadonlyArray<AnySource>, A, E, R
345
177
  const trigger = () => enqueue(false)
346
178
  const refetch = () => enqueue(true)
347
179
 
348
- // `invalidate`: flip `isStale` only. A stale query with active readers then
349
- // auto-refetches — the one place invalidation indirectly fetches.
350
180
  const maybeAutoRefetch = () => {
351
181
  if (stateStore.get().isStale && MutableRef.get(readers) > 0) {
352
182
  refetch()
@@ -361,34 +191,19 @@ export const makeQueryDriver = <Inputs extends ReadonlyArray<AnySource>, A, E, R
361
191
  maybeAutoRefetch()
362
192
  }
363
193
 
364
- const unsubscribe = subscribeAll(trigger)
194
+ const unsubscribe = subscribeQuerySources(sources, extraKey, trigger)
365
195
  yield* Effect.addFinalizer(() => Effect.sync(unsubscribe))
366
196
 
367
- // Hydrate from the persisted value before the first kick: show it instantly,
368
- // marked stale, so the kick refetches over it (stale-while-revalidate).
369
197
  if (persist !== undefined && !disabledNow()) {
370
198
  const hydrateArgs = sources.snapshot()
371
- const cached = yield* queryStore.get(persistKey(persist.key, hydrateArgs))
372
- yield* Option.match(cached, {
373
- onNone: () => Effect.void,
374
- onSome: (raw) =>
375
- Schema.decodeUnknown(persist.schema)(raw).pipe(
376
- Effect.match({
377
- // Corrupt/incompatible cache: ignore and fetch fresh.
378
- onFailure: () => undefined,
379
- onSuccess: (decoded) =>
380
- stateStore.set({
381
- data: Option.some(decoded),
382
- error: Option.none(),
383
- isFetching: false,
384
- isStale: true,
385
- }),
386
- }),
387
- ),
199
+ yield* hydrateQuery({
200
+ cacheKey: typeof persist.key === 'function' ? persist.key(hydrateArgs) : persist.key,
201
+ queryStore,
202
+ schema: persist.schema,
203
+ stateStore,
388
204
  })
389
205
  }
390
206
 
391
- // Kick off the first fetch unless the query starts disabled.
392
207
  const initial = sources.snapshot()
393
208
  if (!disabledNow(initial)) {
394
209
  MutableRef.set(lastKey, keyOf(initial))
@@ -396,27 +211,12 @@ export const makeQueryDriver = <Inputs extends ReadonlyArray<AnySource>, A, E, R
396
211
  Queue.unsafeOffer(requests, { args: initial, generation: nextGeneration() })
397
212
  }
398
213
 
399
- // The public store: the projection, with reader-count tracking wrapped around
400
- // `subscribe`, so a 0→1 transition can auto-refetch a stale query.
401
- const store: Store<AnyAsyncData<A, E>> = {
402
- ...derived.store,
403
- subscribe: (listener) => {
404
- const before = MutableRef.get(readers)
405
- MutableRef.set(readers, before + 1)
406
- const off = derived.store.subscribe(listener)
407
- if (before === 0) {
408
- maybeAutoRefetch()
409
- }
410
- return () => {
411
- off()
412
- MutableRef.update(readers, (count) => Math.max(0, count - 1))
413
- }
414
- },
415
- }
214
+ const store = withReaderTracking({
215
+ store: derived.store,
216
+ readers,
217
+ onFirstReader: maybeAutoRefetch,
218
+ })
416
219
 
417
- // Register the handle so `AsyncCalc.invalidate`/`.refetch` and provider layers
418
- // can act on this query by name. Optional (`serviceOption`): an absent registry
419
- // (a calc wired with no `Engine` in context) just means no imperative control.
420
220
  const handle: QueryHandle = {
421
221
  name: options.name,
422
222
  invalidate,
@@ -426,6 +226,11 @@ export const makeQueryDriver = <Inputs extends ReadonlyArray<AnySource>, A, E, R
426
226
  }
427
227
  const queries = Option.getOrUndefined(yield* Effect.serviceOption(Queries))
428
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
+ }
429
234
  queries.register(handle)
430
235
  yield* Effect.addFinalizer(() => Effect.sync(() => queries.unregister(handle)))
431
236
  }