@everystack/cli 0.4.35 → 0.4.38

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.
@@ -71,6 +71,41 @@ export interface ExecuteSwapOptions {
71
71
  * are still refused: consent cannot cover an object nothing knows how to rebuild.
72
72
  */
73
73
  rebuildDerived?: boolean;
74
+ /**
75
+ * The PAIRED swap: derived schemas that swap alongside the base schema, with their incoming
76
+ * twins already built over `<schema>_incoming` (see swap-pair). This is the zero-downtime path
77
+ * — the derived layer is recreated in parallel and renamed in the same transaction, so it is
78
+ * never absent and never welded to the retiring schema.
79
+ *
80
+ * Supplying this makes the CASCADE gate treat the whole set as one unit: an object depending on
81
+ * a paired schema is INSIDE the swap and rides along, exactly as an object inside the base
82
+ * schema always has.
83
+ */
84
+ paired?: string[];
85
+ /**
86
+ * Build the incoming derived layer. Runs after the artifact lands in `<schema>_incoming` and
87
+ * before the swap transaction, while every live schema keeps serving. Paired swaps only.
88
+ */
89
+ buildPairedDerived?: (runner: QueryRunner) => Promise<void>;
90
+ /**
91
+ * Schema-level USAGE applied inside the swap transaction (renderSwapSchemaUsage), and the
92
+ * (schema, role) pairs asserted after it commits. Without these a swap can land correct data
93
+ * behind schemas no application role can enter — see diffSchemaUsage.
94
+ */
95
+ schemaUsage?: string[];
96
+ schemaUsageRoles?: Array<{ schema: string; role: string }>;
97
+ /**
98
+ * The `_incoming`-qualified identities the paired build must produce (expectedIncomingObjects).
99
+ * Asserted before the rename: a partial derived layer refuses while live is untouched, instead
100
+ * of committing a swap that reports success with objects missing.
101
+ */
102
+ expectedDerived?: string[];
103
+ /**
104
+ * Record provenance for the objects the paired build created, once they are live at their final
105
+ * identities. Without it the next db:reconcile sees the whole layer as drift and rebuilds it —
106
+ * an expensive, ACCESS EXCLUSIVE no-op that surfaces days later on an unrelated run.
107
+ */
108
+ recordProvenance?: (runner: QueryRunner) => Promise<void>;
74
109
  }
75
110
 
76
111
  /** One table's landed-vs-live count, the intrinsic post-swap assertion's unit. */
