@everystack/cli 0.4.44 → 0.4.45

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.
@@ -46,6 +46,7 @@
46
46
  */
47
47
 
48
48
  import { createHash } from 'node:crypto';
49
+ import { canonicalAuthz } from './authz-canonical.js';
49
50
  import type { ModelDescriptor, SequenceDescriptor } from '@everystack/model';
50
51
  import type { SchemaSnapshot, TableSchema } from './schema-introspect.js';
51
52
  import type { AuthzContract, TableContract } from './authz-contract.js';
@@ -62,7 +63,17 @@ import { normalizeDefault, normalizeCheck } from './schema-diff.js';
62
63
  // STANDALONE SEQUENCES enter the canonical form (declared via defineSequence,
63
64
  // introspected from pg_sequence minus serial-owned) — a coverage expansion; the
64
65
  // `sequences` key appears only when any exist, so sequence-free states hash unchanged.
65
- export const FINGERPRINT_VERSION = 3;
66
+ // v4: the AUTHZ canonical form became the reconciler's equivalence relation instead of a
67
+ // name-keyed transcript of the catalog. Policy NAMES leave the hash (a brownfield database
68
+ // names its policies whatever its previous migration tool named them, so hashing the name made
69
+ // a policy carrying the declared authorization read as a different state); policies expand
70
+ // across their roles as a MULTISET (so one policy TO a,b hashes equal to two identical ones TO
71
+ // a and TO b, and duplicates never collapse); PUBLIC stays a single sentinel and is never
72
+ // enumerated; and grants are filtered to the GOVERNED grantees, because the reconciler leaves
73
+ // an ungoverned migrator/ETL role alone and a hash that counts it can never converge.
74
+ // Together these restore the identity the format exists for: fingerprints match exactly when
75
+ // db:generate is a no-op.
76
+ export const FINGERPRINT_VERSION = 4;
66
77
 
67
78
  // ---------------------------------------------------------------------------
68
79
  // Canonical form.
@@ -115,40 +126,18 @@ function canonicalTable(table: TableSchema): Record<string, unknown> {
115
126
  };
116
127
  }
117
128
 
