@everystack/cli 0.4.72 → 0.4.74
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 +27 -0
- package/package.json +1 -1
- package/src/cli/adopt.ts +58 -0
- package/src/cli/apply-execute.ts +8 -4
- package/src/cli/authz-canonical.ts +146 -54
- package/src/cli/authz-contract.ts +151 -7
- package/src/cli/authz-reconcile.ts +58 -29
- package/src/cli/commands/db-adopt.ts +227 -0
- package/src/cli/commands/db-fingerprint.ts +24 -5
- package/src/cli/derived-grants.ts +20 -13
- package/src/cli/derived-plan.ts +15 -5
- package/src/cli/edge-plan.ts +24 -3
- package/src/cli/git-descent.ts +17 -4
- package/src/cli/index.ts +5 -0
- package/src/cli/live-models.ts +65 -0
- package/src/cli/schema-fingerprint-v10.ts +152 -0
- package/src/cli/schema-fingerprint.ts +150 -31
package/src/cli/git-descent.ts
CHANGED
|
@@ -217,8 +217,17 @@ async function evaluateTree(candidate: ModelTreeCandidate, live: LiveState, ctx:
|
|
|
217
217
|
}
|
|
218
218
|
}
|
|
219
219
|
|
|
220
|
+
// A materialized tree that carries NO barrel DECLARES NOTHING — a clean, deterministic 'no',
|
|
221
|
+
// not a failure to read. Every pre-Models repo has such ancestors: the models directory
|
|
222
|
+
// scaffolded (or present for another reason) before its barrel existed. Letting the loader
|
|
223
|
+
// throw on the absent file would classify it `compile-failed`, poisoning the verdict to
|
|
224
|
+
// UNVERIFIABLE — which sends a never-adopted database to "retry/upgrade" instead of to
|
|
225
|
+
// db:adopt. So the missing barrel is decided here, before the loader is ever consulted.
|
|
226
|
+
const barrelPath = path.join(dest, ctx.barrel);
|
|
227
|
+
if (!fs.existsSync(barrelPath)) return { kind: 'no' };
|
|
228
|
+
|
|
220
229
|
const evaluate = async (): Promise<boolean> => {
|
|
221
|
-
const models = await ctx.loader(
|
|
230
|
+
const models = await ctx.loader(barrelPath);
|
|
222
231
|
const governedRoles = governedRolesForModels(models);
|
|
223
232
|
const predicted = predictLiveFingerprint(models, live.snapshot, live.contract, { schema: ctx.schema, governedRoles });
|
|
224
233
|
return predicted === governedLiveFingerprint(live.snapshot, live.contract, governedRoles);
|
|
@@ -623,9 +632,13 @@ export async function proveDescent(
|
|
|
623
632
|
reason:
|
|
624
633
|
`no ancestor of your checkout declares the target's live fingerprint ` +
|
|
625
634
|
`${short(fingerprintLive(live.snapshot, live.contract).hash)} — read ${candidates.length} ` +
|
|
626
|
-
'ancestor model state(s), all cleanly, and none matches.
|
|
627
|
-
'
|
|
628
|
-
'
|
|
635
|
+
'ancestor model state(s), all cleanly, and none matches. Either this database has never been ' +
|
|
636
|
+
'adopted into Models (nothing committed can declare it — the first-adoption case), or it ' +
|
|
637
|
+
'drifted after adoption. THE RUNBOOK: run everystack db:adopt — it renders Models from the ' +
|
|
638
|
+
'live database and PROVES they declare it exactly (it REFUSES rather than paper over any ' +
|
|
639
|
+
'difference); commit AND push that baseline, then re-run db:plan / db:apply and it descends ' +
|
|
640
|
+
'normally. If instead your Models are the source of truth and the database drifted, reconcile ' +
|
|
641
|
+
'with db:generate, or waive the rule deliberately.',
|
|
629
642
|
};
|
|
630
643
|
}
|
|
631
644
|
|
package/src/cli/index.ts
CHANGED
|
@@ -11,6 +11,7 @@ import { dbMigrateCommand, dbSeedCommand, dbResetCommand, dbPsqlCommand, dbDocto
|
|
|
11
11
|
import { dbAuthzPullCommand, dbAuthzDiffCommand, dbAuthzTestCommand, dbAuthzOwnerCommand, dbAuthzReportCommand } from './commands/db-authz';
|
|
12
12
|
import { dbGenerateCommand } from './commands/db-generate';
|
|
13
13
|
import { dbPullCommand } from './commands/db-pull';
|
|
14
|
+
import { dbAdoptCommand } from './commands/db-adopt';
|
|
14
15
|
import { dbReconcileCommand } from './commands/db-reconcile';
|
|
15
16
|
import { dbRefreshCommand } from './commands/db-refresh';
|
|
16
17
|
import { dbFingerprintCommand } from './commands/db-fingerprint';
|
|
@@ -185,6 +186,9 @@ async function main() {
|
|
|
185
186
|
case 'db:pull':
|
|
186
187
|
await dbPullCommand(flags);
|
|
187
188
|
break;
|
|
189
|
+
case 'db:adopt':
|
|
190
|
+
await dbAdoptCommand(flags);
|
|
191
|
+
break;
|
|
188
192
|
case 'db:reconcile':
|
|
189
193
|
await dbReconcileCommand(flags);
|
|
190
194
|
break;
|
|
@@ -366,6 +370,7 @@ Usage:
|
|
|
366
370
|
everystack db:generate [--stage <name> | --database-url <url>] [--name <label>] [--models db/models/index.ts] [--schema-out db/schema.generated.ts] [--allow-drops] [--apply] [--dry-run] Diff models vs the live DB → next migration file, or with --apply execute it directly (one transaction, schema_log recorded, verified by re-diff — no drizzle folder needed; direct connection only; DROPs held back unless --allow-drops). --dry-run prints the edge and writes NOTHING (no migration, no journal entry, no schema refresh) — the preview verb; db:diff computes a models-vs-models edge with no database at all. The resolved --schema-out is recorded in the migration journal: later flag-less runs reuse it (flag > recorded > default), a differing flag updates the record and says so
|
|
367
371
|
everystack db:pull [--stage <name> | --database-url <url>] [--schema public] [--out <dir | file.ts>] [--derived-out <file.ts>] [--abilities live|public-read] Introspect the live DB → render field() Models (the brownfield on-ramp). --out <dir> writes one file per model + index.ts (the default shape); --out <file.ts> writes a single module; stdout otherwise. --derived-out <file.ts> extracts the derived layer (descriptors + sequences) as its own self-contained module — alone it leaves the models untouched (the hand-maintained-barrel splice); with --out the models render omits the now-external derived layer. Every model scaffolds its authz decision as comments (db:check fails until authored). **--abilities live is the brownfield mode**: derive each model's authz from the grants and policies the database ALREADY has, and write the foreign-grantee baseline (db/authz-baseline.json) — this is what you want when adopting an existing schema. --abilities public-read stamps the common stanza (public read, admin write) uncommented — greenfield only, since on an existing database it declares public read of every table. Both are explicit generated code, never a runtime default. --govern-roles <r1,r2> (with --abilities live) transcribes the named FOREIGN roles' grants into privileges — the models then OWN them, so a fresh build recreates them; required for migration deletion when the migrations created grants to an ops/connection role. Explicit only (a re-pull must not silently govern), complete-or-refuse (a listed role holding a column-scoped grant fails the pull before anything is written). --matviews-as-tables renders every matview as defineMaterializedTable with INTROSPECTED fields (the canonical-sync flip: a pipeline-owned table everystack migrates but never refreshes) — names land in an exported materializedTables array to spread into your models; fields come back nullable/unkeyed (matviews carry no PK/NOT NULL) — tighten on review; add --suggest-keys to probe the LIVE rows for functionally-unique columns (one scan per matview) and surface each as a commented .primaryKey() suggestion. docs/derived-objects.md#flipping-a-matview-to-a-materialized-table---matviews-as-tables
|
|
368
372
|
Both introspect via the deployed ops Lambda by default; --database-url (or an inherited DATABASE_URL) connects directly — for a schema that exists only on a local Postgres.
|
|
373
|
+
everystack db:adopt [--stage <name> | --database-url <url>] [--schema public] [--out db/models] [--govern-roles r1,r2] First-time adoption of a pre-Models database, BY PROOF. A production DB that never went through Models has no committed ancestor declaring it, so db:apply refuses it as unverifiable drift and no waiver fits. db:adopt renders field() Models from the live DB (the same renderers db:pull uses, --abilities live), compiles them back, and finalizes the baseline ONLY when the compiled Models declare the live state EXACTLY (predicted == governed live — the same declares-test descent and db:check use). On success it writes --out (default db/models) and prints the \`chore(db): adopt live baseline <fp>\` commit — that commit legitimately declares the live state, so a later db:apply descends for real. PROVE not attest: there is NO bypass flag. When the round-trip is not exact, adoption REFUSES, names the surfaces that diverged, prints the db:generate edge, and writes NOTHING. Commit AND push the baseline before db:apply — an unpushed baseline is an unknown commit to teammates and the ops Lambda.
|
|
369
374
|
everystack db:fingerprint [--stage <name> | --database-url <url>] [--models <barrel>] [--json] Content-address the live base schema (tables+constraints+authz) and compare against the models — MATCH/MISMATCH (exit 1), plus the unfingerprinted-objects report
|
|
370
375
|
everystack db:reconcile [--stage <name> | --database-url <url>] [--apply] [--check] [--baseline] [--rebuild] [--overwrite-drift] [--only a,b] [--json] Reconcile the derived layer (functions/views/matviews/triggers) against the DECLARED descriptors (defineView/defineMaterializedView/defineFunction/defineSql/trigger() on models, from the barrel) — the single home (db/sql is retired; leftover .sql files fail with the migration path): plan with rebuild-cost estimates by default; --check is the CI gate; --apply executes (atomic — DDL + provenance in one transaction) and records provenance + schema_log; --apply --stage runs credential-free in the ops Lambda (no admin URL on the deployer, the db:apply twin), --apply --database-url runs direct. Hand-edits are drift (never overwritten silently). First contact with existing objects: --baseline TRUSTS live == source (records provenance, verifies nothing), --rebuild GUARANTEES it (drop+create from source). They are mutually exclusive. --only <schema.name,…> restricts the run to the named objects (surgical); with --rebuild it FORCES those to rebuild from source even when the hashes show no diff — the recovery exit when a mistaken --rebaseline left a self-consistent-but-wrong provenance row (the dependency cascade rebuilds their live dependents).
|
|
371
376
|
everystack db:refresh [--stage <name> | --database-url <url> | --direct] [--only a,b] [--verify-nonempty] [--list] Refresh the declared materialized views in dependency order, credential-free. --stage runs the whole refresh in the ops Lambda on the operator connection (no URL on the operator's machine — the data-lane twin of the reconcile lane); --database-url refreshes over a local connection (dev); --stage <name> --direct resolves the stage's operator connection from its ops Lambda (credential never on argv) and refreshes CLI-side on an UNBOUNDED clock, for a matview set that exceeds the ops-Lambda 900s limit; --only refreshes a named subset (full identity or bare name); --verify-nonempty gates on populated-but-zero-rows matviews (the dark-panel check — a scoped EXISTS on just what was refreshed, so a promotion script gets the gate with no read authority) and FAILS naming any empty; --list previews the order without connecting. Plain REFRESH (ACCESS EXCLUSIVE); fail-fast, idempotent to re-run.
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* renderLiveModelFiles — the render db:pull and db:adopt SHARE.
|
|
3
|
+
*
|
|
4
|
+
* Both commands turn a live database into `field()` Models with the same posture: derive each
|
|
5
|
+
* model's authz from the grants and policies already there (`abilities: 'live'`), render the
|
|
6
|
+
* derived layer from the live catalog, and write the directory shape (one file per model + an
|
|
7
|
+
* index barrel). Factoring it here is not only DRY — it is the round-trip's correctness. db:adopt
|
|
8
|
+
* PROVES the rendered Models declare the live state; if adopt rendered even slightly differently
|
|
9
|
+
* from pull, the proof could refuse on a cosmetic difference pull produces fine. One renderer,
|
|
10
|
+
* one result.
|
|
11
|
+
*
|
|
12
|
+
* Pure: introspection results in, rendered files out. The command shells do the IO.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { SchemaSnapshot } from './schema-introspect';
|
|
16
|
+
import type { AuthzContract } from './authz-contract';
|
|
17
|
+
import type { DerivedCatalog } from './derived-introspect';
|
|
18
|
+
import { renderModelFiles, pullableTables, modelVarName, type RenderedModelFile } from './model-render';
|
|
19
|
+
import { renderDerivedSource } from './derived-render';
|
|
20
|
+
import { fingerprintLive } from './schema-fingerprint';
|
|
21
|
+
import { ungovernedGrants, ALWAYS_GOVERNED, type GrantExemption } from './authz-reconcile';
|
|
22
|
+
import { deriveAbilities } from './authz-derive';
|
|
23
|
+
|
|
24
|
+
export interface LiveModelRender {
|
|
25
|
+
/** The directory shape: one file per model + `index.ts` (the barrel that exports `models`). */
|
|
26
|
+
files: RenderedModelFile[];
|
|
27
|
+
/** The RAW live fingerprint — the baseline id db:adopt names the commit by. */
|
|
28
|
+
fingerprint: string;
|
|
29
|
+
/** Foreign grantees present at pull, exempt from revocation (the adoption baseline observation). */
|
|
30
|
+
exemptions: GrantExemption[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Render `field()` Models from a live database in the live-abilities, directory shape. The
|
|
35
|
+
* `--govern-roles` complete-or-refuse gate runs here (pure re-derivation on the pulled tables),
|
|
36
|
+
* before any file is written, so a listed role holding a column-scoped grant throws instead of
|
|
37
|
+
* leaving a half-written tree.
|
|
38
|
+
*/
|
|
39
|
+
export function renderLiveModelFiles(
|
|
40
|
+
snapshot: SchemaSnapshot,
|
|
41
|
+
contract: AuthzContract,
|
|
42
|
+
derivedCatalog: DerivedCatalog,
|
|
43
|
+
opts: { schema?: string | string[]; governRoles?: string[] } = {},
|
|
44
|
+
): LiveModelRender {
|
|
45
|
+
const schema = opts.schema ?? 'public';
|
|
46
|
+
const governRoles = opts.governRoles ?? [];
|
|
47
|
+
const pulled = pullableTables(snapshot, schema);
|
|
48
|
+
const knownTables = new Map(pulled.map((t) => [t.table, modelVarName(t.table)]));
|
|
49
|
+
const derived = renderDerivedSource(derivedCatalog, { knownTables, sequences: snapshot.sequences });
|
|
50
|
+
const liveAuthz = new Map(contract.tables.map((t) => [t.table, t]));
|
|
51
|
+
|
|
52
|
+
// The --govern-roles gate, BEFORE any file is written: a governed role holding a column-scoped
|
|
53
|
+
// grant fails the render (deriveAbilities throws) rather than producing a half-owned barrel.
|
|
54
|
+
if (governRoles.length) {
|
|
55
|
+
const pulledNames = new Set(pulled.map((t) => t.table));
|
|
56
|
+
const govern = new Set(governRoles);
|
|
57
|
+
for (const [name, c] of liveAuthz) if (pulledNames.has(name)) deriveAbilities(c, { governRoles: govern });
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const files = renderModelFiles(snapshot, { schema, abilities: 'live', liveAuthz, governRoles, derived });
|
|
61
|
+
// A governed role's grants are DECLARED (transcribed as privileges), so they are not also
|
|
62
|
+
// recorded as exemptions — that would double-book them, declared and exempted at once.
|
|
63
|
+
const exemptions = ungovernedGrants(contract, new Set([...ALWAYS_GOVERNED, ...governRoles]));
|
|
64
|
+
return { files, fingerprint: fingerprintLive(snapshot, contract).hash, exemptions };
|
|
65
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FROZEN v10 canonicalizer — the exact live-side hash the CLI produced under
|
|
3
|
+
* FINGERPRINT_VERSION 10, before the command fan (Bug 2), effective-privilege deadness
|
|
4
|
+
* (Condition A), and serial-sequence ACLs (Bug 3) landed at v11.
|
|
5
|
+
*
|
|
6
|
+
* DO NOT EDIT this to track new behaviour. It exists for ONE purpose: `db:apply`'s
|
|
7
|
+
* fast-forward proof recomputes a `schema_log` row recorded at `fp_version = 10` against a
|
|
8
|
+
* CURRENT live read, and comparing an old-format hash against a current-format one reports a
|
|
9
|
+
* phantom mismatch. So the descent registry recomputes the LIVE side under the ROW's canon —
|
|
10
|
+
* this function — per the standing rule in `RETAINED_CANONS`.
|
|
11
|
+
*
|
|
12
|
+
* It is a SELF-CONTAINED copy of the v10 authz canonical form: the command dimension is NOT
|
|
13
|
+
* fanned, deadness reads the DIRECT grants (never the v11 effective-privilege matrix), and there
|
|
14
|
+
* is no `sequenceAuthz` slice. It reuses only the stable leaf normalizers (deparse + structure),
|
|
15
|
+
* whose drift is the documented deparse caveat and is equal across every version.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { createHash } from 'node:crypto';
|
|
19
|
+
import type { PolicyContract, TableContract } from './authz-contract';
|
|
20
|
+
import { effectivePolicyCheck, holdsPrivilege, policyCommands, isPolicySubsumed } from './authz-contract';
|
|
21
|
+
import type { SchemaSnapshot, TableSchema } from './schema-introspect';
|
|
22
|
+
import { normalizeDefault, normalizeCheck, indexKey } from './schema-diff';
|
|
23
|
+
import { stableStringify, schemaOf, type CanonicalizeOptions } from './schema-fingerprint';
|
|
24
|
+
|
|
25
|
+
const PUBLIC_ROLE = 'public';
|
|
26
|
+
|
|
27
|
+
const byKey = <T>(items: T[], key: (item: T) => string): T[] =>
|
|
28
|
+
[...items].sort((a, b) => (key(a) < key(b) ? -1 : key(a) > key(b) ? 1 : 0));
|
|
29
|
+
|
|
30
|
+
// --- v10 authz canonical form (frozen: the command dimension is NOT fanned) ---
|
|
31
|
+
|
|
32
|
+
/** v10 deadness: a policy governing a privilege none of its roles holds by DIRECT grant. */
|
|
33
|
+
function isPolicyDeadV10(t: TableContract, p: PolicyContract): boolean {
|
|
34
|
+
const roles = p.roles.includes('public') || p.roles.includes('PUBLIC') ? Object.keys(t.grants) : p.roles;
|
|
35
|
+
if (!roles.length) return true;
|
|
36
|
+
return !policyCommands(p).some((cmd) => roles.some((r) => holdsPrivilege(t, r, cmd)));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function stableRuleV10(p: PolicyContract): string {
|
|
40
|
+
return JSON.stringify([p.command, p.permissive, p.using ?? '', effectivePolicyCheck(p) ?? '']);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function expandPolicyV10(p: PolicyContract): string[] {
|
|
44
|
+
const rule = stableRuleV10(p);
|
|
45
|
+
if (p.roles.includes(PUBLIC_ROLE)) return [`${PUBLIC_ROLE}|${rule}`];
|
|
46
|
+
return [...p.roles].sort().map((r) => `${r}|${rule}`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function multiset(entries: string[]): Array<[string, number]> {
|
|
50
|
+
const counts = new Map<string, number>();
|
|
51
|
+
for (const e of entries) counts.set(e, (counts.get(e) ?? 0) + 1);
|
|
52
|
+
return [...counts.entries()].sort(([a], [b]) => (a < b ? -1 : 1));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function canonicalPoliciesV10(policies: readonly PolicyContract[], governed?: ReadonlySet<string>): Array<[string, number]> {
|
|
56
|
+
const entries = policies.flatMap(expandPolicyV10);
|
|
57
|
+
const kept = governed
|
|
58
|
+
? entries.filter((e) => {
|
|
59
|
+
const role = e.slice(0, e.indexOf('|'));
|
|
60
|
+
return governed.has(role) || role === PUBLIC_ROLE || (role.toUpperCase() === 'PUBLIC' && governed.has('PUBLIC'));
|
|
61
|
+
})
|
|
62
|
+
: entries;
|
|
63
|
+
return multiset(kept);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function governedOnly<T>(map: Record<string, T> | undefined, governed: ReadonlySet<string> | undefined): Record<string, T> {
|
|
67
|
+
if (!map) return {};
|
|
68
|
+
if (!governed) return map;
|
|
69
|
+
return Object.fromEntries(
|
|
70
|
+
Object.entries(map).filter(([grantee]) => governed.has(grantee) || (grantee.toUpperCase() === 'PUBLIC' && governed.has('PUBLIC'))),
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function canonicalAuthzV10(contract: TableContract, governed?: ReadonlySet<string>): Record<string, unknown> {
|
|
75
|
+
const grants = governedOnly(contract.grants, governed);
|
|
76
|
+
const columnGrants = governedOnly(contract.columnGrants, governed);
|
|
77
|
+
return {
|
|
78
|
+
table: contract.table,
|
|
79
|
+
rls: { enabled: contract.rls.enabled, forced: contract.rls.forced },
|
|
80
|
+
grants: Object.fromEntries(
|
|
81
|
+
Object.entries(grants).map(([role, privs]) => [role, [...(privs as string[])].sort()] as const).sort(([a], [b]) => (a < b ? -1 : 1)),
|
|
82
|
+
),
|
|
83
|
+
...(Object.keys(columnGrants).length > 0
|
|
84
|
+
? {
|
|
85
|
+
columnGrants: Object.fromEntries(
|
|
86
|
+
Object.entries(columnGrants)
|
|
87
|
+
.map(([role, byPriv]) => [
|
|
88
|
+
role,
|
|
89
|
+
Object.fromEntries(
|
|
90
|
+
Object.entries(byPriv as Record<string, string[]>).map(([priv, cols]) => [priv, [...cols].sort()] as const).sort(([a], [b]) => (a < b ? -1 : 1)),
|
|
91
|
+
),
|
|
92
|
+
] as const)
|
|
93
|
+
.sort(([a], [b]) => (a < b ? -1 : 1)),
|
|
94
|
+
),
|
|
95
|
+
}
|
|
96
|
+
: {}),
|
|
97
|
+
policies: canonicalPoliciesV10(
|
|
98
|
+
contract.policies.filter((p) => !isPolicyDeadV10(contract, p) && !isPolicySubsumed(contract, p)),
|
|
99
|
+
governed,
|
|
100
|
+
),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// --- v10 base-schema canonical form (identical to v11; frozen here for self-containment) ---
|
|
105
|
+
|
|
106
|
+
function canonicalTableV10(table: TableSchema): Record<string, unknown> {
|
|
107
|
+
return {
|
|
108
|
+
table: table.table,
|
|
109
|
+
columns: byKey(
|
|
110
|
+
table.columns.map((c) => ({ name: c.name, type: c.type, notNull: c.notNull, default: normalizeDefault(c.default) })),
|
|
111
|
+
(c) => c.name,
|
|
112
|
+
),
|
|
113
|
+
primaryKey: table.primaryKey,
|
|
114
|
+
uniques: byKey(table.uniques.map((u) => ({ columns: u.columns })), (u) => u.columns.join(',')),
|
|
115
|
+
checks: byKey(table.checks.map((c) => ({ expr: normalizeCheck(c.expr) })), (c) => c.expr),
|
|
116
|
+
foreignKeys: byKey(
|
|
117
|
+
table.foreignKeys.map((f) => ({
|
|
118
|
+
columns: f.columns, refTable: f.refTable, refColumns: f.refColumns,
|
|
119
|
+
...(f.onDelete ? { onDelete: f.onDelete } : {}),
|
|
120
|
+
...(f.onUpdate ? { onUpdate: f.onUpdate } : {}),
|
|
121
|
+
})),
|
|
122
|
+
(f) => stableStringify(f),
|
|
123
|
+
),
|
|
124
|
+
indexes: [...new Set(table.indexes.map(indexKey))].sort(),
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** The frozen v10 live hash for (snapshot, authzTables, opts). Sequence ACLs are NOT hashed. */
|
|
129
|
+
export function fingerprintV10(snapshot: SchemaSnapshot, authzTables: TableContract[], opts: CanonicalizeOptions = {}): string {
|
|
130
|
+
const inScope = opts.schemas ? new Set(opts.schemas) : null;
|
|
131
|
+
const keepTable = (name: string) => inScope === null || inScope.has(schemaOf(name));
|
|
132
|
+
const tables = snapshot.tables.filter((t) => keepTable(t.table));
|
|
133
|
+
const usedEnum = new Set<string>();
|
|
134
|
+
for (const t of tables) for (const c of t.columns) usedEnum.add(schemaOf(c.type) === 'public' ? c.type : c.type.slice(c.type.indexOf('.') + 1));
|
|
135
|
+
const sequences = byKey(
|
|
136
|
+
(snapshot.sequences ?? [])
|
|
137
|
+
.filter((s) => inScope === null || inScope.has(schemaOf(s.name)))
|
|
138
|
+
.map((s) => ({ name: s.name, as: s.as, ...(s.start != null ? { start: s.start } : {}), ...(s.increment != null ? { increment: s.increment } : {}) })),
|
|
139
|
+
(s) => String(s.name),
|
|
140
|
+
);
|
|
141
|
+
const canonical = {
|
|
142
|
+
v: 10,
|
|
143
|
+
tables: byKey(tables.map(canonicalTableV10), (t) => String(t.table)),
|
|
144
|
+
enums: byKey(
|
|
145
|
+
(snapshot.enums ?? []).filter((e) => inScope === null || usedEnum.has(e.name)).map((e) => ({ name: e.name, values: e.values })),
|
|
146
|
+
(e) => e.name,
|
|
147
|
+
),
|
|
148
|
+
authz: byKey(authzTables.filter((t) => keepTable(t.table)).map((t) => canonicalAuthzV10(t, opts.governedRoles)), (t) => String(t.table)),
|
|
149
|
+
...(sequences.length ? { sequences } : {}),
|
|
150
|
+
};
|
|
151
|
+
return createHash('sha256').update(stableStringify(canonical)).digest('hex');
|
|
152
|
+
}
|
|
@@ -43,21 +43,23 @@
|
|
|
43
43
|
* Coverage is partial and SAYS SO: `UNFINGERPRINTED_SQL` enumerates what the
|
|
44
44
|
* base fingerprint does not see (triggers — drift-checked separately by the
|
|
45
45
|
* reconciler's provenance — domains, composite types, foreign/partitioned
|
|
46
|
-
* tables, extensions,
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
46
|
+
* tables, extensions, and the OWNER of a SECURITY DEFINER function that declares
|
|
47
|
+
* none) so partial coverage reads as a report, never as "covered everything".
|
|
48
|
+
* Standalone sequences left this list in v3: covered. SEQUENCE ACLs left it at
|
|
49
|
+
* v11: covered (derived per table from the A6 inheritance rule, measured live).
|
|
50
|
+
* The schema posture and identity columns remain deferred.
|
|
50
51
|
*/
|
|
51
52
|
|
|
52
53
|
import { createHash } from 'node:crypto';
|
|
53
54
|
import { canonicalAuthz } from './authz-canonical';
|
|
54
55
|
import type { ModelDescriptor, SequenceDescriptor } from '@everystack/model';
|
|
55
56
|
import type { SchemaSnapshot, TableSchema } from './schema-introspect';
|
|
56
|
-
import type { AuthzContract, TableContract } from './authz-contract';
|
|
57
|
+
import type { AuthzContract, TableContract, SequenceGrantContract } from './authz-contract';
|
|
57
58
|
import { compileTableSchema, compileEnums, compileSequences } from './schema-compile';
|
|
58
59
|
import { compileTableContract } from './authz-compile';
|
|
59
|
-
import { governedRoleSet } from './authz-reconcile';
|
|
60
|
-
import { normalizeDefault, normalizeCheck, indexKey } from './schema-diff';
|
|
60
|
+
import { governedRoleSet, inheritedSequenceGrants } from './authz-reconcile';
|
|
61
|
+
import { normalizeDefault, normalizeCheck, indexKey, nextvalSequence } from './schema-diff';
|
|
62
|
+
import { fingerprintV10 } from './schema-fingerprint-v10';
|
|
61
63
|
|
|
62
64
|
// v2: expression fields (defaults, checks, partial-index WHERE) normalize through
|
|
63
65
|
// the diff's pg-deparse normalizers before hashing, so the model form and the live
|
|
@@ -114,7 +116,21 @@ import { normalizeDefault, normalizeCheck, indexKey } from './schema-diff';
|
|
|
114
116
|
// it from the canonical form AND the reconciler leaves it alone, before and after. Restrictive
|
|
115
117
|
// policies are never subsumed (they AND, so removing one would WIDEN), and the rule must match
|
|
116
118
|
// exactly including the effective WITH CHECK.
|
|
117
|
-
|
|
119
|
+
// v11 — Bug 2 + Bug 3, one bump. (a) The AUTHZ canonical form FANS THE COMMAND DIMENSION: a
|
|
120
|
+
// policy is one entry per (role, command) with per-command predicate slots (INSERT using='';
|
|
121
|
+
// SELECT/DELETE check=''; INSERT/UPDATE check = check ?? using), so a live FOR ALL and the
|
|
122
|
+
// granular per-command policies a pull produces hash equal when they authorize the same thing —
|
|
123
|
+
// and a FOR ALL that still holds a command a granular set denies stays distinct. Deadness and
|
|
124
|
+
// subsumption move to the ARM: a dead (role,command) arm and a PUBLIC-covered one leave the state.
|
|
125
|
+
// (b) DEADNESS folds ROLE MEMBERSHIP on the live side — a role holding a command through a parent
|
|
126
|
+
// role or PUBLIC is alive, measured by has_table_privilege (the declared side stays on its direct
|
|
127
|
+
// grants). (c) SERIAL-SEQUENCE ACLs enter the hash: per table, the multiset of its serial
|
|
128
|
+
// sequences' grant shapes, derived from the A6 inheritance rule on the declared side and measured
|
|
129
|
+
// on the live side, so a sequence REVOKE moves the fingerprint. Same consequence as every bump: a
|
|
130
|
+
// plan or baseline minted under v10 refuses as a FORMAT CHANGE and must be re-minted (db:plan) or
|
|
131
|
+
// re-baselined (db:reconcile --rebaseline) — no DDL, the fingerprint is a content address. The v10
|
|
132
|
+
// canon is frozen in RETAINED_CANONS so a schema_log row recorded under it still descends.
|
|
133
|
+
export const FINGERPRINT_VERSION = 11;
|
|
118
134
|
|
|
119
135
|
// ---------------------------------------------------------------------------
|
|
120
136
|
// Canonical form.
|
|
@@ -191,6 +207,73 @@ export interface CanonicalState {
|
|
|
191
207
|
/** Standalone sequences — present ONLY when any exist, so a sequence-free state's
|
|
192
208
|
* canonical form (and hash) is byte-identical to the pre-coverage form. */
|
|
193
209
|
sequences?: Array<Record<string, unknown>>;
|
|
210
|
+
/** Serial-sequence ACLs, per table, as `[table, sorted multiset of grant-shape strings]`
|
|
211
|
+
* (v11). Present ONLY when a table owns a serial sequence with a governed shape. */
|
|
212
|
+
sequenceAuthz?: Array<[string, string[]]>;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Is a grantee governed? PUBLIC compares case-insensitively; every other name exactly. */
|
|
216
|
+
function isGovernedGrantee(governed: ReadonlySet<string> | undefined, grantee: string): boolean {
|
|
217
|
+
if (!governed) return true;
|
|
218
|
+
return governed.has(grantee) || (grantee.toUpperCase() === 'PUBLIC' && governed.has('PUBLIC'));
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** One sequence's grant shape as a stable string: governed grantees → sorted privileges. */
|
|
222
|
+
function seqShape(grants: Record<string, string[]>, governed?: ReadonlySet<string>): string {
|
|
223
|
+
const kept = Object.entries(grants)
|
|
224
|
+
.filter(([grantee, privs]) => privs.length > 0 && isGovernedGrantee(governed, grantee))
|
|
225
|
+
.map(([grantee, privs]) => [grantee, [...privs].sort()] as const);
|
|
226
|
+
return stableStringify(Object.fromEntries(kept));
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* The canonical sequence-ACL slice (v11): per table, the sorted MULTISET of its serial
|
|
231
|
+
* sequences' grant shapes.
|
|
232
|
+
*
|
|
233
|
+
* Keyed by TABLE, not by pg's generated sequence name — that name truncates at 63 chars and
|
|
234
|
+
* collision-suffixes, and the DECLARED side (which never introspected) could not reproduce it.
|
|
235
|
+
* A multiset, not a union: a REVOKE on ONE of a table's several sequences still moves the hash.
|
|
236
|
+
*
|
|
237
|
+
* MEASURED vs DERIVED, PER TABLE — the axis the plan's predicted endpoint needs. A table present
|
|
238
|
+
* in the measured set uses its live ACLs (so a REVOKE is seen); a table absent DERIVES from its
|
|
239
|
+
* serial-column count × the A6-inherited grants (the state reconciliation targets). The declared
|
|
240
|
+
* side passes nothing and derives every table; a plain live read passes every measured table;
|
|
241
|
+
* the plan's predicted endpoint passes ONLY the rides-through tables, which reconciliation never
|
|
242
|
+
* touches, and lets the declared tables derive their reconciled target. Identity sequences are
|
|
243
|
+
* excluded on both sides — they inherit nothing, and the reconciler exempts them.
|
|
244
|
+
*/
|
|
245
|
+
function canonicalSequenceAuthz(
|
|
246
|
+
tables: TableSchema[],
|
|
247
|
+
authzTables: TableContract[],
|
|
248
|
+
measured: SequenceGrantContract[] | undefined,
|
|
249
|
+
governed: ReadonlySet<string> | undefined,
|
|
250
|
+
keepTable: (name: string) => boolean,
|
|
251
|
+
): Array<[string, string[]]> {
|
|
252
|
+
const snapshotByTable = new Map(tables.map((t) => [t.table, t]));
|
|
253
|
+
const measuredByTable = new Map<string, SequenceGrantContract[]>();
|
|
254
|
+
for (const s of measured ?? []) {
|
|
255
|
+
(measuredByTable.get(s.table) ?? measuredByTable.set(s.table, []).get(s.table)!).push(s);
|
|
256
|
+
}
|
|
257
|
+
const out: Array<[string, string[]]> = [];
|
|
258
|
+
for (const az of authzTables) {
|
|
259
|
+
if (!keepTable(az.table)) continue;
|
|
260
|
+
let shapes: string[];
|
|
261
|
+
if (measuredByTable.has(az.table)) {
|
|
262
|
+
// LIVE: measure each non-identity sequence's actual ACL — this is what sees a REVOKE.
|
|
263
|
+
shapes = measuredByTable.get(az.table)!.filter((s) => !s.identity).map((s) => seqShape(s.grants, governed));
|
|
264
|
+
} else {
|
|
265
|
+
// DERIVED: one inherited shape per serial column (nextval default); identity columns,
|
|
266
|
+
// which carry no such default, are excluded exactly as the reconciler exempts them.
|
|
267
|
+
const snap = snapshotByTable.get(az.table);
|
|
268
|
+
const serialCount = (snap?.columns ?? []).filter((c) => nextvalSequence(normalizeDefault(c.default)) != null).length;
|
|
269
|
+
if (serialCount === 0) continue;
|
|
270
|
+
const inherited = seqShape(inheritedSequenceGrants(az), governed);
|
|
271
|
+
shapes = Array.from({ length: serialCount }, () => inherited);
|
|
272
|
+
}
|
|
273
|
+
if (shapes.length === 0) continue;
|
|
274
|
+
out.push([az.table, shapes.sort()]);
|
|
275
|
+
}
|
|
276
|
+
return out.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
|
|
194
277
|
}
|
|
195
278
|
|
|
196
279
|
/** The schema of a (possibly) qualified name — `stats.entities` → `stats`, a bare name → `public`. */
|
|
@@ -214,6 +297,14 @@ export interface CanonicalizeOptions {
|
|
|
214
297
|
* every grantee is governed by construction.
|
|
215
298
|
*/
|
|
216
299
|
governedRoles?: ReadonlySet<string>;
|
|
300
|
+
/**
|
|
301
|
+
* The MEASURED serial-sequence ACLs (v11). A table present here is hashed from its live ACLs
|
|
302
|
+
* (so a REVOKE moves the fingerprint); a table absent DERIVES its ACLs from the A6 inheritance
|
|
303
|
+
* rule (the reconciled target). The live path passes every measured table; the declared path
|
|
304
|
+
* passes nothing; the plan's predicted endpoint passes only rides-through tables. See
|
|
305
|
+
* {@link canonicalSequenceAuthz}.
|
|
306
|
+
*/
|
|
307
|
+
sequenceGrants?: SequenceGrantContract[];
|
|
217
308
|
}
|
|
218
309
|
|
|
219
310
|
export function canonicalizeState(
|
|
@@ -256,6 +347,10 @@ export function canonicalizeState(
|
|
|
256
347
|
(t) => String(t.table),
|
|
257
348
|
),
|
|
258
349
|
...(sequences.length ? { sequences } : {}),
|
|
350
|
+
...((() => {
|
|
351
|
+
const sequenceAuthz = canonicalSequenceAuthz(tables, authzTables, opts.sequenceGrants, opts.governedRoles, keepTable);
|
|
352
|
+
return sequenceAuthz.length ? { sequenceAuthz } : {};
|
|
353
|
+
})()),
|
|
259
354
|
};
|
|
260
355
|
}
|
|
261
356
|
|
|
@@ -266,6 +361,37 @@ export interface Fingerprint {
|
|
|
266
361
|
canonical: CanonicalState;
|
|
267
362
|
}
|
|
268
363
|
|
|
364
|
+
/**
|
|
365
|
+
* Which SURFACES of two canonical states diverge — so a MISMATCH names WHERE to look instead of
|
|
366
|
+
* only "run db:generate". Compares each top-level slice (base schema, authorization, sequence
|
|
367
|
+
* ACLs, enums, standalone sequences) as its stable string, and for the per-table slices lists the
|
|
368
|
+
* specific tables whose entry differs. Empty when the two states are identical.
|
|
369
|
+
*/
|
|
370
|
+
export function divergentSurfaces(a: CanonicalState, b: CanonicalState): string[] {
|
|
371
|
+
const out: string[] = [];
|
|
372
|
+
const named = (label: string, av: unknown, bv: unknown): void => {
|
|
373
|
+
if (stableStringify(av) !== stableStringify(bv)) out.push(label);
|
|
374
|
+
};
|
|
375
|
+
// Per-table slices: name the tables that differ, keyed by their `table` field.
|
|
376
|
+
const tablesDiff = (rows: Array<Record<string, unknown>> = []): Map<string, string> =>
|
|
377
|
+
new Map(rows.map((r) => [String(r.table), stableStringify(r)]));
|
|
378
|
+
const tableSurface = (label: string, ar: Array<Record<string, unknown>>, br: Array<Record<string, unknown>>): void => {
|
|
379
|
+
const am = tablesDiff(ar); const bm = tablesDiff(br);
|
|
380
|
+
const differ = [...new Set([...am.keys(), ...bm.keys()])].filter((t) => am.get(t) !== bm.get(t)).sort();
|
|
381
|
+
if (differ.length) out.push(`${label} (${differ.join(', ')})`);
|
|
382
|
+
};
|
|
383
|
+
tableSurface('base schema', a.tables, b.tables);
|
|
384
|
+
tableSurface('authorization', a.authz, b.authz);
|
|
385
|
+
named('enums', a.enums, b.enums);
|
|
386
|
+
named('standalone sequences', a.sequences, b.sequences);
|
|
387
|
+
// sequenceAuthz entries are [table, shapes] — name the tables.
|
|
388
|
+
const seqTables = (rows: Array<[string, string[]]> = []) => new Map(rows.map(([t, s]) => [t, stableStringify(s)]));
|
|
389
|
+
const as = seqTables(a.sequenceAuthz); const bs = seqTables(b.sequenceAuthz);
|
|
390
|
+
const seqDiffer = [...new Set([...as.keys(), ...bs.keys()])].filter((t) => as.get(t) !== bs.get(t)).sort();
|
|
391
|
+
if (seqDiffer.length) out.push(`sequence ACLs (${seqDiffer.join(', ')})`);
|
|
392
|
+
return out;
|
|
393
|
+
}
|
|
394
|
+
|
|
269
395
|
export function fingerprintState(
|
|
270
396
|
snapshot: SchemaSnapshot,
|
|
271
397
|
authzTables: TableContract[],
|
|
@@ -315,13 +441,15 @@ export function governedRolesForModels(
|
|
|
315
441
|
return governedRoleSet(declared, extra ?? []);
|
|
316
442
|
}
|
|
317
443
|
|
|
318
|
-
/** Convenience: the live side, from the two existing introspections.
|
|
444
|
+
/** Convenience: the live side, from the two existing introspections. Passes the measured
|
|
445
|
+
* sequence ACLs so a live serial-sequence REVOKE moves the hash (v11); when the contract never
|
|
446
|
+
* measured them (older recorder), they stay absent and the sequence ACLs derive, as before. */
|
|
319
447
|
export function fingerprintLive(
|
|
320
448
|
snapshot: SchemaSnapshot,
|
|
321
449
|
contract: AuthzContract,
|
|
322
450
|
opts: CanonicalizeOptions = {},
|
|
323
451
|
): Fingerprint {
|
|
324
|
-
return fingerprintState(snapshot, contract.tables, opts);
|
|
452
|
+
return fingerprintState(snapshot, contract.tables, { ...opts, sequenceGrants: contract.sequenceGrants });
|
|
325
453
|
}
|
|
326
454
|
|
|
327
455
|
// ---------------------------------------------------------------------------
|
|
@@ -359,10 +487,11 @@ export type CanonHasher = (
|
|
|
359
487
|
* absent from this map is unrecomputable: `fingerprintLiveAtVersion` returns null and descent
|
|
360
488
|
* SKIPS that row (fails closed), never guesses a hash.
|
|
361
489
|
*
|
|
362
|
-
*
|
|
363
|
-
*
|
|
490
|
+
* v11 is the FIRST bump, so v10 is the first retired entry: `fingerprintV10`, a self-contained
|
|
491
|
+
* frozen copy of the v10 canonical form (no command fan, direct-grant deadness, no sequence ACLs).
|
|
364
492
|
*/
|
|
365
493
|
export const RETAINED_CANONS: Readonly<Record<number, CanonHasher>> = {
|
|
494
|
+
10: (snapshot, tables, opts) => fingerprintV10(snapshot, tables, opts),
|
|
366
495
|
[FINGERPRINT_VERSION]: (snapshot, tables, opts) => fingerprintState(snapshot, tables, opts).hash,
|
|
367
496
|
};
|
|
368
497
|
|
|
@@ -381,7 +510,10 @@ export function fingerprintLiveAtVersion(
|
|
|
381
510
|
): string | null {
|
|
382
511
|
if (version == null) return null;
|
|
383
512
|
const canon = canons[version];
|
|
384
|
-
|
|
513
|
+
// Thread the measured serial-sequence ACLs the way `fingerprintLive` does, so a v11 canon
|
|
514
|
+
// recomputes the SAME hash the apply recorded. A retired canon that predates sequence ACLs
|
|
515
|
+
// (v10) simply ignores the field — its self-contained copy never reads `sequenceGrants`.
|
|
516
|
+
return canon ? canon(snapshot, contract.tables, { ...opts, sequenceGrants: contract.sequenceGrants }) : null;
|
|
385
517
|
}
|
|
386
518
|
|
|
387
519
|
// ---------------------------------------------------------------------------
|
|
@@ -390,12 +522,11 @@ export function fingerprintLiveAtVersion(
|
|
|
390
522
|
|
|
391
523
|
/**
|
|
392
524
|
* Objects outside both the base fingerprint (this module) and the derived
|
|
393
|
-
* manifest (derived-introspect):
|
|
394
|
-
*
|
|
395
|
-
*
|
|
396
|
-
*
|
|
397
|
-
*
|
|
398
|
-
* Both of those last two enter the hash at v11.
|
|
525
|
+
* manifest (derived-introspect): triggers, domains, composite types, foreign
|
|
526
|
+
* tables, partitioned tables, installed extensions — and identity columns, which
|
|
527
|
+
* the models can now say and the builder now builds, but which the hash does not
|
|
528
|
+
* yet cover (deferred past v11 by scope). Serial-sequence ACLs left this list at
|
|
529
|
+
* v11: they are fingerprinted now (see canonicalSequenceAuthz).
|
|
399
530
|
*/
|
|
400
531
|
export const UNFINGERPRINTED_SQL = `
|
|
401
532
|
SELECT 'trigger' AS kind, n.nspname || '.' || c.relname || '.' || t.tgname AS identity
|
|
@@ -421,18 +552,6 @@ JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
|
421
552
|
WHERE c.relkind IN ('f', 'p')
|
|
422
553
|
AND n.nspname NOT IN ('pg_catalog', 'information_schema') AND n.nspname NOT LIKE 'pg_%'
|
|
423
554
|
UNION ALL
|
|
424
|
-
-- A6/B4, the interim before v11. A serial/identity sequence's SHAPE is fingerprinted (it rides
|
|
425
|
-
-- the column default), but its ACL is not — and that ACL is load-bearing: a role that may INSERT
|
|
426
|
-
-- into the table and holds nothing on the sequence fails every INSERT with "permission denied for
|
|
427
|
-
-- sequence". Only sequences that actually CARRY an ACL are named; an ungranted one has nothing
|
|
428
|
-
-- unfingerprinted to say. Enters the hash at v11, batched with the schema posture.
|
|
429
|
-
SELECT 'sequence ACL', n.nspname || '.' || c.relname
|
|
430
|
-
FROM pg_class c
|
|
431
|
-
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
432
|
-
WHERE c.relkind = 'S' AND c.relacl IS NOT NULL
|
|
433
|
-
AND n.nspname NOT IN ('pg_catalog', 'information_schema') AND n.nspname NOT LIKE 'pg_%'
|
|
434
|
-
AND NOT EXISTS (SELECT 1 FROM pg_depend d WHERE d.objid = c.oid AND d.deptype = 'e')
|
|
435
|
-
UNION ALL
|
|
436
555
|
-- B5. A SECURITY DEFINER function executes with its OWNER's rights, so ownership is a privilege
|
|
437
556
|
-- boundary. It is hashed ONLY WHEN DECLARED (derived-source's hashSourceContent prefixes the
|
|
438
557
|
-- owner, so an object without one hashes byte-identically to before the feature existed), and
|