@everystack/cli 0.4.85 → 0.4.86

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.
@@ -53,14 +53,27 @@ export interface ForeignKey {
53
53
  onDelete?: string;
54
54
  onUpdate?: string;
55
55
  /**
56
- * The constraint exists but has NOT been checked against the rows already in the table
57
- * (`convalidated = false`; `pg_get_constraintdef` spells it `… NOT VALID`).
56
+ * The key is enforced on every new write but has NOT been checked against the rows already
57
+ * in the table (`convalidated = false`; `pg_get_constraintdef` spells it `… NOT VALID`).
58
58
  *
59
- * LIVE-SIDE ONLY. A Model declares a foreign key, never a half-installed one, so the
60
- * compiled side never sets this — which is exactly what makes it visible as drift: a live
61
- * NOT VALID key is a DIFFERENT state from the declared (validated) one. It rides the
62
- * canonical fingerprint form for the same reason, so a half-applied key can never report
63
- * MATCH. Omitted (not `false`) when the constraint is validated, so every already-recorded
59
+ * BOTH SIDES SET THIS, and they mean two different things by it:
60
+ *
61
+ * LIVE-OBSERVED — introspection read `convalidated = false`. It says nothing about
62
+ * WHY: an interrupted two-step add, a hand-run `ADD … NOT VALID`, or
63
+ * a key someone deliberately left unproven years ago.
64
+ * DECLARED-INTENTIONAL — the Model said `validate: false` on the reference, which is the
65
+ * author declaring "enforce forward, grandfather legacy" as the
66
+ * PERMANENT state of this key (see `FieldSpec.referencesValidate`).
67
+ * The differ then emits `ADD CONSTRAINT … NOT VALID` and NEVER the
68
+ * paired `VALIDATE`.
69
+ *
70
+ * HOW THE TWO MEET. A declared-intentional key is satisfied by EITHER a live NOT VALID key
71
+ * or a live FULLY-VALID one: PostgreSQL has no "un-validate", so "more proven than you asked
72
+ * for" is not a difference anybody can act on and must not read as drift. The asymmetry only
73
+ * runs the other way — a live NOT VALID key under a declaration that did NOT say
74
+ * `validate: false` IS a real difference, and the edge closes it with a `VALIDATE` alone.
75
+ *
76
+ * Omitted (not `false`) when the key is validated, on both sides, so every already-recorded
64
77
  * hash for a fully-valid database is untouched.
65
78
  */
66
79
  notValid?: boolean;
@@ -393,14 +393,31 @@ export interface StateApplyOptions {
393
393
  * run — with none there is no window between the commit and the record to protect.
394
394
  */
395
395
  readFingerprint?: () => Promise<string>;
396
+ /**
397
+ * `VALIDATE CONSTRAINT`s the caller REMOVED from `statements` before calling, because the row
398
+ * check it ran first already counted the offending rows (see fk-preflight's `withheldValidations`).
399
+ *
400
+ * They are pending validations that were never attempted, and they are recorded and reported
401
+ * exactly like ones that ran and failed — the key is installed, enforced forward and unproven
402
+ * either way, so giving the operator two vocabularies for one situation would only make them
403
+ * work out that it is the same situation. Running them instead would take a lock and a full
404
+ * scan to be told what is already known.
405
+ */
406
+ skippedValidations?: readonly PendingValidation[];
396
407
  }
397
408
 
398
409
  /** A `VALIDATE CONSTRAINT` that did not pass — the key is installed but still unproven. */
399
410
  export interface PendingValidation {
400
411
  /** The exact statement to re-run once the data satisfies the key. */
401
412
  sql: string;
402
- /** PostgreSQL's message, trimmed for the record. */
413
+ /** PostgreSQL's message, trimmed for the record — or why the check was never attempted. */
403
414
  error: string;
415
+ /**
416
+ * The check was WITHHELD, not run: the row preflight had already counted the offending rows.
417
+ * Set only on that path, so `validationPendingReason` can name the data fix without having to
418
+ * pattern-match an error string it wrote itself.
419
+ */
420
+ skipped?: boolean;
404
421
  }
405
422
 
406
423
  export interface StateApplyResult {
@@ -431,14 +448,76 @@ export interface StateApplyResult {
431
448
  * So the row is written as soon as the batch has committed, describing exactly what is true then
432
449
  * (the key installed, none of the checks run yet), and amended when the checks are over.
433
450
  */
451
+ /** The subset of StateApplyOptions the schema_log memoir writes need — nothing execution-only. */
452
+ type SchemaLogFields = Pick<StateApplyOptions, 'actor' | 'gitRef' | 'planRef' | 'fromFingerprint' | 'fpVersion' | 'dirty'>;
453
+
454
+ /**
455
+ * One row per unproven key, carrying the exact statement to re-run. Best-effort, like every
456
+ * other memoir write: failing to record why a key is pending must not mask that it is. Shared by
457
+ * both the normal path (checks run here) and the all-withheld early return (no checks run here,
458
+ * but the withheld ones are just as real a pending state and owe the same rows).
459
+ */
460
+ async function recordPendingValidations(
461
+ runner: QueryRunner,
462
+ pendingValidations: readonly PendingValidation[],
463
+ options: SchemaLogFields,
464
+ ): Promise<void> {
465
+ for (const pending of pendingValidations) {
466
+ try {
467
+ await runner(renderSchemaLogInsert({
468
+ kind: 'state validate', sql: pending.sql,
469
+ outcome: `pending: ${pending.error}`,
470
+ actor: options.actor, gitRef: options.gitRef, planRef: options.planRef,
471
+ fromFingerprint: options.fromFingerprint,
472
+ fpVersion: options.fpVersion ?? FINGERPRINT_VERSION, dirty: options.dirty ?? false,
473
+ }));
474
+ } catch { /* the memoir is best-effort; the row already says what committed */ }
475
+ }
476
+ }
477
+
434
478
  export async function applyGeneratedStatements(
435
479
  runner: QueryRunner,
436
480
  statements: string[],
437
481
  options: StateApplyOptions = {},
438
482
  ): Promise<StateApplyResult> {
439
483
  const classified = classifyGeneratedStatements(statements);
484
+ // Checks the caller withheld before calling. They are outstanding from the first line of this
485
+ // function: the outcome written before the batch already counts them, and they come back in the
486
+ // result whether or not any check actually ran here.
487
+ const skipped: PendingValidation[] = [...(options.skippedValidations ?? [])];
440
488
  if (classified.executable.length === 0) {
441
- return { applied: false, classified, batchSql: '', pendingValidations: [] };
489
+ // No DDL to run — but a re-plan against keys a PREVIOUS apply already withheld still owes
490
+ // the audit trail a row: the promise (docs/cli.md) is every apply lands in schema_log, and
491
+ // an apply whose only content is "these keys are still unproven" is a real apply, not a
492
+ // no-op. Without this, re-running db:apply/db:plan against nothing but a withheld VALIDATE
493
+ // produces a `validation-pending` result the operator sees and a database that remembers
494
+ // nothing about it.
495
+ if (skipped.length > 0) {
496
+ const now = options.now ?? Date.now;
497
+ const started = now();
498
+ let logId: number | undefined;
499
+ // Same annotation the normal path folds in (a honoured lineage waiver, a proven drift
500
+ // origin) — an all-withheld apply is still the row a LATER apply's fast-forward proof
501
+ // reads, so it must carry the same note or that proof silently loses the annotation.
502
+ const note = options.descentNote?.();
503
+ const outcome = ['applied', note, `no schema change; ${skipped.length} constraint(s) still PENDING validation`]
504
+ .filter(Boolean).join(' — ');
505
+ try {
506
+ await runner(ENSURE_RECONCILER_SQL.join(';\n'));
507
+ const logRows = await runner(renderSchemaLogInsert({
508
+ kind: 'state apply', sql: '',
509
+ outcome,
510
+ actor: options.actor, gitRef: options.gitRef, planRef: options.planRef,
511
+ fromFingerprint: options.fromFingerprint,
512
+ fpVersion: options.fpVersion ?? FINGERPRINT_VERSION, dirty: options.dirty ?? false,
513
+ durationMs: now() - started,
514
+ }));
515
+ logId = logRows?.[0]?.id !== undefined ? Number(logRows[0].id) : undefined;
516
+ } catch { /* the memoir is best-effort — the caller's own result still names the debt */ }
517
+ await recordPendingValidations(runner, skipped, options);
518
+ return { applied: false, classified, batchSql: '', pendingValidations: skipped, ...(logId !== undefined ? { logId } : {}) };
519
+ }
520
+ return { applied: false, classified, batchSql: '', pendingValidations: skipped };
442
521
  }
443
522
 
444
523
  const now = options.now ?? Date.now;
@@ -499,7 +578,7 @@ export async function applyGeneratedStatements(
499
578
  // outcome says exactly that — this row is true at the instant it is written, not a prediction
500
579
  // of how the checks will go.
501
580
  const logRows = await runner(renderSchemaLogInsert({
502
- kind: 'state apply', sql: batchSql, outcome: outcomeWith(validations.length),
581
+ kind: 'state apply', sql: batchSql, outcome: outcomeWith(validations.length + skipped.length),
503
582
  actor: options.actor, gitRef: options.gitRef, planRef: options.planRef,
504
583
  fromFingerprint: options.fromFingerprint,
505
584
  ...(landedAfterBatch ? { toFingerprint: landedAfterBatch } : {}),
@@ -511,7 +590,7 @@ export async function applyGeneratedStatements(
511
590
  // Every check is attempted, even after one fails: the operator is owed the COMPLETE list of
512
591
  // keys still unproven, not the first one. Each runs on its own, so one failure cannot take a
513
592
  // sibling's successful validation down with it.
514
- const pendingValidations: PendingValidation[] = [];
593
+ const pendingValidations: PendingValidation[] = [...skipped];
515
594
  for (const statement of validations) {
516
595
  try {
517
596
  await runner(statement);
@@ -533,19 +612,7 @@ export async function applyGeneratedStatements(
533
612
  } catch { /* the memoir is best-effort; the row already says what committed */ }
534
613
  }
535
614
 
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
- }
615
+ await recordPendingValidations(runner, pendingValidations, options);
549
616
 
550
617
  return { applied: true, classified, batchSql, pendingValidations, ...(logId !== undefined ? { logId } : {}) };
551
618
  }