@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/cli",
3
- "version": "0.4.67",
3
+ "version": "0.4.69",
4
4
  "description": "CLI and OTA updates for Expo apps on everystack",
5
5
  "license": "AGPL-3.0-only",
6
6
  "author": "Scalable Technology, Inc. <licensing@scalable.technology>",
@@ -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-version variant found in the field is
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) return s;
212
+ if (next === null) break;
134
213
  s = next;
135
214
  }
136
- return s;
215
+ return stripLiteralTextCast(s);
137
216
  }
@@ -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
- function createIndexChange(table: string, ix: IndexSchema): SchemaChange {
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
- creates.push(createIndexChange(d.table, ix));
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 (!currentByPred.has(pred)) addConstraints.push({ kind: 'addConstraint', table: d.table, constraint: { type: 'check', name: ck.name, expr: ck.expr } });
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
- return `CREATE ${unique}INDEX ${quote(change.name)} ON ${change.table}${using} (${change.columns.map(entry).join(', ')})${include}${where};`;
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]}.` : '';