@everystack/cli 0.3.27 → 0.3.28

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.3.27",
3
+ "version": "0.3.28",
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>",
@@ -169,6 +169,16 @@ export function buildSyncReport(run: SyncRun): string[] {
169
169
  lines.push(`· ${state.classified.notices.length} notice(s) — manual follow-ups, nothing executed for them`);
170
170
  }
171
171
 
172
+ // The base fingerprint is a STATE-layer fact: both endpoints are sampled around
173
+ // the state apply, before compute runs. Print it here, adjacent to the state
174
+ // section, so an empty→full move never reads as if the compute step caused it —
175
+ // the derived layer (functions/views/matviews) is not fingerprinted at all.
176
+ lines.push(
177
+ state.toFingerprint === state.fromFingerprint
178
+ ? `base fingerprint: ${short(state.toFingerprint)} (unchanged)`
179
+ : `base fingerprint: ${short(state.fromFingerprint)} → ${short(state.toFingerprint)} (across the state apply)`,
180
+ );
181
+
172
182
  // Compute.
173
183
  if (compute === null) {
174
184
  lines.push('· compute: no db/sql directory — derived layer not declared, skipped');
@@ -181,12 +191,7 @@ export function buildSyncReport(run: SyncRun): string[] {
181
191
  }
182
192
  }
183
193
 
184
- // Fingerprint.
185
- lines.push(
186
- state.toFingerprint === state.fromFingerprint
187
- ? `fingerprint: ${short(state.toFingerprint)} (unchanged)`
188
- : `fingerprint: ${short(state.fromFingerprint)} → ${short(state.toFingerprint)}`,
189
- );
194
+ // Verdict.
190
195
  if (run.fingerprintMatch) {
191
196
  lines.push('MATCH — the database is the state your checkout declares');
192
197
  } else if (run.stateConverged) {
@@ -27,6 +27,13 @@
27
27
  * - FUNCTIONS are excluded — they belong to the derived layer, whose own
28
28
  * content hashes (derived-introspect) cover them. Base fingerprint + derived
29
29
  * manifest together are the full schema state.
30
+ * - Expressions (column defaults, check predicates, partial-index WHERE) hash
31
+ * through the SAME normalizers the diff uses (`normalizeDefault` /
32
+ * `normalizeCheck`), applied to both sides in `canonicalizeState`. Without
33
+ * this the model side hashes its source form (`'primary'`, `scope = ANY(...)`)
34
+ * while the live side hashes pg's deparse (`'primary'::text`,
35
+ * `(scope = ANY(...))`) — the identity below breaks on any schema with a text
36
+ * default, a check, or a partial index even when the diff is a no-op.
30
37
  * - Deparse stability caveat, stated once: policy/check predicates hash as
31
38
  * Postgres deparses them; a major-version deparser change can shift
32
39
  * fingerprints. The version prefix plus the re-diff path keep that honest.
@@ -43,8 +50,12 @@ import type { SchemaSnapshot, TableSchema } from './schema-introspect.js';
43
50
  import type { AuthzContract, TableContract } from './authz-contract.js';
44
51
  import { compileTableSchema, compileEnums } from './schema-compile.js';
45
52
  import { compileTableContract } from './authz-compile.js';
53
+ import { normalizeDefault, normalizeCheck } from './schema-diff.js';
46
54
 
47
- export const FINGERPRINT_VERSION = 1;
55
+ // v2: expression fields (defaults, checks, partial-index WHERE) normalize through
56
+ // the diff's pg-deparse normalizers before hashing, so the model form and the live
57
+ // deparsed form agree — the identity `fingerprintModels === fingerprintLive` holds.
58
+ export const FINGERPRINT_VERSION = 2;
48
59
 
49
60
  // ---------------------------------------------------------------------------
50
61
  // Canonical form.
@@ -70,13 +81,17 @@ function canonicalTable(table: TableSchema): Record<string, unknown> {
70
81
  return {
71
82
  table: table.table,
72
83
  columns: byKey(
73
- table.columns.map((c) => ({ name: c.name, type: c.type, notNull: c.notNull, default: c.default })),
84
+ // Defaults hash SEMANTICALLY, through the same normalizer the diff uses: the Model
85
+ // emits `'primary'`, pg deparses it to `'primary'::text` — the same state, one hash.
86
+ table.columns.map((c) => ({ name: c.name, type: c.type, notNull: c.notNull, default: normalizeDefault(c.default) })),
74
87
  (c) => c.name,
75
88
  ),
76
89
  primaryKey: table.primaryKey,
77
90
  // Content, not names: a pg-default `_key` and a model-named unique are the same state.
78
91
  uniques: byKey(table.uniques.map((u) => ({ columns: u.columns })), (u) => u.columns.join(',')),
79
- checks: byKey(table.checks.map((c) => ({ expr: c.expr })), (c) => c.expr),
92
+ // Predicates hash as the diff compares them one layer of pg's wrapping parens dropped
93
+ // (`(scope = ANY (...))` == `scope = ANY (...)`), whitespace collapsed.
94
+ checks: byKey(table.checks.map((c) => ({ expr: normalizeCheck(c.expr) })), (c) => c.expr),
80
95
  foreignKeys: byKey(
81
96
  table.foreignKeys.map((f) => ({
82
97
  columns: f.columns, refTable: f.refTable, refColumns: f.refColumns,
@@ -86,7 +101,8 @@ function canonicalTable(table: TableSchema): Record<string, unknown> {
86
101
  (f) => stableStringify(f),
87
102
  ),
88
103
  indexes: byKey(
89
- table.indexes.map((i) => ({ columns: i.columns, unique: i.unique, ...(i.where ? { where: i.where } : {}) })),
104
+ // Partial-index WHERE is a predicate too normalize it the same way as a check.
105
+ table.indexes.map((i) => ({ columns: i.columns, unique: i.unique, ...(i.where ? { where: normalizeCheck(i.where) } : {}) })),
90
106
  (i) => stableStringify(i),
91
107
  ),
92
108
  };