@everystack/cli 0.4.84 → 0.4.85

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.
@@ -26,7 +26,12 @@ import { FUNCTIONS_SQL, contractFunctionRow } from './security-catalog';
26
26
  import { generateMigrationSql, HELD_DROP_PREFIX } from './migration-generate';
27
27
  import type { SequenceDescriptor } from '@everystack/model';
28
28
  import { fingerprintLive, FINGERPRINT_VERSION } from './schema-fingerprint';
29
- import { ENSURE_RECONCILER_SQL, renderSchemaLogInsert, renderSchemaLogFingerprintUpdate } from './derived-apply';
29
+ import {
30
+ ENSURE_RECONCILER_SQL,
31
+ renderSchemaLogInsert,
32
+ renderSchemaLogFingerprintUpdate,
33
+ renderSchemaLogCompletionUpdate,
34
+ } from './derived-apply';
30
35
  import { isPureComment, statementBody, statementHead } from './sql-statement';
31
36
 
32
37
  const NOTICE_PREFIX = '-- NOTICE';
@@ -52,6 +57,32 @@ export function classifyGeneratedStatements(statements: string[]): ClassifiedSta
52
57
  return { executable, heldDrops, notices };
53
58
  }
54
59
 
60
+ /**
61
+ * Split an executable stream into the EDGE and the CHECKS: `ALTER TABLE … VALIDATE CONSTRAINT`
62
+ * is held out of the batch and run afterwards, one statement at a time.
63
+ *
64
+ * This is what makes the two-step foreign-key add worth splitting at all. `ADD CONSTRAINT …
65
+ * NOT VALID` holds an ACCESS EXCLUSIVE lock until its transaction COMMITS, so a VALIDATE in the
66
+ * same transaction runs under that lock and blocks every writer for the whole scan — precisely
67
+ * the outage the split exists to avoid. Run after the commit, the VALIDATE takes its own, much
68
+ * weaker SHARE UPDATE EXCLUSIVE lock, which readers and writers pass through.
69
+ *
70
+ * The second consequence is durability, and it is the point of doing it here rather than in the
71
+ * emitter: a VALIDATE that fails no longer rolls back the edge. The key stays installed and
72
+ * NOT VALID — enforced on every new write, unproven against the old rows — which is a state the
73
+ * next `db:plan` sees and finishes with a VALIDATE alone. An edge that rolled back instead would
74
+ * have thrown away a correct, committed schema change because of data it does not own.
75
+ */
76
+ export function splitValidations(executable: readonly string[]): { batch: string[]; validations: string[] } {
77
+ const batch: string[] = [];
78
+ const validations: string[] = [];
79
+ for (const statement of executable) {
80
+ if (/^ALTER\s+TABLE\s+[\s\S]+?\sVALIDATE\s+CONSTRAINT\b/i.test(statementBody(statement))) validations.push(statement);
81
+ else batch.push(statement);
82
+ }
83
+ return { batch, validations };
84
+ }
85
+
55
86
  /**
56
87
  * The destructive subset of an executable statement stream — destructive
57
88
  * means DATA LOSS (brick 9's classification, design decision 11):
@@ -105,12 +136,14 @@ const ADDITIVE_MATCHERS: RegExp[] = [
105
136
  /^GRANT\b/,
106
137
  /^COMMENT\s+ON\b/,
107
138
  // ADD COLUMN alone. Its siblings `ADD CONSTRAINT` (FOREIGN KEY / CHECK / UNIQUE) and
108
- // `ADD PRIMARY KEY` are deliberately NOT here: each one VALIDATES every row already in the
109
- // table and then RESTRICTS every write after it. An FK add fails on orphan rows, a CHECK add
110
- // fails on any violating row, a UNIQUE add fails on existing duplicates, and ADD PRIMARY KEY
111
- // fails on duplicates or NULLs *and* silently makes every included column NOT NULL. Calling
112
- // those additive tells the reviewer the plan is safe when it can abort the apply outright —
113
- // the same false zero described above. They read as `unclassified`, like `SET NOT NULL`.
139
+ // `ADD PRIMARY KEY` are deliberately NOT here: each one RESTRICTS every write after it, and
140
+ // all but the NOT VALID form also VALIDATE every row already in the table. A CHECK add fails
141
+ // on any violating row, a UNIQUE add fails on existing duplicates, and ADD PRIMARY KEY fails
142
+ // on duplicates or NULLs *and* silently makes every included column NOT NULL. A foreign key
143
+ // is emitted `NOT VALID` and so cannot fail on the old rows — but its `VALIDATE CONSTRAINT`
144
+ // can, and it still refuses every future write that breaks it. Calling any of these additive
145
+ // tells the reviewer the plan is safe when it can abort the apply outright — the same false
146
+ // zero described above. They read as `unclassified`, like `SET NOT NULL`.
114
147
  /^ALTER\s+TABLE\s+[\s\S]+?\sADD\s+COLUMN\b/,
115
148
  // Enabling/forcing RLS only ever RESTRICTS; it cannot widen access or lose a row.
116
149
  /^ALTER\s+TABLE\s+[\s\S]+?\s(ENABLE|FORCE)\s+ROW\s+LEVEL\s+SECURITY\b/,
@@ -344,6 +377,30 @@ export interface StateApplyOptions {
344
377
  * row is still a witness.
345
378
  */
