@playfast/reform 1.1.1 → 1.2.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 +85 -55
- package/package.json +17 -17
- package/src/boundary/boundary.test.ts +82 -33
- package/src/boundary/boundary.ts +141 -32
- package/src/calc/asyncCalc.invalidate.test.ts +733 -32
- package/src/calc/asyncCalc.test.ts +397 -225
- package/src/calc/asyncCalc.ts +115 -100
- package/src/calc/asyncCalcTypes.ts +147 -0
- package/src/calc/calc.test.ts +13 -16
- package/src/calc/calc.ts +44 -18
- package/src/calc/calcFamily.test.ts +90 -32
- package/src/calc/calcFamily.ts +56 -25
- package/src/calc/compose.ts +1 -14
- package/src/channel/channel.ts +25 -6
- package/src/channel/{procedureRegistry.ts → procedures.ts} +41 -23
- package/src/compose/composition.test.ts +19 -0
- package/src/compose/composition.ts +168 -81
- package/src/compose/compositionTypes.ts +249 -0
- package/src/compose/props.ts +13 -6
- package/src/compose/provide.ts +187 -46
- package/src/compose/slot.ts +156 -19
- package/src/compose/structure.test.ts +6 -3
- package/src/compose/structure.ts +106 -13
- package/src/compose/ui.test.ts +19 -3
- package/src/compose/ui.ts +147 -54
- package/src/compose/ui.typecheck.ts +40 -4
- package/src/definition/definition.ts +22 -9
- package/src/event/event.fromSource.test.ts +172 -0
- package/src/event/event.test.ts +10 -0
- package/src/event/event.ts +102 -18
- package/src/feature/feature.mount.test.ts +123 -56
- package/src/feature/feature.test.ts +67 -63
- package/src/feature/feature.ts +222 -262
- package/src/feature/feature.typecheck.ts +224 -91
- package/src/feature/featureBinding.ts +130 -20
- package/src/feature/featureClass.ts +162 -0
- package/src/feature/featureConfig.ts +192 -0
- package/src/feature/featureFactory.ts +163 -0
- package/src/feature/featureModule.ts +136 -0
- package/src/feature/featureModule.typecheck.ts +63 -0
- package/src/feature/featureNativeTree.ts +152 -0
- package/src/feature/featureRequirement.ts +82 -0
- package/src/feature/featureVariants.ts +234 -0
- package/src/feature/mountFeature.ts +54 -0
- package/src/graph/closure.ts +222 -0
- package/src/graph/services.ts +95 -0
- package/src/graph/summary.ts +134 -0
- package/src/index.ts +171 -245
- package/src/internal/bucketCache.ts +39 -0
- package/src/internal/capture.ts +9 -4
- package/src/internal/env.ts +1 -3
- package/src/internal/errors.ts +21 -10
- package/src/internal/queryDriver.ts +61 -25
- package/src/internal/queryDriverHydrate.ts +45 -0
- package/src/internal/queryDriverStore.ts +8 -5
- package/src/internal/queryDriverTypes.ts +11 -11
- package/src/internal/reuse.test.ts +10 -3
- package/src/internal/reuse.ts +5 -2
- package/src/internal/scheduler.ts +4 -1
- package/src/internal/sources.ts +25 -11
- package/src/internal/store.test.ts +27 -6
- package/src/internal/track.ts +3 -2
- package/src/internal.ts +222 -0
- package/src/namespace/namespace.ts +46 -25
- package/src/procedure/procedure.ts +38 -11
- package/src/reducer/reducer.ts +78 -34
- package/src/remote/pendingQueue.ts +145 -0
- package/src/remote/remoteState.test.ts +536 -354
- package/src/remote/remoteState.ts +145 -106
- package/src/remote/remoteState.typecheck.ts +47 -23
- package/src/remote/remoteStateMake.ts +97 -0
- package/src/remote/remoteStateSend.ts +95 -37
- package/src/remote/remoteStateTypes.ts +155 -0
- package/src/runtime/appRuntime.activation.test.ts +104 -0
- package/src/runtime/appRuntime.test.ts +187 -111
- package/src/runtime/appRuntime.ts +36 -130
- package/src/runtime/capturedAppRuntime.ts +274 -0
- package/src/runtime/eventBudget.test.ts +39 -12
- package/src/runtime/eventBudget.ts +9 -8
- package/src/runtime/featureMount.ts +94 -0
- package/src/runtime/hardening.test.ts +22 -13
- package/src/runtime/instrumentation.test.ts +213 -194
- package/src/runtime/instrumentation.ts +6 -11
- package/src/runtime/loop.test.ts +84 -7
- package/src/runtime/loop.ts +69 -40
- package/src/runtime/runtimeHandle.ts +124 -0
- package/src/scene/featureScene.test.ts +4 -2
- package/src/scene/scene.ts +234 -64
- package/src/scene/seedScene.test.ts +69 -86
- package/src/state/state.nominal.typecheck.ts +68 -0
- package/src/state/state.test.ts +17 -0
- package/src/state/state.ts +79 -26
- package/src/state/stateFamily.test.ts +39 -8
- package/src/state/stateFamily.ts +55 -22
- package/src/state/stateGroup.ts +53 -17
- package/src/state/token.ts +40 -10
- package/src/synced/syncedStore.ts +46 -23
- package/src/testkit/flight.testkit.ts +84 -0
- package/src/wire/tree.test.ts +8 -4
- package/src/wire/triggers.test.ts +6 -2
- package/src/wire/triggers.ts +14 -10
- package/src/calc/asyncCalcDefinitions.ts +0 -48
- package/src/internal/ctx.ts +0 -9
- package/src/remote/remoteStateDefinition.ts +0 -135
- package/src/remote/remoteStateLayers.ts +0 -118
- package/src/remote/remoteStateLiveTypes.ts +0 -59
package/src/internal/errors.ts
CHANGED
|
@@ -4,7 +4,9 @@ type TaggedErrorClass<Tag extends string, A extends Record<string, unknown>> = n
|
|
|
4
4
|
args: A,
|
|
5
5
|
) => Cause.YieldableError & { readonly _tag: Tag } & Readonly<A>
|
|
6
6
|
|
|
7
|
-
type EmptyTaggedErrorClass<Tag extends string> = new () => Cause.YieldableError & {
|
|
7
|
+
type EmptyTaggedErrorClass<Tag extends string> = new () => Cause.YieldableError & {
|
|
8
|
+
readonly _tag: Tag
|
|
9
|
+
}
|
|
8
10
|
|
|
9
11
|
const InvalidProvideTargetBase: EmptyTaggedErrorClass<'reform/InvalidProvideTarget'> =
|
|
10
12
|
Data.TaggedError('reform/InvalidProvideTarget')<{}>
|
|
@@ -15,8 +17,10 @@ export class InvalidProvideTarget extends InvalidProvideTargetBase {
|
|
|
15
17
|
}
|
|
16
18
|
}
|
|
17
19
|
|
|
18
|
-
const UnknownGroupStateBase: TaggedErrorClass<
|
|
19
|
-
|
|
20
|
+
const UnknownGroupStateBase: TaggedErrorClass<
|
|
21
|
+
'reform/UnknownGroupState',
|
|
22
|
+
{ readonly name: string }
|
|
23
|
+
> = Data.TaggedError('reform/UnknownGroupState')<{ readonly name: string }>
|
|
20
24
|
|
|
21
25
|
export class UnknownGroupState extends UnknownGroupStateBase {
|
|
22
26
|
override get message(): string {
|
|
@@ -24,8 +28,10 @@ export class UnknownGroupState extends UnknownGroupStateBase {
|
|
|
24
28
|
}
|
|
25
29
|
}
|
|
26
30
|
|
|
27
|
-
const FeatureLoadFailedBase: TaggedErrorClass<
|
|
28
|
-
|
|
31
|
+
const FeatureLoadFailedBase: TaggedErrorClass<
|
|
32
|
+
'reform/FeatureLoadFailed',
|
|
33
|
+
{ readonly cause: unknown }
|
|
34
|
+
> = Data.TaggedError('reform/FeatureLoadFailed')<{ readonly cause: unknown }>
|
|
29
35
|
|
|
30
36
|
export class FeatureLoadFailed extends FeatureLoadFailedBase {
|
|
31
37
|
override get message(): string {
|
|
@@ -33,8 +39,9 @@ export class FeatureLoadFailed extends FeatureLoadFailedBase {
|
|
|
33
39
|
}
|
|
34
40
|
}
|
|
35
41
|
|
|
36
|
-
const AsyncReducerBase: EmptyTaggedErrorClass<'reform/AsyncReducer'> =
|
|
37
|
-
|
|
42
|
+
const AsyncReducerBase: EmptyTaggedErrorClass<'reform/AsyncReducer'> = Data.TaggedError(
|
|
43
|
+
'reform/AsyncReducer',
|
|
44
|
+
)<{}>
|
|
38
45
|
|
|
39
46
|
export class AsyncReducer extends AsyncReducerBase {
|
|
40
47
|
override get message(): string {
|
|
@@ -42,8 +49,9 @@ export class AsyncReducer extends AsyncReducerBase {
|
|
|
42
49
|
}
|
|
43
50
|
}
|
|
44
51
|
|
|
45
|
-
const AsyncSceneLayerBase: EmptyTaggedErrorClass<'reform/AsyncSceneLayer'> =
|
|
46
|
-
|
|
52
|
+
const AsyncSceneLayerBase: EmptyTaggedErrorClass<'reform/AsyncSceneLayer'> = Data.TaggedError(
|
|
53
|
+
'reform/AsyncSceneLayer',
|
|
54
|
+
)<{}>
|
|
47
55
|
|
|
48
56
|
export class AsyncSceneLayer extends AsyncSceneLayerBase {
|
|
49
57
|
override get message(): string {
|
|
@@ -81,7 +89,10 @@ export const forceSync = <A>(thunk: () => A): A => {
|
|
|
81
89
|
const DuplicateRegistrationBase: TaggedErrorClass<
|
|
82
90
|
'reform/DuplicateRegistration',
|
|
83
91
|
{ readonly kind: string; readonly name: string }
|
|
84
|
-
> = Data.TaggedError('reform/DuplicateRegistration')<{
|
|
92
|
+
> = Data.TaggedError('reform/DuplicateRegistration')<{
|
|
93
|
+
readonly kind: string
|
|
94
|
+
readonly name: string
|
|
95
|
+
}>
|
|
85
96
|
|
|
86
97
|
export class DuplicateRegistration extends DuplicateRegistrationBase {
|
|
87
98
|
override get message(): string {
|
|
@@ -1,35 +1,20 @@
|
|
|
1
|
-
import {
|
|
2
|
-
Cause,
|
|
3
|
-
Chunk,
|
|
4
|
-
Effect,
|
|
5
|
-
MutableRef,
|
|
6
|
-
Option,
|
|
7
|
-
Queue,
|
|
8
|
-
type Scope,
|
|
9
|
-
Stream,
|
|
10
|
-
} from 'effect'
|
|
1
|
+
import { Cause, Chunk, Effect, MutableRef, Option, Queue, Runtime, type Scope, Stream } from 'effect'
|
|
11
2
|
import { empty, type QueryState } from '../calc/queryState'
|
|
12
3
|
import { Queries, type QueryHandle } from '../runtime/queries'
|
|
13
4
|
import { reuse } from './reuse'
|
|
14
5
|
import { resolveInstrumentation } from '../runtime/instrumentation'
|
|
15
6
|
import { resolveQueryStore } from './queryStore'
|
|
16
|
-
import
|
|
7
|
+
import { hydratePersistedQuery } from './queryDriverHydrate'
|
|
17
8
|
import {
|
|
18
|
-
hydrateQuery,
|
|
19
9
|
incrementGeneration,
|
|
20
|
-
makePersistWriter,
|
|
21
10
|
makeQueryProjection,
|
|
22
11
|
subscribeQuerySources,
|
|
23
12
|
settleQueryError,
|
|
24
13
|
withReaderTracking,
|
|
25
14
|
} from './queryDriverStore'
|
|
15
|
+
import type { QueryDriver, QueryDriverOptions } from './queryDriverTypes'
|
|
26
16
|
import { resolveScheduler } from './scheduler'
|
|
27
|
-
import {
|
|
28
|
-
type InputsObject,
|
|
29
|
-
type InputStores,
|
|
30
|
-
sameKey,
|
|
31
|
-
wireSources,
|
|
32
|
-
} from './sources'
|
|
17
|
+
import { type InputsObject, type InputStores, sameKey, wireSources } from './sources'
|
|
33
18
|
import { makeStore } from './store'
|
|
34
19
|
import { type AnySource } from '../state/token'
|
|
35
20
|
|
|
@@ -37,6 +22,7 @@ export {
|
|
|
37
22
|
bumpRevision,
|
|
38
23
|
gatedFlag,
|
|
39
24
|
type GatedOf,
|
|
25
|
+
type QueryCodec,
|
|
40
26
|
type QueryDriver,
|
|
41
27
|
type QueryDriverOptions,
|
|
42
28
|
Revision,
|
|
@@ -58,6 +44,10 @@ export const makeQueryDriver = <Inputs extends ReadonlyArray<AnySource>, A, E, R
|
|
|
58
44
|
|
|
59
45
|
const keyOf = (args: InputsObject<Inputs>): ReadonlyArray<unknown> =>
|
|
60
46
|
extraKey === undefined ? sources.keyOf(args) : [...sources.keyOf(args), extraKey.read()]
|
|
47
|
+
const persistKey = (
|
|
48
|
+
key: string | ((inputs: InputsObject<Inputs>) => string),
|
|
49
|
+
args: InputsObject<Inputs>,
|
|
50
|
+
): string => (typeof key === 'function' ? key(args) : key)
|
|
61
51
|
const disabledNow = (args: InputsObject<Inputs> = sources.snapshot()): boolean =>
|
|
62
52
|
options.gated && options.disabled !== undefined ? options.disabled(args) : false
|
|
63
53
|
|
|
@@ -67,8 +57,17 @@ export const makeQueryDriver = <Inputs extends ReadonlyArray<AnySource>, A, E, R
|
|
|
67
57
|
const readers = MutableRef.make(0)
|
|
68
58
|
|
|
69
59
|
const lastKey = MutableRef.make<ReadonlyArray<unknown> | undefined>(undefined)
|
|
60
|
+
// Monotonic source epoch for the one startup hydration attempt. Comparing
|
|
61
|
+
// only the current key is insufficient: A → B → A would otherwise let the
|
|
62
|
+
// original A cache land over the later A request. Same-key refetches do not
|
|
63
|
+
// move this epoch, so cache may still paint during an in-flight revalidation.
|
|
64
|
+
const sourceEpoch = MutableRef.make(0)
|
|
70
65
|
const generation = MutableRef.make(0)
|
|
71
66
|
const nextGeneration = (): number => incrementGeneration(generation)
|
|
67
|
+
// A stale cause needs a strictly newer requested generation. Hydration
|
|
68
|
+
// captures this before the startup kick, while invalidation captures it when
|
|
69
|
+
// the value is marked stale.
|
|
70
|
+
const staleAtGeneration = MutableRef.make(0)
|
|
72
71
|
const markFetching = () => {
|
|
73
72
|
const state = stateStore.get()
|
|
74
73
|
if (!state.isFetching) {
|
|
@@ -89,7 +88,24 @@ export const makeQueryDriver = <Inputs extends ReadonlyArray<AnySource>, A, E, R
|
|
|
89
88
|
})
|
|
90
89
|
return shared
|
|
91
90
|
}
|
|
92
|
-
const
|
|
91
|
+
const settleError = (error: E, failedGeneration: number) => {
|
|
92
|
+
// Preserve the stale cause after failure without duplicating a newer request.
|
|
93
|
+
MutableRef.update(staleAtGeneration, (current) => Math.max(current, failedGeneration))
|
|
94
|
+
settleQueryError(stateStore, error)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const persistWriteTo = (
|
|
98
|
+
target: NonNullable<typeof persist>,
|
|
99
|
+
produced: A,
|
|
100
|
+
args: InputsObject<Inputs>,
|
|
101
|
+
): Effect.Effect<void> =>
|
|
102
|
+
target.codec.encode(produced).pipe(
|
|
103
|
+
Effect.flatMap((encoded) => queryStore.set(persistKey(target.key, args), encoded)),
|
|
104
|
+
// Persistence is best-effort and must not block the visible write.
|
|
105
|
+
Effect.ignore,
|
|
106
|
+
)
|
|
107
|
+
const persistWrite = (produced: A, args: InputsObject<Inputs>): Effect.Effect<void> =>
|
|
108
|
+
persist === undefined ? Effect.void : persistWriteTo(persist, produced, args)
|
|
93
109
|
|
|
94
110
|
const derived = makeQueryProjection({ gated: options.gated, scheduler, stateStore })
|
|
95
111
|
yield* Effect.addFinalizer(() => Effect.sync(derived.unsubscribe))
|
|
@@ -123,7 +139,7 @@ export const makeQueryDriver = <Inputs extends ReadonlyArray<AnySource>, A, E, R
|
|
|
123
139
|
onFailure: (cause): Option.Option<A> => {
|
|
124
140
|
const failure = Cause.failureOption(cause)
|
|
125
141
|
if (Option.isSome(failure) && !disabledNow()) {
|
|
126
|
-
|
|
142
|
+
settleError(failure.value, request.generation)
|
|
127
143
|
}
|
|
128
144
|
return Option.none()
|
|
129
145
|
},
|
|
@@ -161,6 +177,9 @@ export const makeQueryDriver = <Inputs extends ReadonlyArray<AnySource>, A, E, R
|
|
|
161
177
|
const enqueue = (force: boolean) => {
|
|
162
178
|
const args = sources.snapshot()
|
|
163
179
|
if (disabledNow(args)) {
|
|
180
|
+
if (MutableRef.get(lastKey) !== undefined) {
|
|
181
|
+
MutableRef.update(sourceEpoch, (current) => current + 1)
|
|
182
|
+
}
|
|
164
183
|
MutableRef.set(lastKey, undefined)
|
|
165
184
|
stateStore.set(idle)
|
|
166
185
|
return
|
|
@@ -170,6 +189,9 @@ export const makeQueryDriver = <Inputs extends ReadonlyArray<AnySource>, A, E, R
|
|
|
170
189
|
if (!force && previous !== undefined && sameKey(key, previous)) {
|
|
171
190
|
return
|
|
172
191
|
}
|
|
192
|
+
if (previous !== undefined && !sameKey(key, previous)) {
|
|
193
|
+
MutableRef.update(sourceEpoch, (current) => current + 1)
|
|
194
|
+
}
|
|
173
195
|
MutableRef.set(lastKey, key)
|
|
174
196
|
markFetching()
|
|
175
197
|
Queue.unsafeOffer(requests, { args, generation: nextGeneration() })
|
|
@@ -178,7 +200,8 @@ export const makeQueryDriver = <Inputs extends ReadonlyArray<AnySource>, A, E, R
|
|
|
178
200
|
const refetch = () => enqueue(true)
|
|
179
201
|
|
|
180
202
|
const maybeAutoRefetch = () => {
|
|
181
|
-
|
|
203
|
+
const hasCoveringRequest = MutableRef.get(generation) > MutableRef.get(staleAtGeneration)
|
|
204
|
+
if (stateStore.get().isStale && MutableRef.get(readers) > 0 && !hasCoveringRequest) {
|
|
182
205
|
refetch()
|
|
183
206
|
}
|
|
184
207
|
}
|
|
@@ -187,6 +210,7 @@ export const makeQueryDriver = <Inputs extends ReadonlyArray<AnySource>, A, E, R
|
|
|
187
210
|
if (state.isStale) {
|
|
188
211
|
return
|
|
189
212
|
}
|
|
213
|
+
MutableRef.set(staleAtGeneration, MutableRef.get(generation))
|
|
190
214
|
stateStore.set({ ...state, isStale: true })
|
|
191
215
|
maybeAutoRefetch()
|
|
192
216
|
}
|
|
@@ -194,14 +218,26 @@ export const makeQueryDriver = <Inputs extends ReadonlyArray<AnySource>, A, E, R
|
|
|
194
218
|
const unsubscribe = subscribeQuerySources(sources, extraKey, trigger)
|
|
195
219
|
yield* Effect.addFinalizer(() => Effect.sync(unsubscribe))
|
|
196
220
|
|
|
221
|
+
// Fork hydration so an asynchronous store never blocks layer acquisition.
|
|
197
222
|
if (persist !== undefined && !disabledNow()) {
|
|
198
223
|
const hydrateArgs = sources.snapshot()
|
|
199
|
-
|
|
200
|
-
|
|
224
|
+
const hydrateSourceKey = keyOf(hydrateArgs)
|
|
225
|
+
const hydrateSourceEpoch = MutableRef.get(sourceEpoch)
|
|
226
|
+
const hydrate = hydratePersistedQuery({
|
|
201
227
|
queryStore,
|
|
202
|
-
|
|
228
|
+
cacheKey: persistKey(persist.key, hydrateArgs),
|
|
229
|
+
codec: persist.codec,
|
|
203
230
|
stateStore,
|
|
231
|
+
staleAtGeneration,
|
|
232
|
+
capturedGeneration: MutableRef.get(generation),
|
|
233
|
+
isSuperseded: () =>
|
|
234
|
+
disabledNow() ||
|
|
235
|
+
hydrateSourceEpoch !== MutableRef.get(sourceEpoch) ||
|
|
236
|
+
!sameKey(hydrateSourceKey, keyOf(sources.snapshot())),
|
|
204
237
|
})
|
|
238
|
+
const runtime = yield* Effect.runtime<never>()
|
|
239
|
+
const scope = yield* Effect.scope
|
|
240
|
+
Runtime.runFork(runtime)(hydrate, { immediate: true, scope })
|
|
205
241
|
}
|
|
206
242
|
|
|
207
243
|
const initial = sources.snapshot()
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { Effect, MutableRef, Option } from 'effect'
|
|
2
|
+
import type { QueryState } from '../calc/queryState'
|
|
3
|
+
import type { QueryStoreApi } from './queryStore'
|
|
4
|
+
import type { QueryCodec } from './queryDriverTypes'
|
|
5
|
+
import type { Store } from './store'
|
|
6
|
+
|
|
7
|
+
interface HydratePersistedOptions<A, E> {
|
|
8
|
+
readonly queryStore: QueryStoreApi
|
|
9
|
+
readonly cacheKey: string
|
|
10
|
+
readonly codec: QueryCodec<A>
|
|
11
|
+
readonly stateStore: Store<QueryState<A, E>>
|
|
12
|
+
readonly staleAtGeneration: MutableRef.MutableRef<number>
|
|
13
|
+
readonly capturedGeneration: number
|
|
14
|
+
// True once the sources moved past the snapshot this hydration was captured for.
|
|
15
|
+
readonly isSuperseded: () => boolean
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const paint = <A, E>(options: HydratePersistedOptions<A, E>, decoded: A): void => {
|
|
19
|
+
const state = options.stateStore.get()
|
|
20
|
+
if (options.isSuperseded() || Option.isSome(state.data) || Option.isSome(state.error)) {
|
|
21
|
+
return
|
|
22
|
+
}
|
|
23
|
+
MutableRef.update(options.staleAtGeneration, (current) =>
|
|
24
|
+
Math.max(current, options.capturedGeneration),
|
|
25
|
+
)
|
|
26
|
+
options.stateStore.set({ ...state, data: Option.some(decoded), isStale: true })
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Epoch, key, and settled-state guards prevent a late cache from replacing newer truth.
|
|
30
|
+
export const hydratePersistedQuery = <A, E>(
|
|
31
|
+
options: HydratePersistedOptions<A, E>,
|
|
32
|
+
): Effect.Effect<void> =>
|
|
33
|
+
options.queryStore.get(options.cacheKey).pipe(
|
|
34
|
+
Effect.flatMap((cached) =>
|
|
35
|
+
Option.match(cached, {
|
|
36
|
+
onNone: () => Effect.void,
|
|
37
|
+
onSome: (raw) =>
|
|
38
|
+
options.codec
|
|
39
|
+
.decode(raw)
|
|
40
|
+
.pipe(Effect.tap((decoded) => Effect.sync(() => paint(options, decoded)))),
|
|
41
|
+
}),
|
|
42
|
+
),
|
|
43
|
+
// Corrupt/incompatible cache: ignore and fetch fresh.
|
|
44
|
+
Effect.ignore,
|
|
45
|
+
)
|
|
@@ -15,9 +15,7 @@ interface HydrateQueryOptions<A, E> {
|
|
|
15
15
|
readonly stateStore: Store<QueryState<A, E>>
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
-
export const hydrateQuery = <A, E>(
|
|
19
|
-
options: HydrateQueryOptions<A, E>,
|
|
20
|
-
): Effect.Effect<void> =>
|
|
18
|
+
export const hydrateQuery = <A, E>(options: HydrateQueryOptions<A, E>): Effect.Effect<void> =>
|
|
21
19
|
Effect.flatMap(options.queryStore.get(options.cacheKey), (cached) =>
|
|
22
20
|
Option.match(cached, {
|
|
23
21
|
onNone: () => Effect.void,
|
|
@@ -52,7 +50,10 @@ export const persistQueryValue = <A>(options: PersistQueryValueOptions<A>): Effe
|
|
|
52
50
|
|
|
53
51
|
interface PersistWriterOptions<Inputs extends ReadonlyArray<AnySource>, A> {
|
|
54
52
|
readonly persist:
|
|
55
|
-
| {
|
|
53
|
+
| {
|
|
54
|
+
readonly key: string | ((inputs: InputsObject<Inputs>) => string)
|
|
55
|
+
readonly schema: AnySchema<A>
|
|
56
|
+
}
|
|
56
57
|
| undefined
|
|
57
58
|
readonly queryStore: QueryStoreApi
|
|
58
59
|
}
|
|
@@ -136,7 +137,9 @@ interface ReaderStoreOptions<A, E> {
|
|
|
136
137
|
readonly onFirstReader: () => void
|
|
137
138
|
}
|
|
138
139
|
|
|
139
|
-
export const withReaderTracking = <A, E>(
|
|
140
|
+
export const withReaderTracking = <A, E>(
|
|
141
|
+
options: ReaderStoreOptions<A, E>,
|
|
142
|
+
): Store<AnyAsyncData<A, E>> => ({
|
|
140
143
|
...options.store,
|
|
141
144
|
subscribe: (listener) => {
|
|
142
145
|
const before = MutableRef.get(options.readers)
|
|
@@ -1,13 +1,14 @@
|
|
|
1
|
-
import { Brand, Effect, Schema } from 'effect'
|
|
1
|
+
import { Brand, Effect, type ParseResult, Schema } from 'effect'
|
|
2
2
|
import type { AnyAsyncData } from '../calc/asyncData'
|
|
3
3
|
import type { InputsObject, InvalidateBy } from './sources'
|
|
4
4
|
import type { Store } from './store'
|
|
5
5
|
import type { AnySource } from '../state/token'
|
|
6
|
-
import type { AnySchema } from './variance'
|
|
7
6
|
|
|
8
7
|
export type GatedOf<AlwaysOn extends boolean> = AlwaysOn extends true ? false : true
|
|
9
8
|
|
|
10
|
-
export const gatedFlag = <AlwaysOn extends boolean>(
|
|
9
|
+
export const gatedFlag = <AlwaysOn extends boolean>(
|
|
10
|
+
alwaysOn: AlwaysOn | undefined,
|
|
11
|
+
): GatedOf<AlwaysOn> =>
|
|
11
12
|
// 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
|
|
12
13
|
(alwaysOn !== true) as GatedOf<AlwaysOn>
|
|
13
14
|
|
|
@@ -19,35 +20,34 @@ export const RevisionSchema: Schema.Schema<Revision, number> = Schema.Number.pip
|
|
|
19
20
|
export const revisionZero: Revision = Revision(0)
|
|
20
21
|
export const bumpRevision = (revision: Revision): Revision => Revision(revision + 1)
|
|
21
22
|
|
|
23
|
+
// Keep schema invariance behind a covariant persistence boundary.
|
|
24
|
+
export interface QueryCodec<A> {
|
|
25
|
+
readonly encode: (value: A) => Effect.Effect<unknown, ParseResult.ParseError, never>
|
|
26
|
+
readonly decode: (input: unknown) => Effect.Effect<A, ParseResult.ParseError, never>
|
|
27
|
+
}
|
|
28
|
+
|
|
22
29
|
export interface QueryDriverOptions<Inputs extends ReadonlyArray<AnySource>, A, E, R> {
|
|
23
30
|
readonly name: string
|
|
24
31
|
readonly label: string
|
|
25
32
|
readonly gated: boolean
|
|
26
33
|
readonly inputs: Inputs
|
|
27
34
|
readonly query: (inputs: InputsObject<Inputs>) => Effect.Effect<A, E, R>
|
|
28
|
-
// 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
|
|
29
35
|
readonly invalidateBy?: InvalidateBy<Inputs> | undefined
|
|
30
|
-
// 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
|
|
31
36
|
readonly disabled?: ((inputs: InputsObject<Inputs>) => boolean) | undefined
|
|
32
|
-
// 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
|
|
33
37
|
readonly coalesce?: 'switch' | 'trailing' | undefined
|
|
34
|
-
// 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
|
|
35
38
|
readonly reuse?: boolean | undefined
|
|
36
|
-
// 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
|
|
37
39
|
readonly extraKey?:
|
|
38
40
|
| {
|
|
39
41
|
readonly read: () => unknown
|
|
40
42
|
readonly subscribe: (listener: () => void) => () => void
|
|
41
43
|
}
|
|
42
44
|
| undefined
|
|
43
|
-
// 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
|
|
44
45
|
readonly persist?:
|
|
45
46
|
| {
|
|
46
47
|
readonly key: string | ((inputs: InputsObject<Inputs>) => string)
|
|
47
|
-
readonly
|
|
48
|
+
readonly codec: QueryCodec<A>
|
|
48
49
|
}
|
|
49
50
|
| undefined
|
|
50
|
-
// 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
|
|
51
51
|
readonly onSettled?: ((generation: number) => void) | undefined
|
|
52
52
|
}
|
|
53
53
|
|
|
@@ -13,7 +13,10 @@ describe('reuse', () => {
|
|
|
13
13
|
const e0 = { id: 1, name: 'a' }
|
|
14
14
|
const e1 = { id: 2, name: 'b' }
|
|
15
15
|
const prev = [e0, e1]
|
|
16
|
-
const next = [
|
|
16
|
+
const next = [
|
|
17
|
+
{ id: 1, name: 'a' },
|
|
18
|
+
{ id: 2, name: 'B' },
|
|
19
|
+
]
|
|
17
20
|
const out = reuse(prev, next)
|
|
18
21
|
expect(out).not.toBe(prev)
|
|
19
22
|
expect(out[0]).toBe(e0)
|
|
@@ -49,7 +52,10 @@ describe('reuse', () => {
|
|
|
49
52
|
class View extends S.Class<View>('View')({ title: S.String, rows: S.Array(Row) }) {}
|
|
50
53
|
const keep = new Row({ id: 1, name: 'a' })
|
|
51
54
|
const prev = new View({ title: 't', rows: [keep, new Row({ id: 2, name: 'b' })] })
|
|
52
|
-
const next = new View({
|
|
55
|
+
const next = new View({
|
|
56
|
+
title: 't',
|
|
57
|
+
rows: [new Row({ id: 1, name: 'a' }), new Row({ id: 2, name: 'B' })],
|
|
58
|
+
})
|
|
53
59
|
const out = reuse(prev, next)
|
|
54
60
|
expect(out).not.toBe(prev)
|
|
55
61
|
expect(out).toBeInstanceOf(View)
|
|
@@ -77,7 +83,8 @@ describe('reuse', () => {
|
|
|
77
83
|
|
|
78
84
|
it('returns next below the depth bound', () => {
|
|
79
85
|
type Nest = { readonly n?: Nest; readonly v: number }
|
|
80
|
-
const deep = (depth: number): Nest =>
|
|
86
|
+
const deep = (depth: number): Nest =>
|
|
87
|
+
depth === 0 ? { v: 0 } : { v: depth, n: deep(depth - 1) }
|
|
81
88
|
const prev = deep(20)
|
|
82
89
|
const next = deep(20)
|
|
83
90
|
const out = reuse(prev, next)
|
package/src/internal/reuse.ts
CHANGED
|
@@ -54,10 +54,13 @@ const reconcile = ({ prev, next, depth }: ReconcileArgs): unknown => {
|
|
|
54
54
|
}
|
|
55
55
|
if (Array.isArray(prev) && Array.isArray(next)) {
|
|
56
56
|
const reconciled = next.map((element, index) =>
|
|
57
|
-
index < prev.length
|
|
57
|
+
index < prev.length
|
|
58
|
+
? reconcile({ prev: prev[index], next: element, depth: depth - 1 })
|
|
59
|
+
: element,
|
|
58
60
|
)
|
|
59
61
|
const unchanged =
|
|
60
|
-
prev.length === next.length &&
|
|
62
|
+
prev.length === next.length &&
|
|
63
|
+
reconciled.every((entry, index) => Object.is(entry, prev[index]))
|
|
61
64
|
return unchanged ? prev : reconciled
|
|
62
65
|
}
|
|
63
66
|
if (isPlainRecord(prev) && isPlainRecord(next)) {
|
|
@@ -54,7 +54,10 @@ const NotificationsBase: Context.TagClass<Notifications, 'reform/Notifications',
|
|
|
54
54
|
Context.Tag('reform/Notifications')<Notifications, Scheduler>()
|
|
55
55
|
export class Notifications extends NotificationsBase {}
|
|
56
56
|
|
|
57
|
-
export const notificationsLayer: Layer.Layer<Notifications> = Layer.sync(
|
|
57
|
+
export const notificationsLayer: Layer.Layer<Notifications> = Layer.sync(
|
|
58
|
+
Notifications,
|
|
59
|
+
makeScheduler,
|
|
60
|
+
)
|
|
58
61
|
|
|
59
62
|
export const resolveScheduler: Effect.Effect<Scheduler> = Effect.map(
|
|
60
63
|
Effect.serviceOption(Notifications),
|
package/src/internal/sources.ts
CHANGED
|
@@ -1,15 +1,20 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
1
|
+
import { Context, Effect, Equal, Predicate } from 'effect'
|
|
2
|
+
import {
|
|
3
|
+
type AnySource,
|
|
4
|
+
type SourceIdentifier,
|
|
5
|
+
type SourceName,
|
|
6
|
+
type SourceValue,
|
|
7
|
+
} from '../state/token'
|
|
5
8
|
|
|
6
9
|
export type InputsObject<Inputs extends ReadonlyArray<AnySource>> = {
|
|
7
10
|
readonly [S in Inputs[number] as SourceName<S>]: SourceValue<S>
|
|
8
11
|
}
|
|
9
12
|
|
|
10
13
|
export type StoresOf<Items extends ReadonlyArray<unknown>> = {
|
|
11
|
-
[I in keyof Items]: Items[I] extends {
|
|
12
|
-
|
|
14
|
+
[I in keyof Items]: Items[I] extends {
|
|
15
|
+
readonly store: Context.Tag<infer Service, infer _Store>
|
|
16
|
+
}
|
|
17
|
+
? Service
|
|
13
18
|
: never
|
|
14
19
|
}[number]
|
|
15
20
|
|
|
@@ -26,8 +31,20 @@ const inputKey = (input: AnySource): string =>
|
|
|
26
31
|
? input.manifest['name']
|
|
27
32
|
: input.name
|
|
28
33
|
|
|
34
|
+
// Capture an invariant source tag through a covariant read-only surface.
|
|
35
|
+
interface ReadableStore<out A> {
|
|
36
|
+
readonly getSnapshot: () => A
|
|
37
|
+
readonly subscribe: (listener: () => void) => () => void
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const readStore = <Source extends AnySource>(
|
|
41
|
+
source: Source,
|
|
42
|
+
): Effect.Effect<ReadableStore<SourceValue<Source>>, never, SourceIdentifier<Source>> =>
|
|
43
|
+
Context.GenericTag<SourceIdentifier<Source>, ReadableStore<SourceValue<Source>>>(source.store.key)
|
|
44
|
+
|
|
29
45
|
export const sameKey = (left: ReadonlyArray<unknown>, right: ReadonlyArray<unknown>): boolean =>
|
|
30
|
-
left.length === right.length &&
|
|
46
|
+
left.length === right.length &&
|
|
47
|
+
left.every((element, index) => Equal.equals(element, right[index]))
|
|
31
48
|
|
|
32
49
|
export interface WiredSources<Inputs extends ReadonlyArray<AnySource>> {
|
|
33
50
|
readonly snapshot: () => InputsObject<Inputs>
|
|
@@ -41,10 +58,7 @@ export const wireSources = <Inputs extends ReadonlyArray<AnySource>>(
|
|
|
41
58
|
): Effect.Effect<WiredSources<Inputs>, never, InputStores<Inputs>> =>
|
|
42
59
|
// oxlint-disable-next-line reform-rules/no-type-assertion -- restate the precise InputStores<Inputs> union the signature promises; Effect.forEach over a heterogeneous tuple widens R (the single seam this cast lives at)
|
|
43
60
|
Effect.gen(function* () {
|
|
44
|
-
const sources
|
|
45
|
-
inputs,
|
|
46
|
-
(input) => input.store,
|
|
47
|
-
)
|
|
61
|
+
const sources = yield* Effect.forEach(inputs, readStore)
|
|
48
62
|
|
|
49
63
|
const snapshot = (): InputsObject<Inputs> => {
|
|
50
64
|
const args: Record<string, unknown> = {}
|
|
@@ -1,7 +1,14 @@
|
|
|
1
1
|
import { expect, test } from 'vitest'
|
|
2
2
|
import { makeStore } from './store'
|
|
3
3
|
|
|
4
|
-
|
|
4
|
+
// Notifications land on the microtask queue — poll turns of it, never wall-clock.
|
|
5
|
+
const until = async (predicate: () => boolean, rounds = 1000): Promise<void> => {
|
|
6
|
+
if (predicate() || rounds <= 0) {
|
|
7
|
+
return
|
|
8
|
+
}
|
|
9
|
+
await new Promise<void>((resolve) => queueMicrotask(resolve))
|
|
10
|
+
return until(predicate, rounds - 1)
|
|
11
|
+
}
|
|
5
12
|
|
|
6
13
|
const counter = () => ({ n: 0 })
|
|
7
14
|
|
|
@@ -14,19 +21,26 @@ test('subscribe observes changes; getSnapshot is always current', async () => {
|
|
|
14
21
|
|
|
15
22
|
store.set(1)
|
|
16
23
|
expect(store.getSnapshot()).toBe(1)
|
|
17
|
-
await
|
|
24
|
+
await until(() => notifications.n > 0)
|
|
18
25
|
expect(notifications.n).toBe(1)
|
|
19
26
|
})
|
|
20
27
|
|
|
21
28
|
test('a no-op write notifies no one', async () => {
|
|
22
29
|
const store = makeStore('same')
|
|
30
|
+
const witness = makeStore(0)
|
|
23
31
|
const notifications = counter()
|
|
32
|
+
const witnessWakes = counter()
|
|
24
33
|
store.subscribe(() => {
|
|
25
34
|
notifications.n++
|
|
26
35
|
})
|
|
36
|
+
witness.subscribe(() => {
|
|
37
|
+
witnessWakes.n++
|
|
38
|
+
})
|
|
27
39
|
|
|
28
40
|
store.set('same')
|
|
29
|
-
|
|
41
|
+
// Both stores share the scheduler, so the witness flush drains anything the no-op could have queued.
|
|
42
|
+
witness.set(1)
|
|
43
|
+
await until(() => witnessWakes.n > 0)
|
|
30
44
|
expect(notifications.n).toBe(0)
|
|
31
45
|
expect(store.getSnapshot()).toBe('same')
|
|
32
46
|
})
|
|
@@ -42,7 +56,8 @@ test('a batch of writes coalesces into a single notification per microtask', asy
|
|
|
42
56
|
store.set(2)
|
|
43
57
|
store.set(3)
|
|
44
58
|
expect(store.getSnapshot()).toBe(3)
|
|
45
|
-
|
|
59
|
+
// The drain is synchronous: the first wake already carries every write the batch would have fired.
|
|
60
|
+
await until(() => notifications.n > 0)
|
|
46
61
|
expect(notifications.n).toBe(1)
|
|
47
62
|
})
|
|
48
63
|
|
|
@@ -59,20 +74,26 @@ test('stores are independent — one slice does not wake another', async () => {
|
|
|
59
74
|
})
|
|
60
75
|
|
|
61
76
|
a.set(1)
|
|
62
|
-
await
|
|
77
|
+
await until(() => aWakes.n > 0)
|
|
63
78
|
expect(aWakes.n).toBe(1)
|
|
64
79
|
expect(bWakes.n).toBe(0)
|
|
65
80
|
})
|
|
66
81
|
|
|
67
82
|
test('unsubscribe stops notifications', async () => {
|
|
68
83
|
const store = makeStore(0)
|
|
84
|
+
const witness = makeStore(0)
|
|
69
85
|
const notifications = counter()
|
|
86
|
+
const witnessWakes = counter()
|
|
70
87
|
const unsubscribe = store.subscribe(() => {
|
|
71
88
|
notifications.n++
|
|
72
89
|
})
|
|
90
|
+
witness.subscribe(() => {
|
|
91
|
+
witnessWakes.n++
|
|
92
|
+
})
|
|
73
93
|
|
|
74
94
|
unsubscribe()
|
|
75
95
|
store.set(1)
|
|
76
|
-
|
|
96
|
+
witness.set(1)
|
|
97
|
+
await until(() => witnessWakes.n > 0)
|
|
77
98
|
expect(notifications.n).toBe(0)
|
|
78
99
|
})
|
package/src/internal/track.ts
CHANGED
|
@@ -10,8 +10,9 @@ export interface Tracker {
|
|
|
10
10
|
add(source: Subscribable): void
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
-
const CurrentTrackerBase: Context.TagClass<CurrentTracker, 'reform/Tracker', Tracker> =
|
|
14
|
-
|
|
13
|
+
const CurrentTrackerBase: Context.TagClass<CurrentTracker, 'reform/Tracker', Tracker> = Context.Tag(
|
|
14
|
+
'reform/Tracker',
|
|
15
|
+
)<CurrentTracker, Tracker>()
|
|
15
16
|
export class CurrentTracker extends CurrentTrackerBase {}
|
|
16
17
|
|
|
17
18
|
export const readTracked = <A>(store: Store<A>): Effect.Effect<A> =>
|