@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/README.md
CHANGED
|
@@ -449,12 +449,24 @@ class Counter extends Feature.make('counter', {
|
|
|
449
449
|
// lazy: defer the module behind an import
|
|
450
450
|
class Reports extends Feature.make('reports', {
|
|
451
451
|
loadingStrategy: 'lazy',
|
|
452
|
+
composition: ReportsComp,
|
|
452
453
|
load: lazyImport(() => import('./reports.module')),
|
|
454
|
+
eager: featureModule([], PlaceholderLive),
|
|
453
455
|
placeholder: { loading: SpinnerComp, failed: RetryComp },
|
|
454
456
|
}) {}
|
|
455
457
|
```
|
|
456
458
|
|
|
457
|
-
A feature
|
|
459
|
+
A feature's `requires` tuple accepts shared State/Calc carriers, StateGroups, and
|
|
460
|
+
ordinary Effect `Context.Tag`s. A lazy feature shares the app's engine but gets
|
|
461
|
+
its own scope — `mountFeature(binding, parentContext)` loads it, builds its layer
|
|
462
|
+
against the immediate parent, dispatches its boot events, and disposes everything
|
|
463
|
+
when the scope closes. Fill a slot with one via `provide(SomeSlot, Reports)`;
|
|
464
|
+
`provide` merges its optional eager placeholder module automatically while the
|
|
465
|
+
host paints the placeholders and loads the main module.
|
|
466
|
+
|
|
467
|
+
Default Features retain their binding identity too. Their module is merged by
|
|
468
|
+
`provide`, their composition renders synchronously from the parent runtime, and
|
|
469
|
+
their boot events run once for each keyed activation.
|
|
458
470
|
|
|
459
471
|
---
|
|
460
472
|
|
|
@@ -465,6 +477,7 @@ A scene bundles a root composition with the closed wiring that runs it — the s
|
|
|
465
477
|
|
|
466
478
|
```ts
|
|
467
479
|
scene(composition, { provide: [...layers], boot?: [...events] }) // → Scene
|
|
480
|
+
featureScene(eagerFeature, { provide: closedEnvironment }) // → Scene
|
|
468
481
|
seedScene(base, seeds) // tooling: override seed values
|
|
469
482
|
isScene(value) // reflection guard
|
|
470
483
|
|
|
@@ -476,6 +489,10 @@ const AppScene = scene(AppRoot, {
|
|
|
476
489
|
|
|
477
490
|
`provide` is the list of layers that close the app (they must resolve to `MountedServices` — every composition's render service plus the `Bus`). Hand the scene to [`@playfast/reform-react`](https://www.npmjs.com/package/@playfast/reform-react) to mount or to [`@playfast/reform-proof`](https://www.npmjs.com/package/@playfast/reform-proof) to assert. `seedScene` overlays seed values onto already-closed layers (used by the dev tool to preview alternative initial state).
|
|
478
491
|
|
|
492
|
+
`featureScene` is the typed root helper for a default/eager Feature. Its `provide`
|
|
493
|
+
layer closes the engine plus the Feature's declared requirements; lazy Features
|
|
494
|
+
are rejected because an application root must be immediately available.
|
|
495
|
+
|
|
479
496
|
---
|
|
480
497
|
|
|
481
498
|
<a name="engine"></a>
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@playfast/reform",
|
|
3
3
|
"playbook": "./playbook",
|
|
4
|
-
"version": "1.
|
|
4
|
+
"version": "1.1.1",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "The renderer-neutral core of the reform framework — typed, headless state, events, reducers, derived values, async/remote data, and compositions built on Effect.",
|
|
7
7
|
"keywords": [
|
|
@@ -2,12 +2,6 @@ import { expect, it } from '@effect/vitest'
|
|
|
2
2
|
import { Data, Duration, Effect, Layer, Schema as S } from 'effect'
|
|
3
3
|
import { AsyncCalc, Boundary, Calc, Engine, Event, State, StateGroup } from '../index'
|
|
4
4
|
|
|
5
|
-
// Boundary merges several async lifecycles into one Pending/Errored/Ready value
|
|
6
|
-
// — the Suspense-boundary analog, value-level. The behaviours under test: the
|
|
7
|
-
// arm mapping (Loading pends, Idle and SWR-refetching count Ready, Error
|
|
8
|
-
// surfaces), the chained-gate settle-gap pin, the `once` latch, and the
|
|
9
|
-
// notification economy (arm-stable churn wakes nobody).
|
|
10
|
-
|
|
11
5
|
const tick = (ms = 10) => Effect.sleep(Duration.millis(ms))
|
|
12
6
|
|
|
13
7
|
const until = <A>(read: () => A, pred: (a: A) => boolean, rounds = 200): Effect.Effect<A> =>
|
|
@@ -51,13 +45,10 @@ it.live('pending while ANY covered query is on its first load; one Ready when al
|
|
|
51
45
|
})
|
|
52
46
|
expect(store.get()._tag).toBe('Pending')
|
|
53
47
|
|
|
54
|
-
// The fast query settles first — still Pending (the slow one holds it).
|
|
55
48
|
yield* tick(20)
|
|
56
49
|
expect(store.get()._tag).toBe('Pending')
|
|
57
50
|
|
|
58
51
|
yield* until(() => store.get()._tag, (t) => t === 'Ready')
|
|
59
|
-
// Exactly ONE notification fired across the whole boot: Pending→Pending
|
|
60
|
-
// churn (Fast settling) was absorbed by the stable arm reference.
|
|
61
52
|
expect(observed).toEqual(['Ready'])
|
|
62
53
|
}).pipe(Effect.provide(TestLayer))
|
|
63
54
|
})
|
|
@@ -86,8 +77,6 @@ it.live('a gated query that is OFF counts Ready — the off arm must not hold th
|
|
|
86
77
|
)
|
|
87
78
|
return Effect.gen(function* () {
|
|
88
79
|
const store = yield* Gate.store
|
|
89
|
-
// The gated query is Idle (deliberately off): only the always-on first
|
|
90
|
-
// load pends, and Ready arrives without ever waiting on the Idle arm.
|
|
91
80
|
yield* until(() => store.get()._tag, (t) => t === 'Ready')
|
|
92
81
|
expect(store.get()._tag).toBe('Ready')
|
|
93
82
|
}).pipe(Effect.provide(TestLayer))
|
|
@@ -116,7 +105,6 @@ it.live('an SWR refetch never re-pends — Success with refetching: true is Read
|
|
|
116
105
|
const store = yield* Gate.store
|
|
117
106
|
yield* until(() => store.get()._tag, (t) => t === 'Ready')
|
|
118
107
|
|
|
119
|
-
// Move the input: the query goes Success+refetching (SWR) — Ready holds.
|
|
120
108
|
seed.set(2)
|
|
121
109
|
yield* until(() => qStore.get(), (q) => q._tag === 'Success' && q.refetching)
|
|
122
110
|
expect(store.get()._tag).toBe('Ready')
|
|
@@ -157,7 +145,6 @@ it.live('a first-load failure surfaces as Errored with the failure, and a retry
|
|
|
157
145
|
expect(failure instanceof Boom && failure.message).toBe('server down')
|
|
158
146
|
}
|
|
159
147
|
|
|
160
|
-
// The retry path: the server heals, a listed fact refetches, Ready lands.
|
|
161
148
|
healthy.now = true
|
|
162
149
|
yield* Event.dispatch(Poke, {})
|
|
163
150
|
yield* until(() => store.get()._tag, (t) => t === 'Ready')
|
|
@@ -166,12 +153,6 @@ it.live('a first-load failure surfaces as Errored with the failure, and a retry
|
|
|
166
153
|
})
|
|
167
154
|
|
|
168
155
|
it.live('settle-gap pin: a chained gate never leaks Ready between hops', () => {
|
|
169
|
-
// B enables only when A has succeeded — the app's Session → Bootstrap shape.
|
|
170
|
-
// The dangerous instant: A flips Success while B is still Idle (Idle counts
|
|
171
|
-
// Ready), so a naive merge could notify Ready before B's driver flips it to
|
|
172
|
-
// Loading. Layer dependency order (B's driver subscribes to A's store before
|
|
173
|
-
// the boundary does) plus the one-microtask fixpoint flush close the gap;
|
|
174
|
-
// this records every notification to prove no intermediate Ready escapes.
|
|
175
156
|
class Seed extends State.make('seed', S.Number) {}
|
|
176
157
|
class Inputs extends StateGroup.make(Seed) {}
|
|
177
158
|
class A extends AsyncCalc.make('A', {
|
|
@@ -205,19 +186,12 @@ it.live('settle-gap pin: a chained gate never leaks Ready between hops', () => {
|
|
|
205
186
|
expect(store.get()._tag).toBe('Pending')
|
|
206
187
|
|
|
207
188
|
yield* until(() => store.get()._tag, (t) => t === 'Ready')
|
|
208
|
-
yield* tick(30)
|
|
209
|
-
// ONE Ready, at the end — never a Ready while B's hop was still pending.
|
|
189
|
+
yield* tick(30)
|
|
210
190
|
expect(observed).toEqual(['Ready'])
|
|
211
191
|
}).pipe(Effect.provide(TestLayer))
|
|
212
192
|
})
|
|
213
193
|
|
|
214
194
|
it.live('once + a Calc hop in the chain: the latch never freezes a mid-flush Ready', () => {
|
|
215
|
-
// The app's boot shape: AsyncCalc A → plain Calc projecting it → AsyncCalc B
|
|
216
|
-
// gated on the projection. Propagation crosses TWO scheduler-flush rounds,
|
|
217
|
-
// so when A settles there is a mid-flush frame where B has not yet flipped
|
|
218
|
-
// to Loading — a recompute-time latch would freeze that intermediate Ready
|
|
219
|
-
// and the boundary would abandon B's whole first load (caught live by the
|
|
220
|
-
// boot-frames walk: nav in, boards card still spinning).
|
|
221
195
|
class Seed extends State.make('seed', S.Number) {}
|
|
222
196
|
class Inputs extends StateGroup.make(Seed) {}
|
|
223
197
|
class A extends AsyncCalc.make('A', {
|
|
@@ -247,8 +221,6 @@ it.live('once + a Calc hop in the chain: the latch never freezes a mid-flush Rea
|
|
|
247
221
|
return Effect.gen(function* () {
|
|
248
222
|
const bStore = yield* B.store
|
|
249
223
|
const store = yield* Gate.store
|
|
250
|
-
// While B's first load is in flight the boundary MUST still be Pending —
|
|
251
|
-
// a poisoned latch would already report Ready here.
|
|
252
224
|
yield* until(() => bStore.get()._tag, (t) => t === 'Loading')
|
|
253
225
|
expect(store.get()._tag).toBe('Pending')
|
|
254
226
|
yield* until(() => bStore.get()._tag, (t) => t === 'Success')
|
|
@@ -258,9 +230,6 @@ it.live('once + a Calc hop in the chain: the latch never freezes a mid-flush Rea
|
|
|
258
230
|
})
|
|
259
231
|
|
|
260
232
|
it.live('once: a covered gate enabling AFTER the latch never re-pends the boundary', () => {
|
|
261
|
-
// The boot-boundary semantics: a route-gated query (the board) first loads
|
|
262
|
-
// on a later navigation — its own screen owns that loading state, the shell
|
|
263
|
-
// splash must not come back.
|
|
264
233
|
class Route extends State.make('routeFlag', S.Boolean) {}
|
|
265
234
|
class Inputs extends StateGroup.make(Route) {}
|
|
266
235
|
class Always extends AsyncCalc.make('Always', {
|
|
@@ -291,7 +260,6 @@ it.live('once: a covered gate enabling AFTER the latch never re-pends the bounda
|
|
|
291
260
|
const store = yield* Gate.store
|
|
292
261
|
yield* until(() => store.get()._tag, (t) => t === 'Ready')
|
|
293
262
|
|
|
294
|
-
// "Navigate": the routed query enables and goes Loading — latched Ready holds.
|
|
295
263
|
route.set(true)
|
|
296
264
|
yield* until(() => routedStore.get()._tag, (t) => t === 'Loading')
|
|
297
265
|
expect(store.get()._tag).toBe('Ready')
|
package/src/boundary/boundary.ts
CHANGED
|
@@ -1,78 +1,34 @@
|
|
|
1
1
|
import { Context, Data, Effect, Layer, MutableRef, Predicate } from 'effect'
|
|
2
|
+
import type { Effect as EffectType } from 'effect/Effect'
|
|
2
3
|
import { type Manifest, yieldableClass } from '../definition/definition'
|
|
3
4
|
import { resolveScheduler } from '../internal/scheduler'
|
|
4
5
|
import { type InputStores, sameKey, wireSources } from '../internal/sources'
|
|
5
6
|
import { makeDerivedStore, type Store } from '../internal/store'
|
|
6
7
|
import { readTracked } from '../internal/track'
|
|
7
8
|
import { type Source } from '../state/token'
|
|
9
|
+
import type { AnyValue } from '../internal/variance'
|
|
8
10
|
|
|
9
|
-
// Boundary: the Suspense-boundary analog, value-level. One derived store that
|
|
10
|
-
// merges the lifecycles of several async sources into a single
|
|
11
|
-
// Pending/Errored/Ready value, so ONE surface (a splash, a skeleton) covers a
|
|
12
|
-
// whole subtree's first load instead of every query consumer painting its own.
|
|
13
|
-
//
|
|
14
|
-
// Reform can do this without React's throw-a-promise machinery because
|
|
15
|
-
// fetching is layer-driven, not render-driven: an `AsyncCalc` runs because its
|
|
16
|
-
// layer is built, never because something rendered. A boundary therefore only
|
|
17
|
-
// *aggregates* lifecycles — it cannot create render-waterfalls, and content it
|
|
18
|
-
// reveals already has its data.
|
|
19
|
-
//
|
|
20
|
-
// The arm mapping IS the semantics, and most of it falls out of `AsyncData`:
|
|
21
|
-
// Loading → Pending (the arm only exists before the first value,
|
|
22
|
-
// so a boundary is first-load-only by construction)
|
|
23
|
-
// Success → Ready (even `refetching: true` — SWR refetches never
|
|
24
|
-
// re-show the fallback; no transition machinery)
|
|
25
|
-
// Idle → Ready (a gated query that is off is DELIBERATELY off —
|
|
26
|
-
// a signed-out viewer must not wait on a query
|
|
27
|
-
// that only runs signed-in)
|
|
28
|
-
// Error → Errored (the error-boundary half, also value-level)
|
|
29
|
-
//
|
|
30
|
-
// Chained gates (B enables when A succeeds) cannot leak a Ready between hops:
|
|
31
|
-
// the boundary's layer requires every covered store, so each driver subscribes
|
|
32
|
-
// upstream BEFORE the boundary does, the scheduler flush runs listeners in
|
|
33
|
-
// subscription order to a fixpoint within one microtask, and reads are
|
|
34
|
-
// pull-fresh — by the time any subscriber reads, the next hop is already
|
|
35
|
-
// Loading. `boundary.test.ts` pins this with a recorded-notification proof.
|
|
36
|
-
|
|
37
|
-
/** Some covered source is still on its FIRST load — show the fallback. */
|
|
38
11
|
export interface BoundaryPending {
|
|
39
12
|
readonly _tag: 'Pending'
|
|
40
13
|
}
|
|
41
14
|
|
|
42
|
-
/** Some covered source failed before its first value. */
|
|
43
15
|
export interface BoundaryErrored {
|
|
44
16
|
readonly _tag: 'Errored'
|
|
45
|
-
/** The failures, in `over` order (heterogeneous — display via `String`). */
|
|
46
17
|
readonly errors: ReadonlyArray<unknown>
|
|
47
18
|
}
|
|
48
19
|
|
|
49
|
-
/** Every covered source has settled (or is deliberately gated off). */
|
|
50
20
|
export interface BoundaryReady {
|
|
51
21
|
readonly _tag: 'Ready'
|
|
52
22
|
}
|
|
53
23
|
|
|
54
24
|
export type BoundaryState = BoundaryPending | BoundaryErrored | BoundaryReady
|
|
55
25
|
|
|
56
|
-
// Stable arm references: repeated recomputes return the same object, so the
|
|
57
|
-
// derived store's Equal gate (and `useSyncExternalStore`'s stable-snapshot
|
|
58
|
-
// contract) hold without a wrapper. `Errored` carries data, so it is a `Data`
|
|
59
|
-
// struct — value-equal failures coalesce the same way.
|
|
60
26
|
const pendingArm: BoundaryState = { _tag: 'Pending' }
|
|
61
27
|
const readyArm: BoundaryState = { _tag: 'Ready' }
|
|
62
28
|
const erroredArm = (errors: ReadonlyArray<unknown>): BoundaryState =>
|
|
63
29
|
Data.struct({ _tag: 'Errored' as const, errors: Data.array(errors) })
|
|
64
30
|
|
|
65
|
-
|
|
66
|
-
* Anything a boundary can cover: a `Source` whose value is an async lifecycle —
|
|
67
|
-
* an `AsyncCalc`, or anything lifecycle-valued built over one (e.g. a
|
|
68
|
-
* `RemoteState` overlay). The constraint is the same erased `Source` every calc
|
|
69
|
-
* input uses (store invariance rules out a fixed lifecycle value type — each
|
|
70
|
-
* definition's union carries its own `Gated`/`E` arms); the captured `const`
|
|
71
|
-
* tuple keeps each member's precise store requirement, and the runtime merge
|
|
72
|
-
* reads arms through structural guards, so a non-lifecycle source simply
|
|
73
|
-
* counts as settled.
|
|
74
|
-
*/
|
|
75
|
-
export type AnyLifecycleSource = Source<string, any>
|
|
31
|
+
export type AnyLifecycleSource = Source<string, AnyValue>
|
|
76
32
|
|
|
77
33
|
export interface BoundaryManifest<N extends string> extends Manifest {
|
|
78
34
|
readonly kind: 'Boundary'
|
|
@@ -80,13 +36,11 @@ export interface BoundaryManifest<N extends string> extends Manifest {
|
|
|
80
36
|
}
|
|
81
37
|
|
|
82
38
|
export interface BoundaryClass<out N extends string, out Over extends ReadonlyArray<AnyLifecycleSource>>
|
|
83
|
-
extends
|
|
39
|
+
extends EffectType<BoundaryState, never, Store<BoundaryState>> {
|
|
84
40
|
new (): {}
|
|
85
41
|
readonly manifest: BoundaryManifest<N>
|
|
86
42
|
readonly store: Context.Tag<Store<BoundaryState>, Store<BoundaryState>>
|
|
87
|
-
/** The boundary's name, so it doubles as a `Source` input to a calc. */
|
|
88
43
|
readonly name: N
|
|
89
|
-
/** The covered lifecycle sources — read by `live` and the editor's graph. */
|
|
90
44
|
readonly over: Over
|
|
91
45
|
}
|
|
92
46
|
|
|
@@ -95,23 +49,10 @@ export interface BoundaryConfig<Over extends ReadonlyArray<AnyLifecycleSource>>
|
|
|
95
49
|
}
|
|
96
50
|
|
|
97
51
|
export interface BoundaryOptions {
|
|
98
|
-
/**
|
|
99
|
-
* Latch: once Ready, stay Ready. A BOOT boundary wants this — a covered
|
|
100
|
-
* route-gated query that first enables on a later navigation (Idle →
|
|
101
|
-
* Loading) must not re-splash the whole shell; its own screen owns that
|
|
102
|
-
* loading state. Without the latch the boundary re-pends on any covered
|
|
103
|
-
* first load, which is what a per-screen boundary wants.
|
|
104
|
-
*/
|
|
105
52
|
// oxlint-disable-next-line reform-rules/no-optional-fields -- public options-bag input; omission is the documented default (no latch) and external callers pass a plain `{ once: true }` literal
|
|
106
53
|
readonly once?: boolean
|
|
107
54
|
}
|
|
108
55
|
|
|
109
|
-
/**
|
|
110
|
-
* Define a boundary over async sources. `yield* MyBoundary` reads the merged
|
|
111
|
-
* `Pending | Errored | Ready` lifecycle; the consuming view renders ONE
|
|
112
|
-
* fallback on Pending and its content slot on Ready — content reveals
|
|
113
|
-
* together, with data already present (fetching never waited on rendering).
|
|
114
|
-
*/
|
|
115
56
|
export const make = <const N extends string, const Over extends ReadonlyArray<AnyLifecycleSource>>(
|
|
116
57
|
name: N,
|
|
117
58
|
config: BoundaryConfig<Over>,
|
|
@@ -124,25 +65,15 @@ export const make = <const N extends string, const Over extends ReadonlyArray<An
|
|
|
124
65
|
return yieldableClass(read, { manifest, store, name, over: config.over })
|
|
125
66
|
}
|
|
126
67
|
|
|
127
|
-
// The snapshot values arrive through `wireSources`' erased key projection
|
|
128
|
-
// (`ReadonlyArray<unknown>`), so read the two facts the merge needs through
|
|
129
|
-
// honest structural guards — no cast back to `AnyAsyncData`.
|
|
130
68
|
const tagOf = (arm: unknown): string =>
|
|
131
69
|
Predicate.hasProperty(arm, '_tag') && Predicate.isString(arm._tag) ? arm._tag : ''
|
|
132
70
|
const errorOf = (arm: unknown): ReadonlyArray<unknown> =>
|
|
133
71
|
Predicate.hasProperty(arm, 'error') && tagOf(arm) === 'Error' ? [arm.error] : []
|
|
134
72
|
|
|
135
|
-
/**
|
|
136
|
-
* Wire the merge. A derived store over the covered stores (sensed through the
|
|
137
|
-
* same `wireSources` plumbing every calc uses): recomputes are memoized on the
|
|
138
|
-
* input snapshots and the arms are stable references, so input churn that does
|
|
139
|
-
* not move the merged arm wakes nobody.
|
|
140
|
-
*/
|
|
141
73
|
export const live = <N extends string, Over extends ReadonlyArray<AnyLifecycleSource>>(
|
|
142
74
|
boundary: BoundaryClass<N, Over>,
|
|
143
75
|
options: BoundaryOptions = {},
|
|
144
76
|
): Layer.Layer<Store<BoundaryState>, never, InputStores<Over>> =>
|
|
145
|
-
// Scoped so the source subscriptions are released with the layer's scope.
|
|
146
77
|
Layer.scoped(
|
|
147
78
|
boundary.store,
|
|
148
79
|
Effect.gen(function* () {
|
|
@@ -177,15 +108,6 @@ export const live = <N extends string, Over extends ReadonlyArray<AnyLifecycleSo
|
|
|
177
108
|
yield* Effect.addFinalizer(() => Effect.sync(derived.unsubscribe))
|
|
178
109
|
|
|
179
110
|
if (options.once === true) {
|
|
180
|
-
// Latch on CONVERGED values only — at build and on (coalesced,
|
|
181
|
-
// post-fixpoint) notifications — never inside `recompute`. A chained
|
|
182
|
-
// gate with a plain Calc hop between the async stores (bootstrap →
|
|
183
|
-
// current-workspace → boards) propagates over TWO flush rounds, so a
|
|
184
|
-
// mid-flush recompute can observe the frame where the downstream
|
|
185
|
-
// driver has not yet flipped its query to Loading. That intermediate
|
|
186
|
-
// Ready self-corrects within the flush for readers, but a latch taken
|
|
187
|
-
// there would freeze it; a subscriber runs only after the fixpoint,
|
|
188
|
-
// where the value is converged.
|
|
189
111
|
if (recompute()._tag === 'Ready') {
|
|
190
112
|
MutableRef.set(latched, true)
|
|
191
113
|
}
|
|
@@ -2,17 +2,11 @@ import { expect, it } from '@effect/vitest'
|
|
|
2
2
|
import { Duration, Effect, Layer, Option, Schema as S } from 'effect'
|
|
3
3
|
import { AsyncCalc, Engine, Queries, QueryStore, State, StateGroup } from '../index'
|
|
4
4
|
|
|
5
|
-
// The data-oriented half of AsyncCalc: `invalidate` flips `isStale` only (no
|
|
6
|
-
// fetch), `refetch` forces a run, a stale query with active readers (or a fresh
|
|
7
|
-
// subscriber) refetches, and `persist` hydrates + writes through an optional
|
|
8
|
-
// QueryStore. `isStale` is read off the `Queries` registry handle.
|
|
9
|
-
|
|
10
5
|
const tick = (ms = 10) => Effect.sleep(Duration.millis(ms))
|
|
11
6
|
|
|
12
7
|
const isStale = (name: string) =>
|
|
13
8
|
Effect.map(Queries, (q) => q.byName.get(name)?.snapshot().isStale ?? false)
|
|
14
9
|
|
|
15
|
-
// An in-memory QueryStore, so persistence is exercised with no DOM.
|
|
16
10
|
const fakeQueryStore = (seed: Record<string, unknown> = {}) => {
|
|
17
11
|
const map = new Map<string, unknown>(Object.entries(seed))
|
|
18
12
|
const layer = Layer.succeed(QueryStore, {
|
|
@@ -49,13 +43,11 @@ it.live('invalidate marks stale without fetching; a fresh subscriber then refetc
|
|
|
49
43
|
yield* tick()
|
|
50
44
|
expect(runs.n).toBe(1)
|
|
51
45
|
|
|
52
|
-
// No active readers: invalidate flips the flag but fetches nothing.
|
|
53
46
|
yield* AsyncCalc.invalidate(Q)
|
|
54
47
|
yield* tick()
|
|
55
48
|
expect(yield* isStale('Q')).toBe(true)
|
|
56
49
|
expect(runs.n).toBe(1)
|
|
57
50
|
|
|
58
|
-
// A reader arriving over a stale query (the resubscribe/mount analog) refetches.
|
|
59
51
|
store.subscribe(() => {})
|
|
60
52
|
yield* tick()
|
|
61
53
|
expect(runs.n).toBe(2)
|
|
@@ -86,12 +78,11 @@ it.live('invalidate with an active reader auto-refetches immediately, keeping th
|
|
|
86
78
|
|
|
87
79
|
return Effect.gen(function* () {
|
|
88
80
|
const store = yield* Q.store
|
|
89
|
-
store.subscribe(() => {})
|
|
81
|
+
store.subscribe(() => {})
|
|
90
82
|
yield* tick(40)
|
|
91
83
|
expect(store.get()).toMatchObject({ _tag: 'Success', value: 7, refetching: false })
|
|
92
84
|
expect(runs.n).toBe(1)
|
|
93
85
|
|
|
94
|
-
// Stale + active ⇒ refetch now; the last value stays visible (SWR).
|
|
95
86
|
yield* AsyncCalc.invalidate(Q)
|
|
96
87
|
yield* tick(5)
|
|
97
88
|
expect(store.get()).toMatchObject({ _tag: 'Success', value: 7, refetching: true })
|
|
@@ -127,7 +118,6 @@ it.live('refetch forces a run regardless of staleness', () => {
|
|
|
127
118
|
expect(runs.n).toBe(1)
|
|
128
119
|
expect(yield* isStale('Q')).toBe(false)
|
|
129
120
|
|
|
130
|
-
// Not stale, no key movement — refetch still runs.
|
|
131
121
|
yield* AsyncCalc.refetch(Q)
|
|
132
122
|
yield* tick()
|
|
133
123
|
expect(runs.n).toBe(2)
|
|
@@ -137,7 +127,7 @@ it.live('refetch forces a run regardless of staleness', () => {
|
|
|
137
127
|
it.live('persist: hydrates from the QueryStore (stale) then writes the settled value through', () => {
|
|
138
128
|
class Count extends State.make('count', S.Number) {}
|
|
139
129
|
class Inputs extends StateGroup.make(Count) {}
|
|
140
|
-
const store = fakeQueryStore({ P: 99 })
|
|
130
|
+
const store = fakeQueryStore({ P: 99 })
|
|
141
131
|
class Q extends AsyncCalc.make('Q', {
|
|
142
132
|
inputs: [StateGroup.select(Inputs, 'count')],
|
|
143
133
|
output: S.Number,
|
|
@@ -155,10 +145,8 @@ it.live('persist: hydrates from the QueryStore (stale) then writes the settled v
|
|
|
155
145
|
|
|
156
146
|
return Effect.gen(function* () {
|
|
157
147
|
const view = yield* Q.store
|
|
158
|
-
// The persisted value shows instantly, marked refetching (stale-while-revalidate).
|
|
159
148
|
expect(view.get()).toMatchObject({ _tag: 'Success', value: 99, refetching: true })
|
|
160
149
|
|
|
161
|
-
// The fresh fetch lands and is written through to the store.
|
|
162
150
|
yield* tick(45)
|
|
163
151
|
expect(view.get()).toMatchObject({ _tag: 'Success', value: 10, refetching: false })
|
|
164
152
|
expect(store.map.get('P')).toBe(10)
|
|
@@ -2,14 +2,8 @@ import { expect, it } from '@effect/vitest'
|
|
|
2
2
|
import { Data, Duration, Effect, Layer, Schema as S } from 'effect'
|
|
3
3
|
import { AsyncCalc, Engine, Event, State, StateGroup } from '../index'
|
|
4
4
|
|
|
5
|
-
// AsyncCalc is a derived store whose value is the lifecycle of an Effect. It runs
|
|
6
|
-
// the query reactively from its inputs (latest-wins), keeps the last value while
|
|
7
|
-
// re-fetching (stale-while-revalidate), and goes Idle when a gated query disables.
|
|
8
|
-
|
|
9
5
|
const tick = (ms = 10) => Effect.sleep(Duration.millis(ms))
|
|
10
6
|
|
|
11
|
-
// Poll until the predicate holds (bounded) — for assertions that race real
|
|
12
|
-
// timers under parallel-suite host load, where a fixed `tick` budget flakes.
|
|
13
7
|
const until = <A>(read: () => A, pred: (a: A) => boolean, rounds = 200): Effect.Effect<A> =>
|
|
14
8
|
Effect.suspend(() => {
|
|
15
9
|
const a = read()
|
|
@@ -35,7 +29,6 @@ it.live('runs the query and resolves Loading -> Success', () => {
|
|
|
35
29
|
|
|
36
30
|
return Effect.gen(function* () {
|
|
37
31
|
const store = yield* Doubled.store
|
|
38
|
-
// Before the forked query resolves, the value is Loading.
|
|
39
32
|
expect(store.get()._tag).toBe('Loading')
|
|
40
33
|
yield* tick(40)
|
|
41
34
|
const value = store.get()
|
|
@@ -92,7 +85,6 @@ it.live('a dependency change re-fetches, keeping the last value with refetching:
|
|
|
92
85
|
yield* tick(40)
|
|
93
86
|
expect(store.get()).toMatchObject({ _tag: 'Success', value: 10, refetching: false })
|
|
94
87
|
|
|
95
|
-
// Move the input: the prior value is kept while the new fetch is in flight.
|
|
96
88
|
source.set(7)
|
|
97
89
|
yield* tick(5)
|
|
98
90
|
expect(store.get()).toMatchObject({ _tag: 'Success', value: 10, refetching: true })
|
|
@@ -126,9 +118,6 @@ it.live('latest-wins: a rapid second change cancels the in-flight run', () => {
|
|
|
126
118
|
yield* tick(40)
|
|
127
119
|
expect(store.get()).toMatchObject({ _tag: 'Success', value: 2 })
|
|
128
120
|
|
|
129
|
-
// Two changes in quick succession: the first run is switched out before it
|
|
130
|
-
// can write, so only the latest value lands (poll — fixed sleeps flake when
|
|
131
|
-
// a loaded host starves the 25ms query timer).
|
|
132
121
|
source.set(3)
|
|
133
122
|
yield* tick(5)
|
|
134
123
|
source.set(4)
|
|
@@ -181,7 +170,6 @@ it.live('invalidateBy: an input change outside the key does not re-fetch', () =>
|
|
|
181
170
|
runs.n += 1
|
|
182
171
|
return page
|
|
183
172
|
}),
|
|
184
|
-
// Only `page` is part of the key; `status` churn must not re-fetch.
|
|
185
173
|
invalidateBy: ({ page }) => [page],
|
|
186
174
|
})
|
|
187
175
|
const TestLayer = QLive.pipe(
|
|
@@ -195,12 +183,10 @@ it.live('invalidateBy: an input change outside the key does not re-fetch', () =>
|
|
|
195
183
|
yield* tick()
|
|
196
184
|
expect(runs.n).toBe(1)
|
|
197
185
|
|
|
198
|
-
// status moves, key unchanged -> no re-fetch.
|
|
199
186
|
status.set('b')
|
|
200
187
|
yield* tick()
|
|
201
188
|
expect(runs.n).toBe(1)
|
|
202
189
|
|
|
203
|
-
// page moves, key changes -> one re-fetch.
|
|
204
190
|
page.set(2)
|
|
205
191
|
yield* tick()
|
|
206
192
|
expect(runs.n).toBe(2)
|
|
@@ -218,7 +204,6 @@ it.live('reuse: a refetch returning mostly-equal data keeps unchanged element id
|
|
|
218
204
|
alwaysOn: true,
|
|
219
205
|
}) {}
|
|
220
206
|
const RowsLive = AsyncCalc.live(Rows, {
|
|
221
|
-
// Fresh objects every run — only the row matching `page` differs.
|
|
222
207
|
query: ({ page }) =>
|
|
223
208
|
Effect.succeed([
|
|
224
209
|
{ id: 1, name: 'one' },
|
|
@@ -240,7 +225,6 @@ it.live('reuse: a refetch returning mostly-equal data keeps unchanged element id
|
|
|
240
225
|
(v) => v._tag === 'Success' && v.refetching === false && v.value !== first.value,
|
|
241
226
|
)
|
|
242
227
|
if (second._tag !== 'Success') return
|
|
243
|
-
// Row 1 didn't move: same identity. Row 2 did: fresh.
|
|
244
228
|
expect(second.value[0]).toBe(first.value[0])
|
|
245
229
|
expect(second.value[1]).not.toBe(first.value[1])
|
|
246
230
|
expect(second.value[1]).toEqual({ id: 2, name: 'two@2' })
|
|
@@ -250,8 +234,6 @@ it.live('reuse: a refetch returning mostly-equal data keeps unchanged element id
|
|
|
250
234
|
it.live('coalesce trailing: a burst during one flight completes it and runs ONE trailing refetch', () => {
|
|
251
235
|
class Count extends State.make('count', S.Number) {}
|
|
252
236
|
class Inputs extends StateGroup.make(Count) {}
|
|
253
|
-
// Completion log: the sync body runs AFTER the delay, so the order proves
|
|
254
|
-
// which runs actually finished (and that the in-flight one was not cancelled).
|
|
255
237
|
const completed: number[] = []
|
|
256
238
|
class Doubled extends AsyncCalc.make('Doubled', {
|
|
257
239
|
inputs: [StateGroup.select(Inputs, 'count')],
|
|
@@ -274,9 +256,6 @@ it.live('coalesce trailing: a burst during one flight completes it and runs ONE
|
|
|
274
256
|
yield* until(() => store.get(), (v) => v._tag === 'Success')
|
|
275
257
|
expect(completed).toEqual([1])
|
|
276
258
|
|
|
277
|
-
// Three key changes during the first change's flight: the in-flight run (2)
|
|
278
|
-
// completes and lands, the middle change (3) is conflated away, and exactly
|
|
279
|
-
// one trailing run fetches the latest key (4).
|
|
280
259
|
source.set(2)
|
|
281
260
|
yield* tick(5)
|
|
282
261
|
source.set(3)
|
|
@@ -310,8 +289,6 @@ it.live('coalesce trailing: the stale settle re-flags refetching until the trail
|
|
|
310
289
|
const store = yield* Doubled.store
|
|
311
290
|
yield* until(() => store.get(), (v) => v._tag === 'Success')
|
|
312
291
|
|
|
313
|
-
// Two changes: when the in-flight result (4) lands, a trailing run is still
|
|
314
|
-
// pending — the value must read as syncing, not settled.
|
|
315
292
|
source.set(2)
|
|
316
293
|
yield* tick(5)
|
|
317
294
|
source.set(3)
|
|
@@ -353,8 +330,6 @@ it.live('coalesce trailing: a disable while a request waits in the slot skips th
|
|
|
353
330
|
const store = yield* Q.store
|
|
354
331
|
yield* until(() => store.get(), (v) => v._tag === 'Success')
|
|
355
332
|
|
|
356
|
-
// 2 starts a flight, 3 waits in the slot, then the gate closes: the flight's
|
|
357
|
-
// stale result must not land (Idle), and the queued request must not run.
|
|
358
333
|
source.set(2)
|
|
359
334
|
yield* tick(5)
|
|
360
335
|
source.set(3)
|
|
@@ -364,7 +339,6 @@ it.live('coalesce trailing: a disable while a request waits in the slot skips th
|
|
|
364
339
|
expect(store.get()._tag).toBe('Idle')
|
|
365
340
|
expect(completed).toEqual([1, 2])
|
|
366
341
|
|
|
367
|
-
// Re-enabling re-runs (the driver forgot its key when switched off).
|
|
368
342
|
source.set(5)
|
|
369
343
|
const settled = yield* until(() => store.get(), (v) => v._tag === 'Success')
|
|
370
344
|
expect(settled).toMatchObject({ _tag: 'Success', value: 10 })
|
|
@@ -372,9 +346,6 @@ it.live('coalesce trailing: a disable while a request waits in the slot skips th
|
|
|
372
346
|
}).pipe(Effect.provide(TestLayer))
|
|
373
347
|
})
|
|
374
348
|
|
|
375
|
-
// `invalidateOn` is sugar for a hidden revision state + reducer appended to the
|
|
376
|
-
// key — these proofs need the Engine (the Reducers registry + bus + drain loop).
|
|
377
|
-
|
|
378
349
|
it.live('invalidateOn: dispatching a listed event re-fetches with refetching: true', () => {
|
|
379
350
|
class Count extends State.make('count', S.Number) {}
|
|
380
351
|
class Inputs extends StateGroup.make(Count) {}
|
|
@@ -404,8 +375,6 @@ it.live('invalidateOn: dispatching a listed event re-fetches with refetching: tr
|
|
|
404
375
|
expect(store.get()).toMatchObject({ _tag: 'Success', value: 4, refetching: false })
|
|
405
376
|
expect(runs.n).toBe(1)
|
|
406
377
|
|
|
407
|
-
// The fact lands: the hidden revision bumps, the key moves, the value is
|
|
408
|
-
// kept while the re-fetch is in flight (SWR), then settles.
|
|
409
378
|
yield* Event.dispatch(Poke, {})
|
|
410
379
|
yield* tick(5)
|
|
411
380
|
expect(store.get()).toMatchObject({ _tag: 'Success', value: 4, refetching: true })
|
|
@@ -445,13 +414,10 @@ it.live('invalidateOn: composes with invalidateBy — non-key churn stays inert,
|
|
|
445
414
|
yield* tick()
|
|
446
415
|
expect(runs.n).toBe(1)
|
|
447
416
|
|
|
448
|
-
// Outside the user key: still no re-fetch (the hidden revision rides along
|
|
449
|
-
// WITHOUT widening the key to every input).
|
|
450
417
|
status.set('b')
|
|
451
418
|
yield* tick()
|
|
452
419
|
expect(runs.n).toBe(1)
|
|
453
420
|
|
|
454
|
-
// The listed event always re-fetches.
|
|
455
421
|
yield* Event.dispatch(Poke, {})
|
|
456
422
|
yield* tick()
|
|
457
423
|
expect(runs.n).toBe(2)
|
|
@@ -487,8 +453,6 @@ it.live('invalidateOn: a listed event while disabled stays Idle, and enabling re
|
|
|
487
453
|
yield* tick()
|
|
488
454
|
expect(store.get()._tag).toBe('Idle')
|
|
489
455
|
|
|
490
|
-
// A bump while gated off runs nothing — the driver shows Idle and forgets
|
|
491
|
-
// its key, so the eventual enable always re-runs.
|
|
492
456
|
yield* Event.dispatch(Poke, {})
|
|
493
457
|
yield* tick()
|
|
494
458
|
expect(store.get()._tag).toBe('Idle')
|
|
@@ -543,8 +507,6 @@ it.live('invalidateOn: two async calcs keep independent hidden revisions', () =>
|
|
|
543
507
|
yield* tick()
|
|
544
508
|
expect(runs).toEqual({ a: 1, b: 1 })
|
|
545
509
|
|
|
546
|
-
// Each calc's hidden revision is namespaced by its own name: A's fact moves
|
|
547
|
-
// only A.
|
|
548
510
|
yield* Event.dispatch(PokeA, {})
|
|
549
511
|
yield* tick()
|
|
550
512
|
expect(runs).toEqual({ a: 2, b: 1 })
|