@everystack/cli 0.4.68 → 0.4.69
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/package.json +1 -1
- package/src/cli/deparse-normal.ts +12 -0
- package/src/cli/schema-diff.ts +68 -8
package/package.json
CHANGED
|
@@ -52,6 +52,18 @@
|
|
|
52
52
|
* the CI-pure `db:diff` lane does not have. That class earns a provable text rewrite here,
|
|
53
53
|
* verified end-to-end by `db:check`'s from-scratch build on a real database.
|
|
54
54
|
*
|
|
55
|
+
* PAREN DRIFT adjudicated 2026-08-24: a COMPOUND predicate deparses fully parenthesized
|
|
56
|
+
* (`((a) AND (b))`) while the author writes minimal parens — pure cross-PRODUCER syntax drift.
|
|
57
|
+
* The resolution is NOT a normalization rule here: text identity stays as-is, plus a pg-form
|
|
58
|
+
* authoring convention (declare the predicate as pg deparses it) and a PAREN-ONLY DETECTOR in
|
|
59
|
+
* schema-diff.ts (`stripAllParens`) that recognizes the churn and prints the exact live form to
|
|
60
|
+
* adopt. Do NOT add a precedence- or paren-aware text rule — a safe one needs a real lexer, and
|
|
61
|
+
* a lexer bug is a FALSE-MATCH vector (`(a OR b) AND c` vs `a OR (b AND c)` collapse equal). The
|
|
62
|
+
* NEXT syntax-drift class the authoring convention cannot absorb — one where NO author-writable
|
|
63
|
+
* text round-trips `pg_get_expr` — is the trigger to adopt libpg_query (AST identity); at that
|
|
64
|
+
* point the coercion rules (`::text`, array-cast distribution) MIGRATE to AST transforms, they
|
|
65
|
+
* do not disappear. Paren drift is not that class: the convention absorbs it completely.
|
|
66
|
+
*
|
|
55
67
|
* Applied at the PRODUCERS (live introspection's `predicate()`, the compiler's policy
|
|
56
68
|
* assembly), so every downstream comparison — the identity matcher, the canonical hash, the
|
|
57
69
|
* drift detail — sees normalized text without holding its own copy of this rule.
|
package/src/cli/schema-diff.ts
CHANGED
|
@@ -63,7 +63,7 @@ export type SchemaChange =
|
|
|
63
63
|
| { kind: 'setIdentity'; table: string; column: string; identity: 'always' | 'byDefault' | null; from: 'always' | 'byDefault' | null }
|
|
64
64
|
/** A type change → real `ALTER COLUMN ... TYPE`. `risk` decides USING + a data-loss warning. */
|
|
65
65
|
| { kind: 'alterType'; table: string; column: string; from: string; to: string; risk: TypeChangeRisk }
|
|
66
|
-
| { kind: 'addConstraint'; table: string; constraint: ConstraintSpec }
|
|
66
|
+
| { kind: 'addConstraint'; table: string; constraint: ConstraintSpec; note?: string }
|
|
67
67
|
| { kind: 'dropConstraint'; table: string; name: string }
|
|
68
68
|
| { kind: 'addPrimaryKey'; table: string; columns: string[] }
|
|
69
69
|
/** A PK change other than first-time add — a notice (the drop needs its constraint name). */
|
|
@@ -97,7 +97,7 @@ export type SchemaChange =
|
|
|
97
97
|
/** An enum value the DB has but the Model dropped/renamed — Postgres can't drop one in place (a notice). */
|
|
98
98
|
| { kind: 'enumValuesChanged'; name: string; from: string[]; to: string[] }
|
|
99
99
|
/** A standalone index to create → `CREATE [UNIQUE] INDEX … [USING …] (…) [INCLUDE …] [WHERE …]`. */
|
|
100
|
-
| { kind: 'createIndex'; table: string; name: string; columns: string[]; unique: boolean; where?: string; using?: string; include?: string[] }
|
|
100
|
+
| { kind: 'createIndex'; table: string; name: string; columns: string[]; unique: boolean; where?: string; using?: string; include?: string[]; note?: string }
|
|
101
101
|
/** A standalone index present in the DB but no longer declared → `DROP INDEX` (a removal — gated). */
|
|
102
102
|
| { kind: 'dropIndex'; table: string; name: string }
|
|
103
103
|
/** A content-identical index the model names differently → `ALTER INDEX … RENAME TO` (B3). */
|
|
@@ -339,6 +339,34 @@ export function normalizeCheck(expr: string): string {
|
|
|
339
339
|
return s.replace(/\s+/g, ' ');
|
|
340
340
|
}
|
|
341
341
|
|
|
342
|
+
/**
|
|
343
|
+
* Strip EVERY parenthesis and collapse whitespace. DIAGNOSTIC-ONLY — never an identity or
|
|
344
|
+
* matcher input. Blind paren removal is UNSAFE for identity: it crosses operator precedence,
|
|
345
|
+
* so `(a OR b) AND c` and `a OR (b AND c)` collapse to the same text (a false match, the one
|
|
346
|
+
* disaster the matcher must avoid). It is safe HERE because it only recognizes that a diff is
|
|
347
|
+
* paren-only, to explain the churn — a wrong guess at worst suggests adopting the live text,
|
|
348
|
+
* which always converges. Quote-naive for the same reason (see docs; adjudicated 2026-08-24).
|
|
349
|
+
*/
|
|
350
|
+
export function stripAllParens(expr: string): string {
|
|
351
|
+
return expr.replace(/[()]/g, ' ').replace(/\s+/g, ' ').trim();
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* When a predicate diff is PAREN-ONLY, the churn is not a real change — Postgres stores
|
|
356
|
+
* predicates fully parenthesized while the model author writes minimal parens. The fix is to
|
|
357
|
+
* declare the live form; this note hands the operator the exact text to paste. `kind` is
|
|
358
|
+
* "index"/"check", `name` the declared object, `liveName` the live object, `liveExpr` the
|
|
359
|
+
* predicate to adopt.
|
|
360
|
+
*/
|
|
361
|
+
function parenOnlyNote(kind: string, name: string, liveName: string, liveExpr: string): string {
|
|
362
|
+
// Hedged deliberately. The paren-blind match cannot tell redundant parens from a real
|
|
363
|
+
// PRECEDENCE edit — `a OR (b AND c)` and `(a OR b) AND c` strip to the same text. So the
|
|
364
|
+
// note must NOT assert "this is not a change": if the operator DID intend a logic change,
|
|
365
|
+
// adopting the live form would silently revert it. The "if you did not intend" clause is
|
|
366
|
+
// true in both cases and points the common (redundant-paren) case at the converging fix.
|
|
367
|
+
return `-- NOTE: ${kind} "${name}" differs from the live ${kind} "${liveName}" by parenthesization. If you did NOT intend a change to the predicate's logic, this is churn — Postgres stores predicates fully parenthesized, so declare the live form to converge: ${liveExpr}`;
|
|
368
|
+
}
|
|
369
|
+
|
|
342
370
|
// ---------------------------------------------------------------------------
|
|
343
371
|
// Diff.
|
|
344
372
|
// ---------------------------------------------------------------------------
|
|
@@ -532,13 +560,19 @@ export function diffSchema(desired: SchemaSnapshot, current: SchemaSnapshot, ren
|
|
|
532
560
|
];
|
|
533
561
|
}
|
|
534
562
|
|
|
535
|
-
/** A `createIndex` change from an index spec (the new-table path and the per-table diff share it).
|
|
536
|
-
|
|
563
|
+
/** A `createIndex` change from an index spec (the new-table path and the per-table diff share it).
|
|
564
|
+
* `parenTwin` is a live index that matches this one on everything but parenthesization — when
|
|
565
|
+
* present, the create carries an advisory note pointing at the live form to adopt. */
|
|
566
|
+
function createIndexChange(table: string, ix: IndexSchema, parenTwin?: IndexSchema): SchemaChange {
|
|
567
|
+
const note = parenTwin?.where
|
|
568
|
+
? parenOnlyNote('index', ix.name, parenTwin.name, `WHERE ${parenTwin.where}`)
|
|
569
|
+
: undefined;
|
|
537
570
|
return {
|
|
538
571
|
kind: 'createIndex', table, name: ix.name, columns: ix.columns, unique: ix.unique,
|
|
539
572
|
...(ix.where ? { where: ix.where } : {}),
|
|
540
573
|
...(ix.using ? { using: ix.using } : {}),
|
|
541
574
|
...(ix.include?.length ? { include: ix.include } : {}),
|
|
575
|
+
...(note ? { note } : {}),
|
|
542
576
|
};
|
|
543
577
|
}
|
|
544
578
|
|
|
@@ -554,6 +588,14 @@ export function indexKey(ix: IndexSchema): string {
|
|
|
554
588
|
return `${ix.unique ? 'u' : ''}|${entries}|${normalizeCheck(ix.where ?? '')}|${ix.using ?? 'btree'}|${(ix.include ?? []).join(',')}`;
|
|
555
589
|
}
|
|
556
590
|
|
|
591
|
+
/** The same key with EVERY paren stripped — DIAGNOSTIC-ONLY (see {@link stripAllParens}).
|
|
592
|
+
* Two indexes with the same paren-blind key but different {@link indexKey} differ by
|
|
593
|
+
* parenthesization alone, which is churn, not a change. Never used for matching/identity. */
|
|
594
|
+
function parenBlindIndexKey(ix: IndexSchema): string {
|
|
595
|
+
const entries = ix.columns.map((c) => stripAllParens(c)).join(',');
|
|
596
|
+
return `${ix.unique ? 'u' : ''}|${entries}|${stripAllParens(ix.where ?? '')}|${ix.using ?? 'btree'}|${(ix.include ?? []).join(',')}`;
|
|
597
|
+
}
|
|
598
|
+
|
|
557
599
|
/**
|
|
558
600
|
* Diff standalone indexes by CONTENT — an index with the same key entries, uniqueness, predicate,
|
|
559
601
|
* method and INCLUDE is the same index whatever it is called, so a pulled index re-generates
|
|
@@ -580,6 +622,11 @@ function diffIndexes(
|
|
|
580
622
|
): void {
|
|
581
623
|
const desiredByKey = new Map(d.indexes.map((ix) => [indexKey(ix), ix]));
|
|
582
624
|
const currentByKey = new Map(c.indexes.map((ix) => [indexKey(ix), ix]));
|
|
625
|
+
// Paren-blind lookup: a desired index with no exact match may still have a live twin that
|
|
626
|
+
// differs only by parenthesization (Postgres deparses predicates fully parenthesized). That
|
|
627
|
+
// is churn, not a change; the create carries a note pointing at the live form. Diagnostic
|
|
628
|
+
// only — this map never decides identity.
|
|
629
|
+
const currentByParenBlind = new Map(c.indexes.map((ix) => [parenBlindIndexKey(ix), ix]));
|
|
583
630
|
// A same-NAME index whose CONTENT changed is a redefinition, not a removal, and PostgreSQL has
|
|
584
631
|
// no CREATE OR REPLACE INDEX — it must drop first. Reported by GridironDB: a full unique index
|
|
585
632
|
// became two partial ones, the new partial index derived the SAME generated name as the live
|
|
@@ -597,7 +644,11 @@ function diffIndexes(
|
|
|
597
644
|
for (const [k, ix] of desiredByKey) {
|
|
598
645
|
const live = currentByKey.get(k);
|
|
599
646
|
if (!live) {
|
|
600
|
-
|
|
647
|
+
const twin = currentByParenBlind.get(parenBlindIndexKey(ix));
|
|
648
|
+
// Only annotate a twin that is actually being dropped (no exact desired match), so the
|
|
649
|
+
// note names a live index that is really churning, not one that also round-trips.
|
|
650
|
+
const churning = twin && !desiredByKey.has(indexKey(twin));
|
|
651
|
+
creates.push(createIndexChange(d.table, ix, churning ? twin : undefined));
|
|
601
652
|
} else if (ix.nameDeclared && live.name !== ix.name) {
|
|
602
653
|
renames.push({ kind: 'renameIndex', table: d.table, from: live.name, to: ix.name });
|
|
603
654
|
}
|
|
@@ -749,11 +800,19 @@ function constraintContentKey(c: ConstraintSpec): string {
|
|
|
749
800
|
function diffChecks(d: TableSchema, c: TableSchema, dropConstraints: SchemaChange[], addConstraints: SchemaChange[]): void {
|
|
750
801
|
const desiredByPred = new Map(d.checks.map((ck) => [normalizeCheck(ck.expr), ck]));
|
|
751
802
|
const currentByPred = new Map(c.checks.map((ck) => [normalizeCheck(ck.expr), ck]));
|
|
803
|
+
// Paren-blind lookup, diagnostic only (see diffIndexes): a compound CHECK churns for the
|
|
804
|
+
// same reason — pg stores `((a) AND (b))`, the model writes `a AND b`.
|
|
805
|
+
const currentByParenBlind = new Map(c.checks.map((ck) => [stripAllParens(ck.expr), ck]));
|
|
752
806
|
for (const [pred, ck] of currentByPred) {
|
|
753
807
|
if (!desiredByPred.has(pred)) dropConstraints.push({ kind: 'dropConstraint', table: d.table, name: ck.name });
|
|
754
808
|
}
|
|
755
809
|
for (const [pred, ck] of desiredByPred) {
|
|
756
|
-
if (
|
|
810
|
+
if (currentByPred.has(pred)) continue;
|
|
811
|
+
const twin = currentByParenBlind.get(stripAllParens(ck.expr));
|
|
812
|
+
// Annotate only a twin that is itself being dropped (no exact desired match) — a real churn.
|
|
813
|
+
const churning = twin && !desiredByPred.has(normalizeCheck(twin.expr));
|
|
814
|
+
const note = churning ? parenOnlyNote('check', ck.name, twin.name, twin.expr) : undefined;
|
|
815
|
+
addConstraints.push({ kind: 'addConstraint', table: d.table, constraint: { type: 'check', name: ck.name, expr: ck.expr }, ...(note ? { note } : {}) });
|
|
757
816
|
}
|
|
758
817
|
}
|
|
759
818
|
|
|
@@ -937,7 +996,7 @@ function emitOne(change: SchemaChange, opts: EmitOptions): string {
|
|
|
937
996
|
return `${warn}\n${ddl}`;
|
|
938
997
|
}
|
|
939
998
|
case 'addConstraint':
|
|
940
|
-
return addConstraintSql(change.table, change.constraint);
|
|
999
|
+
return (change.note ? `${change.note}\n` : '') + addConstraintSql(change.table, change.constraint);
|
|
941
1000
|
case 'dropConstraint':
|
|
942
1001
|
return `ALTER TABLE ${change.table} DROP CONSTRAINT IF EXISTS ${quote(change.name)};`;
|
|
943
1002
|
case 'addPrimaryKey':
|
|
@@ -983,7 +1042,8 @@ function emitOne(change: SchemaChange, opts: EmitOptions): string {
|
|
|
983
1042
|
const entry = (c: string): string => (/^[a-z_][a-z0-9_$]*$/i.test(c) ? quote(c) : c);
|
|
984
1043
|
const include = change.include?.length ? ` INCLUDE (${colList(change.include)})` : '';
|
|
985
1044
|
const where = change.where ? ` WHERE ${change.where}` : '';
|
|
986
|
-
|
|
1045
|
+
const stmt = `CREATE ${unique}INDEX ${quote(change.name)} ON ${change.table}${using} (${change.columns.map(entry).join(', ')})${include}${where};`;
|
|
1046
|
+
return change.note ? `${change.note}\n${stmt}` : stmt;
|
|
987
1047
|
}
|
|
988
1048
|
case 'dropIndex': {
|
|
989
1049
|
const schema = change.table.includes('.') ? `${change.table.split('.')[0]}.` : '';
|