@@ -158,19 +193,36 @@ export function dropDependentSql(d: CrossSchemaDependent): string {
158
193
  * Verified against a live PostgreSQL 16 fixture: a three-deep view chain, a function returning
159
194
  * SETOF a table, a view inside the schema, and an unrelated view in another schema.
160
195
  */
161
- export function crossSchemaDependentsQuery(schema: string, incoming: string, retiring: string): string {
196
+ export function crossSchemaDependentsQuery(
197
+ schema: string,
198
+ incoming: string,
199
+ retiring: string,
200
+ opts: { paired?: string[]; incomingSuffix?: string; retiringSuffix?: string } = {},
201
+ ): string {
162
202
  const q = (s: string) => s.replace(/'/g, "''");
163
- const skip = `ARRAY['${q(incoming)}','${q(retiring)}']`;
164
- const notSelf = `n.nspname <> '${q(schema)}' AND n.nspname <> ALL (${skip})`;
203
+ const arr = (xs: string[]) => `ARRAY[${xs.map((s) => `'${q(s)}'`).join(',')}]`;
204
+ const paired = opts.paired ?? [];
205
+ const inSuffix = opts.incomingSuffix ?? '_incoming';
206
+ const reSuffix = opts.retiringSuffix ?? '_retiring';
207
+
208
+ // A PAIRED derived schema is INSIDE the swap: its objects are rebuilt into the incoming twin
209
+ // and renamed in the same transaction, so they are not at risk and must not trigger a refusal.
210
+ // They also seed the walk — something outside the set depending on a PAIRED schema is destroyed
211
+ // by that schema's rename + drop just as surely as a dependent of the base schema is.
212
+ const swapped = [schema, ...paired];
213
+ const twins = paired.flatMap((p) => [`${p}${inSuffix}`, `${p}${reSuffix}`]);
214
+ const skip = arr([incoming, retiring, ...twins]);
215
+ const notSelf = `n.nspname <> ALL (${arr(swapped)}) AND n.nspname <> ALL (${skip})`;
216
+ const seedIn = `= ANY (${arr(swapped)})`;
165
217
  return `WITH RECURSIVE seed AS (
166
218
  SELECT c.oid AS oid, 'pg_class'::regclass AS cls
167
- FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace WHERE n.nspname = '${q(schema)}'
219
+ FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace WHERE n.nspname ${seedIn}
168
220
  UNION
169
221
  SELECT t.oid, 'pg_type'::regclass
170
- FROM pg_type t JOIN pg_namespace n ON n.oid = t.typnamespace WHERE n.nspname = '${q(schema)}'
222
+ FROM pg_type t JOIN pg_namespace n ON n.oid = t.typnamespace WHERE n.nspname ${seedIn}
171
223
  UNION
172
224
  SELECT p.oid, 'pg_proc'::regclass
173
- FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace WHERE n.nspname = '${q(schema)}'
225
+ FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace WHERE n.nspname ${seedIn}
174
226
  ), closure AS (
175
227
  SELECT oid, cls FROM seed
176
228
  UNION
@@ -199,6 +251,33 @@ SELECT DISTINCT n.nspname, p.proname,
199
251
  ORDER BY 1, 2`;
200
252
  }
201
253
 
254
+ /**
255
+ * Every view / matview / function physically living in `schemas` — what a paired swap is about to
256
+ * replace wholesale.
257
+ *
258
+ * The paired swap renames a derived schema out and its rebuilt twin in, then drops the retiring
259
+ * one. The twin is built from the DECLARED descriptors, so anything live-but-undeclared in that
260
+ * schema has no counterpart in the twin and the drop destroys it. Pairing widens the set of
261
+ * schemas the gate considers "inside" the swap; this is what keeps that from becoming a licence to
262
+ * delete. Same reasoning as the undeclared-dependent refusal, applied one schema over.
263
+ */
264
+ export function schemaObjectsQuery(schemas: string[]): string {
265
+ const q = (s: string) => s.replace(/'/g, "''");
266
+ const list = `ARRAY[${schemas.map((s) => `'${q(s)}'`).join(',')}]`;
267
+ return `SELECT n.nspname AS dep_schema, c.relname AS dep_name,
268
+ CASE c.relkind WHEN 'v' THEN 'view' WHEN 'm' THEN 'materialized view' ELSE c.relkind::text END AS dep_kind,
269
+ ''::text AS dep_args
270
+ FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
271
+ WHERE n.nspname = ANY (${list}) AND c.relkind IN ('v','m')
272
+ UNION
273
+ SELECT n.nspname, p.proname,
274
+ CASE p.prokind WHEN 'p' THEN 'procedure' ELSE 'function' END,
275
+ pg_get_function_identity_arguments(p.oid)
276
+ FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace
277
+ WHERE n.nspname = ANY (${list})
278
+ ORDER BY 1, 2`;
279
+ }
280
+
202
281
  /** The refusal message — names what is at risk, and which of the two refusals this is. */
203
282
  export function crossSchemaDependentsRefusal(
204
283
  schema: string,
@@ -253,6 +332,72 @@ export function isFatalVerdict(verdict: SwapVerdict): boolean {
253
332
  return checks.some((c) => !c.ok && (c.severity ?? 'fatal') === 'fatal');
254
333
  }
255
334
 
335
+ /**
336
+ * The reachability assertion: every role that holds a declared grant in a swapped schema must
337
+ * still have USAGE on it after the swap.
338
+ *
339
+ * The row-count assertion proves the DATA landed. This proves the data can be REACHED, which is a
340
+ * different failure and a quieter one: a swap that lands perfect data behind a schema no
341
+ * application role can enter reports success while every endpoint 500s. PostgreSQL surfaces a
342
+ * missing schema USAGE as ABSENCE rather than denial, so the error the operator sees is
343
+ * `relation "..." does not exist` — pointing at the table, not the grant.
344
+ *
345
+ * Pure: the impure half reads has_schema_privilege and hands the rows here.
346
+ */
347
+ export function diffSchemaUsage(rows: Array<{ schema: string; role: string; ok: boolean }>): SwapCheck[] {
348
+ return rows
349
+ .filter((r) => !r.ok)
350
+ .map((r) => ({
351
+ name: `usage:${r.schema}:${r.role}`,
352
+ ok: false,
353
+ severity: 'fatal' as const,
354
+ detail: `role ${r.role} has no USAGE on schema ${r.schema} after the swap — it holds declared grants there, so every one of them is unreachable (PostgreSQL will report the tables as "does not exist").`,
355
+ }));
356
+ }
357
+
358
+ /** `has_schema_privilege` for each (role, schema) pair — catalog-only, locks nothing. */
359
+ export function schemaUsageQuery(pairs: Array<{ schema: string; role: string }>): string {
360
+ const q = (s: string) => s.replace(/'/g, "''");
361
+ const values = pairs.map((p) => `('${q(p.schema)}','${q(p.role)}')`).join(',');
362
+ return `SELECT s AS schema, r AS role,
363
+ (EXISTS (SELECT 1 FROM pg_roles WHERE rolname = r)
364
+ AND has_schema_privilege(r, s, 'USAGE')) AS ok
365
+ FROM (VALUES ${values}) AS t(s, r)`;
366
+ }
367
+
368
+ /**
369
+ * The derived-layer completeness assertion: every declared object in the swap set must actually
370
+ * EXIST in the incoming schemas after the build.
371
+ *
372
+ * The row-count assertion proves the base tables landed. This proves the derived layer did. They
373
+ * are different failures, and this one was silent: a build that throws is caught, but a build that
374
+ * quietly produces 38 of 79 objects sailed straight through to a successful swap. Seen in the
375
+ * field — a consumer's run printed the success line with half the layer missing.
376
+ *
377
+ * The same silent-success class as the original `--direct` swap reporting success without landing
378
+ * anything. That got a per-table count; the derived layer never got the equivalent until now.
379
+ *
380
+ * Pure: the caller reads the incoming catalog and hands both sides here.
381
+ */
382
+ export function diffDerivedObjects(expected: string[], present: string[]): SwapCheck[] {
383
+ const have = new Set(present);
384
+ const missing = expected.filter((id) => !have.has(id)).sort();
385
+ if (missing.length === 0) return [];
386
+ const shown = missing.slice(0, 20).join(', ');
387
+ return [{
388
+ name: 'derived:incomplete',
389
+ ok: false,
390
+ severity: 'fatal',
391
+ detail: `the incoming derived layer is INCOMPLETE — ${missing.length} of ${expected.length} declared object(s) were not built: `
392
+ + `${shown}${missing.length > 20 ? `, and ${missing.length - 20} more` : ''}.`,
393
+ }];
394
+ }
395
+
396
+ /** Every view / matview / function physically present in the given schemas, as `schema.name`. */
397
+ export function objectsPresentQuery(schemas: string[]): string {
398
+ return schemaObjectsQuery(schemas);
399
+ }
400
+
256
401
  /** Double-quote a Postgres identifier read from the catalog (embedded quotes doubled). */
257
402
  function quoteIdent(name: string): string {
258
403
  return `"${name.replace(/"/g, '""')}"`;
@@ -282,12 +427,43 @@ export async function executeSwap(runner: QueryRunner, opts: ExecuteSwapOptions)
282
427
  };
283
428
  }
284
429
 
285
- const plan = renderSchemaSwap(opts.models, { schema: opts.schema, incoming: opts.incoming, retiring: opts.retiring });
430
+ const paired = opts.paired ?? [];
431
+ const plan = renderSchemaSwap(opts.models, {
432
+ schema: opts.schema,
433
+ incoming: opts.incoming,
434
+ retiring: opts.retiring,
435
+ paired,
436
+ schemaUsage: opts.schemaUsage,
437
+ });
286
438
 
287
439
  // 1b. THE CASCADE GATE. Refuse before the snapshot — before anything moves at all — if any object
288
440
  // outside this schema depends on it. The swap's final DROP SCHEMA ... CASCADE would destroy
289
441
  // them silently, and a silent destroyer is the worst thing this command could be.
290
- const depRows = await runner(crossSchemaDependentsQuery(opts.schema, plan.incoming, plan.retiring));
442
+ // 1a. PAIRED PRE-FLIGHT. A paired schema is replaced wholesale by a twin built from the declared
443
+ // descriptors, so anything live in it that is NOT declared has no counterpart in the twin
444
+ // and the retiring drop would destroy it. Pairing must never become a quieter way to lose an
445
+ // object than the unpaired gate already refuses to be.
446
+ if (paired.length > 0) {
447
+ const declared = new Set(opts.declaredIdentities ?? []);
448
+ const liveRows = await runner(schemaObjectsQuery(paired));
449
+ const orphans: CrossSchemaDependent[] = liveRows
450
+ .map((r: any) => ({ schema: r.dep_schema, name: r.dep_name, kind: r.dep_kind, args: r.dep_args || undefined }))
451
+ .filter((d: CrossSchemaDependent) => !declared.has(dependentIdentity(d)));
452
+ if (orphans.length > 0) {
453
+ const name = (d: CrossSchemaDependent) => `${d.schema}.${d.name} (${d.kind})`;
454
+ return {
455
+ status: 'refused-dependents',
456
+ reason: `${orphans.length} object(s) live in the paired schema(s) ${paired.join(', ')} but are NOT declared, so the rebuilt schema would not contain them and the swap would DESTROY them: `
457
+ + `${orphans.slice(0, 20).map(name).join(', ')}${orphans.length > 20 ? `, and ${orphans.length - 20} more` : ''}. `
458
+ + `A paired swap replaces the whole derived schema with one built from db/models — anything not declared there has nothing to rebuild it. `
459
+ + `Nothing was changed. Declare them in db/models, or drop them yourself if they are genuinely disposable.`,
460
+ dependents: orphans,
461
+ undeclaredDependents: orphans,
462
+ };
463
+ }
464
+ }
465
+
466
+ const depRows = await runner(crossSchemaDependentsQuery(opts.schema, plan.incoming, plan.retiring, { paired }));
291
467
  const dependents: CrossSchemaDependent[] = depRows.map((r: any) => ({
292
468
  schema: r.dep_schema, name: r.dep_name, kind: r.dep_kind, args: r.dep_args || undefined,
293
469
  }));
@@ -341,6 +517,51 @@ export async function executeSwap(runner: QueryRunner, opts: ExecuteSwapOptions)
341
517
  };
342
518
  }
343
519
 
520
+ // 3b. PAIRED: build the derived layer over the incoming base tables, while every live schema
521
+ // keeps serving. This is the whole point — the layer exists in full before the rename, so
522
+ // there is no window in which it is absent and no refresh that could read stale rows.
523
+ // A failure here leaves live untouched: nothing has been renamed yet.
524
+ if (opts.buildPairedDerived) {
525
+ const twins = paired.map((p) => `${p}_incoming`);
526
+ for (const t of twins) await runner(`DROP SCHEMA IF EXISTS ${quoteIdent(t)} CASCADE`);
527
+ try {
528
+ await opts.buildPairedDerived(runner);
529
+ } catch (err: any) {
530
+ for (const t of twins) {
531
+ try { await runner(`DROP SCHEMA IF EXISTS ${quoteIdent(t)} CASCADE`); } catch { /* best effort */ }
532
+ }
533
+ try { await runner(`DROP SCHEMA IF EXISTS ${quoteIdent(plan.incoming)} CASCADE`); } catch { /* best effort */ }
534
+ return {
535
+ status: 'refused-integrity',
536
+ reason: `building the incoming derived layer failed, so the swap was NOT applied and live is untouched: ${String(err?.message ?? err)}. `
537
+ + `The declared descriptors must compose against the INCOMING base schema — if the artifact's shape no longer matches what the derived layer selects, that mismatch surfaces here rather than after the rename.`,
538
+ };
539
+ }
540
+ // COMPLETENESS. A build that throws is caught above; a build that quietly produces only some
541
+ // of its objects is not, and used to reach a successful swap. Assert BEFORE the rename, while
542
+ // live is still untouched — a refusal here costs nothing, where the same finding after the
543
+ // rename would cost a snapshot restore.
544
+ if (opts.expectedDerived?.length) {
545
+ const rows = await runner(objectsPresentQuery(twins.length ? [plan.incoming, ...twins] : [plan.incoming]));
546
+ const present = rows.map((r: any) => `${r.dep_schema}.${r.dep_name}`);
547
+ const checks = diffDerivedObjects(opts.expectedDerived, present);
548
+ if (checks.length > 0) {
549
+ for (const t of twins) {
550
+ try { await runner(`DROP SCHEMA IF EXISTS ${quoteIdent(t)} CASCADE`); } catch { /* best effort */ }
551
+ }
552
+ try { await runner(`DROP SCHEMA IF EXISTS ${quoteIdent(plan.incoming)} CASCADE`); } catch { /* best effort */ }
553
+ return {
554
+ status: 'refused-integrity',
555
+ reason: `${checks[0].detail} The swap was NOT applied and live is untouched. `
556
+ + `A partial derived layer is what a swap that reports success while half the layer is missing looks like from the inside — this refuses instead.`,
557
+ verdict: { ok: false, checks },
558
+ };
559
+ }
560
+ log(`derived layer complete: ${opts.expectedDerived.length} declared object(s) present in the incoming schemas`);
561
+ }
562
+ log(`built the incoming derived layer into ${twins.join(', ')} — live still serving the old one`);
563
+ }
564
+
344
565
  // 4. The atomic swap. A FK re-validation failure (a bad artifact) rolls the whole thing back.
345
566
  await runner('BEGIN');
346
567
  try {
@@ -372,6 +593,26 @@ export async function executeSwap(runner: QueryRunner, opts: ExecuteSwapOptions)
372
593
  };
373
594
  }
374
595
 
596
+ // 5a-bis. The REACHABILITY assertion. Counts prove the data landed; this proves an application
597
+ // role can still get to it. A swap that commits perfect data behind a schema nobody can
598
+ // enter looks like a success and reads, at the app, as every table having vanished.
599
+ if (opts.schemaUsageRoles?.length) {
600
+ const usageRows = await runner(schemaUsageQuery(opts.schemaUsageRoles));
601
+ const usageChecks = diffSchemaUsage(
602
+ usageRows.map((r: any) => ({ schema: r.schema, role: r.role, ok: r.ok === true })),
603
+ );
604
+ if (usageChecks.length > 0) {
605
+ if (opts.rollbackToSnapshot) await opts.rollbackToSnapshot();
606
+ return {
607
+ status: 'rolled-back-verify',
608
+ reason: `the swap landed but the schemas are unreachable: ${usageChecks.map((c) => c.detail).join(' ')} `
609
+ + `${opts.rollbackToSnapshot ? 'Rolled back to the pre-swap snapshot.' : 'NO snapshot was configured to roll back to.'}`,
610
+ verdict: { ok: false, checks: usageChecks },
611
+ };
612
+ }
613
+ log(`reachability: ${opts.schemaUsageRoles.length} (role, schema) pair(s) verified — the app can still read through the swapped schemas`);
614
+ }
615
+
375
616
  // 5b. Verify (post-commit). Fatal → roll back to the snapshot; warn → surface only.
376
617
  let verdict: SwapVerdict | undefined;
377
618
  if (opts.verify) {
@@ -386,8 +627,25 @@ export async function executeSwap(runner: QueryRunner, opts: ExecuteSwapOptions)
386
627
  }
387
628
  }
388
629
 
389
- // 6. Drop the retiring schema — the swap is committed and verified.
390
- await runner(dropRetiringSql(plan.retiring));
630
+ // 6. Drop EVERY retiring schema — the swap is committed and verified. On a paired swap that is
631
+ // the base plus each derived twin; dropping only the base would strand the old derived
632
+ // schemas, which the next swap then collides with on its own rename.
633
+ for (const r of plan.retiringSchemas) await runner(dropRetiringSql(r));
634
+
635
+ // 7. Bookkeeping. The build created the derived layer with raw DDL; the reconciler knows nothing
636
+ // about it until this runs. Recorded AFTER the retiring drop so the catalog read behind it
637
+ // sees only the live objects. A failure here does not undo a good swap — the data is correct
638
+ // and serving; the cost is a redundant rebuild on the next reconcile, which is what this
639
+ // prevents rather than something it can break.
640
+ if (opts.recordProvenance) {
641
+ try {
642
+ await opts.recordProvenance(runner);
643
+ } catch (err: any) {
644
+ log(`WARNING: the swap succeeded but recording provenance failed: ${String(err?.message ?? err)}. `
645
+ + `The derived layer is live and correct; the next db:reconcile will rebuild it needlessly. `
646
+ + `Run db:reconcile --rebaseline to record it without DDL.`);
647
+ }
648
+ }
391
649
 
392
650
  const warnings = (verdict?.checks ?? []).filter((c) => !c.ok && c.severity === 'warn');
393
651
  return { status: 'swapped', verdict, ...(warnings.length ? { warnings } : {}) };
@@ -78,6 +78,24 @@ export interface HeartbeatSample {
78
78
  blockedBy: string | null;
79
79
  }
80
80
 
81
+ /**
82
+ * What the CLIENT has sent, to sit alongside what the server reports.
83
+ *
84
+ * The server side alone cannot tell a stalled load from a finished one. Both look like
85
+ * `Client/ClientRead` with flat progress: the server is waiting for the next command. The difference
86
+ * is whether there is anything left to send, and only the client knows that. Without this the
87
+ * detector called the deadlock signature on a run that had landed all 5,984,956 rows and was one
88
+ * second from completing.
89
+ */
90
+ export interface ClientFeed {
91
+ /** Bytes handed to psql's stdin so far. */
92
+ fedBytes: number;
93
+ /** Bytes the restore has to feed in total (the rewritten SQL file's size). */
94
+ totalBytes: number;
95
+ /** The local reader has written everything — nothing more is coming from our side. */
96
+ done: boolean;
97
+ }
98
+
81
99
  export type Liveness =
82
100
  /** Rows or COPY bytes grew since the last poll. */
83
101
  | 'progressing'
@@ -85,6 +103,12 @@ export type Liveness =
85
103
  | 'blocked'
86
104
  /** Flat progress AND the server is waiting on the client: the deadlock signature. */
87
105
  | 'client-stall'
106
+ /**
107
+ * The client has sent everything and the server is waiting on it. That is not a stall — it is the
108
+ * tail of a successful load (the final commit, the connection winding down). Distinguishing this
109
+ * from `client-stall` is the whole reason ClientFeed exists.
110
+ */
111
+ | 'finishing'
88
112
  /** Connected and working, but nothing measurable moved this poll (DDL, index build). */
89
113
  | 'busy'
90
114
  /** No psql backend connected — not started yet, or already gone. */
@@ -218,9 +242,14 @@ function movedForward(prev: HeartbeatSample, cur: HeartbeatSample): boolean {
218
242
  * 3. no predecessor → busy (cannot claim progress OR a stall on the first poll)
219
243
  * 4. rows/bytes grew → progressing, EVEN in ClientRead (the healthy high-latency case)
220
244
  * 5. schema not created → busy (DDL phase, nothing to measure)
221
- * 6. flat + ClientRead → client-stall (the deadlock signature)
245
+ * 6. flat + ClientRead + the client still has bytes to send → client-stall (the deadlock signature)
246
+ * 7. flat + ClientRead + the client has sent everything → finishing (the successful tail)
247
+ *
248
+ * Step 7 is the B5 fix. "Nothing is moving" was treated as sufficient evidence of a stall, and it is
249
+ * not: at the end of a healthy load nothing moves either. A stall claim now requires UNLANDED WORK —
250
+ * bytes the client still owes the server — which is why `feed` is threaded this far down.
222
251
  */
223
- export function classify(prev: HeartbeatSample | null, cur: HeartbeatSample): Liveness {
252
+ export function classify(prev: HeartbeatSample | null, cur: HeartbeatSample, feed?: ClientFeed): Liveness {
224
253
  if (cur.state === null) return 'absent';
225
254
  if (!prev) return 'busy';
226
255
  // PROGRESS OUTRANKS EVERY SCARY WAIT STATE, including Lock. A restore takes relation locks
@@ -231,7 +260,7 @@ export function classify(prev: HeartbeatSample | null, cur: HeartbeatSample): Li
231
260
  if (movedForward(prev, cur)) return 'progressing';
232
261
  if (cur.waitEventType === 'Lock') return 'blocked';
233
262
  if (!cur.schemaExists) return 'busy';
234
- if (cur.waitEvent === 'ClientRead') return 'client-stall';
263
+ if (cur.waitEvent === 'ClientRead') return feed?.done ? 'finishing' : 'client-stall';
235
264
  return 'busy';
236
265
  }
237
266
 
@@ -251,16 +280,70 @@ function groupNum(n: number): string {
251
280
  return n.toLocaleString('en-US');
252
281
  }
253
282
 
254
- /** One operator-readable line per poll. Says what moved, what it is waiting on, and how long in. */
255
- export function formatSample(prev: HeartbeatSample | null, cur: HeartbeatSample, liveness: Liveness): string {
283
+ /** `1.9 GB`, `512 KB` bytes at poll-line resolution. */
284
+ function fmtBytes(n: number): string {
285
+ const units = ['B', 'KB', 'MB', 'GB', 'TB'];
286
+ let v = n;
287
+ let u = 0;
288
+ while (v >= 1024 && u < units.length - 1) { v /= 1024; u += 1; }
289
+ return `${u === 0 ? v : v.toFixed(1)} ${units[u]}`;
290
+ }
291
+
292
+ /**
293
+ * WHICH signal moved between two samples, named honestly.
294
+ *
295
+ * The bug (B6): the progressing line said "COPY advancing" whenever the row delta was zero,
296
+ * regardless of what had actually moved. So a restore in its index phase — relations growing, no
297
+ * COPY anywhere — printed "COPY advancing", and so did the poll right after the final COPY finished.
298
+ * An idle tail read as a live load. Each branch below reports only what it actually observed.
299
+ */
300
+ export function progressLabel(prev: HeartbeatSample, cur: HeartbeatSample): string {
301
+ const dRows = (cur.rows ?? 0) - (prev.rows ?? 0);
302
+ if (dRows > 0) {
303
+ const secs = Math.max(1, (cur.elapsedMs - prev.elapsedMs) / 1000);
304
+ return `+${groupNum(dRows)} row(s) (${groupNum(Math.round(dRows / secs))} rows/s)`;
305
+ }
306
+ const copyMoved = cur.copyBytes > prev.copyBytes || cur.copyRows > prev.copyRows;
307
+ // "Advancing" is a claim about NOW, so it needs a COPY running now — not merely one that moved.
308
+ if (copyMoved && cur.copies > 0) return 'COPY advancing';
309
+ const dRel = cur.relations - prev.relations;
310
+ if (dRel > 0) return `+${groupNum(dRel)} relation(s) created`;
311
+ if (copyMoved) return 'a COPY completed since the last poll';
312
+ return 'something moved';
313
+ }
314
+
315
+ /** The client-side position clause — `client fed 1.9 GB/2.0 GB (95%)`. Empty when unknown. */
316
+ function clientClause(feed?: ClientFeed): string {
317
+ if (!feed) return '';
318
+ const pct = feed.totalBytes > 0 ? Math.floor((feed.fedBytes / feed.totalBytes) * 100) : 0;
319
+ const sent = feed.done ? 'client has fed ALL' : 'client fed';
320
+ return `, ${sent} ${fmtBytes(feed.fedBytes)}/${fmtBytes(feed.totalBytes)} (${pct}%)`;
321
+ }
322
+
323
+ /**
324
+ * One operator-readable line per poll. Says what moved, what it is waiting on, how long in — and, when
325
+ * known, what the CLIENT has sent.
326
+ *
327
+ * That last part is B7. A `Client/ClientRead` wait with the client 20% through the file and the same
328
+ * wait with the file fully fed are completely different situations, and they used to print
329
+ * identically: two debugging sessions went down the wrong path because "the client is not sending"
330
+ * and "the instance has no I/O left" were indistinguishable in the log.
331
+ */
332
+ export function formatSample(
333
+ prev: HeartbeatSample | null,
334
+ cur: HeartbeatSample,
335
+ liveness: Liveness,
336
+ feed?: ClientFeed,
337
+ ): string {
256
338
  const at = `t+${humanElapsed(cur.elapsedMs)}`;
257
339
  const wait = cur.waitEventType ? `${cur.waitEventType}/${cur.waitEvent ?? '?'}` : (cur.state ?? 'idle');
340
+ const client = clientClause(feed);
258
341
 
259
342
  if (liveness === 'absent') {
260
343
  return `restore: no psql backend connected to the target (${at}) — the loader has not started yet, or has already exited.`;
261
344
  }
262
345
  if (!cur.schemaExists) {
263
- return `restore: schema not created yet, no tables yet — DDL phase, ${wait} (${at}).`;
346
+ return `restore: schema not created yet, no tables yet — DDL phase, ${wait}${client} (${at}).`;
264
347
  }
265
348
 
266
349
  const rows = cur.rows === null ? 'unknown' : groupNum(cur.rows);
@@ -275,20 +358,18 @@ export function formatSample(prev: HeartbeatSample | null, cur: HeartbeatSample,
275
358
  if (liveness === 'blocked') {
276
359
  // Naming the holder is the difference between an actionable report and a shrug.
277
360
  const by = cur.blockedBy ? ` HELD BY ${cur.blockedBy}` : ' (holder not visible — it may belong to another role)';
278
- return `restore: waiting on ${wait} with no progress this poll${by} — ${scope} (${at}).`;
361
+ return `restore: waiting on ${wait} with no progress this poll${by} — ${scope}${client} (${at}).`;
362
+ }
363
+ if (liveness === 'finishing') {
364
+ return `restore: ${scope}${client} — FINISHING: everything has been sent, the server is winding the load down (${wait}), ${at}.`;
279
365
  }
280
366
  if (liveness === 'client-stall') {
281
- return `restore: nothing landed since the last poll and the server is waiting on the client (${wait}) — ${scope} (${at}).`;
367
+ return `restore: nothing landed since the last poll and the server is waiting on the client (${wait}) — ${scope}${client} (${at}).`;
282
368
  }
283
369
  if (liveness === 'progressing' && prev) {
284
- const dRows = (cur.rows ?? 0) - (prev.rows ?? 0);
285
- const secs = Math.max(1, (cur.elapsedMs - prev.elapsedMs) / 1000);
286
- const moved = dRows > 0
287
- ? `+${groupNum(dRows)} row(s) (${groupNum(Math.round(dRows / secs))} rows/s)`
288
- : 'COPY advancing';
289
- return `restore: ${scope}, ${moved} — ${wait} (${at}).`;
370
+ return `restore: ${scope}, ${progressLabel(prev, cur)} ${wait}${client} (${at}).`;
290
371
  }
291
- return `restore: ${scope}, nothing new this poll — ${wait} (${at}).`;
372
+ return `restore: ${scope}, nothing new this poll — ${wait}${client} (${at}).`;
292
373
  }
293
374
 
294
375
  /**
@@ -335,6 +416,12 @@ export interface HeartbeatOptions {
335
416
  * backend for. Must not throw; a sink that does is ignored so it cannot kill the heartbeat.
336
417
  */
337
418
  onSample?: (sample: HeartbeatSample) => void;
419
+ /**
420
+ * The client's current write position, read fresh each poll. Supplying it is what lets the
421
+ * heartbeat tell a stalled load from a finishing one (see ClientFeed) — without it the detector
422
+ * falls back to server-side-only reasoning and will call a quiet tail a stall.
423
+ */
424
+ clientFeed?: () => ClientFeed;
338
425
  }
339
426
 
340
427
  /**
@@ -363,8 +450,10 @@ export function startHeartbeat(runner: QueryRunner, opts: HeartbeatOptions): ()
363
450
  // Raw sample first: a caller acting on the probe (the dead-backend watchdog) must see every
364
451
  // sample, and must never be able to break the heartbeat by throwing.
365
452
  try { opts.onSample?.(cur); } catch { /* a sink's failure is not the probe's problem */ }
366
- const liveness = classify(prev, cur);
367
- const line = formatSample(prev, cur, liveness);
453
+ let feed: ClientFeed | undefined;
454
+ try { feed = opts.clientFeed?.(); } catch { /* same: a bad reader must not kill the probe */ }
455
+ const liveness = classify(prev, cur, feed);
456
+ const line = formatSample(prev, cur, liveness, feed);
368
457
 
369
458
  if (liveness === 'blocked') {
370
459
  warn(line);