effect-browser 0.3.0-beta.7 → 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.
@@ -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([Opening, Reopening, Open, Lost, Down, Closed, Kept]);
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
- * At most one generation opens and at most one serves. Both do while a rotation makes the next
68
- * before it breaks the current, and `Down` keeps serving one that a rotation failed to replace.
69
- */
70
- export type State<A> =
71
- | { readonly _tag: "Opening"; readonly number: number; readonly serving: Live<A> | undefined }
72
- | { readonly _tag: "Open"; readonly serving: Live<A> }
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: Live<A> | undefined;
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
- | { readonly _tag: "Released"; readonly number: number; readonly released: Released };
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
- | { readonly _tag: "Release"; readonly live: Live<A> }
107
- /** Close its scope without asking its provider to release it. */
108
- | { readonly _tag: "Leave"; readonly live: Live<A> };
109
-
110
- export interface Step<A> {
111
- readonly state: State<A>;
112
- /** Generation numbers and their new states, to publish in this order. */
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>) => (state._tag === "Retired" ? undefined : state.serving);
126
-
127
- /** The state after `input`, with the events to publish and the commands to run. */
128
- export const transition = <A>(state: State<A>, input: Input<A>, exclusive: boolean): Step<A> => {
129
- const release = (live: Live<A>): Command<A> => ({ _tag: "Release", live });
130
- const stay: Step<A> = { state, events: [], commands: [] };
131
-
132
- /**
133
- * Open `number`, ending `current` now if it `ends`; exclusive generations open only once its
134
- * release has finished. Otherwise `current` serves until the next one opens.
135
- */
136
- const next = (
137
- number: number,
138
- reopen: boolean,
139
- current: Live<A> | undefined,
140
- ends: boolean,
141
- ): Step<A> => ({
142
- state: { _tag: "Opening", number, serving: ends ? undefined : current },
143
- events: [[number, reopen ? new Reopening() : new Opening()]],
144
- commands: [
145
- ...(ends && current !== undefined ? [release(current)] : []),
146
- { _tag: "Open", number, after: ends && exclusive ? current?.number : undefined },
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 } = input;
153
-
154
- // A generation nobody waits for any more, as after `Retire`, is released at once.
155
- if (state._tag !== "Opening" || state.number !== live.number)
156
- return { ...stay, commands: [release(live)] };
157
-
158
- return {
159
- state: { _tag: "Open", serving: live },
160
- events: [[live.number, new Open()]],
161
- commands: [
162
- { _tag: "Watch", live },
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
- return {
174
- state: { _tag: "Down", number, detail, cause, serving: state.serving },
175
- events,
176
- commands: [],
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
- return { ...stay, events: [[input.number, new Closed({})]] };
181
- case "Released":
182
- return { ...stay, events: [[input.number, new Closed({ released: input.released })]] };
183
- case "Lost": {
184
- const lost = servingOf(state);
185
-
186
- if (state._tag === "Retired" || lost?.number !== input.number) return stay;
187
- const events = [[lost.number, new Lost({ cause: input.cause })] as const];
188
-
189
- // A rotation already opening the next generation carries on without the lost one.
190
- if (state._tag === "Opening")
191
- return { state: { ...state, serving: undefined }, events, commands: [release(lost)] };
192
-
193
- const reopened = next(
194
- (state._tag === "Open" ? lost.number : state.number) + 1,
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 (state._tag === "Retired") return stay;
204
- // A rotation time comes only for the generation still serving with nothing opening.
205
- if (input.due !== undefined && (state._tag !== "Open" || state.serving.number !== input.due))
206
- return stay;
207
- if (state._tag === "Opening") return { ...stay, reply: state.number };
208
- const number = (state._tag === "Open" ? state.serving.number : state.number) + 1;
209
-
210
- return { ...next(number, false, state.serving, exclusive), reply: number };
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
- case "Keep": {
214
- const current = servingOf(state);
215
-
216
- if (state._tag === "Retired") return stay;
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
- * What a caller waiting for generation `target`, or for any when it is undefined, gets in
241
- * `state`; undefined while it should go on waiting.
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
- const serve = (live: Live<A>) => ({ _tag: "Serve" as const, live });
248
-
249
- switch (state._tag) {
250
- case "Retired":
251
- return { _tag: "Unavailable", reason: "retired", detail: "the supervisor was retired" };
252
- case "Open":
253
- return target === undefined || state.serving.number >= target
254
- ? serve(state.serving)
255
- : undefined;
256
- case "Opening":
257
- return target === undefined && state.serving !== undefined ? serve(state.serving) : undefined;
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
  };