118
- function canonicalAuthzTable(contract: TableContract): Record<string, unknown> {
119
- return {
120
- table: contract.table,
121
- rls: { enabled: contract.rls.enabled, forced: contract.rls.forced },
122
- grants: Object.fromEntries(
123
- Object.entries(contract.grants)
124
- .map(([role, privs]) => [role, [...privs].sort()] as const)
125
- .sort(([a], [b]) => (a < b ? -1 : 1)),
126
- ),
127
- ...(contract.columnGrants && Object.keys(contract.columnGrants).length > 0
128
- ? {
129
- columnGrants: Object.fromEntries(
130
- Object.entries(contract.columnGrants)
131
- .map(([role, byPriv]) => [
132
- role,
133
- Object.fromEntries(
134
- Object.entries(byPriv)
135
- .map(([priv, cols]) => [priv, [...cols].sort()] as const)
136
- .sort(([a], [b]) => (a < b ? -1 : 1)),
137
- ),
138
- ] as const)
139
- .sort(([a], [b]) => (a < b ? -1 : 1)),
140
- ),
141
- }
142
- : {}),
143
- policies: byKey(
144
- contract.policies.map((p) => ({
145
- name: p.name, command: p.command, roles: [...p.roles].sort(),
146
- permissive: p.permissive, using: p.using, check: p.check,
147
- })),
148
- (p) => p.name,
149
- ),
150
- // The handler-side columns exposure block is NOT a database fact — excluded.
151
- };
129
+ /**
130
+ * The authorization slice of the canonical form.
131
+ *
132
+ * Delegates to `authz-canonical`, which the reconciler and the differ read too. Keeping a
133
+ * private copy here is what let the fingerprint drift out of step with the reconciler and
134
+ * report permanent drift on a database that had nothing to reconcile.
135
+ */
136
+ function canonicalAuthzTable(
137
+ contract: TableContract,
138
+ governed?: ReadonlySet<string>,
139
+ ): Record<string, unknown> {
140
+ return canonicalAuthz(contract, governed);
152
141
  }
153
142
 
154
143
  export interface CanonicalState {
@@ -171,6 +160,17 @@ export interface CanonicalizeOptions {
171
160
  /** Restrict the state to these schemas — the content address of ONE schema (e.g. a
172
161
  * schema-scoped artifact) instead of the whole database. Omitted = every schema. */
173
162
  schemas?: string[];
163
+ /**
164
+ * The grantees the models govern. PASS THIS WHENEVER HASHING A LIVE CONTRACT.
165
+ *
166
+ * The reconciler leaves an ungoverned grantee alone by design, so a live grant to a
167
+ * migrator or ETL role is never reconciled. A hash that counts it describes a state the
168
+ * models can never reach: the operator gets "nothing to do" from the plan and "you have
169
+ * drifted" from the gate, with no action in between that resolves it. Omitted means "hash
170
+ * every grantee", which is correct only for a contract compiled FROM the models, where
171
+ * every grantee is governed by construction.
172
+ */
173
+ governedRoles?: ReadonlySet<string>;
174
174
  }
175
175
 
176
176
  export function canonicalizeState(
@@ -208,7 +208,10 @@ export function canonicalizeState(
208
208
  .map((e) => ({ name: e.name, values: e.values })),
209
209
  (e) => e.name,
210
210
  ),
211
- authz: byKey(authzTables.filter((t) => keepTable(t.table)).map(canonicalAuthzTable), (t) => String(t.table)),
211
+ authz: byKey(
212
+ authzTables.filter((t) => keepTable(t.table)).map((t) => canonicalAuthzTable(t, opts.governedRoles)),
213
+ (t) => String(t.table),
214
+ ),
212
215
  ...(sequences.length ? { sequences } : {}),
213
216
  };
214
217
  }
@@ -305,3 +308,52 @@ export interface UnfingerprintedObject {
305
308
  export function mapUnfingerprintedRows(rows: Array<{ kind: unknown; identity: unknown }>): UnfingerprintedObject[] {
306
309
  return rows.map((r) => ({ kind: String(r.kind), identity: String(r.identity) }));
307
310
  }
311
+
312
+
313
+ // ---------------------------------------------------------------------------
314
+ // Comparing a STORED fingerprint against live reality.
315
+ // ---------------------------------------------------------------------------
316
+
317
+ /**
318
+ * A fingerprint as recorded in an artifact — a plan, a baseline, an export stamp.
319
+ *
320
+ * `v` is the format it was computed under. It must be stored ALONGSIDE the hash, not merely
321
+ * mixed into it: a hash alone cannot say why it differs, and the difference between "you have
322
+ * drifted" and "the format changed" is the difference between an operator hunting a phantom
323
+ * change and an operator running one re-baseline.
324
+ */
325
+ export interface StoredFingerprint {
326
+ hash: string;
327
+ /** The FINGERPRINT_VERSION in force when the hash was computed. Absent = pre-v4 artifact. */
328
+ v?: number;
329
+ }
330
+
331
+ export type FingerprintComparison =
332
+ | { kind: 'match' }
333
+ | { kind: 'drift' }
334
+ | { kind: 'format-changed'; stored: number | 'unstamped'; current: number };
335
+
336
+ /**
337
+ * Compare a stored fingerprint to one computed now.
338
+ *
339
+ * A raw hash compare across formats reports DRIFT, which is a lie — the state may be
340
+ * untouched while the way we describe it changed. Every hash comparison that spans an artifact
341
+ * boundary must come through here so a format change reads as a format change.
342
+ */
343
+ export function compareStoredFingerprint(
344
+ stored: StoredFingerprint,
345
+ currentHash: string,
346
+ currentVersion: number = FINGERPRINT_VERSION,
347
+ ): FingerprintComparison {
348
+ if (stored.v !== currentVersion) {
349
+ return { kind: 'format-changed', stored: stored.v ?? 'unstamped', current: currentVersion };
350
+ }
351
+ return stored.hash === currentHash ? { kind: 'match' } : { kind: 'drift' };
352
+ }
353
+
354
+ /** The operator-facing sentence for a format change. Names the fix, never the phantom drift. */
355
+ export function formatChangedMessage(c: Extract<FingerprintComparison, { kind: 'format-changed' }>): string {
356
+ return `fingerprint format changed (recorded v${c.stored}, current v${c.current}) — this is NOT drift. `
357
+ + 'The artifact predates the current canonical form, so its hash cannot be compared. '
358
+ + 'Re-mint the plan (db:plan) or re-baseline the stage (db:reconcile --rebaseline); no DDL is involved.';
359
+ }