@remit/web-client 0.0.116 → 0.0.118

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.
@@ -1,5 +1,5 @@
1
1
  import assert from "node:assert/strict";
2
- import { beforeEach, describe, test } from "node:test";
2
+ import { describe, test } from "node:test";
3
3
  import type {
4
4
  RemitImapSystemUpdateResponse,
5
5
  RemitImapSystemUpdateRun,
@@ -8,17 +8,13 @@ import { ApiError } from "./api";
8
8
  import {
9
9
  APPLY_BUDGET_SECONDS,
10
10
  appliesSchemaMigration,
11
- clearStoredRun,
12
11
  type DeriveInput,
13
12
  deriveUpdateSurface,
14
13
  type HeldRun,
15
14
  isSurfaceAbsent,
16
- loadHeldRun,
17
15
  mapUpdatePhase,
18
16
  NEVER_CAME_BACK_MARGIN_SECONDS,
19
17
  releaseFromCheck,
20
- SELF_UPDATE_RUN_KEY,
21
- saveHeldRun,
22
18
  } from "./self-update-state";
23
19
 
24
20
  const NOW = Date.parse("2026-07-20T12:00:00.000Z");
@@ -60,6 +56,7 @@ function held(overrides: Partial<HeldRun> = {}): HeldRun {
60
56
  runId: "upd_1",
61
57
  attemptedVersion: "0.9.4",
62
58
  previousVersion: "0.9.3",
59
+ phase: "preparing",
63
60
  startedAt: NOW - 10_000,
64
61
  ...overrides,
65
62
  };
@@ -279,7 +276,7 @@ describe("deriveUpdateSurface — check and run stay independent", () => {
279
276
  });
280
277
 
281
278
  describe("deriveUpdateSurface — terminal outcomes", () => {
282
- test("a rolledBack run renders message and command verbatim and clears the id", () => {
279
+ test("a rolledBack run renders message and command verbatim", () => {
283
280
  const result = deriveUpdateSurface(
284
281
  input({
285
282
  data: response({
@@ -291,7 +288,6 @@ describe("deriveUpdateSurface — terminal outcomes", () => {
291
288
  }),
292
289
  }),
293
290
  );
294
- assert.equal(result.clearStoredRun, true);
295
291
  assert.equal(result.surface.status, "ready");
296
292
  if (result.surface.status !== "ready") return;
297
293
  const section = result.surface.section;
@@ -374,7 +370,11 @@ describe("deriveUpdateSurface — a held run across a restart", () => {
374
370
  if (result.surface.status !== "ready") return;
375
371
  assert.equal(result.surface.section.status, "applying");
376
372
  assert.equal(result.surface.overlay.kind, "applying");
377
- assert.equal(result.clearStoredRun, false);
373
+ assert.equal(
374
+ result.surface.overlay.kind === "applying" &&
375
+ result.surface.overlay.phase,
376
+ "reconnecting",
377
+ );
378
378
  });
379
379
 
380
380
  test("a failed request without a held run is a check-level failure", () => {
@@ -402,7 +402,6 @@ describe("deriveUpdateSurface — a held run across a restart", () => {
402
402
  assert.equal(result.surface.status, "ready");
403
403
  if (result.surface.status !== "ready") return;
404
404
  assert.equal(result.surface.overlay.kind, "neverCameBack");
405
- assert.equal(result.clearStoredRun, true);
406
405
  });
407
406
 
408
407
  test("never-came-back never claims the rollback ran", () => {
@@ -449,7 +448,6 @@ describe("deriveUpdateSurface — a held run across a restart", () => {
449
448
  }),
450
449
  );
451
450
  assert.equal(result.releaseHeld, true);
452
- assert.equal(result.clearStoredRun, true);
453
451
  assert.equal(
454
452
  result.surface.status === "ready" && result.surface.section.status,
455
453
  "succeeded",
@@ -470,72 +468,33 @@ describe("deriveUpdateSurface — a held run across a restart", () => {
470
468
  assert.equal(result.releaseHeld, true);
471
469
  });
472
470
 
473
- test("a first poll still in flight keeps applying inside the budget", () => {
471
+ test("a poll with no answer yet claims no phase at all", () => {
474
472
  const result = deriveUpdateSurface(
475
473
  input({ held: held({ startedAt: NOW - 20_000 }) }),
476
474
  );
477
- if (
478
- result.surface.status !== "ready" ||
479
- result.surface.overlay.kind !== "applying"
480
- ) {
481
- assert.fail("expected the applying overlay");
482
- }
483
- assert.equal(result.surface.overlay.phase, "preparing");
484
- assert.equal(result.clearStoredRun, false);
475
+ assert.equal(result.surface.status, "loading");
485
476
  });
486
477
 
487
- test("a poll that never answers gives up at the budget", () => {
478
+ test("a poll that never answers still claims no phase past the budget", () => {
488
479
  const result = deriveUpdateSurface(
489
480
  input({ held: held({ startedAt: NOW - BUDGET_MS - 60_000 }) }),
490
481
  );
491
- assert.equal(result.surface.status, "ready");
492
- if (result.surface.status !== "ready") return;
493
- assert.equal(result.surface.overlay.kind, "neverCameBack");
494
- assert.equal(result.clearStoredRun, true);
482
+ assert.equal(result.surface.status, "loading");
495
483
  });
496
484
 
497
- test("an early server answer with no run yet keeps applying", () => {
485
+ test("the server's own account of the run it accepted carries the phase", () => {
498
486
  const result = deriveUpdateSurface(
499
- input({ data: response({ run: null }), held: held() }),
500
- );
501
- assert.equal(
502
- result.surface.status === "ready" && result.surface.overlay.kind,
503
- "applying",
504
- );
505
- });
506
- });
507
-
508
- function installMemoryStorage(): void {
509
- const store = new Map<string, string>();
510
- globalThis.localStorage = {
511
- getItem: (k: string) => store.get(k) ?? null,
512
- setItem: (k: string, v: string) => void store.set(k, v),
513
- removeItem: (k: string) => void store.delete(k),
514
- clear: () => store.clear(),
515
- key: () => null,
516
- length: 0,
517
- } as Storage;
518
- }
519
-
520
- describe("held-run persistence", () => {
521
- beforeEach(installMemoryStorage);
522
-
523
- test("saves, loads and clears under the documented key", () => {
524
- const record = held();
525
- saveHeldRun(record);
526
- assert.equal(
527
- localStorage.getItem(SELF_UPDATE_RUN_KEY),
528
- JSON.stringify(record),
487
+ input({
488
+ data: response({ run: null }),
489
+ held: held({ phase: "restarting" }),
490
+ }),
529
491
  );
530
- assert.deepEqual(loadHeldRun(), record);
531
- clearStoredRun();
532
- assert.equal(loadHeldRun(), null);
533
- });
534
-
535
- test("ignores a malformed stored value", () => {
536
- localStorage.setItem(SELF_UPDATE_RUN_KEY, "{not json");
537
- assert.equal(loadHeldRun(), null);
538
- localStorage.setItem(SELF_UPDATE_RUN_KEY, JSON.stringify({ runId: 1 }));
539
- assert.equal(loadHeldRun(), null);
492
+ if (
493
+ result.surface.status !== "ready" ||
494
+ result.surface.overlay.kind !== "applying"
495
+ ) {
496
+ assert.fail("expected the applying overlay");
497
+ }
498
+ assert.equal(result.surface.overlay.phase, "restarting");
540
499
  });
541
500
  });
@@ -3,16 +3,19 @@
3
3
  *
4
4
  * `GET /system/update` returns three independent things: the running version,
5
5
  * the outcome of the last manifest check, and the state of the current or last
6
- * run. This module folds that response — plus a run id the client persisted
7
- * before the server restarted, plus the clock — into the single `SelfUpdateState`
8
- * the `@remit/ui` components render, and into the full-window blocking overlay.
6
+ * run. This module folds that response — plus the run this client started and
7
+ * holds in memory, plus the clock — into the single `SelfUpdateState` the
8
+ * `@remit/ui` components render, and into the full-window blocking overlay.
9
9
  *
10
10
  * The design constraints (RFC 037 Interface, issue #135) live here:
11
- * - A held run id turns a failed request into `applying`, never `unreachable`.
11
+ * - The server decides whether an update is running and how far along it is.
12
+ * Every phase shown is one the server reported for this run; an answer the
13
+ * client is still waiting for renders no phase at all.
14
+ * - A held run turns a failed request into `applying`, never `unreachable`.
12
15
  * - Once the apply budget plus a margin has passed with no answer — a failed
13
- * request or one still in flight that silence becomes "the server never
14
- * came back", a state that never claims the rollback ran, because from a
15
- * dead connection the client cannot know.
16
+ * request, or a server answering without accounting for the run that
17
+ * silence becomes "the server never came back", a state that never claims
18
+ * the rollback ran, because from a dead connection the client cannot know.
16
19
  * - The check block and the run block are independent: a check that cannot
17
20
  * reach the update source is a failed check, never a failed update.
18
21
  */
@@ -25,9 +28,6 @@ import type {
25
28
  import type { ReleaseInfo, SelfUpdateState, UpdatePhase } from "@remit/ui";
26
29
  import { getErrorStatus } from "./error-classifier";
27
30
 
28
- /** localStorage key holding the run the client is resuming across a restart. */
29
- export const SELF_UPDATE_RUN_KEY = "remit.self-update.run";
30
-
31
31
  /**
32
32
  * The longest an apply can plausibly take — pull, snapshot, stop, start, gate —
33
33
  * before a silent server is treated as gone rather than still working. A five
@@ -42,15 +42,17 @@ export const FALLBACK_LOGS_COMMAND = "remit logs";
42
42
  const RELEASE_TAG_BASE = "https://github.com/remit-mail/reader/releases/tag/";
43
43
 
44
44
  /**
45
- * The record the client persists the moment it asks for an update, so a reload
46
- * or a second tab can resume watching a run that outlives the page that started
47
- * it. Everything needed to render the blocking screens without a reachable
48
- * server is here.
45
+ * The run this client asked for, kept in memory for as long as the page that
46
+ * asked lives. Nothing is written to storage: a run lasts about a minute and
47
+ * only the server knows how it ends, so a page that was not there for the
48
+ * request starts from the server's answer instead of from a record of its own.
49
49
  */
50
50
  export interface HeldRun {
51
51
  runId: string;
52
52
  attemptedVersion: string;
53
53
  previousVersion: string;
54
+ /** The phase the server reported when it accepted the run. */
55
+ phase: UpdatePhase;
54
56
  /** Epoch millis when the client began holding this run. */
55
57
  startedAt: number;
56
58
  }
@@ -89,9 +91,7 @@ export interface DeriveInput {
89
91
 
90
92
  export interface DeriveResult {
91
93
  surface: UpdateSurface;
92
- /** The persisted resume token should be removed from localStorage. */
93
- clearStoredRun: boolean;
94
- /** The in-memory held run should be dropped — the run is fully resolved. */
94
+ /** The held run should be dropped the server has accounted for it. */
95
95
  releaseHeld: boolean;
96
96
  }
97
97
 
@@ -159,46 +159,6 @@ export function appliesSchemaMigration(
159
159
  return target > current;
160
160
  }
161
161
 
162
- export function loadHeldRun(): HeldRun | null {
163
- try {
164
- const raw = localStorage.getItem(SELF_UPDATE_RUN_KEY);
165
- if (!raw) return null;
166
- const parsed: unknown = JSON.parse(raw);
167
- if (!isHeldRun(parsed)) return null;
168
- return parsed;
169
- } catch {
170
- return null;
171
- }
172
- }
173
-
174
- export function saveHeldRun(run: HeldRun): void {
175
- try {
176
- localStorage.setItem(SELF_UPDATE_RUN_KEY, JSON.stringify(run));
177
- } catch {
178
- // Best effort: private mode or quota. A lost token degrades resume, not
179
- // correctness — the server remains the authority on the run.
180
- }
181
- }
182
-
183
- export function clearStoredRun(): void {
184
- try {
185
- localStorage.removeItem(SELF_UPDATE_RUN_KEY);
186
- } catch {
187
- // Best effort, as above.
188
- }
189
- }
190
-
191
- function isHeldRun(value: unknown): value is HeldRun {
192
- if (!value || typeof value !== "object") return false;
193
- const candidate = value as Record<string, unknown>;
194
- return (
195
- typeof candidate.runId === "string" &&
196
- typeof candidate.attemptedVersion === "string" &&
197
- typeof candidate.previousVersion === "string" &&
198
- typeof candidate.startedAt === "number"
199
- );
200
- }
201
-
202
162
  function budgetLimitSeconds(): number {
203
163
  return APPLY_BUDGET_SECONDS + NEVER_CAME_BACK_MARGIN_SECONDS;
204
164
  }
@@ -316,20 +276,14 @@ function checkSection(
316
276
  function ready(
317
277
  section: SelfUpdateState,
318
278
  overlay: UpdateOverlay,
319
- clearStoredRun: boolean,
320
279
  releaseHeld: boolean,
321
280
  ): DeriveResult {
322
- return {
323
- surface: { status: "ready", section, overlay },
324
- clearStoredRun,
325
- releaseHeld,
326
- };
281
+ return { surface: { status: "ready", section, overlay }, releaseHeld };
327
282
  }
328
283
 
329
284
  /**
330
- * The client gave up waiting. The stored token goes so a reload cannot resume
331
- * the same dead wait, while the in-memory hold stays: the screen has to sit
332
- * still, and its retry has to keep polling, until the server answers for itself.
285
+ * The client gave up waiting. The hold stays: the screen has to sit still, and
286
+ * its retry has to keep polling, until the server answers for itself.
333
287
  */
334
288
  function neverCameBack(held: HeldRun, elapsedSeconds: number): DeriveResult {
335
289
  return {
@@ -350,15 +304,15 @@ function neverCameBack(held: HeldRun, elapsedSeconds: number): DeriveResult {
350
304
  logsCommand: FALLBACK_LOGS_COMMAND,
351
305
  },
352
306
  },
353
- clearStoredRun: true,
354
307
  releaseHeld: false,
355
308
  };
356
309
  }
357
310
 
358
311
  /**
359
312
  * A held run resolves to one of: still applying, gave up ("never came back"),
360
- * recovered but unaccountable, or — returning `null` — resolved terminally on
361
- * the server, in which case the caller renders the outcome from the response.
313
+ * unaccounted for, still waiting on a first answer, or — returning `null` —
314
+ * resolved terminally on the server, in which case the caller renders the
315
+ * outcome from the response.
362
316
  */
363
317
  function deriveHeld(
364
318
  held: HeldRun,
@@ -385,7 +339,6 @@ function deriveHeld(
385
339
  ),
386
340
  { kind: "applying", target: run.targetVersion, phase, elapsedSeconds },
387
341
  false,
388
- false,
389
342
  );
390
343
  }
391
344
 
@@ -408,35 +361,13 @@ function deriveHeld(
408
361
  elapsedSeconds,
409
362
  },
410
363
  false,
411
- false,
412
364
  );
413
365
  }
414
366
 
415
- // No answer yet the resume request is still in flight. A request that is
416
- // pending looks exactly like one that will never settle, so the budget bounds
417
- // this wait too: within it the client stays applying, past it it stops
418
- // claiming an install is running and says what it cannot account for.
367
+ // Nothing has come back yet. Silence is not a phase, so the surface waits
368
+ // rather than describing an install it has heard nothing about.
419
369
  if (data === undefined) {
420
- if (elapsedSeconds > budgetLimitSeconds()) {
421
- return neverCameBack(held, elapsedSeconds);
422
- }
423
- return ready(
424
- applyingSection(
425
- held.runId,
426
- currentVersion,
427
- held.attemptedVersion,
428
- "preparing",
429
- elapsedSeconds,
430
- ),
431
- {
432
- kind: "applying",
433
- target: held.attemptedVersion,
434
- phase: "preparing",
435
- elapsedSeconds,
436
- },
437
- false,
438
- false,
439
- );
370
+ return { surface: { status: "loading" }, releaseHeld: false };
440
371
  }
441
372
 
442
373
  // The server answered, but not with our run. If we have been gone longer than
@@ -455,55 +386,48 @@ function deriveHeld(
455
386
  },
456
387
  overlay: { kind: "none" },
457
388
  },
458
- clearStoredRun: true,
459
389
  releaseHeld: true,
460
390
  };
461
391
  }
462
392
 
463
- // Early: the server is up but has not written our run yet. Still applying.
464
- const phase =
465
- run !== null && run.outcome === null
466
- ? mapUpdatePhase(run.phase)
467
- : "preparing";
393
+ // The updater picks the request up off a control file, so the seam keeps
394
+ // reporting the previous run for a moment. The phase stays the one the server
395
+ // gave when it accepted this run, until the server reports a newer one.
468
396
  return ready(
469
397
  applyingSection(
470
398
  held.runId,
471
399
  currentVersion,
472
400
  held.attemptedVersion,
473
- phase,
401
+ held.phase,
474
402
  elapsedSeconds,
475
403
  ),
476
- { kind: "applying", target: held.attemptedVersion, phase, elapsedSeconds },
477
- false,
404
+ {
405
+ kind: "applying",
406
+ target: held.attemptedVersion,
407
+ phase: held.phase,
408
+ elapsedSeconds,
409
+ },
478
410
  false,
479
411
  );
480
412
  }
481
413
 
482
- function displayFromData(input: DeriveInput): {
483
- surface: UpdateSurface;
484
- clearStoredRun: boolean;
485
- } {
414
+ function displayFromData(input: DeriveInput): UpdateSurface {
486
415
  const { data, isError, isFetching, dismissedRunId, checkRequested, now } =
487
416
  input;
488
417
 
489
418
  if (isError) {
490
419
  return {
491
- surface: {
492
- status: "ready",
493
- section: {
494
- status: "checkFailed",
495
- version: data?.currentVersion ?? "the current version",
496
- reason: "Remit could not reach the update service.",
497
- },
498
- overlay: { kind: "none" },
420
+ status: "ready",
421
+ section: {
422
+ status: "checkFailed",
423
+ version: data?.currentVersion ?? "the current version",
424
+ reason: "Remit could not reach the update service.",
499
425
  },
500
- clearStoredRun: true,
426
+ overlay: { kind: "none" },
501
427
  };
502
428
  }
503
429
 
504
- if (!data) {
505
- return { surface: { status: "loading" }, clearStoredRun: false };
506
- }
430
+ if (!data) return { status: "loading" };
507
431
 
508
432
  const run = data.run;
509
433
  const dismissed =
@@ -511,12 +435,9 @@ function displayFromData(input: DeriveInput): {
511
435
 
512
436
  if (run !== null && !dismissed && run.outcome !== null) {
513
437
  return {
514
- surface: {
515
- status: "ready",
516
- section: terminalSection(data, run, run.outcome),
517
- overlay: { kind: "none" },
518
- },
519
- clearStoredRun: true,
438
+ status: "ready",
439
+ section: terminalSection(data, run, run.outcome),
440
+ overlay: { kind: "none" },
520
441
  };
521
442
  }
522
443
 
@@ -524,33 +445,27 @@ function displayFromData(input: DeriveInput): {
524
445
  const elapsedSeconds = elapsedSince(parseIso(run.startedAt) ?? now, now);
525
446
  const phase = mapUpdatePhase(run.phase);
526
447
  return {
527
- surface: {
528
- status: "ready",
529
- section: applyingSection(
530
- run.runId,
531
- run.fromVersion,
532
- run.targetVersion,
533
- phase,
534
- elapsedSeconds,
535
- ),
536
- overlay: {
537
- kind: "applying",
538
- target: run.targetVersion,
539
- phase,
540
- elapsedSeconds,
541
- },
448
+ status: "ready",
449
+ section: applyingSection(
450
+ run.runId,
451
+ run.fromVersion,
452
+ run.targetVersion,
453
+ phase,
454
+ elapsedSeconds,
455
+ ),
456
+ overlay: {
457
+ kind: "applying",
458
+ target: run.targetVersion,
459
+ phase,
460
+ elapsedSeconds,
542
461
  },
543
- clearStoredRun: false,
544
462
  };
545
463
  }
546
464
 
547
465
  return {
548
- surface: {
549
- status: "ready",
550
- section: checkSection(data, checkRequested && isFetching, now),
551
- overlay: { kind: "none" },
552
- },
553
- clearStoredRun: false,
466
+ status: "ready",
467
+ section: checkSection(data, checkRequested && isFetching, now),
468
+ overlay: { kind: "none" },
554
469
  };
555
470
  }
556
471
 
@@ -559,29 +474,15 @@ export function deriveUpdateSurface(input: DeriveInput): DeriveResult {
559
474
  const run = data?.run ?? null;
560
475
 
561
476
  if (isSurfaceAbsent(error) && !held) {
562
- return {
563
- surface: { status: "absent" },
564
- clearStoredRun: true,
565
- releaseHeld: true,
566
- };
477
+ return { surface: { status: "absent" }, releaseHeld: true };
567
478
  }
568
479
 
569
480
  if (held) {
570
481
  const heldResult = deriveHeld(held, data, run, isError, now);
571
482
  if (heldResult) return heldResult;
572
483
  // Our run resolved terminally — render it from the response and let go.
573
- const resolved = displayFromData(input);
574
- return {
575
- surface: resolved.surface,
576
- clearStoredRun: true,
577
- releaseHeld: true,
578
- };
484
+ return { surface: displayFromData(input), releaseHeld: true };
579
485
  }
580
486
 
581
- const display = displayFromData(input);
582
- return {
583
- surface: display.surface,
584
- clearStoredRun: display.clearStoredRun,
585
- releaseHeld: false,
586
- };
487
+ return { surface: displayFromData(input), releaseHeld: false };
587
488
  }