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