@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.
@@ -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(path.join(dest, ctx.barrel));
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. The database was hand-edited ' +
627
- '(drift), or the deploying branch is not in this checkout. Align the models with the live ' +
628
- 'database (db:pull, commit), or waive the rule deliberately.',
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, SEQUENCE ACLs, and the OWNER of a SECURITY DEFINER
47
- * function that declares none) so partial coverage reads as a report, never as
48
- * "covered everything". Standalone sequences left this list in v3: covered.
49
- * Sequence ACLs and the schema posture are scheduled to leave it at v11.
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
- export const FINGERPRINT_VERSION = 10;
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
- * Today the map holds exactly the current version — there is nothing retired yet. It is the
363
- * mechanism and the rule that ship now; the first retired entry lands with the first bump.
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
- return canon ? canon(snapshot, contract.tables, opts) : null;
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): standalone sequences, triggers, domains, composite
394
- * types, foreign tables, partitioned tables, installed extensions — sequence
395
- * ACLs, whose SHAPE is fingerprinted (a serial sequence rides its column default)
396
- * while the PRIVILEGES on it are not — and identity columns, which the models can
397
- * now say and the builder now builds, but which the hash does not yet cover.
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