@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.
- package/package.json +2 -2
- package/src/cli/authz-adoption-class.ts +265 -0
- package/src/cli/authz-baseline.ts +25 -3
- package/src/cli/authz-canonical.ts +146 -0
- package/src/cli/authz-compile.ts +46 -6
- package/src/cli/authz-contract.ts +120 -25
- package/src/cli/authz-derive.ts +99 -14
- package/src/cli/authz-identity.ts +222 -0
- package/src/cli/authz-ownership.ts +193 -0
- package/src/cli/authz-reconcile.ts +19 -27
- package/src/cli/commands/db-generate.ts +50 -0
- package/src/cli/commands/db-plan.ts +34 -2
- package/src/cli/commands/db-pull.ts +20 -1
- package/src/cli/edge-plan.ts +14 -2
- package/src/cli/index.ts +1 -17
- package/src/cli/model-render.ts +19 -2
- package/src/cli/output.ts +25 -3
- package/src/cli/parse-flags.ts +39 -0
- package/src/cli/schema-fingerprint.ts +88 -36
|
@@ -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
|
-
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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(
|
|
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
|
+
}
|