@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/cli",
3
- "version": "0.4.68",
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>",
@@ -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.
@@ -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]}.` : '';