effect-browser 0.3.0-beta.6 → 0.3.0-beta.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/README.md +124 -21
- package/dist/{Browser-B5AV7zPS.mjs → Browser-CNwK0r0q.mjs} +2 -2
- package/dist/{Browser-B5AV7zPS.mjs.map → Browser-CNwK0r0q.mjs.map} +1 -1
- package/dist/Browser.d.mts +2 -2
- package/dist/Browser.mjs +1 -1
- package/dist/{BrowserEvent-DzdNdvoh.d.mts → BrowserEvent-BRu2juEz.d.mts} +3 -1
- package/dist/BrowserEvent.d.mts +1 -1
- package/dist/BrowserEvent.mjs +2 -0
- package/dist/BrowserEvent.mjs.map +1 -1
- package/dist/Cdp.mjs +1 -1
- package/dist/{Change-DVP2IJWm.d.mts → Change-iMlcuzVD.d.mts} +2 -2
- package/dist/Change.d.mts +1 -1
- package/dist/Chromium.mjs +1 -1
- package/dist/Expect.d.mts +1 -1
- package/dist/Expect.mjs +1 -1
- package/dist/Moment.d.mts +1 -1
- package/dist/{Page-DYTdVhYa.d.mts → Page-C30aG2RQ.d.mts} +6 -4
- package/dist/Page-D4281dIi.mjs.map +1 -1
- package/dist/Page.d.mts +1 -1
- package/dist/Plan-BvCS9qAf.d.mts +209 -0
- package/dist/{Plan-5MdsRzE5.mjs → Plan-CyQNv49C.mjs} +232 -38
- package/dist/Plan-CyQNv49C.mjs.map +1 -0
- package/dist/Plan.d.mts +2 -2
- package/dist/Plan.mjs +2 -2
- package/dist/Policy.d.mts +1 -1
- package/dist/Presentation.d.mts +1 -1
- package/dist/Presentation.mjs +1 -1
- package/dist/Stage.d.mts +1 -1
- package/dist/Stage.mjs +1 -1
- package/dist/{Supervisor-Im6-wbg5.d.mts → Supervisor-CYWFK3vM.d.mts} +106 -56
- package/dist/Supervisor-TNKNOVzu.mjs +599 -0
- package/dist/Supervisor-TNKNOVzu.mjs.map +1 -0
- package/dist/Supervisor.d.mts +2 -2
- package/dist/Supervisor.mjs +2 -2
- package/dist/index.d.mts +5 -5
- package/dist/index.mjs +3 -3
- package/dist/{page-BkRcLCWZ.mjs → page-DHeYWEs-.mjs} +27 -14
- package/dist/page-DHeYWEs-.mjs.map +1 -0
- package/package.json +1 -1
- package/src/BrowserEvent.ts +2 -0
- package/src/Page.ts +3 -1
- package/src/Plan.ts +365 -56
- package/src/Supervisor.ts +384 -138
- package/src/internal/input/perform.ts +2 -0
- package/src/internal/page/bridge.ts +3 -3
- package/src/internal/reading/ready.inpage.ts +17 -7
- package/src/internal/reading/ready.ts +9 -3
- package/src/internal/supervisor/lifecycle.ts +291 -140
- package/dist/Plan-5MdsRzE5.mjs.map +0 -1
- package/dist/Plan-CNNOU1Tx.d.mts +0 -112
- package/dist/Supervisor-BtEG93gR.mjs +0 -420
- package/dist/Supervisor-BtEG93gR.mjs.map +0 -1
- package/dist/page-BkRcLCWZ.mjs.map +0 -1
|
@@ -90,7 +90,7 @@ const install = (
|
|
|
90
90
|
): PageApi => {
|
|
91
91
|
const installed = globalThis.__effectBrowser;
|
|
92
92
|
|
|
93
|
-
if (installed !== undefined && installed.version ===
|
|
93
|
+
if (installed !== undefined && installed.version === 19) return installed;
|
|
94
94
|
const named = makeNames();
|
|
95
95
|
const walked = makeWalk(named);
|
|
96
96
|
const placing = makeContext(named, walked);
|
|
@@ -105,10 +105,10 @@ const install = (
|
|
|
105
105
|
const located = makeTargets(named, walked, placing);
|
|
106
106
|
const guarded = makeGuard(named, located, makeEvidence(named, placing));
|
|
107
107
|
const edited = makeEdit(named, guarded, placing);
|
|
108
|
-
const readiness = makeReady(walked, texts, kept);
|
|
108
|
+
const readiness = makeReady(walked, texts, kept, recorder);
|
|
109
109
|
|
|
110
110
|
const api: PageApi = {
|
|
111
|
-
version:
|
|
111
|
+
version: 19,
|
|
112
112
|
snapshot: read.snapshot,
|
|
113
113
|
find: subjected.find,
|
|
114
114
|
until: makeExpectations(named, subjected).until,
|
|
@@ -6,12 +6,14 @@
|
|
|
6
6
|
* frame. So a canvas mounted blank, or a spinner alone, is still loading. A screen that only says
|
|
7
7
|
* "Loading…" in words reads as ready, and a WebGL canvas drawn once, without keeping its drawing,
|
|
8
8
|
* reads as blank. Asked for a quiet spell, where the page's changes are recorded, nothing in view
|
|
9
|
-
* may have changed for that long either.
|
|
10
|
-
*
|
|
9
|
+
* may have changed for that long either. Content mode starts and refreshes the recorder and
|
|
10
|
+
* observes a full quiet interval from its first check in each document. After input, it waits a
|
|
11
|
+
* task and a frame. See `names.inpage.ts` for what a page-side part may use.
|
|
11
12
|
*/
|
|
12
13
|
import { Schema } from "effect";
|
|
13
14
|
|
|
14
15
|
import type { History } from "../timeline/history.inpage.ts";
|
|
16
|
+
import type { Recorder } from "../timeline/record.inpage.ts";
|
|
15
17
|
import type { Texts } from "./text.inpage.ts";
|
|
16
18
|
import type { Walk } from "./walk.inpage.ts";
|
|
17
19
|
|
|
@@ -31,7 +33,7 @@ export const ReadinessSchema = Schema.Array(
|
|
|
31
33
|
|
|
32
34
|
export type Readiness = typeof ReadinessSchema.Type;
|
|
33
35
|
|
|
34
|
-
export const ready = (walked: Walk, texts: Texts, kept: History) => {
|
|
36
|
+
export const ready = (walked: Walk, texts: Texts, kept: History, recorder: Recorder) => {
|
|
35
37
|
const { inView, visible } = walked;
|
|
36
38
|
// A canvas is read through a small copy: reading it directly would give it a context of ours.
|
|
37
39
|
let copy: OffscreenCanvasRenderingContext2D | null | undefined;
|
|
@@ -86,7 +88,7 @@ export const ready = (walked: Walk, texts: Texts, kept: History) => {
|
|
|
86
88
|
};
|
|
87
89
|
|
|
88
90
|
// In a frame, before it is drawn: a canvas drawn every frame still holds its drawing then.
|
|
89
|
-
const check = (quietMillis: number): Promise<Readiness> =>
|
|
91
|
+
const check = (quietMillis: number, observed: number): Promise<Readiness> =>
|
|
90
92
|
painted().then((paints) => {
|
|
91
93
|
const waiting: Array<Readiness[number]> = [];
|
|
92
94
|
|
|
@@ -113,7 +115,10 @@ export const ready = (walked: Walk, texts: Texts, kept: History) => {
|
|
|
113
115
|
if (document.fonts.status !== "loaded") waiting.push("fonts");
|
|
114
116
|
if (showing(document.querySelectorAll("[aria-busy=true]")).length > 0) waiting.push("busy");
|
|
115
117
|
if (!content()) waiting.push("content");
|
|
116
|
-
if (
|
|
118
|
+
if (
|
|
119
|
+
Math.max(observed, kept.changing()) >
|
|
120
|
+
performance.timeOrigin + performance.now() - quietMillis
|
|
121
|
+
)
|
|
117
122
|
waiting.push("changing");
|
|
118
123
|
|
|
119
124
|
return waiting;
|
|
@@ -123,11 +128,16 @@ export const ready = (walked: Walk, texts: Texts, kept: History) => {
|
|
|
123
128
|
* Check until the page is ready, or for `millis`, and say what it is still waiting for; with
|
|
124
129
|
* `quietMillis`, until nothing the record saw in view has changed for that long, too.
|
|
125
130
|
*/
|
|
126
|
-
const wait = async (millis: number, quietMillis = 0): Promise<Readiness> => {
|
|
131
|
+
const wait = async (millis: number, quietMillis = 0, contentOnly = false): Promise<Readiness> => {
|
|
127
132
|
const until = performance.now() + millis;
|
|
133
|
+
let observed = contentOnly ? undefined : Number.NEGATIVE_INFINITY;
|
|
128
134
|
|
|
129
135
|
for (;;) {
|
|
130
|
-
|
|
136
|
+
if (contentOnly) {
|
|
137
|
+
recorder.start();
|
|
138
|
+
if (kept.begun()) observed ??= performance.timeOrigin + performance.now();
|
|
139
|
+
}
|
|
140
|
+
const waiting = await check(quietMillis, observed ?? Number.POSITIVE_INFINITY);
|
|
131
141
|
|
|
132
142
|
if (waiting.length === 0 || performance.now() >= until) return waiting;
|
|
133
143
|
const { promise, resolve } = Promise.withResolvers<void>();
|
|
@@ -9,14 +9,16 @@
|
|
|
9
9
|
* connection of its own, a round trip there: each answer comes behind every frame sent before it on
|
|
10
10
|
* its connection, and a frame that arrives first starts the spell again. Where the page's changes
|
|
11
11
|
* are recorded, its evidence includes them: nothing in view may have changed for the spell, which a
|
|
12
|
-
* DOM page's changes show better than its paint, and no stall on the way can hide.
|
|
12
|
+
* DOM page's changes show better than its paint, and no stall on the way can hide. Content
|
|
13
|
+
* stillness starts that record itself and ignores capture silence. Each new document starts a
|
|
14
|
+
* new observation, while retries of destroyed script contexts share the caller's deadline.
|
|
13
15
|
*/
|
|
14
16
|
import { Duration, Effect, Schedule, Stream } from "effect";
|
|
15
17
|
|
|
16
18
|
import { Timeout } from "../../BrowserError.ts";
|
|
17
19
|
import type { ReadyOptions } from "../../Page.ts";
|
|
18
20
|
import { type Bridge, scriptCall } from "../page/bridge.ts";
|
|
19
|
-
import { decodeWith, failWith, type PageContext } from "../page/context.ts";
|
|
21
|
+
import { contextGone, decodeWith, failWith, type PageContext } from "../page/context.ts";
|
|
20
22
|
import type * as Capture from "../pictures/capture.ts";
|
|
21
23
|
import { ReadinessSchema } from "./ready.inpage.ts";
|
|
22
24
|
|
|
@@ -34,9 +36,11 @@ export const make =
|
|
|
34
36
|
"ready",
|
|
35
37
|
Math.max(0, Math.round(until - page.now())),
|
|
36
38
|
options.quietMillis ?? 0,
|
|
39
|
+
options.still === "content",
|
|
37
40
|
),
|
|
38
41
|
),
|
|
39
42
|
).pipe(
|
|
43
|
+
Effect.retry({ while: contextGone, schedule: Schedule.spaced(Duration.millis(10)) }),
|
|
40
44
|
Effect.flatMap(decodeWith("ready", ReadinessSchema)),
|
|
41
45
|
Effect.tap((waiting) => Effect.annotateCurrentSpan({ waiting: waiting.join(",") })),
|
|
42
46
|
Effect.filterOrElse(
|
|
@@ -95,8 +99,10 @@ export const make =
|
|
|
95
99
|
Effect.gen(function* () {
|
|
96
100
|
const until = page.now() + Duration.toMillis(timeout);
|
|
97
101
|
|
|
102
|
+
if (options.still === "content") yield* bridge.registerRecorder("ready");
|
|
98
103
|
yield* settled(until);
|
|
99
|
-
if (options.quietMillis !== undefined
|
|
104
|
+
if (options.quietMillis !== undefined && options.still !== "content")
|
|
105
|
+
yield* quiet(options.quietMillis, until);
|
|
100
106
|
}).pipe(page.within("ready", timeout)),
|
|
101
107
|
),
|
|
102
108
|
page.span("Page.ready"),
|
|
@@ -28,6 +28,12 @@ export class Reopening extends Schema.TaggedClass<Reopening>()("Reopening", {})
|
|
|
28
28
|
/** Open: `browser` gives this generation until the next one opens. */
|
|
29
29
|
export class Open extends Schema.TaggedClass<Open>()("Open", {}) {}
|
|
30
30
|
|
|
31
|
+
/** No longer serving; holds end at this host-monotonic deadline or when their owners let go. */
|
|
32
|
+
export class Draining extends Schema.TaggedClass<Draining>()("Draining", {
|
|
33
|
+
reason: Schema.Literals(["rotated", "lost", "retired"]),
|
|
34
|
+
revokeAt: Schema.Finite,
|
|
35
|
+
}) {}
|
|
36
|
+
|
|
31
37
|
/** Lost: its connection dropped, or its provider ended its session, as `cause` says. */
|
|
32
38
|
export class Lost extends Schema.TaggedClass<Lost>()("Lost", {
|
|
33
39
|
cause: DisconnectCause,
|
|
@@ -53,7 +59,16 @@ export class Closed extends Schema.TaggedClass<Closed>()("Closed", {
|
|
|
53
59
|
*/
|
|
54
60
|
export class Kept extends Schema.TaggedClass<Kept>()("Kept", {}) {}
|
|
55
61
|
|
|
56
|
-
export const GenerationState = Schema.Union([
|
|
62
|
+
export const GenerationState = Schema.Union([
|
|
63
|
+
Opening,
|
|
64
|
+
Reopening,
|
|
65
|
+
Open,
|
|
66
|
+
Draining,
|
|
67
|
+
Lost,
|
|
68
|
+
Down,
|
|
69
|
+
Closed,
|
|
70
|
+
Kept,
|
|
71
|
+
]);
|
|
57
72
|
|
|
58
73
|
export type GenerationState = typeof GenerationState.Type;
|
|
59
74
|
|
|
@@ -63,168 +78,314 @@ export interface Live<A> {
|
|
|
63
78
|
readonly value: A;
|
|
64
79
|
}
|
|
65
80
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
81
|
+
export interface Held<A, H> extends Live<A> {
|
|
82
|
+
readonly holds: ReadonlySet<H>;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Release ownership remains here until the provider and generation scope have finished. */
|
|
86
|
+
export interface Drain<A, H> {
|
|
87
|
+
readonly _tag: "Draining" | "Releasing" | "Leaving";
|
|
88
|
+
readonly live: Held<A, H>;
|
|
89
|
+
readonly ending: Draining;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** One serving generation, one shared acquisition, and prior generations still owned. */
|
|
93
|
+
export type State<A, H = unknown> = (
|
|
94
|
+
| { readonly _tag: "Opening"; readonly number: number; readonly serving: Held<A, H> | undefined }
|
|
95
|
+
| { readonly _tag: "Open"; readonly serving: Held<A, H> }
|
|
73
96
|
| {
|
|
74
97
|
readonly _tag: "Down";
|
|
75
98
|
readonly number: number;
|
|
76
99
|
readonly detail: string;
|
|
77
100
|
readonly cause: unknown;
|
|
78
|
-
readonly serving:
|
|
101
|
+
readonly serving: Held<A, H> | undefined;
|
|
79
102
|
}
|
|
80
|
-
| { readonly _tag: "Retired" }
|
|
103
|
+
| { readonly _tag: "Retired" }
|
|
104
|
+
) & { readonly draining: ReadonlyMap<number, Drain<A, H>> };
|
|
81
105
|
|
|
82
|
-
export type Input<A> =
|
|
106
|
+
export type Input<A, H = unknown> =
|
|
83
107
|
| { readonly _tag: "Opened"; readonly live: Live<A> }
|
|
84
|
-
/** The open of `number` failed for good. */
|
|
85
108
|
| {
|
|
86
109
|
readonly _tag: "Failed";
|
|
87
110
|
readonly number: number;
|
|
88
111
|
readonly detail: string;
|
|
89
112
|
readonly cause: unknown;
|
|
90
113
|
}
|
|
91
|
-
/** The open of `number` was stopped, as on retiring, before it opened. */
|
|
92
114
|
| { readonly _tag: "Abandoned"; readonly number: number }
|
|
93
115
|
| { readonly _tag: "Lost"; readonly number: number; readonly cause: DisconnectCause }
|
|
94
|
-
/** A caller asks for the next generation, or the rotation time of `due` came. */
|
|
95
116
|
| { readonly _tag: "Rotate"; readonly due?: number | undefined }
|
|
96
117
|
| { readonly _tag: "Retire" }
|
|
97
|
-
/** Stop as `Retire` does, but leave the serving generation running. */
|
|
98
118
|
| { readonly _tag: "Keep" }
|
|
99
|
-
| {
|
|
119
|
+
| {
|
|
120
|
+
readonly _tag: "Hold";
|
|
121
|
+
readonly number?: number | undefined;
|
|
122
|
+
readonly hold: H;
|
|
123
|
+
}
|
|
124
|
+
| { readonly _tag: "Let"; readonly number: number; readonly hold: H }
|
|
125
|
+
| { readonly _tag: "Revoke"; readonly number: number }
|
|
126
|
+
| {
|
|
127
|
+
readonly _tag: "Released";
|
|
128
|
+
readonly number: number;
|
|
129
|
+
readonly released?: Released | undefined;
|
|
130
|
+
};
|
|
100
131
|
|
|
101
132
|
export type Command<A> =
|
|
102
|
-
/** Open `number`, after the release of `after` has finished. */
|
|
103
133
|
| { readonly _tag: "Open"; readonly number: number; readonly after: number | undefined }
|
|
104
|
-
/** Watch a generation that opened for its loss and its rotation time. */
|
|
105
134
|
| { readonly _tag: "Watch"; readonly live: Live<A> }
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
135
|
+
/** Signal ending and arrange revocation. Repeated only when loss/retirement accelerates it. */
|
|
136
|
+
| { readonly _tag: "Drain"; readonly live: Live<A>; readonly ending: Draining }
|
|
137
|
+
/** Revoke/close holds, await their Let inputs, release the provider, then close its scope. */
|
|
138
|
+
| { readonly _tag: "Release"; readonly live: Live<A>; readonly ending: Draining }
|
|
139
|
+
| { readonly _tag: "Leave"; readonly live: Live<A>; readonly ending: Draining };
|
|
140
|
+
|
|
141
|
+
export interface Step<A, H = unknown> {
|
|
142
|
+
readonly state: State<A, H>;
|
|
113
143
|
readonly events: ReadonlyArray<readonly [number, GenerationState]>;
|
|
114
144
|
readonly commands: ReadonlyArray<Command<A>>;
|
|
115
|
-
/** For `Rotate`, the generation to wait for, unless retired. */
|
|
116
145
|
readonly reply?: number | undefined;
|
|
146
|
+
readonly borrowed?: Live<A> | undefined;
|
|
117
147
|
}
|
|
118
148
|
|
|
119
|
-
export const start = <A>(): Step<A> => ({
|
|
120
|
-
state: { _tag: "Opening", number: 1, serving: undefined },
|
|
149
|
+
export const start = <A, H = unknown>(): Step<A, H> => ({
|
|
150
|
+
state: { _tag: "Opening", number: 1, serving: undefined, draining: new Map() },
|
|
121
151
|
events: [[1, new Opening()]],
|
|
122
152
|
commands: [{ _tag: "Open", number: 1, after: undefined }],
|
|
123
153
|
});
|
|
124
154
|
|
|
125
|
-
const servingOf = <A>(state: State<A>) =>
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
155
|
+
const servingOf = <A, H>(state: State<A, H>) =>
|
|
156
|
+
state._tag === "Retired" ? undefined : state.serving;
|
|
157
|
+
|
|
158
|
+
/** The holds still owned for a generation, including one whose release has begun. */
|
|
159
|
+
export const lookup = <A, H>(state: State<A, H>, number: number): Held<A, H> | undefined =>
|
|
160
|
+
servingOf(state)?.number === number ? servingOf(state) : state.draining.get(number)?.live;
|
|
161
|
+
|
|
162
|
+
/** A transition's local result builder; input state and its collections remain immutable. */
|
|
163
|
+
interface Change<A, H> {
|
|
164
|
+
readonly now: number;
|
|
165
|
+
readonly handover: number;
|
|
166
|
+
readonly exclusive: boolean;
|
|
167
|
+
state: State<A, H>;
|
|
168
|
+
events: Array<readonly [number, GenerationState]>;
|
|
169
|
+
commands: Array<Command<A>>;
|
|
170
|
+
reply?: number;
|
|
171
|
+
borrowed?: Live<A>;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** The sole writer of a drain's ending and phase; each release command is emitted once. */
|
|
175
|
+
const draining = <A, H>(change: Change<A, H>, drain: Drain<A, H>) => {
|
|
176
|
+
const before = change.state.draining.get(drain.live.number);
|
|
177
|
+
const { live, ending, _tag } = drain;
|
|
178
|
+
|
|
179
|
+
change.state = {
|
|
180
|
+
...change.state,
|
|
181
|
+
draining: new Map(change.state.draining).set(live.number, drain),
|
|
182
|
+
};
|
|
183
|
+
if (before?.ending !== ending) {
|
|
184
|
+
change.events.push([live.number, ending]);
|
|
185
|
+
change.commands.push({ _tag: "Drain", live, ending });
|
|
186
|
+
}
|
|
187
|
+
if (_tag !== "Draining" && before?._tag !== _tag)
|
|
188
|
+
change.commands.push({ _tag: _tag === "Leaving" ? "Leave" : "Release", live, ending });
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
const end = <A, H>(
|
|
192
|
+
change: Change<A, H>,
|
|
193
|
+
live: Held<A, H>,
|
|
194
|
+
reason: Draining["reason"],
|
|
195
|
+
keep = false,
|
|
196
|
+
) => {
|
|
197
|
+
const ending = new Draining({
|
|
198
|
+
reason,
|
|
199
|
+
revokeAt: change.now + (reason === "rotated" ? change.handover : 0),
|
|
148
200
|
});
|
|
149
201
|
|
|
202
|
+
draining(change, {
|
|
203
|
+
live,
|
|
204
|
+
ending,
|
|
205
|
+
_tag: keep
|
|
206
|
+
? "Leaving"
|
|
207
|
+
: live.holds.size === 0 || ending.revokeAt <= change.now
|
|
208
|
+
? "Releasing"
|
|
209
|
+
: "Draining",
|
|
210
|
+
});
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
const revoke = <A, H>(change: Change<A, H>, number: number, reason?: "lost" | "retired") => {
|
|
214
|
+
const drain = change.state.draining.get(number);
|
|
215
|
+
|
|
216
|
+
if (drain?._tag !== "Draining" || (reason === undefined && change.now < drain.ending.revokeAt))
|
|
217
|
+
return;
|
|
218
|
+
draining(change, {
|
|
219
|
+
...drain,
|
|
220
|
+
_tag: "Releasing",
|
|
221
|
+
ending: reason === undefined ? drain.ending : new Draining({ reason, revokeAt: change.now }),
|
|
222
|
+
});
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
const holds = <A, H>(
|
|
226
|
+
change: Change<A, H>,
|
|
227
|
+
input: Extract<Input<A, H>, { readonly _tag: "Hold" | "Let" }>,
|
|
228
|
+
) => {
|
|
229
|
+
const number = input.number ?? servingOf(change.state)?.number;
|
|
230
|
+
const live = number === undefined ? undefined : lookup(change.state, number);
|
|
231
|
+
|
|
232
|
+
if (live === undefined) return;
|
|
233
|
+
const drain = change.state.draining.get(live.number);
|
|
234
|
+
|
|
235
|
+
if (
|
|
236
|
+
input._tag === "Hold" &&
|
|
237
|
+
drain !== undefined &&
|
|
238
|
+
(drain._tag !== "Draining" || change.now >= drain.ending.revokeAt)
|
|
239
|
+
)
|
|
240
|
+
return;
|
|
241
|
+
if (live.holds.has(input.hold) === (input._tag === "Hold")) return;
|
|
242
|
+
const holds = new Set(live.holds);
|
|
243
|
+
|
|
244
|
+
if (input._tag === "Hold") {
|
|
245
|
+
holds.add(input.hold);
|
|
246
|
+
change.borrowed = live;
|
|
247
|
+
} else holds.delete(input.hold);
|
|
248
|
+
const held = { ...live, holds };
|
|
249
|
+
|
|
250
|
+
if (drain !== undefined)
|
|
251
|
+
draining(change, {
|
|
252
|
+
...drain,
|
|
253
|
+
live: held,
|
|
254
|
+
_tag: drain._tag === "Draining" && holds.size === 0 ? "Releasing" : drain._tag,
|
|
255
|
+
});
|
|
256
|
+
else if (change.state._tag !== "Retired") change.state = { ...change.state, serving: held };
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
const next = <A, H>(change: Change<A, H>, number: number, reopen: boolean) => {
|
|
260
|
+
const { exclusive } = change;
|
|
261
|
+
const current = servingOf(change.state);
|
|
262
|
+
|
|
263
|
+
const after = exclusive
|
|
264
|
+
? (current?.number ?? change.state.draining.keys().next().value)
|
|
265
|
+
: undefined;
|
|
266
|
+
|
|
267
|
+
if (current !== undefined && (reopen || exclusive))
|
|
268
|
+
end(change, current, reopen ? "lost" : "rotated");
|
|
269
|
+
change.state = {
|
|
270
|
+
_tag: "Opening",
|
|
271
|
+
number,
|
|
272
|
+
serving: reopen || exclusive ? undefined : current,
|
|
273
|
+
draining: change.state.draining,
|
|
274
|
+
};
|
|
275
|
+
change.events.push([number, reopen ? new Reopening() : new Opening()]);
|
|
276
|
+
change.commands.push({ _tag: "Open", number, after });
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
const lost = <A, H>(
|
|
280
|
+
change: Change<A, H>,
|
|
281
|
+
input: Extract<Input<A, H>, { readonly _tag: "Lost" }>,
|
|
282
|
+
) => {
|
|
283
|
+
const { state } = change;
|
|
284
|
+
const drain = state.draining.get(input.number);
|
|
285
|
+
const current = servingOf(state);
|
|
286
|
+
|
|
287
|
+
if (drain?._tag === "Draining") {
|
|
288
|
+
change.events.push([input.number, new Lost({ cause: input.cause })]);
|
|
289
|
+
revoke(change, input.number, "lost");
|
|
290
|
+
} else if (state._tag !== "Retired" && current?.number === input.number) {
|
|
291
|
+
change.events.push([input.number, new Lost({ cause: input.cause })]);
|
|
292
|
+
if (state._tag !== "Opening")
|
|
293
|
+
next(change, (state._tag === "Open" ? current.number : state.number) + 1, true);
|
|
294
|
+
else {
|
|
295
|
+
change.state = { ...state, serving: undefined };
|
|
296
|
+
end(change, current, "lost");
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
/** Pure admission and lifecycle changes. A rejected hold preserves state identity. */
|
|
302
|
+
export const transition = <A, H = unknown>(
|
|
303
|
+
state: State<A, H>,
|
|
304
|
+
input: Input<A, H>,
|
|
305
|
+
exclusive: boolean,
|
|
306
|
+
now = 0,
|
|
307
|
+
handover = 30_000,
|
|
308
|
+
): Step<A, H> => {
|
|
309
|
+
const change: Change<A, H> = {
|
|
310
|
+
state,
|
|
311
|
+
events: [],
|
|
312
|
+
commands: [],
|
|
313
|
+
exclusive,
|
|
314
|
+
handover,
|
|
315
|
+
now,
|
|
316
|
+
};
|
|
317
|
+
|
|
150
318
|
switch (input._tag) {
|
|
319
|
+
case "Hold":
|
|
320
|
+
case "Let":
|
|
321
|
+
holds(change, input);
|
|
322
|
+
break;
|
|
323
|
+
case "Revoke":
|
|
324
|
+
revoke(change, input.number);
|
|
325
|
+
break;
|
|
151
326
|
case "Opened": {
|
|
152
|
-
const { live
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
if (state._tag !== "Opening" || state.number !== live.number)
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
state
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
...(state.serving === undefined ? [] : [release(state.serving)]),
|
|
164
|
-
],
|
|
165
|
-
};
|
|
327
|
+
const live = { ...input.live, holds: new Set<H>() };
|
|
328
|
+
|
|
329
|
+
if (lookup(state, live.number) !== undefined) break;
|
|
330
|
+
if (state._tag !== "Opening" || state.number !== live.number) end(change, live, "retired");
|
|
331
|
+
else {
|
|
332
|
+
change.events.push([live.number, new Open()]);
|
|
333
|
+
change.commands.push({ _tag: "Watch", live });
|
|
334
|
+
if (state.serving !== undefined) end(change, state.serving, "rotated");
|
|
335
|
+
change.state = { _tag: "Open", serving: live, draining: change.state.draining };
|
|
336
|
+
}
|
|
337
|
+
break;
|
|
166
338
|
}
|
|
167
339
|
case "Failed": {
|
|
168
340
|
const { number, detail, cause } = input;
|
|
169
|
-
const events = [[number, new Down({ detail, cause })] as const];
|
|
170
|
-
|
|
171
|
-
if (state._tag !== "Opening" || state.number !== input.number) return { ...stay, events };
|
|
172
341
|
|
|
173
|
-
|
|
174
|
-
state
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
};
|
|
342
|
+
if (state._tag === "Opening" && state.number === number)
|
|
343
|
+
change.state = { ...state, _tag: "Down", detail, cause };
|
|
344
|
+
change.events.push([number, new Down({ detail, cause })]);
|
|
345
|
+
break;
|
|
178
346
|
}
|
|
179
347
|
case "Abandoned":
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
true,
|
|
196
|
-
lost,
|
|
197
|
-
true,
|
|
198
|
-
);
|
|
199
|
-
|
|
200
|
-
return { ...reopened, events: [...events, ...reopened.events] };
|
|
348
|
+
change.events.push([input.number, new Closed({})]);
|
|
349
|
+
break;
|
|
350
|
+
case "Released": {
|
|
351
|
+
const drain = state.draining.get(input.number);
|
|
352
|
+
|
|
353
|
+
if (drain === undefined || drain._tag === "Draining" || drain.live.holds.size > 0) break;
|
|
354
|
+
const draining = new Map(state.draining);
|
|
355
|
+
|
|
356
|
+
draining.delete(input.number);
|
|
357
|
+
change.state = { ...state, draining };
|
|
358
|
+
change.events.push([
|
|
359
|
+
input.number,
|
|
360
|
+
input.released === undefined ? new Kept() : new Closed({ released: input.released }),
|
|
361
|
+
]);
|
|
362
|
+
break;
|
|
201
363
|
}
|
|
364
|
+
case "Lost":
|
|
365
|
+
lost(change, input);
|
|
366
|
+
break;
|
|
202
367
|
case "Rotate": {
|
|
203
|
-
if (
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
368
|
+
if (
|
|
369
|
+
state._tag === "Retired" ||
|
|
370
|
+
(input.due !== undefined && (state._tag !== "Open" || state.serving.number !== input.due))
|
|
371
|
+
)
|
|
372
|
+
break;
|
|
373
|
+
change.reply =
|
|
374
|
+
state._tag === "Opening"
|
|
375
|
+
? state.number
|
|
376
|
+
: (state._tag === "Open" ? state.serving.number : state.number) + 1;
|
|
377
|
+
if (state._tag !== "Opening") next(change, change.reply, false);
|
|
378
|
+
break;
|
|
211
379
|
}
|
|
380
|
+
case "Keep":
|
|
212
381
|
case "Retire":
|
|
213
|
-
|
|
214
|
-
const
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
if (current === undefined) return { state: { _tag: "Retired" }, events: [], commands: [] };
|
|
218
|
-
|
|
219
|
-
return input._tag === "Retire"
|
|
220
|
-
? { state: { _tag: "Retired" }, events: [], commands: [release(current)] }
|
|
221
|
-
: {
|
|
222
|
-
state: { _tag: "Retired" },
|
|
223
|
-
events: [[current.number, new Kept()]],
|
|
224
|
-
commands: [{ _tag: "Leave", live: current }],
|
|
225
|
-
};
|
|
226
|
-
}
|
|
382
|
+
if (state._tag === "Retired") break;
|
|
383
|
+
for (const number of state.draining.keys()) revoke(change, number, "retired");
|
|
384
|
+
if (state.serving !== undefined) end(change, state.serving, "retired", input._tag === "Keep");
|
|
385
|
+
change.state = { _tag: "Retired", draining: change.state.draining };
|
|
227
386
|
}
|
|
387
|
+
|
|
388
|
+
return change;
|
|
228
389
|
};
|
|
229
390
|
|
|
230
391
|
export type Resolution<A> =
|
|
@@ -236,30 +397,20 @@ export type Resolution<A> =
|
|
|
236
397
|
readonly cause?: unknown;
|
|
237
398
|
};
|
|
238
399
|
|
|
239
|
-
/**
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
*/
|
|
243
|
-
export const resolve = <A>(
|
|
244
|
-
state: State<A>,
|
|
400
|
+
/** An unheld browser resolution; use obtains its hold atomically through Hold instead. */
|
|
401
|
+
export const resolve = <A, H>(
|
|
402
|
+
state: State<A, H>,
|
|
245
403
|
target: number | undefined,
|
|
246
404
|
): Resolution<A> | undefined => {
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
case "Down":
|
|
259
|
-
if (target === undefined && state.serving !== undefined) return serve(state.serving);
|
|
260
|
-
|
|
261
|
-
return target === undefined || state.number >= target
|
|
262
|
-
? { _tag: "Unavailable", reason: "down", detail: state.detail, cause: state.cause }
|
|
263
|
-
: undefined;
|
|
264
|
-
}
|
|
405
|
+
if (state._tag === "Retired")
|
|
406
|
+
return { _tag: "Unavailable", reason: "retired", detail: "the supervisor was retired" };
|
|
407
|
+
if (
|
|
408
|
+
state.serving !== undefined &&
|
|
409
|
+
(target === undefined || (state._tag === "Open" && state.serving.number >= target))
|
|
410
|
+
)
|
|
411
|
+
return { _tag: "Serve", live: state.serving };
|
|
412
|
+
if (state._tag === "Down" && (target === undefined || state.number >= target))
|
|
413
|
+
return { _tag: "Unavailable", reason: "down", detail: state.detail, cause: state.cause };
|
|
414
|
+
|
|
415
|
+
return undefined;
|
|
265
416
|
};
|