@steve31415/baselib 3.30.0 → 3.32.0

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 CHANGED
@@ -29,7 +29,9 @@ ERROR log (`~/plasticine-way/docs/SECURITY.md`, "Resend API key").
29
29
 
30
30
  Bins: `check-test-owners` — the fleet's structural test-coverage gate; every
31
31
  app runs it from `npm run verify`. `pw-deploy` / `pw-rollback` — the fleet
32
- deploy (`~/plasticine-way/docs/OPERATIONS.md`, "Deployment safety").
32
+ deploy (`~/plasticine-way/docs/OPERATIONS.md`, "Deployment safety"); an
33
+ app's hooks module (`DeployHooks` in `src/deploy/types.ts`) extends both,
34
+ `postRollback` being the one hook `pw-rollback` runs.
33
35
  `pw-sql` / `pw-logs` — read-only production queries (database, Axiom), the
34
36
  first step of any production investigation (same doc, "Investigating
35
37
  production state").
@@ -7,7 +7,7 @@ import { hostname } from 'node:os';
7
7
  import { must, mustJson } from './exec.js';
8
8
  import { openEvidence, PhaseTimer } from './evidence.js';
9
9
  import { acquireLock, listReleaseRecords, placeHold, releaseLock } from './gcs.js';
10
- import { DEFAULT_ACCOUNT, loadConfig } from './config.js';
10
+ import { DEFAULT_ACCOUNT, loadConfig, loadHooks } from './config.js';
11
11
  import { pickRevisionForSha, releaseEntriesFrom, selectRollbackTarget, readServingState, SHA40, } from './plan.js';
12
12
  export async function runRollback(options) {
13
13
  const log = options.log ?? ((m) => console.error(`[pw-rollback] ${m}`));
@@ -19,6 +19,7 @@ export async function runRollback(options) {
19
19
  const requested = argv.find((a) => SHA40.test(a));
20
20
  const timer = new PhaseTimer(log);
21
21
  let evidenceDir = '';
22
+ let rolledBackTo = '';
22
23
  let lock = null;
23
24
  try {
24
25
  const config = await loadConfig(options.repoRoot);
@@ -151,6 +152,26 @@ export async function runRollback(options) {
151
152
  throw new Error(`${service.name}: post-rollback smoke failed (${lastDetail})`);
152
153
  log(`${service.name}: smoke passed`);
153
154
  }
155
+ // The hooks module is loaded only now: a broken one must not stand
156
+ // between an incident and the traffic move.
157
+ timer.enter('hooks');
158
+ rolledBackTo = target;
159
+ const hooks = await loadHooks(options.repoRoot, config);
160
+ if (hooks.postRollback) {
161
+ const ctx = {
162
+ sha: target,
163
+ baseSha: servingShas.length === 1 ? servingShas[0] : null,
164
+ config,
165
+ repoRoot: options.repoRoot,
166
+ contextDir: '',
167
+ evidenceDir,
168
+ imageDigest: targets[0].imageDigest,
169
+ serving,
170
+ exec: runner,
171
+ log,
172
+ };
173
+ await hooks.postRollback(ctx);
174
+ }
154
175
  timer.close();
155
176
  log(`rolled back to ${target.slice(0, 12)}`);
156
177
  log(`hold placed on ${servingShas.map((sha) => sha.slice(0, 12)).join(', ')} — ` +
@@ -162,6 +183,10 @@ export async function runRollback(options) {
162
183
  const message = error instanceof Error ? error.message : String(error);
163
184
  console.error(message);
164
185
  console.error(`rollback failed in phase: ${timer.current}`);
186
+ if (rolledBackTo) {
187
+ console.error(`traffic IS on ${rolledBackTo.slice(0, 12)} and the hold is placed; only the app's ` +
188
+ 'postRollback hook failed — finish what it does by hand');
189
+ }
165
190
  if (evidenceDir)
166
191
  console.error(`evidence: ${evidenceDir}`);
167
192
  return 1;
@@ -120,4 +120,11 @@ export interface DeployHooks {
120
120
  smoke?: (ctx: HookContext) => Promise<void>;
121
121
  /** Runs after everything else succeeded (e.g. notes's release state). */
122
122
  postSuccess?: (ctx: HookContext) => Promise<void>;
123
+ /** pw-rollback only: runs after traffic is back on the target build, the
124
+ * hold is placed and the smoke passed — for what a traffic move does not
125
+ * carry (e.g. podcasts' render Job image). `sha` and `imageDigest` are the
126
+ * rollback target's, `serving` is the state rolled back from, and
127
+ * `contextDir` is empty (nothing is built). A throw fails the command but
128
+ * undoes nothing. */
129
+ postRollback?: (ctx: HookContext) => Promise<void>;
123
130
  }
@@ -32,6 +32,9 @@ export interface CatchupResult<S extends EventBody> {
32
32
  }
33
33
  export interface SyncBrowserRuntime {
34
34
  now(): number;
35
+ /** A monotonic clock (`performance.now()`), used only to tell a page that
36
+ * was not running from one that was. Defaults to `performance.now()`. */
37
+ monotonicNow?(): number;
35
38
  randomGuid(): string;
36
39
  createSocket(url: string): WebSocket;
37
40
  sendBeacon(url: string, body: Blob): boolean;
@@ -171,6 +174,9 @@ export declare class SyncClient<B, C extends EventBody, S extends EventBody, Sum
171
174
  private lastContactAt;
172
175
  private spellCounters;
173
176
  private returnWindow;
177
+ private clockOffset;
178
+ private stallCoveredUntil;
179
+ private visibleSinceMono;
174
180
  private readonly counters;
175
181
  constructor(options: SyncClientOptions<B, C, S, Summary>);
176
182
  init(): Promise<void>;
@@ -192,6 +198,17 @@ export declare class SyncClient<B, C extends EventBody, S extends EventBody, Sum
192
198
  state(): SyncClientState;
193
199
  subscribe(listener: (state: SyncClientState) => void): () => void;
194
200
  private installRuntimeListeners;
201
+ private monoNow;
202
+ private rebaselineClocks;
203
+ /** Runs first in every client timer. A device asleep stops the monotonic
204
+ * clock and the timers with it, so the wall clock's lead over it grows by
205
+ * the sleep; a frozen page leaves both clocks running and its timers run
206
+ * late by the monotonic one. Overlapping late timers count once. Only time
207
+ * the page counted as visible is counted (hidden pages are throttled by
208
+ * design and their time is `hiddenMs`). */
209
+ private observeStall;
210
+ private setTimer;
211
+ private setRepeating;
195
212
  private isHidden;
196
213
  private now;
197
214
  /** Counters with the running hidden spell's time folded in. */
@@ -35,6 +35,9 @@ const RETURN_QUICK_RETRY_MS = 250;
35
35
  * resolves (notes, 2026-10-01: four minutes). Fast failures (offline) are
36
36
  * not logged. */
37
37
  const SLOW_HANDSHAKE_MS = 3_000;
38
+ /** A timer that runs at least this late, or a wall-clock jump of at least
39
+ * this much past the monotonic clock, counts as time the page was stalled. */
40
+ const STALL_MIN_MS = 1_000;
38
41
  const SOCKET_CONNECTING = 0;
39
42
  const SOCKET_OPEN = 1;
40
43
  class LifecycleAbort {
@@ -106,8 +109,13 @@ export class SyncClient {
106
109
  lastContactAt = null;
107
110
  spellCounters = null;
108
111
  returnWindow = null;
112
+ // Stall accounting (Counters.stalledMs).
113
+ clockOffset = null;
114
+ stallCoveredUntil = 0;
115
+ visibleSinceMono = 0;
109
116
  counters = {
110
117
  hiddenMs: 0,
118
+ stalledMs: 0,
111
119
  freezes: 0,
112
120
  resumes: 0,
113
121
  watchdogKills: 0,
@@ -277,6 +285,7 @@ export class SyncClient {
277
285
  }
278
286
  installRuntimeListeners() {
279
287
  const runtime = this.options.runtime;
288
+ this.rebaselineClocks();
280
289
  // Loaded hidden (a background tab): the spell starts now; the first
281
290
  // connect still runs, and its failure goes dormant rather than retrying.
282
291
  if (runtime.visibility() === 'hidden') {
@@ -296,6 +305,48 @@ export class SyncClient {
296
305
  if (onResume)
297
306
  this.removeListeners.push(onResume);
298
307
  }
308
+ monoNow() {
309
+ return this.options.runtime.monotonicNow?.() ?? performance.now();
310
+ }
311
+ rebaselineClocks() {
312
+ this.clockOffset = this.now() - this.monoNow();
313
+ }
314
+ /** Runs first in every client timer. A device asleep stops the monotonic
315
+ * clock and the timers with it, so the wall clock's lead over it grows by
316
+ * the sleep; a frozen page leaves both clocks running and its timers run
317
+ * late by the monotonic one. Overlapping late timers count once. Only time
318
+ * the page counted as visible is counted (hidden pages are throttled by
319
+ * design and their time is `hiddenMs`). */
320
+ observeStall(dueMono) {
321
+ const mono = this.monoNow();
322
+ const offset = this.now() - mono;
323
+ const visible = !this.isHidden();
324
+ if (visible && this.clockOffset !== null && offset - this.clockOffset >= STALL_MIN_MS) {
325
+ this.counters.stalledMs += offset - this.clockOffset;
326
+ }
327
+ this.clockOffset = offset;
328
+ if (visible && mono - dueMono >= STALL_MIN_MS) {
329
+ const from = Math.max(dueMono, this.stallCoveredUntil, this.visibleSinceMono);
330
+ if (mono > from)
331
+ this.counters.stalledMs += mono - from;
332
+ this.stallCoveredUntil = mono;
333
+ }
334
+ }
335
+ setTimer(callback, delayMs) {
336
+ const dueMono = this.monoNow() + delayMs;
337
+ return this.options.runtime.setTimeout(() => {
338
+ this.observeStall(dueMono);
339
+ callback();
340
+ }, delayMs);
341
+ }
342
+ setRepeating(callback, periodMs) {
343
+ let dueMono = this.monoNow() + periodMs;
344
+ return this.options.runtime.setInterval(() => {
345
+ this.observeStall(dueMono);
346
+ dueMono = this.monoNow() + periodMs;
347
+ callback();
348
+ }, periodMs);
349
+ }
299
350
  isHidden() {
300
351
  return this.hiddenSince !== null;
301
352
  }
@@ -310,6 +361,7 @@ export class SyncClient {
310
361
  static delta(since, now) {
311
362
  return {
312
363
  hiddenMs: Math.max(0, now.hiddenMs - since.hiddenMs),
364
+ stalledMs: now.stalledMs - since.stalledMs,
313
365
  freezes: now.freezes - since.freezes,
314
366
  resumes: now.resumes - since.resumes,
315
367
  watchdogKills: now.watchdogKills - since.watchdogKills,
@@ -323,6 +375,8 @@ export class SyncClient {
323
375
  if (this.halted || this.disposed || this.hiddenSince !== null)
324
376
  return;
325
377
  this.finishReturnWindow();
378
+ // A device that slept and then hid the page: the sleep counts as hidden.
379
+ this.rebaselineClocks();
326
380
  this.hiddenSince = this.now();
327
381
  this.spellCounters = this.snapshot();
328
382
  this.hiddenSocketFailed = false;
@@ -339,6 +393,8 @@ export class SyncClient {
339
393
  return;
340
394
  const hiddenSince = this.hiddenSince;
341
395
  if (hiddenSince !== null) {
396
+ this.rebaselineClocks();
397
+ this.visibleSinceMono = this.monoNow();
342
398
  const hiddenMs = Math.max(0, this.now() - hiddenSince);
343
399
  const spell = this.spellCounters ?? this.snapshot();
344
400
  this.counters.hiddenMs += hiddenMs;
@@ -382,7 +438,7 @@ export class SyncClient {
382
438
  // replaces it.
383
439
  if (this.connectReplyTimer !== null)
384
440
  this.options.runtime.clearTimeout(this.connectReplyTimer);
385
- this.connectReplyTimer = this.options.runtime.setTimeout(() => {
441
+ this.connectReplyTimer = this.setTimer(() => {
386
442
  this.connectReplyTimer = null;
387
443
  if (this.socket === socket && !this.isHidden())
388
444
  this.replaceSocket();
@@ -452,7 +508,7 @@ export class SyncClient {
452
508
  if (this.socketCreatedHidden)
453
509
  this.counters.backgroundSocketAttempts++;
454
510
  let killedByWatchdog = false;
455
- this.watchdogTimer = this.options.runtime.setTimeout(() => {
511
+ this.watchdogTimer = this.setTimer(() => {
456
512
  if (socket.readyState !== SOCKET_OPEN) {
457
513
  this.counters.watchdogKills++;
458
514
  killedByWatchdog = true;
@@ -479,8 +535,8 @@ export class SyncClient {
479
535
  }
480
536
  this.cancelBackgroundTick();
481
537
  this.sendConnect();
482
- this.pingTimer = this.options.runtime.setInterval(() => this.sendRaw({ type: 'ping' }), PING_INTERVAL_MS);
483
- this.rolloverTimer = this.options.runtime.setTimeout(() => {
538
+ this.pingTimer = this.setRepeating(() => this.sendRaw({ type: 'ping' }), PING_INTERVAL_MS);
539
+ this.rolloverTimer = this.setTimer(() => {
484
540
  this.options.logger.info('ws rollover', {});
485
541
  this.plannedClose = socket;
486
542
  socket.close();
@@ -556,7 +612,7 @@ export class SyncClient {
556
612
  return;
557
613
  const interval = this.options.backgroundCatchupMs ?? DEFAULT_BACKGROUND_CATCHUP_MS;
558
614
  const due = this.lastTickAt === null ? 0 : Math.max(0, this.lastTickAt + interval - this.now());
559
- this.backgroundTickTimer = this.options.runtime.setTimeout(() => {
615
+ this.backgroundTickTimer = this.setTimer(() => {
560
616
  this.backgroundTickTimer = null;
561
617
  void this.backgroundTick();
562
618
  }, Math.min(due, interval));
@@ -633,7 +689,7 @@ export class SyncClient {
633
689
  const slot = { kind, controller };
634
690
  this.catchupSlot = slot;
635
691
  this.abortControllers.add(controller);
636
- const timeout = this.options.runtime.setTimeout(() => controller.abort(), BACKGROUND_FETCH_TIMEOUT_MS);
692
+ const timeout = this.setTimer(() => controller.abort(), BACKGROUND_FETCH_TIMEOUT_MS);
637
693
  let ok = false;
638
694
  try {
639
695
  const result = await this.options.fetchCatchup(this.options.store.currentSeq(), controller.signal);
@@ -678,7 +734,7 @@ export class SyncClient {
678
734
  async withTimeout(operation) {
679
735
  const controller = new AbortController();
680
736
  this.abortControllers.add(controller);
681
- const timeout = this.options.runtime.setTimeout(() => controller.abort(), BACKGROUND_FETCH_TIMEOUT_MS);
737
+ const timeout = this.setTimer(() => controller.abort(), BACKGROUND_FETCH_TIMEOUT_MS);
682
738
  try {
683
739
  return await operation(controller.signal);
684
740
  }
@@ -704,7 +760,7 @@ export class SyncClient {
704
760
  failures: 0,
705
761
  firstFailure: null,
706
762
  quickRetryUsed: false,
707
- timer: this.options.runtime.setTimeout(() => this.finishReturnWindow(), RETURN_REPORT_TIMEOUT_MS),
763
+ timer: this.setTimer(() => this.finishReturnWindow(), RETURN_REPORT_TIMEOUT_MS),
708
764
  };
709
765
  }
710
766
  maybeFinishReturnWindow() {
@@ -785,13 +841,13 @@ export class SyncClient {
785
841
  }
786
842
  let remaining = delay;
787
843
  this.publish({ kind: 'reconnecting', pending: this.pendingCount(), inSeconds: remaining });
788
- this.countdownTimer = this.options.runtime.setInterval(() => {
844
+ this.countdownTimer = this.setRepeating(() => {
789
845
  remaining--;
790
846
  if (remaining > 0) {
791
847
  this.publish({ kind: 'reconnecting', pending: this.pendingCount(), inSeconds: remaining });
792
848
  }
793
849
  }, 1000);
794
- this.reconnectTimer = this.options.runtime.setTimeout(() => {
850
+ this.reconnectTimer = this.setTimer(() => {
795
851
  if (this.countdownTimer !== null)
796
852
  this.options.runtime.clearInterval(this.countdownTimer);
797
853
  this.countdownTimer = null;
@@ -858,7 +914,7 @@ export class SyncClient {
858
914
  if (this.escalateTimer !== null)
859
915
  this.options.runtime.clearTimeout(this.escalateTimer);
860
916
  this.escalateArmedAt = this.options.runtime.now();
861
- this.escalateTimer = this.options.runtime.setTimeout(() => {
917
+ this.escalateTimer = this.setTimer(() => {
862
918
  this.escalateTimer = null;
863
919
  const late = this.options.runtime.now() - this.escalateArmedAt - OUTAGE_ESCALATE_MS;
864
920
  if (this.options.runtime.visibility() === 'hidden' || late > 5000)
@@ -1153,7 +1209,7 @@ export class SyncClient {
1153
1209
  }
1154
1210
  armFailureTimer(eventGuid) {
1155
1211
  this.clearFailureTimer(eventGuid);
1156
- this.failureTimers.set(eventGuid, this.options.runtime.setTimeout(() => this.failUnconfirmed(eventGuid), FAILURE_TIMEOUT_MS));
1212
+ this.failureTimers.set(eventGuid, this.setTimer(() => this.failUnconfirmed(eventGuid), FAILURE_TIMEOUT_MS));
1157
1213
  }
1158
1214
  /** On return: a verdict needs 60 s of visible time (review B2). */
1159
1215
  rearmFailureTimers() {
@@ -1195,6 +1251,7 @@ export class SyncClient {
1195
1251
  wasClean: info?.wasClean,
1196
1252
  visibleAtDrop: info?.visibleAtDrop,
1197
1253
  hiddenMs: delta?.hiddenMs,
1254
+ stalledMs: delta?.stalledMs,
1198
1255
  watchdogKills: delta?.watchdogKills,
1199
1256
  backgroundCatchups: delta?.backgroundCatchups,
1200
1257
  httpPostedEvents: delta?.httpPostedEvents,
@@ -1332,7 +1389,7 @@ export class SyncClient {
1332
1389
  return;
1333
1390
  // A hidden tab's timer throttling may stretch this well past the nominal
1334
1391
  // delay; that only postpones recovery, it never breaks it.
1335
- this.resyncBackoffTimer = this.options.runtime.setTimeout(() => {
1392
+ this.resyncBackoffTimer = this.setTimer(() => {
1336
1393
  this.resyncBackoffTimer = null;
1337
1394
  void this.resyncFromServer('retry after resync backoff');
1338
1395
  }, RESYNC_RETRY_MS);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@steve31415/baselib",
3
- "version": "3.30.0",
3
+ "version": "3.32.0",
4
4
  "description": "Plasticine new-world shared platform library: logging, auth, service-to-service auth, db, HTTP, sync, app updates",
5
5
  "type": "module",
6
6
  "license": "MIT",