@everystack/cli 0.4.67 → 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 +84 -5
- package/src/cli/schema-diff.ts +68 -8
package/package.json
CHANGED
|
@@ -24,13 +24,45 @@
|
|
|
24
24
|
* for n ≥ 1. PostgreSQL defines an array-to-array cast element-wise (parse_coerce), so
|
|
25
25
|
* the two expressions denote the same value for every input; wrapping an element in
|
|
26
26
|
* parentheses is parse-neutral. The empty array is EXCLUDED: `ARRAY[]` without a cast has
|
|
27
|
-
* no type, so its cast is load-bearing and stays.
|
|
27
|
+
* no type, so its cast is load-bearing and stays. (The measured CROSS-VERSION case: two PG
|
|
28
|
+
* majors const-fold the same DDL differently.)
|
|
29
|
+
*
|
|
30
|
+
* LITERAL-TEXT-CAST STRIP: `'lit'::text == 'lit'`, but ONLY the bare `text` cast applied
|
|
31
|
+
* directly to a single-quoted string literal. This is a CROSS-PRODUCER class, present on
|
|
32
|
+
* every version: PostgreSQL coerces an unknown-type string literal compared to a text
|
|
33
|
+
* column and RECORDS `'lit'::text` in the stored tree, while the author's model source
|
|
34
|
+
* writes the un-cast `'lit'`. The two producers' texts never converge without this rule, so
|
|
35
|
+
* a `text_col = 'lit'` partial index / CHECK / policy re-diffs DROP+CREATE forever and the
|
|
36
|
+
* fingerprint gate cannot MATCH. Safe because `text` takes no typmod and its input function
|
|
37
|
+
* is the identity on the literal's bytes — the cast cannot change the value. The rule is
|
|
38
|
+
* `text`-ONLY for exactly that reason: `bpchar`/`character(n)` blank-pad or blank-trim,
|
|
39
|
+
* `varchar(n)`/`"char"` truncate — those casts CHANGE the value, so stripping one would
|
|
40
|
+
* collapse two opposite-truth predicates to equal text (a false match, the disaster). The
|
|
41
|
+
* array-type cast `::text[]` is likewise excluded — it carries the element type. See the
|
|
42
|
+
* `char(2)` false-match test for why the family is not widened.
|
|
28
43
|
*
|
|
29
44
|
* Anything the scanner does not positively recognize is left byte-for-byte unchanged — an
|
|
30
45
|
* unrecognized spelling is a MISS (drop + create, the pre-existing behavior), never a guess.
|
|
31
|
-
* Escalation is named, not implied: the SECOND cross-
|
|
46
|
+
* Escalation is named, not implied: the SECOND cross-VERSION variant found in the field is
|
|
32
47
|
* the trigger to adopt libpg_query and move identity onto normalized ASTs (Ty, 2026-08-09) —
|
|
33
|
-
* a growing rule table over raw text is where scanning stops being defensible.
|
|
48
|
+
* a growing rule table over raw text is where scanning stops being defensible. One correction
|
|
49
|
+
* to that trigger (2026-08-24): libpg_query closes cross-VERSION SYNTAX drift (whitespace,
|
|
50
|
+
* parens, keyword case), NOT cross-PRODUCER COERCION drift — a raw parse tree carries no type
|
|
51
|
+
* resolution, so `'lit'` and `'lit'::text` still differ structurally without a catalog, which
|
|
52
|
+
* the CI-pure `db:diff` lane does not have. That class earns a provable text rewrite here,
|
|
53
|
+
* verified end-to-end by `db:check`'s from-scratch build on a real database.
|
|
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.
|
|
34
66
|
*
|
|
35
67
|
* Applied at the PRODUCERS (live introspection's `predicate()`, the compiler's policy
|
|
36
68
|
* assembly), so every downstream comparison — the identity matcher, the canonical hash, the
|
|
@@ -121,6 +153,53 @@ function distributeOnce(s: string): string | null {
|
|
|
121
153
|
return null;
|
|
122
154
|
}
|
|
123
155
|
|
|
156
|
+
/**
|
|
157
|
+
* A bare `text` cast token — `::text` or `::pg_catalog.text` — from just past a literal's
|
|
158
|
+
* closing quote. Whitespace around `::` is tolerated (pg emits none; an author might). The
|
|
159
|
+
* trailing `\b` rejects `::textfoo`; the caller additionally rejects a following `[` so the
|
|
160
|
+
* array-type cast `::text[]` is never stripped.
|
|
161
|
+
*/
|
|
162
|
+
const LITERAL_TEXT_CAST = /^\s*::\s*(?:pg_catalog\.)?text\b/;
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Strip a bare `text` cast applied directly to a single-quoted string literal, wherever one
|
|
166
|
+
* appears. One left-to-right pass is complete: removing a cast token cannot create a new
|
|
167
|
+
* `'literal'::text` pair to its left, and it never forms an `(ARRAY[…])::T[]` for the other
|
|
168
|
+
* rule. Quoted regions are skipped so a `::text` inside a literal is content, not structure.
|
|
169
|
+
*/
|
|
170
|
+
function stripLiteralTextCast(s: string): string {
|
|
171
|
+
let out = '';
|
|
172
|
+
let i = 0;
|
|
173
|
+
while (i < s.length) {
|
|
174
|
+
const c = s[i];
|
|
175
|
+
if (c === "'") {
|
|
176
|
+
const close = skipQuoted(s, i);
|
|
177
|
+
if (close < 0) return out + s.slice(i); // unterminated literal — leave the rest alone
|
|
178
|
+
out += s.slice(i, close + 1); // the literal itself, verbatim
|
|
179
|
+
i = close + 1;
|
|
180
|
+
// Drop a bare text cast on the literal, and any stacked `::text::text`, so the result
|
|
181
|
+
// is a true fixpoint. `::text[]` is the array-type cast (element type, load-bearing),
|
|
182
|
+
// not a scalar text cast — the look-ahead skips whitespace so `::text []` is kept too.
|
|
183
|
+
let m = LITERAL_TEXT_CAST.exec(s.slice(i));
|
|
184
|
+
while (m && !/^\s*\[/.test(s.slice(i + m[0].length))) {
|
|
185
|
+
i += m[0].length; // drop the cast token
|
|
186
|
+
m = LITERAL_TEXT_CAST.exec(s.slice(i));
|
|
187
|
+
}
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
if (c === '"') {
|
|
191
|
+
const close = skipQuoted(s, i);
|
|
192
|
+
if (close < 0) return out + s.slice(i);
|
|
193
|
+
out += s.slice(i, close + 1); // a quoted identifier — never a string literal, skip whole
|
|
194
|
+
i = close + 1;
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
out += c;
|
|
198
|
+
i++;
|
|
199
|
+
}
|
|
200
|
+
return out;
|
|
201
|
+
}
|
|
202
|
+
|
|
124
203
|
/**
|
|
125
204
|
* Normalize one deparsed expression. Idempotent; unrecognized text returns unchanged.
|
|
126
205
|
* Fixpoint-bounded: nesting deeper than the cap returns its progress, which is still
|
|
@@ -130,8 +209,8 @@ export function normalizeDeparsedExpr(text: string): string {
|
|
|
130
209
|
let s = text;
|
|
131
210
|
for (let n = 0; n < 64; n++) {
|
|
132
211
|
const next = distributeOnce(s);
|
|
133
|
-
if (next === null)
|
|
212
|
+
if (next === null) break;
|
|
134
213
|
s = next;
|
|
135
214
|
}
|
|
136
|
-
return s;
|
|
215
|
+
return stripLiteralTextCast(s);
|
|
137
216
|
}
|
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]}.` : '';
|