@everystack/cli 0.4.84 → 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 +18 -1
- package/package.json +3 -3
- package/src/cli/apply-execute.ts +123 -2
- package/src/cli/apply-refusals.ts +112 -0
- package/src/cli/commands/db-apply.ts +104 -7
- package/src/cli/commands/db-plan.ts +30 -0
- package/src/cli/derived-apply.ts +17 -0
- package/src/cli/edge-plan.ts +64 -13
- package/src/cli/fk-orphan-advisory.ts +170 -0
- package/src/cli/fk-preflight.ts +414 -0
- package/src/cli/migration-generate.ts +8 -7
- package/src/cli/model-render.ts +10 -0
- package/src/cli/plain-output.ts +10 -2
- package/src/cli/schema-compile.ts +14 -1
- package/src/cli/schema-diff.ts +137 -13
- package/src/cli/schema-fingerprint-v12.ts +239 -0
- package/src/cli/schema-fingerprint.ts +52 -3
- package/src/cli/schema-introspect.ts +36 -0
- package/src/cli/state-apply.ts +227 -13
package/src/cli/state-apply.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
109
|
-
//
|
|
110
|
-
//
|
|
111
|
-
//
|
|
112
|
-
//
|
|
113
|
-
//
|
|
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,47 @@ 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
|
+
* `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[];
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/** A `VALIDATE CONSTRAINT` that did not pass — the key is installed but still unproven. */
|
|
410
|
+
export interface PendingValidation {
|
|
411
|
+
/** The exact statement to re-run once the data satisfies the key. */
|
|
412
|
+
sql: string;
|
|
413
|
+
/** PostgreSQL's message, trimmed for the record — or why the check was never attempted. */
|
|
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;
|
|
347
421
|
}
|
|
348
422
|
|
|
349
423
|
export interface StateApplyResult {
|
|
@@ -354,32 +428,117 @@ export interface StateApplyResult {
|
|
|
354
428
|
batchSql: string;
|
|
355
429
|
/** schema_log row id for this apply, when one was written. */
|
|
356
430
|
logId?: number;
|
|
431
|
+
/**
|
|
432
|
+
* Constraints left NOT VALID because their post-commit `VALIDATE` failed. The edge itself
|
|
433
|
+
* COMMITTED — this is not a rollback and must never be reported as one. Empty on a clean apply.
|
|
434
|
+
*/
|
|
435
|
+
pendingValidations: PendingValidation[];
|
|
357
436
|
}
|
|
358
437
|
|
|
359
438
|
/**
|
|
360
439
|
* Execute the executable subset of a generated diff as one batch and record
|
|
361
440
|
* it in schema_log. A failed batch rolls back (one implicit transaction),
|
|
362
441
|
* logs the failure best-effort, and rethrows.
|
|
442
|
+
*
|
|
443
|
+
* ORDER OF WRITES, and it is the point: batch → RECORD → checks → amend the record. The
|
|
444
|
+
* `VALIDATE CONSTRAINT` pass is the longest thing an apply does (a full scan per key) and the
|
|
445
|
+
* one a wall clock or a dropped connection actually kills. Recording after it meant that death
|
|
446
|
+
* left the DDL committed with nothing on the record — and a live state no commit on any ref
|
|
447
|
+
* declares reads to the NEXT apply as proven drift, which is the loudest possible wrong answer.
|
|
448
|
+
* So the row is written as soon as the batch has committed, describing exactly what is true then
|
|
449
|
+
* (the key installed, none of the checks run yet), and amended when the checks are over.
|
|
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).
|
|
363
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
|
+
|
|
364
478
|
export async function applyGeneratedStatements(
|
|
365
479
|
runner: QueryRunner,
|
|
366
480
|
statements: string[],
|
|
367
481
|
options: StateApplyOptions = {},
|
|
368
482
|
): Promise<StateApplyResult> {
|
|
369
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 ?? [])];
|
|
370
488
|
if (classified.executable.length === 0) {
|
|
371
|
-
|
|
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 };
|
|
372
521
|
}
|
|
373
522
|
|
|
374
523
|
const now = options.now ?? Date.now;
|
|
375
524
|
await runner(ENSURE_RECONCILER_SQL.join(';\n'));
|
|
376
525
|
|
|
526
|
+
// The row checks run AFTER the edge commits — see splitValidations for why that is the whole
|
|
527
|
+
// point of the two-step add, and why a failed check is a pending state rather than a rollback.
|
|
528
|
+
const { batch, validations } = splitValidations(classified.executable);
|
|
529
|
+
|
|
377
530
|
// Semicolon on its own line: a statement whose last line is a comment
|
|
378
531
|
// cannot swallow the separator.
|
|
379
|
-
|
|
532
|
+
//
|
|
533
|
+
// `batchSql` is the batch that RAN IN THIS TRANSACTION — the checks are not in it. The record
|
|
534
|
+
// written below is a record of what committed, and the checks had not been attempted at that
|
|
535
|
+
// point; they join the row on the amendment, where their outcome is known.
|
|
536
|
+
const batchSql = batch.join('\n;\n');
|
|
537
|
+
const validationSql = validations.join('\n;\n');
|
|
538
|
+
const fullSql = [batchSql, validationSql].filter((s) => s.length > 0).join('\n;\n');
|
|
380
539
|
const started = now();
|
|
381
540
|
try {
|
|
382
|
-
await runner(batchSql);
|
|
541
|
+
if (batch.length > 0) await runner(batchSql);
|
|
383
542
|
} catch (err: any) {
|
|
384
543
|
try {
|
|
385
544
|
await runner(renderSchemaLogInsert({
|
|
@@ -395,16 +554,67 @@ export async function applyGeneratedStatements(
|
|
|
395
554
|
}
|
|
396
555
|
|
|
397
556
|
const note = options.descentNote?.();
|
|
557
|
+
// The edge APPLIED — that word is load-bearing (the descent ledger matches `applied%`, and the
|
|
558
|
+
// schema changes really are committed). What is still unproven is named in the same line, so
|
|
559
|
+
// nobody reads a half-proven key as a finished one.
|
|
560
|
+
const outcomeWith = (pending: number): string =>
|
|
561
|
+
['applied', note, pending > 0 ? `${pending} constraint(s) PENDING validation` : undefined]
|
|
562
|
+
.filter(Boolean)
|
|
563
|
+
.join(' — ');
|
|
564
|
+
|
|
565
|
+
// The hash of what just committed, read now rather than predicted. Only when checks follow:
|
|
566
|
+
// with none, the record goes in the same breath as the commit (as it always has) and the
|
|
567
|
+
// caller's own post-apply introspection stamps it — reading it twice would buy nothing and
|
|
568
|
+
// cost a full introspection on every apply. Best-effort: a fingerprint that cannot be read
|
|
569
|
+
// must not cost us the record.
|
|
570
|
+
let landedAfterBatch: string | undefined;
|
|
571
|
+
if (validations.length > 0 && options.readFingerprint) {
|
|
572
|
+
try {
|
|
573
|
+
landedAfterBatch = await options.readFingerprint();
|
|
574
|
+
} catch { /* the record matters more than the stamp */ }
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
// THE WITNESS, written before the long step. Every check is still outstanding here, and the
|
|
578
|
+
// outcome says exactly that — this row is true at the instant it is written, not a prediction
|
|
579
|
+
// of how the checks will go.
|
|
398
580
|
const logRows = await runner(renderSchemaLogInsert({
|
|
399
|
-
kind: 'state apply', sql: batchSql, outcome:
|
|
581
|
+
kind: 'state apply', sql: batchSql, outcome: outcomeWith(validations.length + skipped.length),
|
|
400
582
|
actor: options.actor, gitRef: options.gitRef, planRef: options.planRef,
|
|
401
583
|
fromFingerprint: options.fromFingerprint,
|
|
584
|
+
...(landedAfterBatch ? { toFingerprint: landedAfterBatch } : {}),
|
|
402
585
|
fpVersion: options.fpVersion ?? FINGERPRINT_VERSION, dirty: options.dirty ?? false,
|
|
403
586
|
durationMs: now() - started,
|
|
404
587
|
}));
|
|
405
588
|
const logId = logRows?.[0]?.id !== undefined ? Number(logRows[0].id) : undefined;
|
|
406
589
|
|
|
407
|
-
|
|
590
|
+
// Every check is attempted, even after one fails: the operator is owed the COMPLETE list of
|
|
591
|
+
// keys still unproven, not the first one. Each runs on its own, so one failure cannot take a
|
|
592
|
+
// sibling's successful validation down with it.
|
|
593
|
+
const pendingValidations: PendingValidation[] = [...skipped];
|
|
594
|
+
for (const statement of validations) {
|
|
595
|
+
try {
|
|
596
|
+
await runner(statement);
|
|
597
|
+
} catch (err: any) {
|
|
598
|
+
pendingValidations.push({ sql: statement, error: String(err?.message ?? err).slice(0, 500) });
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
// The amendment: the SAME row, finished. A second `state apply` row would put two applies in
|
|
603
|
+
// the ledger for one edge — and the descent witness reads that ledger. So the checks and their
|
|
604
|
+
// result land on the row that already describes this apply, never beside it.
|
|
605
|
+
if (validations.length > 0 && logId !== undefined) {
|
|
606
|
+
try {
|
|
607
|
+
await runner(renderSchemaLogCompletionUpdate(logId, {
|
|
608
|
+
outcome: outcomeWith(pendingValidations.length),
|
|
609
|
+
sql: fullSql,
|
|
610
|
+
durationMs: now() - started,
|
|
611
|
+
}));
|
|
612
|
+
} catch { /* the memoir is best-effort; the row already says what committed */ }
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
await recordPendingValidations(runner, pendingValidations, options);
|
|
616
|
+
|
|
617
|
+
return { applied: true, classified, batchSql, pendingValidations, ...(logId !== undefined ? { logId } : {}) };
|
|
408
618
|
}
|
|
409
619
|
|
|
410
620
|
// ---------------------------------------------------------------------------
|
|
@@ -433,6 +643,9 @@ export interface StateSyncOutcome {
|
|
|
433
643
|
toFingerprint: string;
|
|
434
644
|
/** Executable statements still differing after apply — empty is the bar. */
|
|
435
645
|
remaining: string[];
|
|
646
|
+
/** Constraints whose post-commit `VALIDATE` failed: installed, enforced, still unproven. They
|
|
647
|
+
* also show up in `remaining` as a VALIDATE the next run re-emits. */
|
|
648
|
+
pendingValidations: PendingValidation[];
|
|
436
649
|
/** schema_log row id, when DDL ran. */
|
|
437
650
|
logId?: number;
|
|
438
651
|
/** Fresh post-apply introspection (the pre-apply one when nothing ran). */
|
|
@@ -463,7 +676,7 @@ export async function applyStateAndVerify(
|
|
|
463
676
|
if (classified.executable.length === 0) {
|
|
464
677
|
return {
|
|
465
678
|
statements, classified, applied: false,
|
|
466
|
-
fromFingerprint, toFingerprint: fromFingerprint, remaining: [],
|
|
679
|
+
fromFingerprint, toFingerprint: fromFingerprint, remaining: [], pendingValidations: [],
|
|
467
680
|
after: { snapshot: current, contract: liveAuthz },
|
|
468
681
|
};
|
|
469
682
|
}
|
|
@@ -484,6 +697,7 @@ export async function applyStateAndVerify(
|
|
|
484
697
|
return {
|
|
485
698
|
statements, classified, applied: true,
|
|
486
699
|
fromFingerprint, toFingerprint, remaining,
|
|
700
|
+
pendingValidations: result.pendingValidations,
|
|
487
701
|
...(result.logId !== undefined ? { logId: result.logId } : {}),
|
|
488
702
|
after: { snapshot, contract },
|
|
489
703
|
};
|