@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.
- 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 +6 -46
- package/src/calc/calcFamily.test.ts +0 -12
- package/src/calc/calcFamily.ts +6 -48
- 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 +72 -174
- 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 -229
- 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 +42 -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 +0 -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 +40 -83
- 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 +213 -192
- package/src/runtime/instrumentation.ts +0 -49
- package/src/runtime/loop.test.ts +0 -17
- package/src/runtime/loop.ts +25 -55
- package/src/runtime/queries.ts +0 -17
- package/src/scene/featureScene.test.ts +59 -0
- package/src/scene/scene.ts +32 -59
- package/src/scene/seedScene.test.ts +96 -116
- package/src/state/state.ts +7 -26
- package/src/state/stateFamily.test.ts +2 -11
- package/src/state/stateFamily.ts +9 -52
- 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,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
|
|
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 {
|
|
33
|
+
import { makeStore } from './store'
|
|
28
34
|
import { type AnySource } from '../state/token'
|
|
29
35
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
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
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
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
|
}
|