346
379
  descentNote?: () => string | undefined;
380
+ /**
381
+ * Read the live base fingerprint, for the row written the instant the batch commits.
382
+ *
383
+ * The `state apply` row is only a descent WITNESS if it carries a `to_fingerprint`
384
+ * (SCHEMA_LOG_WITNESS_SQL requires one), and it must be the hash of the database AS IT IS at
385
+ * that moment — with the new key installed and NOT VALID. Stamping the plan's target state
386
+ * there would be a prediction, not a record: if the process then dies mid-VALIDATE, the next
387
+ * apply recomputes the live hash, does not match the stamped one, and the row it was given to
388
+ * protect itself with is inert. The plan's own endpoint is stamped by the caller AFTER the
389
+ * last check passes.
390
+ *
391
+ * Optional: without it the row still goes in before the checks, and the caller fills the
392
+ * fingerprint in when it re-introspects (db:sync's lane). Called ONLY when there are checks to
393
+ * run — with none there is no window between the commit and the record to protect.
394
+ */
395
+ readFingerprint?: () => Promise<string>;
396
+ }
397
+
398
+ /** A `VALIDATE CONSTRAINT` that did not pass — the key is installed but still unproven. */
399
+ export interface PendingValidation {
400
+ /** The exact statement to re-run once the data satisfies the key. */
401
+ sql: string;
402
+ /** PostgreSQL's message, trimmed for the record. */
403
+ error: string;
347
404
  }
348
405
 
