@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.
- package/README.md +18 -1
- package/package.json +1 -1
- package/src/boundary/boundary.test.ts +1 -33
- package/src/boundary/boundary.ts +4 -82
- package/src/calc/asyncCalc.invalidate.test.ts +2 -14
- package/src/calc/asyncCalc.test.ts +0 -38
- package/src/calc/asyncCalc.ts +11 -163
- package/src/calc/asyncCalcDefinitions.ts +48 -0
- package/src/calc/asyncData.ts +0 -37
- package/src/calc/calc.test.ts +4 -28
- package/src/calc/calc.ts +16 -44
- package/src/calc/calcFamily.test.ts +0 -12
- package/src/calc/calcFamily.ts +12 -49
- package/src/calc/compose.test.ts +1 -12
- package/src/calc/compose.ts +0 -22
- package/src/calc/queryState.ts +0 -23
- package/src/channel/channel.ts +78 -173
- package/src/channel/procedureRegistry.ts +90 -0
- package/src/compose/composition.ts +7 -67
- package/src/compose/host.ts +0 -6
- package/src/compose/props.ts +3 -9
- package/src/compose/provide.ts +19 -33
- package/src/compose/slot.ts +2 -30
- package/src/compose/structure.test.ts +0 -6
- package/src/compose/structure.ts +3 -67
- package/src/compose/ui.test.ts +0 -4
- package/src/compose/ui.ts +17 -111
- package/src/compose/ui.typecheck.ts +0 -14
- package/src/definition/definition.ts +0 -32
- package/src/event/event.test.ts +0 -3
- package/src/event/event.ts +5 -14
- package/src/event/eventGroup.ts +0 -1
- package/src/feature/feature.mount.test.ts +66 -54
- package/src/feature/feature.test.ts +67 -46
- package/src/feature/feature.ts +185 -214
- package/src/feature/feature.typecheck.ts +29 -16
- package/src/feature/featureBinding.ts +48 -0
- package/src/index.ts +245 -202
- package/src/internal/capture.ts +0 -20
- package/src/internal/ctx.ts +2 -9
- package/src/internal/env.ts +9 -0
- package/src/internal/errors.test.ts +0 -6
- package/src/internal/errors.ts +14 -48
- package/src/internal/inspect.test.ts +0 -6
- package/src/internal/inspect.ts +0 -12
- package/src/internal/queryDriver.ts +48 -243
- package/src/internal/queryDriverStore.ts +153 -0
- package/src/internal/queryDriverTypes.ts +57 -0
- package/src/internal/queryEvents.ts +0 -12
- package/src/internal/queryStore.ts +0 -14
- package/src/internal/reuse.test.ts +0 -11
- package/src/internal/reuse.ts +0 -28
- package/src/internal/scheduler.ts +0 -43
- package/src/internal/seeds.ts +0 -14
- package/src/internal/sources.ts +4 -41
- package/src/internal/stateRegistry.ts +0 -14
- package/src/internal/store.test.ts +1 -3
- package/src/internal/store.ts +10 -37
- package/src/internal/track.ts +0 -18
- package/src/internal/variance.ts +5 -0
- package/src/namespace/namespace.test.ts +46 -0
- package/src/namespace/namespace.ts +85 -0
- package/src/procedure/procedure.ts +2 -24
- package/src/reducer/reducer.ts +3 -21
- package/src/remote/remoteState.test.ts +92 -75
- package/src/remote/remoteState.ts +99 -486
- package/src/remote/remoteState.typecheck.ts +0 -33
- package/src/remote/remoteStateDefinition.ts +135 -0
- package/src/remote/remoteStateLayers.ts +118 -0
- package/src/remote/remoteStateLiveTypes.ts +59 -0
- package/src/remote/remoteStateSend.ts +64 -0
- package/src/runtime/appRuntime.test.ts +197 -0
- package/src/runtime/appRuntime.ts +131 -0
- package/src/runtime/bus.ts +2 -15
- package/src/runtime/eventBudget.test.ts +155 -0
- package/src/runtime/eventBudget.ts +119 -0
- package/src/runtime/hardening.test.ts +74 -9
- package/src/runtime/instrumentation.test.ts +324 -0
- package/src/runtime/instrumentation.ts +44 -0
- package/src/runtime/loop.test.ts +0 -17
- package/src/runtime/loop.ts +41 -58
- package/src/runtime/queries.ts +0 -17
- package/src/scene/featureScene.test.ts +59 -0
- package/src/scene/scene.ts +51 -44
- package/src/scene/seedScene.test.ts +96 -116
- package/src/state/state.ts +12 -28
- package/src/state/stateFamily.test.ts +2 -11
- package/src/state/stateFamily.ts +20 -54
- package/src/state/stateGroup.ts +2 -49
- package/src/state/token.ts +4 -17
- package/src/synced/syncedStore.ts +7 -42
- package/src/ui/node.ts +0 -6
- package/src/ui/trigger.ts +0 -5
- package/src/wire/tree.test.ts +0 -3
- package/src/wire/tree.ts +0 -39
- package/src/wire/triggers.test.ts +0 -4
- package/src/wire/triggers.ts +5 -27
package/src/internal/errors.ts
CHANGED
|
@@ -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"')
|
package/src/internal/inspect.ts
CHANGED
|
@@ -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
|
|
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 {
|
|
33
|
+
import { makeStore } from './store'
|
|
27
34
|
import { type AnySource } from '../state/token'
|
|
28
35
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
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
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
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
|
}
|