@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.
- package/CHANGELOG.md +11 -0
- package/package.json +2 -2
- package/src/cli/apply-execute.ts +80 -19
- package/src/cli/apply-refusals.ts +47 -9
- package/src/cli/commands/db-apply.ts +85 -4
- package/src/cli/commands/db-plan.ts +29 -0
- package/src/cli/edge-plan.ts +55 -13
- package/src/cli/fk-orphan-advisory.ts +170 -0
- package/src/cli/fk-preflight.ts +126 -43
- package/src/cli/model-render.ts +10 -0
- package/src/cli/schema-compile.ts +14 -1
- package/src/cli/schema-diff.ts +47 -10
- package/src/cli/schema-fingerprint.ts +26 -0
- package/src/cli/schema-introspect.ts +20 -7
- package/src/cli/state-apply.ts +84 -17
|
@@ -53,14 +53,27 @@ export interface ForeignKey {
|
|
|
53
53
|
onDelete?: string;
|
|
54
54
|
onUpdate?: string;
|
|
55
55
|
/**
|
|
56
|
-
* The
|
|
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
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
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;
|
package/src/cli/state-apply.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
}
|