@playfast/reform 0.0.7 → 0.0.8
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 +2 -2
- package/src/boundary/boundary.test.ts +301 -0
- package/src/boundary/boundary.ts +192 -0
- package/src/calc/asyncCalc.test.ts +556 -0
- package/src/calc/asyncCalc.ts +267 -0
- package/src/calc/asyncData.ts +96 -0
- package/src/calc/calc.test.ts +287 -0
- package/src/calc/calc.ts +120 -0
- package/src/calc/calcFamily.test.ts +206 -0
- package/src/calc/calcFamily.ts +194 -0
- package/src/calc/compose.test.ts +68 -0
- package/src/calc/compose.ts +69 -0
- package/src/channel/channel.ts +269 -0
- package/src/compose/composition.ts +112 -0
- package/{dist/compose/host.d.ts → src/compose/host.ts} +9 -8
- package/{dist/compose/props.js → src/compose/props.ts} +7 -5
- package/src/compose/provide.ts +70 -0
- package/{dist/compose/slot.d.ts → src/compose/slot.ts} +36 -19
- package/src/compose/ui.test.ts +62 -0
- package/src/compose/ui.ts +226 -0
- package/src/compose/ui.typecheck.ts +56 -0
- package/src/definition/definition.ts +76 -0
- package/src/event/event.test.ts +23 -0
- package/src/event/event.ts +65 -0
- package/src/event/eventGroup.ts +14 -0
- package/src/feature/feature.mount.test.ts +82 -0
- package/src/feature/feature.test.ts +60 -0
- package/{dist/feature/feature.d.ts → src/feature/feature.ts} +178 -92
- package/src/feature/feature.typecheck.ts +108 -0
- package/src/index.ts +176 -0
- package/{dist/internal/capture.d.ts → src/internal/capture.ts} +13 -11
- package/{dist/internal/ctx.d.ts → src/internal/ctx.ts} +8 -4
- package/src/internal/errors.test.ts +33 -0
- package/src/internal/errors.ts +145 -0
- package/src/internal/inspect.test.ts +28 -0
- package/src/internal/inspect.ts +34 -0
- package/src/internal/queryDriver.ts +247 -0
- package/src/internal/reuse.test.ts +116 -0
- package/src/internal/reuse.ts +73 -0
- package/src/internal/scheduler.ts +93 -0
- package/{dist/internal/seeds.d.ts → src/internal/seeds.ts} +6 -3
- package/src/internal/sources.ts +104 -0
- package/{dist/internal/stateRegistry.js → src/internal/stateRegistry.ts} +18 -14
- package/src/internal/store.test.ts +80 -0
- package/src/internal/store.ts +105 -0
- package/{dist/internal/track.d.ts → src/internal/track.ts} +17 -11
- package/src/procedure/procedure.ts +89 -0
- package/src/reducer/reducer.ts +137 -0
- package/src/remote/remoteState.test.ts +695 -0
- package/src/remote/remoteState.ts +571 -0
- package/src/remote/remoteState.typecheck.ts +195 -0
- package/src/runtime/bus.ts +40 -0
- package/src/runtime/hardening.test.ts +69 -0
- package/src/runtime/loop.test.ts +178 -0
- package/src/runtime/loop.ts +171 -0
- package/src/scene/scene.ts +76 -0
- package/src/scene/seedScene.test.ts +169 -0
- package/src/state/state.ts +94 -0
- package/src/state/stateFamily.test.ts +138 -0
- package/src/state/stateFamily.ts +174 -0
- package/src/state/stateGroup.ts +105 -0
- package/{dist/state/token.d.ts → src/state/token.ts} +22 -13
- package/src/synced/syncedStore.ts +99 -0
- package/{dist/ui/node.d.ts → src/ui/node.ts} +3 -3
- package/{dist/ui/trigger.d.ts → src/ui/trigger.ts} +1 -2
- package/src/wire/tree.test.ts +81 -0
- package/src/wire/tree.ts +129 -0
- package/src/wire/triggers.test.ts +76 -0
- package/src/wire/triggers.ts +98 -0
- package/dist/boundary/boundary.d.ts +0 -72
- package/dist/boundary/boundary.d.ts.map +0 -1
- package/dist/boundary/boundary.js +0 -83
- package/dist/boundary/boundary.js.map +0 -1
- package/dist/calc/asyncCalc.d.ts +0 -91
- package/dist/calc/asyncCalc.d.ts.map +0 -1
- package/dist/calc/asyncCalc.js +0 -95
- package/dist/calc/asyncCalc.js.map +0 -1
- package/dist/calc/asyncData.d.ts +0 -57
- package/dist/calc/asyncData.d.ts.map +0 -1
- package/dist/calc/asyncData.js +0 -34
- package/dist/calc/asyncData.js.map +0 -1
- package/dist/calc/calc.d.ts +0 -57
- package/dist/calc/calc.d.ts.map +0 -1
- package/dist/calc/calc.js +0 -58
- package/dist/calc/calc.js.map +0 -1
- package/dist/calc/calcFamily.d.ts +0 -57
- package/dist/calc/calcFamily.d.ts.map +0 -1
- package/dist/calc/calcFamily.js +0 -124
- package/dist/calc/calcFamily.js.map +0 -1
- package/dist/calc/compose.d.ts +0 -16
- package/dist/calc/compose.d.ts.map +0 -1
- package/dist/calc/compose.js +0 -7
- package/dist/calc/compose.js.map +0 -1
- package/dist/channel/channel.d.ts +0 -115
- package/dist/channel/channel.d.ts.map +0 -1
- package/dist/channel/channel.js +0 -138
- package/dist/channel/channel.js.map +0 -1
- package/dist/compose/composition.d.ts +0 -72
- package/dist/compose/composition.d.ts.map +0 -1
- package/dist/compose/composition.js +0 -46
- package/dist/compose/composition.js.map +0 -1
- package/dist/compose/host.d.ts.map +0 -1
- package/dist/compose/host.js +0 -6
- package/dist/compose/host.js.map +0 -1
- package/dist/compose/props.d.ts +0 -13
- package/dist/compose/props.d.ts.map +0 -1
- package/dist/compose/props.js.map +0 -1
- package/dist/compose/provide.d.ts +0 -22
- package/dist/compose/provide.d.ts.map +0 -1
- package/dist/compose/provide.js +0 -28
- package/dist/compose/provide.js.map +0 -1
- package/dist/compose/slot.d.ts.map +0 -1
- package/dist/compose/slot.js +0 -23
- package/dist/compose/slot.js.map +0 -1
- package/dist/compose/ui.d.ts +0 -135
- package/dist/compose/ui.d.ts.map +0 -1
- package/dist/compose/ui.js +0 -63
- package/dist/compose/ui.js.map +0 -1
- package/dist/definition/definition.d.ts +0 -33
- package/dist/definition/definition.d.ts.map +0 -1
- package/dist/definition/definition.js +0 -42
- package/dist/definition/definition.js.map +0 -1
- package/dist/event/event.d.ts +0 -33
- package/dist/event/event.d.ts.map +0 -1
- package/dist/event/event.js +0 -30
- package/dist/event/event.js.map +0 -1
- package/dist/event/eventGroup.d.ts +0 -9
- package/dist/event/eventGroup.d.ts.map +0 -1
- package/dist/event/eventGroup.js +0 -4
- package/dist/event/eventGroup.js.map +0 -1
- package/dist/feature/feature.d.ts.map +0 -1
- package/dist/feature/feature.js +0 -98
- package/dist/feature/feature.js.map +0 -1
- package/dist/index.d.ts +0 -53
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js +0 -62
- package/dist/index.js.map +0 -1
- package/dist/internal/capture.d.ts.map +0 -1
- package/dist/internal/capture.js +0 -12
- package/dist/internal/capture.js.map +0 -1
- package/dist/internal/ctx.d.ts.map +0 -1
- package/dist/internal/ctx.js +0 -2
- package/dist/internal/ctx.js.map +0 -1
- package/dist/internal/errors.d.ts +0 -89
- package/dist/internal/errors.d.ts.map +0 -1
- package/dist/internal/errors.js +0 -102
- package/dist/internal/errors.js.map +0 -1
- package/dist/internal/inspect.d.ts +0 -17
- package/dist/internal/inspect.d.ts.map +0 -1
- package/dist/internal/inspect.js +0 -32
- package/dist/internal/inspect.js.map +0 -1
- package/dist/internal/queryDriver.d.ts +0 -65
- package/dist/internal/queryDriver.d.ts.map +0 -1
- package/dist/internal/queryDriver.js +0 -134
- package/dist/internal/queryDriver.js.map +0 -1
- package/dist/internal/reuse.d.ts +0 -10
- package/dist/internal/reuse.d.ts.map +0 -1
- package/dist/internal/reuse.js +0 -68
- package/dist/internal/reuse.js.map +0 -1
- package/dist/internal/scheduler.d.ts +0 -47
- package/dist/internal/scheduler.d.ts.map +0 -1
- package/dist/internal/scheduler.js +0 -70
- package/dist/internal/scheduler.js.map +0 -1
- package/dist/internal/seeds.d.ts.map +0 -1
- package/dist/internal/seeds.js +0 -17
- package/dist/internal/seeds.js.map +0 -1
- package/dist/internal/sources.d.ts +0 -39
- package/dist/internal/sources.d.ts.map +0 -1
- package/dist/internal/sources.js +0 -59
- package/dist/internal/sources.js.map +0 -1
- package/dist/internal/stateRegistry.d.ts +0 -2
- package/dist/internal/stateRegistry.d.ts.map +0 -1
- package/dist/internal/stateRegistry.js.map +0 -1
- package/dist/internal/store.d.ts +0 -47
- package/dist/internal/store.d.ts.map +0 -1
- package/dist/internal/store.js +0 -73
- package/dist/internal/store.js.map +0 -1
- package/dist/internal/track.d.ts.map +0 -1
- package/dist/internal/track.js +0 -19
- package/dist/internal/track.js.map +0 -1
- package/dist/procedure/procedure.d.ts +0 -40
- package/dist/procedure/procedure.d.ts.map +0 -1
- package/dist/procedure/procedure.js +0 -50
- package/dist/procedure/procedure.js.map +0 -1
- package/dist/reducer/reducer.d.ts +0 -44
- package/dist/reducer/reducer.d.ts.map +0 -1
- package/dist/reducer/reducer.js +0 -63
- package/dist/reducer/reducer.js.map +0 -1
- package/dist/remote/remoteState.d.ts +0 -119
- package/dist/remote/remoteState.d.ts.map +0 -1
- package/dist/remote/remoteState.js +0 -270
- package/dist/remote/remoteState.js.map +0 -1
- package/dist/runtime/bus.d.ts +0 -27
- package/dist/runtime/bus.d.ts.map +0 -1
- package/dist/runtime/bus.js +0 -21
- package/dist/runtime/bus.js.map +0 -1
- package/dist/runtime/loop.d.ts +0 -45
- package/dist/runtime/loop.d.ts.map +0 -1
- package/dist/runtime/loop.js +0 -117
- package/dist/runtime/loop.js.map +0 -1
- package/dist/scene/scene.d.ts +0 -44
- package/dist/scene/scene.d.ts.map +0 -1
- package/dist/scene/scene.js +0 -31
- package/dist/scene/scene.js.map +0 -1
- package/dist/state/state.d.ts +0 -37
- package/dist/state/state.d.ts.map +0 -1
- package/dist/state/state.js +0 -46
- package/dist/state/state.js.map +0 -1
- package/dist/state/stateFamily.d.ts +0 -79
- package/dist/state/stateFamily.d.ts.map +0 -1
- package/dist/state/stateFamily.js +0 -99
- package/dist/state/stateFamily.js.map +0 -1
- package/dist/state/stateGroup.d.ts +0 -50
- package/dist/state/stateGroup.d.ts.map +0 -1
- package/dist/state/stateGroup.js +0 -54
- package/dist/state/stateGroup.js.map +0 -1
- package/dist/state/token.d.ts.map +0 -1
- package/dist/state/token.js +0 -20
- package/dist/state/token.js.map +0 -1
- package/dist/synced/syncedStore.d.ts +0 -51
- package/dist/synced/syncedStore.d.ts.map +0 -1
- package/dist/synced/syncedStore.js +0 -43
- package/dist/synced/syncedStore.js.map +0 -1
- package/dist/ui/node.d.ts.map +0 -1
- package/dist/ui/node.js +0 -2
- package/dist/ui/node.js.map +0 -1
- package/dist/ui/trigger.d.ts.map +0 -1
- package/dist/ui/trigger.js +0 -2
- package/dist/ui/trigger.js.map +0 -1
- package/dist/wire/tree.d.ts +0 -66
- package/dist/wire/tree.d.ts.map +0 -1
- package/dist/wire/tree.js +0 -60
- package/dist/wire/tree.js.map +0 -1
- package/dist/wire/triggers.d.ts +0 -44
- package/dist/wire/triggers.d.ts.map +0 -1
- package/dist/wire/triggers.js +0 -31
- package/dist/wire/triggers.js.map +0 -1
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@playfast/reform",
|
|
3
3
|
"playbook": "./playbook",
|
|
4
|
-
"version": "0.0.
|
|
4
|
+
"version": "0.0.8",
|
|
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": [
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"./*": "./src/*.ts"
|
|
29
29
|
},
|
|
30
30
|
"files": [
|
|
31
|
-
"
|
|
31
|
+
"src",
|
|
32
32
|
"README.md"
|
|
33
33
|
],
|
|
34
34
|
"scripts": {
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
import { expect, it } from '@effect/vitest'
|
|
2
|
+
import { Data, Duration, Effect, Layer, Schema as S } from 'effect'
|
|
3
|
+
import { AsyncCalc, Boundary, Calc, Engine, Event, State, StateGroup } from '../index'
|
|
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
|
+
const tick = (ms = 10) => Effect.sleep(Duration.millis(ms))
|
|
12
|
+
|
|
13
|
+
const until = <A>(read: () => A, pred: (a: A) => boolean, rounds = 200): Effect.Effect<A> =>
|
|
14
|
+
Effect.suspend(() => {
|
|
15
|
+
const a = read()
|
|
16
|
+
return pred(a) || rounds <= 0
|
|
17
|
+
? Effect.succeed(a)
|
|
18
|
+
: Effect.flatMap(tick(10), () => until(read, pred, rounds - 1))
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
class Boom extends Data.TaggedError('Boom')<{ readonly message: string }> {}
|
|
22
|
+
|
|
23
|
+
it.live('pending while ANY covered query is on its first load; one Ready when all settle', () => {
|
|
24
|
+
class Seed extends State.make('seed', S.Number) {}
|
|
25
|
+
class Inputs extends StateGroup.make(Seed) {}
|
|
26
|
+
class Fast extends AsyncCalc.make('Fast', {
|
|
27
|
+
inputs: [StateGroup.select(Inputs, 'seed')],
|
|
28
|
+
output: S.Number,
|
|
29
|
+
alwaysOn: true,
|
|
30
|
+
}) {}
|
|
31
|
+
class Slow extends AsyncCalc.make('Slow', {
|
|
32
|
+
inputs: [StateGroup.select(Inputs, 'seed')],
|
|
33
|
+
output: S.Number,
|
|
34
|
+
alwaysOn: true,
|
|
35
|
+
}) {}
|
|
36
|
+
class Gate extends Boundary.make('Gate', { over: [Fast, Slow] }) {}
|
|
37
|
+
const TestLayer = Boundary.live(Gate).pipe(
|
|
38
|
+
Layer.provideMerge(
|
|
39
|
+
Layer.merge(
|
|
40
|
+
AsyncCalc.live(Fast, { query: ({ seed }) => Effect.succeed(seed).pipe(Effect.delay('10 millis')) }),
|
|
41
|
+
AsyncCalc.live(Slow, { query: ({ seed }) => Effect.succeed(seed).pipe(Effect.delay('40 millis')) }),
|
|
42
|
+
),
|
|
43
|
+
),
|
|
44
|
+
Layer.provideMerge(StateGroup.live(Inputs, { seed: 1 })),
|
|
45
|
+
)
|
|
46
|
+
return Effect.gen(function* () {
|
|
47
|
+
const store = yield* Gate.store
|
|
48
|
+
const observed: Array<string> = []
|
|
49
|
+
store.subscribe(() => {
|
|
50
|
+
observed.push(store.get()._tag)
|
|
51
|
+
})
|
|
52
|
+
expect(store.get()._tag).toBe('Pending')
|
|
53
|
+
|
|
54
|
+
// The fast query settles first — still Pending (the slow one holds it).
|
|
55
|
+
yield* tick(20)
|
|
56
|
+
expect(store.get()._tag).toBe('Pending')
|
|
57
|
+
|
|
58
|
+
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
|
+
expect(observed).toEqual(['Ready'])
|
|
62
|
+
}).pipe(Effect.provide(TestLayer))
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it.live('a gated query that is OFF counts Ready — the off arm must not hold the gate', () => {
|
|
66
|
+
class Flag extends State.make('flag', S.Boolean) {}
|
|
67
|
+
class Inputs extends StateGroup.make(Flag) {}
|
|
68
|
+
class Always extends AsyncCalc.make('Always', {
|
|
69
|
+
inputs: [StateGroup.select(Inputs, 'flag')],
|
|
70
|
+
output: S.Number,
|
|
71
|
+
alwaysOn: true,
|
|
72
|
+
}) {}
|
|
73
|
+
class Gated extends AsyncCalc.make('Gated', {
|
|
74
|
+
inputs: [StateGroup.select(Inputs, 'flag')],
|
|
75
|
+
output: S.Number,
|
|
76
|
+
}) {}
|
|
77
|
+
class Gate extends Boundary.make('Gate', { over: [Always, Gated] }) {}
|
|
78
|
+
const TestLayer = Boundary.live(Gate).pipe(
|
|
79
|
+
Layer.provideMerge(
|
|
80
|
+
Layer.merge(
|
|
81
|
+
AsyncCalc.live(Always, { query: () => Effect.succeed(1) }),
|
|
82
|
+
AsyncCalc.live(Gated, { query: () => Effect.succeed(2), disabled: ({ flag }) => !flag }),
|
|
83
|
+
),
|
|
84
|
+
),
|
|
85
|
+
Layer.provideMerge(StateGroup.live(Inputs, { flag: false })),
|
|
86
|
+
)
|
|
87
|
+
return Effect.gen(function* () {
|
|
88
|
+
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
|
+
yield* until(() => store.get()._tag, (t) => t === 'Ready')
|
|
92
|
+
expect(store.get()._tag).toBe('Ready')
|
|
93
|
+
}).pipe(Effect.provide(TestLayer))
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it.live('an SWR refetch never re-pends — Success with refetching: true is Ready', () => {
|
|
97
|
+
class Seed extends State.make('seed', S.Number) {}
|
|
98
|
+
class Inputs extends StateGroup.make(Seed) {}
|
|
99
|
+
class Q extends AsyncCalc.make('Q', {
|
|
100
|
+
inputs: [StateGroup.select(Inputs, 'seed')],
|
|
101
|
+
output: S.Number,
|
|
102
|
+
alwaysOn: true,
|
|
103
|
+
}) {}
|
|
104
|
+
class Gate extends Boundary.make('Gate', { over: [Q] }) {}
|
|
105
|
+
const TestLayer = Boundary.live(Gate).pipe(
|
|
106
|
+
Layer.provideMerge(
|
|
107
|
+
AsyncCalc.live(Q, {
|
|
108
|
+
query: ({ seed }) => Effect.succeed(seed * 2).pipe(Effect.delay('15 millis')),
|
|
109
|
+
}),
|
|
110
|
+
),
|
|
111
|
+
Layer.provideMerge(StateGroup.live(Inputs, { seed: 1 })),
|
|
112
|
+
)
|
|
113
|
+
return Effect.gen(function* () {
|
|
114
|
+
const seed = yield* StateGroup.select(Inputs, 'seed').store
|
|
115
|
+
const qStore = yield* Q.store
|
|
116
|
+
const store = yield* Gate.store
|
|
117
|
+
yield* until(() => store.get()._tag, (t) => t === 'Ready')
|
|
118
|
+
|
|
119
|
+
// Move the input: the query goes Success+refetching (SWR) — Ready holds.
|
|
120
|
+
seed.set(2)
|
|
121
|
+
yield* until(() => qStore.get(), (q) => q._tag === 'Success' && q.refetching)
|
|
122
|
+
expect(store.get()._tag).toBe('Ready')
|
|
123
|
+
yield* until(() => qStore.get(), (q) => q._tag === 'Success' && !q.refetching)
|
|
124
|
+
expect(store.get()._tag).toBe('Ready')
|
|
125
|
+
}).pipe(Effect.provide(TestLayer))
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it.live('a first-load failure surfaces as Errored with the failure, and a retry recovers', () => {
|
|
129
|
+
class Seed extends State.make('seed', S.Number) {}
|
|
130
|
+
class Inputs extends StateGroup.make(Seed) {}
|
|
131
|
+
class Poke extends Event.make('Poke', S.Struct({})) {}
|
|
132
|
+
const healthy = { now: false }
|
|
133
|
+
class Q extends AsyncCalc.make('Q', {
|
|
134
|
+
inputs: [StateGroup.select(Inputs, 'seed')],
|
|
135
|
+
output: S.Number,
|
|
136
|
+
error: S.instanceOf(Boom),
|
|
137
|
+
alwaysOn: true,
|
|
138
|
+
}) {}
|
|
139
|
+
class Gate extends Boundary.make('Gate', { over: [Q] }) {}
|
|
140
|
+
const TestLayer = Boundary.live(Gate).pipe(
|
|
141
|
+
Layer.provideMerge(
|
|
142
|
+
AsyncCalc.live(Q, {
|
|
143
|
+
query: () =>
|
|
144
|
+
healthy.now ? Effect.succeed(1) : Effect.fail(new Boom({ message: 'server down' })),
|
|
145
|
+
invalidateOn: [Poke],
|
|
146
|
+
}),
|
|
147
|
+
),
|
|
148
|
+
Layer.provideMerge(StateGroup.live(Inputs, { seed: 1 })),
|
|
149
|
+
Layer.provideMerge(Engine),
|
|
150
|
+
)
|
|
151
|
+
return Effect.gen(function* () {
|
|
152
|
+
const store = yield* Gate.store
|
|
153
|
+
const errored = yield* until(() => store.get(), (s) => s._tag === 'Errored')
|
|
154
|
+
if (errored._tag === 'Errored') {
|
|
155
|
+
expect(errored.errors).toHaveLength(1)
|
|
156
|
+
const failure = errored.errors[0]
|
|
157
|
+
expect(failure instanceof Boom && failure.message).toBe('server down')
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// The retry path: the server heals, a listed fact refetches, Ready lands.
|
|
161
|
+
healthy.now = true
|
|
162
|
+
yield* Event.dispatch(Poke, {})
|
|
163
|
+
yield* until(() => store.get()._tag, (t) => t === 'Ready')
|
|
164
|
+
expect(store.get()._tag).toBe('Ready')
|
|
165
|
+
}).pipe(Effect.provide(TestLayer))
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
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
|
+
class Seed extends State.make('seed', S.Number) {}
|
|
176
|
+
class Inputs extends StateGroup.make(Seed) {}
|
|
177
|
+
class A extends AsyncCalc.make('A', {
|
|
178
|
+
inputs: [StateGroup.select(Inputs, 'seed')],
|
|
179
|
+
output: S.Number,
|
|
180
|
+
alwaysOn: true,
|
|
181
|
+
}) {}
|
|
182
|
+
class B extends AsyncCalc.make('B', {
|
|
183
|
+
inputs: [A],
|
|
184
|
+
output: S.Number,
|
|
185
|
+
}) {}
|
|
186
|
+
class Gate extends Boundary.make('Gate', { over: [A, B] }) {}
|
|
187
|
+
const TestLayer = Boundary.live(Gate).pipe(
|
|
188
|
+
Layer.provideMerge(
|
|
189
|
+
AsyncCalc.live(B, {
|
|
190
|
+
query: () => Effect.succeed(2).pipe(Effect.delay('15 millis')),
|
|
191
|
+
disabled: ({ A: a }) => a._tag !== 'Success',
|
|
192
|
+
}),
|
|
193
|
+
),
|
|
194
|
+
Layer.provideMerge(
|
|
195
|
+
AsyncCalc.live(A, { query: () => Effect.succeed(1).pipe(Effect.delay('15 millis')) }),
|
|
196
|
+
),
|
|
197
|
+
Layer.provideMerge(StateGroup.live(Inputs, { seed: 1 })),
|
|
198
|
+
)
|
|
199
|
+
return Effect.gen(function* () {
|
|
200
|
+
const store = yield* Gate.store
|
|
201
|
+
const observed: Array<string> = []
|
|
202
|
+
store.subscribe(() => {
|
|
203
|
+
observed.push(store.get()._tag)
|
|
204
|
+
})
|
|
205
|
+
expect(store.get()._tag).toBe('Pending')
|
|
206
|
+
|
|
207
|
+
yield* until(() => store.get()._tag, (t) => t === 'Ready')
|
|
208
|
+
yield* tick(30) // let any straggler notification land before judging
|
|
209
|
+
// ONE Ready, at the end — never a Ready while B's hop was still pending.
|
|
210
|
+
expect(observed).toEqual(['Ready'])
|
|
211
|
+
}).pipe(Effect.provide(TestLayer))
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
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
|
+
class Seed extends State.make('seed', S.Number) {}
|
|
222
|
+
class Inputs extends StateGroup.make(Seed) {}
|
|
223
|
+
class A extends AsyncCalc.make('A', {
|
|
224
|
+
inputs: [StateGroup.select(Inputs, 'seed')],
|
|
225
|
+
output: S.Number,
|
|
226
|
+
alwaysOn: true,
|
|
227
|
+
}) {}
|
|
228
|
+
class Hop extends Calc.make('Hop', { inputs: [A], output: S.Number }) {}
|
|
229
|
+
class B extends AsyncCalc.make('B', {
|
|
230
|
+
inputs: [Hop],
|
|
231
|
+
output: S.Number,
|
|
232
|
+
}) {}
|
|
233
|
+
class Gate extends Boundary.make('Gate', { over: [A, B] }) {}
|
|
234
|
+
const TestLayer = Boundary.live(Gate, { once: true }).pipe(
|
|
235
|
+
Layer.provideMerge(
|
|
236
|
+
AsyncCalc.live(B, {
|
|
237
|
+
query: () => Effect.succeed(2).pipe(Effect.delay('25 millis')),
|
|
238
|
+
disabled: ({ Hop: hop }) => hop < 0,
|
|
239
|
+
}),
|
|
240
|
+
),
|
|
241
|
+
Layer.provideMerge(Calc.live(Hop, ({ A: a }) => (a._tag === 'Success' ? a.value : -1))),
|
|
242
|
+
Layer.provideMerge(
|
|
243
|
+
AsyncCalc.live(A, { query: () => Effect.succeed(1).pipe(Effect.delay('15 millis')) }),
|
|
244
|
+
),
|
|
245
|
+
Layer.provideMerge(StateGroup.live(Inputs, { seed: 1 })),
|
|
246
|
+
)
|
|
247
|
+
return Effect.gen(function* () {
|
|
248
|
+
const bStore = yield* B.store
|
|
249
|
+
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
|
+
yield* until(() => bStore.get()._tag, (t) => t === 'Loading')
|
|
253
|
+
expect(store.get()._tag).toBe('Pending')
|
|
254
|
+
yield* until(() => bStore.get()._tag, (t) => t === 'Success')
|
|
255
|
+
yield* until(() => store.get()._tag, (t) => t === 'Ready')
|
|
256
|
+
expect(store.get()._tag).toBe('Ready')
|
|
257
|
+
}).pipe(Effect.provide(TestLayer))
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
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
|
+
class Route extends State.make('routeFlag', S.Boolean) {}
|
|
265
|
+
class Inputs extends StateGroup.make(Route) {}
|
|
266
|
+
class Always extends AsyncCalc.make('Always', {
|
|
267
|
+
inputs: [StateGroup.select(Inputs, 'routeFlag')],
|
|
268
|
+
output: S.Number,
|
|
269
|
+
alwaysOn: true,
|
|
270
|
+
}) {}
|
|
271
|
+
class Routed extends AsyncCalc.make('Routed', {
|
|
272
|
+
inputs: [StateGroup.select(Inputs, 'routeFlag')],
|
|
273
|
+
output: S.Number,
|
|
274
|
+
}) {}
|
|
275
|
+
class Gate extends Boundary.make('Gate', { over: [Always, Routed] }) {}
|
|
276
|
+
const TestLayer = Boundary.live(Gate, { once: true }).pipe(
|
|
277
|
+
Layer.provideMerge(
|
|
278
|
+
Layer.merge(
|
|
279
|
+
AsyncCalc.live(Always, { query: () => Effect.succeed(1) }),
|
|
280
|
+
AsyncCalc.live(Routed, {
|
|
281
|
+
query: () => Effect.succeed(2).pipe(Effect.delay('20 millis')),
|
|
282
|
+
disabled: ({ routeFlag }) => !routeFlag,
|
|
283
|
+
}),
|
|
284
|
+
),
|
|
285
|
+
),
|
|
286
|
+
Layer.provideMerge(StateGroup.live(Inputs, { routeFlag: false })),
|
|
287
|
+
)
|
|
288
|
+
return Effect.gen(function* () {
|
|
289
|
+
const route = yield* StateGroup.select(Inputs, 'routeFlag').store
|
|
290
|
+
const routedStore = yield* Routed.store
|
|
291
|
+
const store = yield* Gate.store
|
|
292
|
+
yield* until(() => store.get()._tag, (t) => t === 'Ready')
|
|
293
|
+
|
|
294
|
+
// "Navigate": the routed query enables and goes Loading — latched Ready holds.
|
|
295
|
+
route.set(true)
|
|
296
|
+
yield* until(() => routedStore.get()._tag, (t) => t === 'Loading')
|
|
297
|
+
expect(store.get()._tag).toBe('Ready')
|
|
298
|
+
yield* until(() => routedStore.get()._tag, (t) => t === 'Success')
|
|
299
|
+
expect(store.get()._tag).toBe('Ready')
|
|
300
|
+
}).pipe(Effect.provide(TestLayer))
|
|
301
|
+
})
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { Context, Data, Effect, Layer, MutableRef, Predicate } from 'effect'
|
|
2
|
+
import { type Manifest, yieldableClass } from '../definition/definition'
|
|
3
|
+
import { resolveScheduler } from '../internal/scheduler'
|
|
4
|
+
import { type InputStores, sameKey, wireSources } from '../internal/sources'
|
|
5
|
+
import { makeDerivedStore, type Store } from '../internal/store'
|
|
6
|
+
import { readTracked } from '../internal/track'
|
|
7
|
+
import { type Source } from '../state/token'
|
|
8
|
+
|
|
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
|
+
export interface BoundaryPending {
|
|
39
|
+
readonly _tag: 'Pending'
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Some covered source failed before its first value. */
|
|
43
|
+
export interface BoundaryErrored {
|
|
44
|
+
readonly _tag: 'Errored'
|
|
45
|
+
/** The failures, in `over` order (heterogeneous — display via `String`). */
|
|
46
|
+
readonly errors: ReadonlyArray<unknown>
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Every covered source has settled (or is deliberately gated off). */
|
|
50
|
+
export interface BoundaryReady {
|
|
51
|
+
readonly _tag: 'Ready'
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export type BoundaryState = BoundaryPending | BoundaryErrored | BoundaryReady
|
|
55
|
+
|
|
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
|
+
const pendingArm: BoundaryState = { _tag: 'Pending' }
|
|
61
|
+
const readyArm: BoundaryState = { _tag: 'Ready' }
|
|
62
|
+
const erroredArm = (errors: ReadonlyArray<unknown>): BoundaryState =>
|
|
63
|
+
Data.struct({ _tag: 'Errored' as const, errors: Data.array(errors) })
|
|
64
|
+
|
|
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>
|
|
76
|
+
|
|
77
|
+
export interface BoundaryManifest<N extends string> extends Manifest {
|
|
78
|
+
readonly kind: 'Boundary'
|
|
79
|
+
readonly name: N
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface BoundaryClass<out N extends string, out Over extends ReadonlyArray<AnyLifecycleSource>>
|
|
83
|
+
extends Effect.Effect<BoundaryState, never, Store<BoundaryState>> {
|
|
84
|
+
new (): {}
|
|
85
|
+
readonly manifest: BoundaryManifest<N>
|
|
86
|
+
readonly store: Context.Tag<Store<BoundaryState>, Store<BoundaryState>>
|
|
87
|
+
/** The boundary's name, so it doubles as a `Source` input to a calc. */
|
|
88
|
+
readonly name: N
|
|
89
|
+
/** The covered lifecycle sources — read by `live` and the editor's graph. */
|
|
90
|
+
readonly over: Over
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface BoundaryConfig<Over extends ReadonlyArray<AnyLifecycleSource>> {
|
|
94
|
+
readonly over: Over
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
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
|
+
readonly once?: boolean
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Define a boundary over async sources. `yield* MyBoundary` reads the merged
|
|
110
|
+
* `Pending | Errored | Ready` lifecycle; the consuming view renders ONE
|
|
111
|
+
* fallback on Pending and its content slot on Ready — content reveals
|
|
112
|
+
* together, with data already present (fetching never waited on rendering).
|
|
113
|
+
*/
|
|
114
|
+
export const make = <const N extends string, const Over extends ReadonlyArray<AnyLifecycleSource>>(
|
|
115
|
+
name: N,
|
|
116
|
+
config: BoundaryConfig<Over>,
|
|
117
|
+
): BoundaryClass<N, Over> => {
|
|
118
|
+
const store = Context.GenericTag<Store<BoundaryState>, Store<BoundaryState>>(
|
|
119
|
+
`reform/boundary/${name}`,
|
|
120
|
+
)
|
|
121
|
+
const manifest: BoundaryManifest<N> = { kind: 'Boundary', name }
|
|
122
|
+
const read = Effect.flatMap(store, readTracked)
|
|
123
|
+
return yieldableClass(read, { manifest, store, name, over: config.over })
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// The snapshot values arrive through `wireSources`' erased key projection
|
|
127
|
+
// (`ReadonlyArray<unknown>`), so read the two facts the merge needs through
|
|
128
|
+
// honest structural guards — no cast back to `AnyAsyncData`.
|
|
129
|
+
const tagOf = (arm: unknown): string =>
|
|
130
|
+
Predicate.hasProperty(arm, '_tag') && Predicate.isString(arm._tag) ? arm._tag : ''
|
|
131
|
+
const errorOf = (arm: unknown): ReadonlyArray<unknown> =>
|
|
132
|
+
Predicate.hasProperty(arm, 'error') && tagOf(arm) === 'Error' ? [arm.error] : []
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Wire the merge. A derived store over the covered stores (sensed through the
|
|
136
|
+
* same `wireSources` plumbing every calc uses): recomputes are memoized on the
|
|
137
|
+
* input snapshots and the arms are stable references, so input churn that does
|
|
138
|
+
* not move the merged arm wakes nobody.
|
|
139
|
+
*/
|
|
140
|
+
export const live = <N extends string, Over extends ReadonlyArray<AnyLifecycleSource>>(
|
|
141
|
+
boundary: BoundaryClass<N, Over>,
|
|
142
|
+
options: BoundaryOptions = {},
|
|
143
|
+
): Layer.Layer<Store<BoundaryState>, never, InputStores<Over>> =>
|
|
144
|
+
// Scoped so the source subscriptions are released with the layer's scope.
|
|
145
|
+
Layer.scoped(
|
|
146
|
+
boundary.store,
|
|
147
|
+
Effect.gen(function* () {
|
|
148
|
+
const scheduler = yield* resolveScheduler
|
|
149
|
+
const sources = yield* wireSources(boundary.over)
|
|
150
|
+
|
|
151
|
+
const latched = MutableRef.make(false)
|
|
152
|
+
const memo = MutableRef.make<
|
|
153
|
+
{ readonly key: ReadonlyArray<unknown>; readonly value: BoundaryState } | undefined
|
|
154
|
+
>(undefined)
|
|
155
|
+
const recompute = (): BoundaryState => {
|
|
156
|
+
if (options.once === true && MutableRef.get(latched)) return readyArm
|
|
157
|
+
const arms = sources.keyOf(sources.snapshot())
|
|
158
|
+
const prev = MutableRef.get(memo)
|
|
159
|
+
if (prev !== undefined && sameKey(arms, prev.key)) return prev.value
|
|
160
|
+
const errors = arms.flatMap(errorOf)
|
|
161
|
+
const value =
|
|
162
|
+
errors.length > 0
|
|
163
|
+
? erroredArm(errors)
|
|
164
|
+
: arms.some((arm) => tagOf(arm) === 'Loading')
|
|
165
|
+
? pendingArm
|
|
166
|
+
: readyArm
|
|
167
|
+
MutableRef.set(memo, { key: arms, value })
|
|
168
|
+
return value
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const derived = makeDerivedStore(recompute, sources.subscribe, scheduler)
|
|
172
|
+
yield* Effect.addFinalizer(() => Effect.sync(derived.unsubscribe))
|
|
173
|
+
|
|
174
|
+
if (options.once === true) {
|
|
175
|
+
// Latch on CONVERGED values only — at build and on (coalesced,
|
|
176
|
+
// post-fixpoint) notifications — never inside `recompute`. A chained
|
|
177
|
+
// gate with a plain Calc hop between the async stores (bootstrap →
|
|
178
|
+
// current-workspace → boards) propagates over TWO flush rounds, so a
|
|
179
|
+
// mid-flush recompute can observe the frame where the downstream
|
|
180
|
+
// driver has not yet flipped its query to Loading. That intermediate
|
|
181
|
+
// Ready self-corrects within the flush for readers, but a latch taken
|
|
182
|
+
// there would freeze it; a subscriber runs only after the fixpoint,
|
|
183
|
+
// where the value is converged.
|
|
184
|
+
if (recompute()._tag === 'Ready') MutableRef.set(latched, true)
|
|
185
|
+
const offSelf = derived.store.subscribe(() => {
|
|
186
|
+
if (derived.store.get()._tag === 'Ready') MutableRef.set(latched, true)
|
|
187
|
+
})
|
|
188
|
+
yield* Effect.addFinalizer(() => Effect.sync(offSelf))
|
|
189
|
+
}
|
|
190
|
+
return derived.store
|
|
191
|
+
}),
|
|
192
|
+
)
|