@everystack/cli 0.4.99 → 0.4.100

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 CHANGED
@@ -4,6 +4,20 @@ All notable changes to `@everystack/cli` are documented here.
4
4
 
5
5
  Format based on [Keep a Changelog](https://keepachangelog.com/).
6
6
 
7
+ ## [0.4.100] - 2026-10-16
8
+
9
+ ### Added
10
+
11
+ - check() takes an explicit constraint name; db:pull keeps a live name
12
+
13
+ ### Fixed
14
+
15
+ - db:apply runs a rename-only plan
16
+
17
+ ### Documentation
18
+
19
+ - a check folded into .validate() takes the field check name
20
+
7
21
  ## [0.4.99] - 2026-10-14
8
22
 
9
23
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/cli",
3
- "version": "0.4.99",
3
+ "version": "0.4.100",
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>",
@@ -145,7 +145,7 @@
145
145
  "structured-headers": "1.0.1",
146
146
  "tsx": "4.21.0",
147
147
  "typescript": "5.9.3",
148
- "@everystack/model": "0.4.21"
148
+ "@everystack/model": "0.4.22"
149
149
  },
150
150
  "peerDependencies": {
151
151
  "@everystack/server": ">=0.4.0",
@@ -73,6 +73,7 @@ import {
73
73
  import { validationPendingReason, pendingValidationsAreSafe } from './apply-refusals';
74
74
  import { ENSURE_RECONCILER_SQL, renderSchemaLogInsert, renderSchemaLogFingerprintUpdate } from './derived-apply';
75
75
  import { checkPlanPrecondition, planHash, defaultAclHash, type EdgePlan } from './edge-plan';
76
+ import { renameEdgeVerdict } from './catalog-rename';
76
77
  import type { DescentLedgerRow, LedgerRead } from './git-descent';
77
78
 
78
79
  /**
@@ -477,7 +478,20 @@ export async function executeApplyPlan(
477
478
  }
478
479
 
479
480
  if (live === plan.toFingerprint) {
480
- return { status: 'already-applied', liveFingerprint: live };
481
+ // The hash is blind to a catalog rename (a check is keyed by predicate, an index or key by
482
+ // content), so a plan of renames alone has from == to == live whether or not it ran. Only
483
+ // then do the names decide, read from the same snapshot as the lock. Not landed: fall
484
+ // through, and every gate below runs. Half landed, or in a shape the plan never predicted:
485
+ // stale. Everything else is today's idempotent re-apply.
486
+ const verdict = plan.fromFingerprint === plan.toFingerprint
487
+ ? renameEdgeVerdict(plan.statements, before)
488
+ : { verdict: 'not-renames' as const };
489
+ if (verdict.verdict === 'stale') {
490
+ const reason = `this plan is stale: ${verdict.reason}. Re-mint with db:plan.`;
491
+ await recordRefusal(runner, plan, live, 'rename edge', reason, options);
492
+ return { status: 'refused', liveFingerprint: live, reason };
493
+ }
494
+ if (verdict.verdict !== 'unsettled') return { status: 'already-applied', liveFingerprint: live };
481
495
  }
482
496
  const pre = checkPlanPrecondition(plan, live);
483
497
  if (!pre.ok) {
@@ -0,0 +1,115 @@
1
+ /**
2
+ * catalog-rename — the catalog-only renames the planner emits, their parser, and the verdict
3
+ * db:apply reads from them.
4
+ *
5
+ * A rename of a check, a foreign key or an index changes no fingerprint: a check is hashed by its
6
+ * predicate and an index or key by its content. So a plan whose ONLY change is such a rename has
7
+ * from == to == live, and the endpoint cannot tell a rename that ran from one that did not. The
8
+ * live names can. The renderer and the parser live side by side here so they cannot drift; the
9
+ * parser accepts exactly what the renderer writes and nothing else.
10
+ */
11
+
12
+ import type { SchemaSnapshot } from './schema-introspect';
13
+ import { isPureComment } from './sql-statement';
14
+
15
+ /** The same quoting schema-diff uses for every identifier it emits. */
16
+ const quote = (name: string): string => `"${name}"`;
17
+
18
+ /** `public.orders` → `public.`; an unqualified table carries no qualifier. */
19
+ const schemaPrefix = (table: string): string => (table.includes('.') ? `${table.split('.')[0]}.` : '');
20
+
21
+ export interface RenameChange {
22
+ /** Schema-qualified, as the snapshot spells it (`public.orders`). */
23
+ table: string;
24
+ from: string;
25
+ to: string;
26
+ }
27
+
28
+ /** `ALTER TABLE … RENAME CONSTRAINT` — catalog-only and instant; the constraint keeps its identity. */
29
+ export function renderConstraintRename(change: RenameChange): string {
30
+ return `ALTER TABLE ${change.table} RENAME CONSTRAINT ${quote(change.from)} TO ${quote(change.to)};`;
31
+ }
32
+
33
+ /** `ALTER INDEX … RENAME TO` — no rebuild. The schema qualifies the OLD name only. */
34
+ export function renderIndexRename(change: RenameChange): string {
35
+ return `ALTER INDEX ${schemaPrefix(change.table)}${quote(change.from)} RENAME TO ${quote(change.to)};`;
36
+ }
37
+
38
+ export type CatalogRename =
39
+ | { kind: 'constraint'; table: string; from: string; to: string }
40
+ /** ALTER INDEX names no table — an index name is unique within its schema, so the schema is the scope. */
41
+ | { kind: 'index'; schema: string; from: string; to: string };
42
+
43
+ const IDENT = '"([^"]*)"';
44
+ const CONSTRAINT_RE = new RegExp(`^ALTER TABLE (\\S+) RENAME CONSTRAINT ${IDENT} TO ${IDENT};$`);
45
+ const INDEX_RE = new RegExp(`^ALTER INDEX (?:([^\\s."]+)\\.)?${IDENT} RENAME TO ${IDENT};$`);
46
+
47
+ /** The rename a statement is, if it is exactly one the renderers above produce; otherwise null. */
48
+ export function parseCatalogRename(sql: string): CatalogRename | null {
49
+ const c = CONSTRAINT_RE.exec(sql);
50
+ if (c) return { kind: 'constraint', table: c[1], from: c[2], to: c[3] };
51
+ const i = INDEX_RE.exec(sql);
52
+ // An unqualified ALTER INDEX resolves through search_path; the renderer only writes one for an
53
+ // unqualified table, which the snapshot spells as `public`.
54
+ if (i) return { kind: 'index', schema: i[1] ?? 'public', from: i[2], to: i[3] };
55
+ return null;
56
+ }
57
+
58
+ export type RenameEdgeVerdict =
59
+ /** Empty, or some statement is not a recognized rename — the names have nothing to say. */
60
+ | { verdict: 'not-renames' }
61
+ /** Every rename already landed: `to` present, `from` absent. */
62
+ | { verdict: 'settled' }
63
+ /** No rename has landed: `from` present, `to` absent. The edge still has to run. */
64
+ | { verdict: 'unsettled' }
65
+ /** Anything else — the plan describes neither the database before it nor after it. */
66
+ | { verdict: 'stale'; reason: string };
67
+
68
+ /**
69
+ * Where each rename in the edge stands in the BEFORE snapshot (the one the lock was computed
70
+ * from). Only an edge made entirely of recognized renames gets a verdict other than
71
+ * `not-renames`; one rename in an odd state, or a mix of landed and not, makes the plan stale.
72
+ */
73
+ export function renameEdgeVerdict(statements: readonly string[], before: SchemaSnapshot): RenameEdgeVerdict {
74
+ // A comment is never run, and the planner writes them beside real changes (an inert
75
+ // renamedFrom notice, a held drop). They must not hide a rename edge from the names.
76
+ const real = statements.filter((sql) => !isPureComment(sql));
77
+ if (real.length === 0) return { verdict: 'not-renames' };
78
+ const renames: CatalogRename[] = [];
79
+ for (const sql of real) {
80
+ const rename = parseCatalogRename(sql);
81
+ if (!rename) return { verdict: 'not-renames' };
82
+ renames.push(rename);
83
+ }
84
+
85
+ const settled: string[] = [];
86
+ const unsettled: string[] = [];
87
+ for (const rename of renames) {
88
+ let present: Set<string>;
89
+ let where: string;
90
+ if (rename.kind === 'constraint') {
91
+ const table = before.tables.find((t) => t.table === rename.table);
92
+ if (!table) return { verdict: 'stale', reason: `table ${rename.table} does not exist` };
93
+ present = new Set([...table.checks, ...table.foreignKeys, ...table.uniques].map((c) => c.name));
94
+ where = `on ${rename.table}`;
95
+ } else {
96
+ present = new Set(before.tables
97
+ .filter((t) => t.table.startsWith(`${rename.schema}.`))
98
+ .flatMap((t) => t.indexes.map((ix) => ix.name)));
99
+ where = `in schema ${rename.schema}`;
100
+ }
101
+ const hasFrom = present.has(rename.from);
102
+ const hasTo = present.has(rename.to);
103
+ const label = `"${rename.from}" → "${rename.to}"`;
104
+ if (hasTo && !hasFrom) settled.push(label);
105
+ else if (hasFrom && !hasTo) unsettled.push(label);
106
+ else if (hasFrom && hasTo) return { verdict: 'stale', reason: `both "${rename.from}" and "${rename.to}" exist ${where}` };
107
+ else return { verdict: 'stale', reason: `neither "${rename.from}" nor "${rename.to}" exists ${where}` };
108
+ }
109
+ if (unsettled.length === 0) return { verdict: 'settled' };
110
+ if (settled.length === 0) return { verdict: 'unsettled' };
111
+ return {
112
+ verdict: 'stale',
113
+ reason: `some of its renames are already applied (${settled.join(', ')}) and some are not (${unsettled.join(', ')})`,
114
+ };
115
+ }
@@ -24,7 +24,7 @@ import { generatedIndexName, generatedForeignKeyName } from './schema-compile';
24
24
  // The soft-delete RULE itself — one boolean combination, shared with `defineModel`'s refusal.
25
25
  // Only the FACT EXTRACTION is duplicated here (this renderer holds text, not descriptors);
26
26
  // the rule is not, so the two surfaces cannot drift on when a declaration is required.
27
- import { needsSoftDeleteDeclaration, privilegesGrantDelete } from '@everystack/model';
27
+ import { needsSoftDeleteDeclaration, privilegesGrantDelete, positionalCheckName } from '@everystack/model';
28
28
 
29
29
  /**
30
30
  * Reverse a CHECK predicate back into the `.validate(z…)` that produced it — the inverse of
@@ -599,8 +599,18 @@ function renderConstraintsBlock(table: TableSchema, checks: CheckConstraint[], k
599
599
  for (const u of table.uniques) {
600
600
  if (u.columns.length > 1) items.push(`unique(${u.columns.map((c) => tsLiteral(toCamelCase(c))).join(', ')})`);
601
601
  }
602
+ // R9 — a check keeps the name the database has. Said only when it is NOT the positional name
603
+ // the compiler would give it: position counts only the checks rendered bare, exactly as the
604
+ // compiler counts only unnamed ones, so every pulled check compiles back to its live name.
605
+ let position = 0;
602
606
  for (const ck of checks) {
603
- items.push(`check(sql\`${normalizeCheck(ck.expr)}\`)`);
607
+ const predicate = `sql\`${normalizeCheck(ck.expr)}\``;
608
+ if (ck.name === positionalCheckName(bareName(table.table), position)) {
609
+ items.push(`check(${predicate})`);
610
+ position++;
611
+ } else {
612
+ items.push(`check(${predicate}, { name: ${tsLiteral(ck.name)} })`);
613
+ }
604
614
  }
605
615
  for (const ix of table.indexes) {
606
616
  // Plain entries render as field keys; canonical-text entries (expressions, DESC,
@@ -655,9 +665,15 @@ export function renderModelBlock(table: TableSchema, known: Set<string>, enums:
655
665
  const validates = new Map<string, string>();
656
666
  const columnNames = new Set(table.columns.map((c) => c.name));
657
667
  const tableChecks: CheckConstraint[] = [];
668
+ // A `.validate()` compiles to a CHECK named `<table>_<column>_check`. When a DIFFERENT live
669
+ // check already holds that name, folding this one into the field would give the model two
670
+ // constraints under one name, so it stays a table-level check and keeps its own.
671
+ const liveCheckNames = new Set(table.checks.map((ck) => ck.name));
658
672
  for (const ck of table.checks) {
659
673
  const v = checkToValidate(ck.expr);
660
- if (v && columnNames.has(v.column) && !validates.has(v.column)) validates.set(v.column, v.zod);
674
+ const fieldCheckName = v ? `${bareName(table.table)}_${v.column}_check` : '';
675
+ const nameFree = ck.name === fieldCheckName || !liveCheckNames.has(fieldCheckName);
676
+ if (v && columnNames.has(v.column) && !validates.has(v.column) && nameFree) validates.set(v.column, v.zod);
661
677
  else tableChecks.push(ck);
662
678
  }
663
679
 
@@ -20,6 +20,7 @@
20
20
  */
21
21
 
22
22
  import type { ModelDescriptor, FieldSpec, SequenceDescriptor } from '@everystack/model';
23
+ import { positionalCheckName } from '@everystack/model';
23
24
  import type { TableSchema, EnumType, SequenceSchema, UniqueConstraint, CheckConstraint, IndexSchema, ForeignKey } from './schema-introspect';
24
25
  import { nextvalSequence, identityClause, type RenameMap } from './schema-diff';
25
26
  import { normalizeDeparsedExpr } from './deparse-normal';
@@ -59,6 +60,13 @@ export function modelConstraintSpecs(model: ModelDescriptor): { uniques: UniqueC
59
60
  const indexes: IndexSchema[] = [];
60
61
  let checkIndex = 0;
61
62
  const usedIndexNames = new Set<string>();
63
+ // A field's `.validate()` CHECK is named `<table>_<column>_check`; a declared table check may
64
+ // not take that name, or CREATE TABLE fails with two constraints under one name.
65
+ const fieldCheckNames = new Map<string, string>();
66
+ for (const [key, field] of Object.entries(model.fields)) {
67
+ const col = toSnakeCase(key);
68
+ if (fieldCheckPredicate(col, field.spec)) fieldCheckNames.set(`${model.table}_${col}_check`, key);
69
+ }
62
70
  for (const con of model.constraints) {
63
71
  if (con.kind === 'unique') {
64
72
  const cols = con.columns.map(toSnakeCase);
@@ -67,7 +75,20 @@ export function modelConstraintSpecs(model: ModelDescriptor): { uniques: UniqueC
67
75
  // Normalized like the live producer (schema-introspect): a model that transcribes a
68
76
  // pulled predicate must compare equal to the database it came from, whichever of the
69
77
  // two equivalent deparse spellings each side happens to hold. See deparse-normal.ts.
70
- checks.push({ name: `${model.table}_check_${checkIndex++}`, expr: normalizeDeparsedExpr(con.predicate) });
78
+ //
79
+ // A DECLARED name is used verbatim and takes no position, so naming one check never
80
+ // renames its unnamed siblings; defineModel already refused duplicates and collisions
81
+ // with a positional name.
82
+ if (con.name !== undefined && fieldCheckNames.has(con.name)) {
83
+ throw new Error(
84
+ `model '${model.table}': check(sql\`${con.predicate}\`) is named '${con.name}', the name of the CHECK that field '${fieldCheckNames.get(con.name)}'.validate() compiles to. Choose another name.`,
85
+ );
86
+ }
87
+ checks.push({
88
+ name: con.name ?? positionalCheckName(model.table, checkIndex++),
89
+ expr: normalizeDeparsedExpr(con.predicate),
90
+ ...(con.name !== undefined ? { nameDeclared: true } : {}),
91
+ });
71
92
  } else if (con.kind === 'index') {
72
93
  // Plain entries are field keys → snake_cased; raw sql`` entries (expressions,
73
94
  // DESC, opclass) pass verbatim — they are already SQL (Brick E).
@@ -33,6 +33,7 @@ import type {
33
33
  IndexSchema,
34
34
  SequenceSchema,
35
35
  } from './schema-introspect';
36
+ import { renderConstraintRename, renderIndexRename } from './catalog-rename';
36
37
 
37
38
  // ---------------------------------------------------------------------------
38
39
  // The change set.
@@ -849,8 +850,12 @@ export function foreignKeyContentKey(
849
850
  * same predicate is the same check no matter what it's called, so a pulled check (whose DB
850
851
  * name we didn't choose) re-generates clean. A changed predicate drops the old (by its real
851
852
  * name) and adds the new.
853
+ *
854
+ * R9, the CHECK half of B3/A8: a predicate match whose DECLARED name disagrees with the live
855
+ * one is the same check under another name — `RENAME CONSTRAINT`, catalog-only, no re-scan.
856
+ * An undeclared (positional) name never renames the database.
852
857
  */
853
- function diffChecks(d: TableSchema, c: TableSchema, dropConstraints: SchemaChange[], addConstraints: SchemaChange[]): void {
858
+ function diffChecks(d: TableSchema, c: TableSchema, dropConstraints: SchemaChange[], addConstraints: SchemaChange[], renames: SchemaChange[]): void {
854
859
  const desiredByPred = new Map(d.checks.map((ck) => [normalizeCheck(ck.expr), ck]));
855
860
  const currentByPred = new Map(c.checks.map((ck) => [normalizeCheck(ck.expr), ck]));
856
861
  // Paren-blind lookup, diagnostic only (see diffIndexes): a compound CHECK churns for the
@@ -860,7 +865,11 @@ function diffChecks(d: TableSchema, c: TableSchema, dropConstraints: SchemaChang
860
865
  if (!desiredByPred.has(pred)) dropConstraints.push({ kind: 'dropConstraint', table: d.table, name: ck.name });
861
866
  }
862
867
  for (const [pred, ck] of desiredByPred) {
863
- if (currentByPred.has(pred)) continue;
868
+ const live = currentByPred.get(pred);
869
+ if (live) {
870
+ if (ck.nameDeclared && live.name !== ck.name) renames.push({ kind: 'renameConstraint', table: d.table, from: live.name, to: ck.name });
871
+ continue;
872
+ }
864
873
  const twin = currentByParenBlind.get(stripAllParens(ck.expr));
865
874
  // Annotate only a twin that is itself being dropped (no exact desired match) — a real churn.
866
875
  const churning = twin && !desiredByPred.has(normalizeCheck(twin.expr));
@@ -921,7 +930,7 @@ function diffConstraints(d: TableSchema, c: TableSchema, dropConstraints: Schema
921
930
  for (const cur of bucket) dropConstraints.push({ kind: 'dropConstraint', table: d.table, name: cur.name });
922
931
  }
923
932
 
924
- diffChecks(d, c, dropConstraints, addConstraints);
933
+ diffChecks(d, c, dropConstraints, addConstraints, renames);
925
934
 
926
935
  // Primary key: a first-time add is safe; any other change needs the existing
927
936
  // constraint's name to drop it (which the snapshot discards), so it is surfaced.
@@ -1170,18 +1179,17 @@ function emitOne(change: SchemaChange, opts: EmitOptions): string | string[] {
1170
1179
  }
1171
1180
  case 'renameConstraint':
1172
1181
  // Catalog-only and instant — the constraint and its backing index keep their identity.
1173
- return `ALTER TABLE ${change.table} RENAME CONSTRAINT ${quote(change.from)} TO ${quote(change.to)};`;
1182
+ // Rendered beside its parser: db:apply reads this exact text back (catalog-rename).
1183
+ return renderConstraintRename(change);
1174
1184
  case 'redefineIndex': {
1175
1185
  // Same statement as a removal; a DIFFERENT decision, which is why it is a different kind.
1176
1186
  const schema = change.table.includes('.') ? `${change.table.split('.')[0]}.` : '';
1177
1187
  return `DROP INDEX ${schema}${quote(change.name)};`;
1178
1188
  }
1179
- case 'renameIndex': {
1180
- // Non-destructive and instant — a catalog rename, no rebuild. The schema qualifier comes
1181
- // from the table; ALTER INDEX takes it on the OLD name only.
1182
- const schema = change.table.includes('.') ? `${change.table.split('.')[0]}.` : '';
1183
- return `ALTER INDEX ${schema}${quote(change.from)} RENAME TO ${quote(change.to)};`;
1184
- }
1189
+ case 'renameIndex':
1190
+ // Non-destructive and instant — a catalog rename, no rebuild. Rendered beside its parser:
1191
+ // db:apply reads this exact text back (catalog-rename).
1192
+ return renderIndexRename(change);
1185
1193
  case 'enumValuesChanged':
1186
1194
  return `-- NOTICE: enum ${quote(change.name)} values changed ([${change.from.join(', ')}] → [${change.to.join(', ')}]) in a way Postgres can't apply in place (a removal, reorder, or rename). ADD VALUE only appends; reshaping an enum needs a manual type swap (create new type, ALTER COLUMN ... TYPE using a cast, drop old).`;
1187
1195
  case 'createSequence':
@@ -88,6 +88,8 @@ export interface CheckConstraint {
88
88
  name: string;
89
89
  /** The predicate text, as `pg_get_constraintdef` deparses it. */
90
90
  expr: string;
91
+ /** The name was DECLARED (`check(sql, { name })`), not positional — same rule as {@link IndexSchema.nameDeclared}. */
92
+ nameDeclared?: boolean;
91
93
  }
92
94
 
93
95
  /** A standalone index (NOT one backing a PK or UNIQUE constraint — those are excluded). */