349
406
  export interface StateApplyResult {
@@ -354,12 +411,25 @@ export interface StateApplyResult {
354
411
  batchSql: string;
355
412
  /** schema_log row id for this apply, when one was written. */
356
413
  logId?: number;
414
+ /**
415
+ * Constraints left NOT VALID because their post-commit `VALIDATE` failed. The edge itself
416
+ * COMMITTED — this is not a rollback and must never be reported as one. Empty on a clean apply.
417
+ */
418
+ pendingValidations: PendingValidation[];
357
419
  }
358
420
 
359
421
  /**
360
422
  * Execute the executable subset of a generated diff as one batch and record
361
423
  * it in schema_log. A failed batch rolls back (one implicit transaction),
362
424
  * logs the failure best-effort, and rethrows.
425
+ *
426
+ * ORDER OF WRITES, and it is the point: batch → RECORD → checks → amend the record. The
427
+ * `VALIDATE CONSTRAINT` pass is the longest thing an apply does (a full scan per key) and the
428
+ * one a wall clock or a dropped connection actually kills. Recording after it meant that death
429
+ * left the DDL committed with nothing on the record — and a live state no commit on any ref
430
+ * declares reads to the NEXT apply as proven drift, which is the loudest possible wrong answer.
431
+ * So the row is written as soon as the batch has committed, describing exactly what is true then
432
+ * (the key installed, none of the checks run yet), and amended when the checks are over.
363
433
  */
364
434
  export async function applyGeneratedStatements(
365
435
  runner: QueryRunner,
@@ -368,18 +438,28 @@ export async function applyGeneratedStatements(
368
438
  ): Promise<StateApplyResult> {
369
439
  const classified = classifyGeneratedStatements(statements);
370
440
  if (classified.executable.length === 0) {
371
- return { applied: false, classified, batchSql: '' };
441
+ return { applied: false, classified, batchSql: '', pendingValidations: [] };
372
442
  }
373
443
 
374
444
  const now = options.now ?? Date.now;
375
445
  await runner(ENSURE_RECONCILER_SQL.join(';\n'));
376
446
 
447
+ // The row checks run AFTER the edge commits — see splitValidations for why that is the whole
448
+ // point of the two-step add, and why a failed check is a pending state rather than a rollback.
449
+ const { batch, validations } = splitValidations(classified.executable);
450
+
377
451
  // Semicolon on its own line: a statement whose last line is a comment
378
452
  // cannot swallow the separator.
379
- const batchSql = classified.executable.join('\n;\n');
453
+ //
454
+ // `batchSql` is the batch that RAN IN THIS TRANSACTION — the checks are not in it. The record
455
+ // written below is a record of what committed, and the checks had not been attempted at that
456
+ // point; they join the row on the amendment, where their outcome is known.
457
+ const batchSql = batch.join('\n;\n');
458
+ const validationSql = validations.join('\n;\n');
459
+ const fullSql = [batchSql, validationSql].filter((s) => s.length > 0).join('\n;\n');
380
460
  const started = now();
381
461
  try {
382
- await runner(batchSql);
462
+ if (batch.length > 0) await runner(batchSql);
383
463
  } catch (err: any) {
384
464
  try {
385
465
  await runner(renderSchemaLogInsert({
@@ -395,16 +475,79 @@ export async function applyGeneratedStatements(
395
475
  }
396
476
 
397
477
  const note = options.descentNote?.();
478
+ // The edge APPLIED — that word is load-bearing (the descent ledger matches `applied%`, and the
479
+ // schema changes really are committed). What is still unproven is named in the same line, so
480
+ // nobody reads a half-proven key as a finished one.
481
+ const outcomeWith = (pending: number): string =>
482
+ ['applied', note, pending > 0 ? `${pending} constraint(s) PENDING validation` : undefined]
483
+ .filter(Boolean)
484
+ .join(' — ');
485
+
486
+ // The hash of what just committed, read now rather than predicted. Only when checks follow:
487
+ // with none, the record goes in the same breath as the commit (as it always has) and the
488
+ // caller's own post-apply introspection stamps it — reading it twice would buy nothing and
489
+ // cost a full introspection on every apply. Best-effort: a fingerprint that cannot be read
490
+ // must not cost us the record.
491
+ let landedAfterBatch: string | undefined;
492
+ if (validations.length > 0 && options.readFingerprint) {
493
+ try {
494
+ landedAfterBatch = await options.readFingerprint();
495
+ } catch { /* the record matters more than the stamp */ }
496
+ }
497
+
498
+ // THE WITNESS, written before the long step. Every check is still outstanding here, and the
499
+ // outcome says exactly that — this row is true at the instant it is written, not a prediction
500
+ // of how the checks will go.
398
501
  const logRows = await runner(renderSchemaLogInsert({
399
- kind: 'state apply', sql: batchSql, outcome: note ? `applied — ${note}` : 'applied',
502
+ kind: 'state apply', sql: batchSql, outcome: outcomeWith(validations.length),
400
503
  actor: options.actor, gitRef: options.gitRef, planRef: options.planRef,
401
504
  fromFingerprint: options.fromFingerprint,
505
+ ...(landedAfterBatch ? { toFingerprint: landedAfterBatch } : {}),
402
506
  fpVersion: options.fpVersion ?? FINGERPRINT_VERSION, dirty: options.dirty ?? false,
403
507
  durationMs: now() - started,
404
508
  }));
405
509
  const logId = logRows?.[0]?.id !== undefined ? Number(logRows[0].id) : undefined;
406
510
 
407
- return { applied: true, classified, batchSql, ...(logId !== undefined ? { logId } : {}) };
511
+ // Every check is attempted, even after one fails: the operator is owed the COMPLETE list of
512
+ // keys still unproven, not the first one. Each runs on its own, so one failure cannot take a
513
+ // sibling's successful validation down with it.
514
+ const pendingValidations: PendingValidation[] = [];
515
+ for (const statement of validations) {
516
+ try {
517
+ await runner(statement);
518
+ } catch (err: any) {
519
+ pendingValidations.push({ sql: statement, error: String(err?.message ?? err).slice(0, 500) });
520
+ }
521
+ }
522
+
523
+ // The amendment: the SAME row, finished. A second `state apply` row would put two applies in
524
+ // the ledger for one edge — and the descent witness reads that ledger. So the checks and their
525
+ // result land on the row that already describes this apply, never beside it.
526
+ if (validations.length > 0 && logId !== undefined) {
527
+ try {
528
+ await runner(renderSchemaLogCompletionUpdate(logId, {
529
+ outcome: outcomeWith(pendingValidations.length),
530
+ sql: fullSql,
531
+ durationMs: now() - started,
532
+ }));
533
+ } catch { /* the memoir is best-effort; the row already says what committed */ }
534
+ }
535
+
536
+ // One row per unproven key, carrying the exact statement to re-run. Best-effort, like every
537
+ // other memoir write: failing to record why a key is pending must not mask that it is.
538
+ for (const pending of pendingValidations) {
539
+ try {
540
+ await runner(renderSchemaLogInsert({
541
+ kind: 'state validate', sql: pending.sql,
542
+ outcome: `pending: ${pending.error}`,
543
+ actor: options.actor, gitRef: options.gitRef, planRef: options.planRef,
544
+ fromFingerprint: options.fromFingerprint,
545
+ fpVersion: options.fpVersion ?? FINGERPRINT_VERSION, dirty: options.dirty ?? false,
546
+ }));
547
+ } catch { /* see above */ }
548
+ }
549
+
550
+ return { applied: true, classified, batchSql, pendingValidations, ...(logId !== undefined ? { logId } : {}) };
408
551
  }
409
552
 
410
553
  // ---------------------------------------------------------------------------
@@ -433,6 +576,9 @@ export interface StateSyncOutcome {
433
576
  toFingerprint: string;
434
577
  /** Executable statements still differing after apply — empty is the bar. */
435
578
  remaining: string[];
579
+ /** Constraints whose post-commit `VALIDATE` failed: installed, enforced, still unproven. They
580
+ * also show up in `remaining` as a VALIDATE the next run re-emits. */
581
+ pendingValidations: PendingValidation[];
436
582
  /** schema_log row id, when DDL ran. */
437
583
  logId?: number;
438
584
  /** Fresh post-apply introspection (the pre-apply one when nothing ran). */
@@ -463,7 +609,7 @@ export async function applyStateAndVerify(
463
609
  if (classified.executable.length === 0) {
464
610
  return {
465
611
  statements, classified, applied: false,
466
- fromFingerprint, toFingerprint: fromFingerprint, remaining: [],
612
+ fromFingerprint, toFingerprint: fromFingerprint, remaining: [], pendingValidations: [],
467
613
  after: { snapshot: current, contract: liveAuthz },
468
614
  };
469
615
  }
@@ -484,6 +630,7 @@ export async function applyStateAndVerify(
484
630
  return {
485
631
  statements, classified, applied: true,
486
632
  fromFingerprint, toFingerprint, remaining,
633
+ pendingValidations: result.pendingValidations,
487
634
  ...(result.logId !== undefined ? { logId: result.logId } : {}),
488
635
  after: { snapshot, contract },
489
636
  };