@everystack/cli 0.4.82 → 0.4.83

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.
@@ -0,0 +1,234 @@
1
+ /**
2
+ * FROZEN v11 canonicalizer — the exact live-side hash the CLI produced under
3
+ * FINGERPRINT_VERSION 11, before serial-sequence grants narrowed to USAGE-only at v12.
4
+ *
5
+ * DO NOT EDIT this to track new behaviour. It exists for ONE purpose: `db:apply`'s
6
+ * fast-forward proof recomputes a `schema_log` row recorded at `fp_version = 11` against a
7
+ * CURRENT live read, and comparing an old-format hash against a current-format one reports a
8
+ * phantom mismatch. So the descent registry recomputes the LIVE side under the ROW's canon —
9
+ * this function — per the standing rule in `RETAINED_CANONS`.
10
+ *
11
+ * It is a SELF-CONTAINED copy of the v11 canonical form: the command fan and effective-privilege
12
+ * deadness are byte-identical to v12 (frozen here so a FUTURE canon change to the live authz form
13
+ * cannot silently move a recorded v11 hash), and the ONLY behavioural difference from v12 is the
14
+ * serial-sequence inheritance rule — pinned here to the v11 set `SELECT + UPDATE + USAGE`, which
15
+ * v12 narrowed to USAGE alone. It reuses only the stable leaf normalizers (deparse + structure)
16
+ * and the authz-contract privilege oracles, whose behaviour is equal across every version.
17
+ */
18
+
19
+ import { createHash } from 'node:crypto';
20
+ import type { PolicyContract, TableContract, SequenceGrantContract } from './authz-contract';
21
+ import { holdsPrivilegeEffective, policyCommands } from './authz-contract';
22
+ import type { SchemaSnapshot, TableSchema } from './schema-introspect';
23
+ import { normalizeDefault, normalizeCheck, indexKey, nextvalSequence } from './schema-diff';
24
+ import { stableStringify, schemaOf, type CanonicalizeOptions } from './schema-fingerprint';
25
+
26
+ const PUBLIC_ROLE = 'public';
27
+
28
+ const byKey = <T>(items: T[], key: (item: T) => string): T[] =>
29
+ [...items].sort((a, b) => (key(a) < key(b) ? -1 : key(a) > key(b) ? 1 : 0));
30
+
31
+ // --- v11 authz canonical form (frozen: the command fan, identical to v12) ---
32
+
33
+ interface PolicyArm {
34
+ role: string;
35
+ command: string;
36
+ permissive: boolean;
37
+ using: string;
38
+ check: string;
39
+ }
40
+
41
+ function policyArms(p: PolicyContract): PolicyArm[] {
42
+ const roles = p.roles.includes(PUBLIC_ROLE) ? [PUBLIC_ROLE] : [...p.roles].sort();
43
+ const arms: PolicyArm[] = [];
44
+ for (const command of policyCommands(p)) {
45
+ const using = command === 'INSERT' ? '' : (p.using ?? '');
46
+ const check = command === 'SELECT' || command === 'DELETE' ? '' : ((p.check ?? p.using) ?? '');
47
+ for (const role of roles) arms.push({ role, command, permissive: p.permissive, using, check });
48
+ }
49
+ return arms;
50
+ }
51
+
52
+ function armKey(a: PolicyArm): string {
53
+ return `${a.role}|${JSON.stringify([a.command, a.permissive, a.using, a.check])}`;
54
+ }
55
+
56
+ function multiset(entries: string[]): Array<[string, number]> {
57
+ const counts = new Map<string, number>();
58
+ for (const e of entries) counts.set(e, (counts.get(e) ?? 0) + 1);
59
+ return [...counts.entries()].sort(([a], [b]) => (a < b ? -1 : 1));
60
+ }
61
+
62
+ function governedArm(governed: ReadonlySet<string>, role: string): boolean {
63
+ return governed.has(role) || role === PUBLIC_ROLE || (role.toUpperCase() === 'PUBLIC' && governed.has('PUBLIC'));
64
+ }
65
+
66
+ function armDead(t: TableContract, a: PolicyArm): boolean {
67
+ if (a.role === PUBLIC_ROLE || a.role.toUpperCase() === 'PUBLIC') {
68
+ const grantees = t.effectivePrivileges
69
+ ? Object.keys(t.effectivePrivileges)
70
+ : [...new Set([...Object.keys(t.grants), ...Object.keys(t.columnGrants ?? {})])];
71
+ return !grantees.some((r) => holdsPrivilegeEffective(t, r, a.command));
72
+ }
73
+ return !holdsPrivilegeEffective(t, a.role, a.command);
74
+ }
75
+
76
+ function armSubsumed(a: PolicyArm, alive: readonly PolicyArm[]): boolean {
77
+ if (!a.permissive || a.role === PUBLIC_ROLE || a.role.toUpperCase() === 'PUBLIC') return false;
78
+ return alive.some((o) =>
79
+ (o.role === PUBLIC_ROLE || o.role.toUpperCase() === 'PUBLIC')
80
+ && o.permissive && o.command === a.command && o.using === a.using && o.check === a.check);
81
+ }
82
+
83
+ function canonicalPolicyArms(contract: TableContract, governed?: ReadonlySet<string>): Array<[string, number]> {
84
+ const alive = contract.policies.flatMap(policyArms).filter((a) => !armDead(contract, a));
85
+ const kept = alive
86
+ .filter((a) => !armSubsumed(a, alive))
87
+ .filter((a) => (governed ? governedArm(governed, a.role) : true));
88
+ return multiset(kept.map(armKey));
89
+ }
90
+
91
+ function governedOnly<T>(map: Record<string, T> | undefined, governed: ReadonlySet<string> | undefined): Record<string, T> {
92
+ if (!map) return {};
93
+ if (!governed) return map;
94
+ return Object.fromEntries(
95
+ Object.entries(map).filter(([grantee]) => governed.has(grantee) || (grantee.toUpperCase() === 'PUBLIC' && governed.has('PUBLIC'))),
96
+ );
97
+ }
98
+
99
+ function canonicalAuthzV11(contract: TableContract, governed?: ReadonlySet<string>): Record<string, unknown> {
100
+ const grants = governedOnly(contract.grants, governed);
101
+ const columnGrants = governedOnly(contract.columnGrants, governed);
102
+ return {
103
+ table: contract.table,
104
+ rls: { enabled: contract.rls.enabled, forced: contract.rls.forced },
105
+ grants: Object.fromEntries(
106
+ Object.entries(grants).map(([role, privs]) => [role, [...(privs as string[])].sort()] as const).sort(([a], [b]) => (a < b ? -1 : 1)),
107
+ ),
108
+ ...(Object.keys(columnGrants).length > 0
109
+ ? {
110
+ columnGrants: Object.fromEntries(
111
+ Object.entries(columnGrants)
112
+ .map(([role, byPriv]) => [
113
+ role,
114
+ Object.fromEntries(
115
+ Object.entries(byPriv as Record<string, string[]>).map(([priv, cols]) => [priv, [...cols].sort()] as const).sort(([a], [b]) => (a < b ? -1 : 1)),
116
+ ),
117
+ ] as const)
118
+ .sort(([a], [b]) => (a < b ? -1 : 1)),
119
+ ),
120
+ }
121
+ : {}),
122
+ policies: canonicalPolicyArms(contract, governed),
123
+ };
124
+ }
125
+
126
+ // --- v11 base-schema canonical form (identical to v12; frozen here for self-containment) ---
127
+
128
+ function canonicalTableV11(table: TableSchema): Record<string, unknown> {
129
+ return {
130
+ table: table.table,
131
+ columns: byKey(
132
+ table.columns.map((c) => ({ name: c.name, type: c.type, notNull: c.notNull, default: normalizeDefault(c.default) })),
133
+ (c) => c.name,
134
+ ),
135
+ primaryKey: table.primaryKey,
136
+ uniques: byKey(table.uniques.map((u) => ({ columns: u.columns })), (u) => u.columns.join(',')),
137
+ checks: byKey(table.checks.map((c) => ({ expr: normalizeCheck(c.expr) })), (c) => c.expr),
138
+ foreignKeys: byKey(
139
+ table.foreignKeys.map((f) => ({
140
+ columns: f.columns, refTable: f.refTable, refColumns: f.refColumns,
141
+ ...(f.onDelete ? { onDelete: f.onDelete } : {}),
142
+ ...(f.onUpdate ? { onUpdate: f.onUpdate } : {}),
143
+ })),
144
+ (f) => stableStringify(f),
145
+ ),
146
+ indexes: [...new Set(table.indexes.map(indexKey))].sort(),
147
+ };
148
+ }
149
+
150
+ // --- v11 serial-sequence ACL slice (frozen: the derived set is SELECT + UPDATE + USAGE) ---
151
+
152
+ /**
153
+ * The v11 inheritance rule: an INSERT-holder inherits SELECT + UPDATE + USAGE on the table's
154
+ * serial sequence. This is the ONLY line that differs from the live (v12) form, which derives
155
+ * USAGE alone. Pinned as a literal here so this frozen canon never tracks the live change.
156
+ */
157
+ function inheritedSequenceGrantsV11(table: TableContract): Record<string, string[]> {
158
+ const out: Record<string, string[]> = {};
159
+ for (const [grantee, privileges] of Object.entries(table.grants)) {
160
+ if (privileges.includes('INSERT')) out[grantee] = ['SELECT', 'UPDATE', 'USAGE'];
161
+ }
162
+ return out;
163
+ }
164
+
165
+ function isGovernedGrantee(governed: ReadonlySet<string> | undefined, grantee: string): boolean {
166
+ if (!governed) return true;
167
+ return governed.has(grantee) || (grantee.toUpperCase() === 'PUBLIC' && governed.has('PUBLIC'));
168
+ }
169
+
170
+ function seqShape(grants: Record<string, string[]>, governed?: ReadonlySet<string>): string {
171
+ const kept = Object.entries(grants)
172
+ .filter(([grantee, privs]) => privs.length > 0 && isGovernedGrantee(governed, grantee))
173
+ .map(([grantee, privs]) => [grantee, [...privs].sort()] as const);
174
+ return stableStringify(Object.fromEntries(kept));
175
+ }
176
+
177
+ function canonicalSequenceAuthzV11(
178
+ tables: TableSchema[],
179
+ authzTables: TableContract[],
180
+ measured: SequenceGrantContract[] | undefined,
181
+ governed: ReadonlySet<string> | undefined,
182
+ keepTable: (name: string) => boolean,
183
+ ): Array<[string, string[]]> {
184
+ const snapshotByTable = new Map(tables.map((t) => [t.table, t]));
185
+ const measuredByTable = new Map<string, SequenceGrantContract[]>();
186
+ for (const s of measured ?? []) {
187
+ (measuredByTable.get(s.table) ?? measuredByTable.set(s.table, []).get(s.table)!).push(s);
188
+ }
189
+ const out: Array<[string, string[]]> = [];
190
+ for (const az of authzTables) {
191
+ if (!keepTable(az.table)) continue;
192
+ let shapes: string[];
193
+ if (measuredByTable.has(az.table)) {
194
+ shapes = measuredByTable.get(az.table)!.filter((s) => !s.identity).map((s) => seqShape(s.grants, governed));
195
+ } else {
196
+ const snap = snapshotByTable.get(az.table);
197
+ const serialCount = (snap?.columns ?? []).filter((c) => nextvalSequence(normalizeDefault(c.default)) != null).length;
198
+ if (serialCount === 0) continue;
199
+ const inherited = seqShape(inheritedSequenceGrantsV11(az), governed);
200
+ shapes = Array.from({ length: serialCount }, () => inherited);
201
+ }
202
+ if (shapes.length === 0) continue;
203
+ out.push([az.table, shapes.sort()]);
204
+ }
205
+ return out.sort(([a], [b]) => (a < b ? -1 : 1));
206
+ }
207
+
208
+ /** The frozen v11 live hash for (snapshot, authzTables, opts). */
209
+ export function fingerprintV11(snapshot: SchemaSnapshot, authzTables: TableContract[], opts: CanonicalizeOptions = {}): string {
210
+ const inScope = opts.schemas ? new Set(opts.schemas) : null;
211
+ const keepTable = (name: string) => inScope === null || inScope.has(schemaOf(name));
212
+ const tables = snapshot.tables.filter((t) => keepTable(t.table));
213
+ const usedEnum = new Set<string>();
214
+ 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));
215
+ const sequences = byKey(
216
+ (snapshot.sequences ?? [])
217
+ .filter((s) => inScope === null || inScope.has(schemaOf(s.name)))
218
+ .map((s) => ({ name: s.name, as: s.as, ...(s.start != null ? { start: s.start } : {}), ...(s.increment != null ? { increment: s.increment } : {}) })),
219
+ (s) => String(s.name),
220
+ );
221
+ const sequenceAuthz = canonicalSequenceAuthzV11(tables, authzTables, opts.sequenceGrants, opts.governedRoles, keepTable);
222
+ const canonical = {
223
+ v: 11,
224
+ tables: byKey(tables.map(canonicalTableV11), (t) => String(t.table)),
225
+ enums: byKey(
226
+ (snapshot.enums ?? []).filter((e) => inScope === null || usedEnum.has(e.name)).map((e) => ({ name: e.name, values: e.values })),
227
+ (e) => e.name,
228
+ ),
229
+ authz: byKey(authzTables.filter((t) => keepTable(t.table)).map((t) => canonicalAuthzV11(t, opts.governedRoles)), (t) => String(t.table)),
230
+ ...(sequences.length ? { sequences } : {}),
231
+ ...(sequenceAuthz.length ? { sequenceAuthz } : {}),
232
+ };
233
+ return createHash('sha256').update(stableStringify(canonical)).digest('hex');
234
+ }
@@ -60,6 +60,7 @@ import { compileTableContract } from './authz-compile';
60
60
  import { governedRoleSet, inheritedSequenceGrants } from './authz-reconcile';
61
61
  import { normalizeDefault, normalizeCheck, indexKey, nextvalSequence } from './schema-diff';
62
62
  import { fingerprintV10 } from './schema-fingerprint-v10';
63
+ import { fingerprintV11 } from './schema-fingerprint-v11';
63
64
 
64
65
  // v2: expression fields (defaults, checks, partial-index WHERE) normalize through
65
66
  // the diff's pg-deparse normalizers before hashing, so the model form and the live
@@ -130,7 +131,17 @@ import { fingerprintV10 } from './schema-fingerprint-v10';
130
131
  // plan or baseline minted under v10 refuses as a FORMAT CHANGE and must be re-minted (db:plan) or
131
132
  // re-baselined (db:reconcile --rebaseline) — no DDL, the fingerprint is a content address. The v10
132
133
  // canon is frozen in RETAINED_CANONS so a schema_log row recorded under it still descends.
133
- export const FINGERPRINT_VERSION = 11;
134
+ // v12 — SERIAL-SEQUENCE grants narrowed to LEAST PRIVILEGE. The v11 A6 rule derived SELECT + UPDATE
135
+ // + USAGE for every INSERT-holder, handing each one `setval` control of a surrogate key it never
136
+ // needs; it widened a consumer's governed prod migrator by 28 UPDATE grants nobody asked for. The
137
+ // derived set is now USAGE alone (exactly what `nextval` needs — see SEQUENCE_PRIVILEGES_FOR_INSERT),
138
+ // so `canonicalSequenceAuthz`'s DERIVED shape moves for any serial table with a governed INSERT, and
139
+ // the fingerprint with it. Same consequence as every bump: a plan or baseline minted under v11
140
+ // refuses as a FORMAT CHANGE and must be re-minted — no DDL, the fingerprint is a content address.
141
+ // On every deployed stage the next db:plan mints a REVOKE narrowing those sequence grants to USAGE
142
+ // (the report classifies it as narrowing access). The v11 canon is frozen in schema-fingerprint-v11
143
+ // and registered in RETAINED_CANONS so a schema_log row recorded under it still descends.
144
+ export const FINGERPRINT_VERSION = 12;
134
145
 
135
146
  // ---------------------------------------------------------------------------
136
147
  // Canonical form.
@@ -489,11 +500,13 @@ export type CanonHasher = (
489
500
  * absent from this map is unrecomputable: `fingerprintLiveAtVersion` returns null and descent
490
501
  * SKIPS that row (fails closed), never guesses a hash.
491
502
  *
492
- * v11 is the FIRST bump, so v10 is the first retired entry: `fingerprintV10`, a self-contained
493
- * frozen copy of the v10 canonical form (no command fan, direct-grant deadness, no sequence ACLs).
503
+ * Retired entries: `fingerprintV10` (no command fan, direct-grant deadness, no sequence ACLs) and
504
+ * `fingerprintV11` (the command fan and sequence ACLs, but the pre-least-privilege derivation that
505
+ * gave an INSERT-holder SELECT + UPDATE + USAGE on its sequence).
494
506
  */
495
507
  export const RETAINED_CANONS: Readonly<Record<number, CanonHasher>> = {
496
508
  10: (snapshot, tables, opts) => fingerprintV10(snapshot, tables, opts),
509
+ 11: (snapshot, tables, opts) => fingerprintV11(snapshot, tables, opts),
497
510
  [FINGERPRINT_VERSION]: (snapshot, tables, opts) => fingerprintState(snapshot, tables, opts).hash,
498
511
  };
499
512
 
@@ -527,7 +540,7 @@ export function fingerprintLiveAtVersion(
527
540
  * manifest (derived-introspect): triggers, domains, composite types, foreign
528
541
  * tables, partitioned tables, installed extensions — and identity columns, which
529
542
  * the models can now say and the builder now builds, but which the hash does not
530
- * yet cover (deferred past v11 by scope). Serial-sequence ACLs left this list at
543
+ * yet cover (deferred past v12 by scope). Serial-sequence ACLs left this list at
531
544
  * v11: they are fingerprinted now (see canonicalSequenceAuthz).
532
545
  */
533
546
  export const UNFINGERPRINTED_SQL = `
@@ -573,8 +586,9 @@ UNION ALL
573
586
  -- A14. A column's GENERATED … AS IDENTITY is READ, DECLARED and BUILT, but it is NOT hashed:
574
587
  -- the fingerprint's coverage is a version contract, and a property entering the hash inside a
575
588
  -- version makes two operators on different CLI builds disagree about the same database. v11
576
- -- took the sequence ACLs and deliberately left this and the schema posture out; both stay
577
- -- named here until a canon bump (a v12 candidate) carries them together. The gap is real and
589
+ -- took the sequence ACLs and deliberately left this and the schema posture out (v12 narrowed
590
+ -- the sequence derivation and carried neither); both stay named here until a later canon bump
591
+ -- carries them together. The gap is real and
578
592
  -- narrow: an identity that is silently dropped or flipped ALWAYS to BY DEFAULT leaves the MATCH
579
593
  -- claim green. It is not silent on the write path — db:generate/db:plan diff it (the differ
580
594
  -- compares identity) — and the round-trip oracle's pg_dump comparison catches it.
@@ -0,0 +1,98 @@
1
+ /**
2
+ * sql-statement — reading ONE generated statement, before anybody classifies it.
3
+ *
4
+ * Every classifier over db:generate's statement stream starts by answering the same question:
5
+ * which lines of this statement are SQL, and which are the `-- WARNING:` / `-- NOTICE:` prologue
6
+ * the emitter attached to it? The answer decides what a destructive check tests, which bucket a
7
+ * histogram counts, and what verb the plain change list prints. It was written out by hand eight
8
+ * times across plain-output.ts, state-apply.ts and edge-plan.ts — the same
9
+ * `split('\n').find((l) => l.trim() !== '' && !l.trim().startsWith('--'))`, with two different
10
+ * fallbacks and no shared name. Two classifiers that read a statement differently disagree about
11
+ * it before either one has decided anything, so the reading lives here, once.
12
+ *
13
+ * There are TWO readings, and which one a caller wants is a real decision:
14
+ *
15
+ * statementBody the WHOLE statement on one line. What you MATCH against. A generated
16
+ * statement is not single-line — the emitters format `ALTER TABLE`,
17
+ * `CREATE TRIGGER`, `GRANT` and every function body across lines — so a
18
+ * matcher that sees only line 1 sees a subject with no verb.
19
+ * statementHead the FIRST SQL line. What you PRINT when one line of an operator-facing
20
+ * preview is all there is room for.
21
+ *
22
+ * This module READS. It does not classify: what counts as destructive is state-apply's
23
+ * `classifyDestructive` (the single source the --confirm / snapshot / approver gate and
24
+ * plain-output's ⚠ line both run on), what counts as a histogram kind is state-apply's
25
+ * `KIND_MATCHERS`, and what a change is called in plain words is plain-output's `describeChange`.
26
+ * Those are three different questions on three different axes, deliberately not merged.
27
+ *
28
+ * Pure, and dependency-free on purpose — plain-output is a pure renderer and must stay one.
29
+ */
30
+
31
+ /**
32
+ * The SQL lines of a statement: everything that is not blank and not a `--` comment,
33
+ * each still carrying its own leading whitespace.
34
+ *
35
+ * A statement's comment prologue is documentation the emitter wrote for the operator. It is
36
+ * never the verb, and a reader that stops at line 1 reports `-- WARNING: DROP IDENTITY …` as
37
+ * the change rather than the ALTER underneath it.
38
+ */
39
+ export function statementBodyLines(statement: string): string[] {
40
+ return statement.split('\n').filter((l) => l.trim() !== '' && !l.trim().startsWith('--'));
41
+ }
42
+
43
+ /**
44
+ * The WHOLE statement as one line: comments and blank lines dropped, whitespace collapsed,
45
+ * CASE PRESERVED. This is the reading every MATCHER wants.
46
+ *
47
+ * Two failures it exists to stop, one in each direction.
48
+ *
49
+ * UNDER-reading, the dangerous one. A matcher anchored at `^` against the first line only sees
50
+ * `ALTER TABLE public.foo` for `ALTER TABLE public.foo\n DROP COLUMN bar;` — the verb clause is
51
+ * on line 2. The plain change list matched nothing and printed `+` on a statement that REMOVES a
52
+ * column, and `revokeTarget` returned null for a `REVOKE` sitting under a `-- WARNING:` prologue,
53
+ * so a real privilege strip skipped the --confirm / snapshot / approver ceremony.
54
+ *
55
+ * OVER-reading, the other one. A matcher run against the RAW text reads the emitter's prose:
56
+ * `-- WARNING: this used to DROP TABLE …` ahead of an `ADD COLUMN` classified the addition as a
57
+ * drop. The comment is documentation, not DDL.
58
+ *
59
+ * Case is left exactly as the emitter wrote it. Callers whose matchers are upper-case uppercase
60
+ * the result themselves — the grantee `revokeTarget` returns out of this string is an identifier,
61
+ * and `"Odd-Role"` is not `"ODD-ROLE"`.
62
+ *
63
+ * The result is MATCHED against, never executed: a function body losing its own formatting here
64
+ * costs nothing, because the statement that runs is always the original string.
65
+ */
66
+ export function statementBody(statement: string): string {
67
+ return statementBodyLines(statement).join(' ').replace(/\s+/g, ' ').trim();
68
+ }
69
+
70
+ /**
71
+ * The first SQL line, trimmed — the verb. Empty string when the statement is all comment.
72
+ *
73
+ * For callers that want ONE line of a statement, not the statement: the `! <ddl>` previews in
74
+ * the plan summary. Anything that MATCHES wants {@link statementBody} instead — see the
75
+ * under-reading failure documented there. Callers that PRINT the head want
76
+ * {@link statementHeadOrSelf}, so a pure-comment statement still shows the operator something.
77
+ */
78
+ export function statementHead(statement: string): string {
79
+ const [first] = statementBodyLines(statement);
80
+ return (first ?? '').trim();
81
+ }
82
+
83
+ /**
84
+ * The first SQL line, trimmed, falling back to the whole statement when there is none.
85
+ *
86
+ * For the paths that ECHO the statement at the operator (`! <ddl>` in the plan summary):
87
+ * printing nothing for a statement that is all comment would silently drop a line the reader
88
+ * is counting.
89
+ */
90
+ export function statementHeadOrSelf(statement: string): string {
91
+ const [first] = statementBodyLines(statement);
92
+ return (first ?? statement).trim();
93
+ }
94
+
95
+ /** Every line is blank or a comment — nothing executable inside. */
96
+ export function isPureComment(statement: string): boolean {
97
+ return statementBodyLines(statement).length === 0;
98
+ }
@@ -27,16 +27,10 @@ import { generateMigrationSql, HELD_DROP_PREFIX } from './migration-generate';
27
27
  import type { SequenceDescriptor } from '@everystack/model';
28
28
  import { fingerprintLive, FINGERPRINT_VERSION } from './schema-fingerprint';
29
29
  import { ENSURE_RECONCILER_SQL, renderSchemaLogInsert, renderSchemaLogFingerprintUpdate } from './derived-apply';
30
+ import { isPureComment, statementBody, statementHead } from './sql-statement';
30
31
 
31
32
  const NOTICE_PREFIX = '-- NOTICE';
32
33
 
33
- /** Every line is a comment (or blank) — nothing executable inside. */
34
- function isPureComment(statement: string): boolean {
35
- return statement
36
- .split('\n')
37
- .every((line) => line.trim() === '' || line.trim().startsWith('--'));
38
- }
39
-
40
34
  export interface ClassifiedStatements {
41
35
  /** Real DDL (possibly carrying leading -- WARNING lines) — what apply runs. */
42
36
  executable: string[];
@@ -105,11 +99,19 @@ export interface DestructiveBreakdown {
105
99
  * the same rule the reconciler already applies to grants: absence of knowledge means LEAVE IT
106
100
  * ALONE and say so, never "assume it is fine".
107
101
  */
102
+ // TODO(PR-1b): classify on emitter kind, not rendered text — see the fast-follow item on the board.
108
103
  const ADDITIVE_MATCHERS: RegExp[] = [
109
104
  /^CREATE\s+(TABLE|POLICY|INDEX|UNIQUE\s+INDEX|SEQUENCE|EXTENSION|TYPE|SCHEMA)\b/,
110
105
  /^GRANT\b/,
111
106
  /^COMMENT\s+ON\b/,
112
- /^ALTER\s+TABLE\s+[\s\S]+?\sADD\s+(COLUMN|CONSTRAINT|PRIMARY\s+KEY)\b/,
107
+ // ADD COLUMN alone. Its siblings `ADD CONSTRAINT` (FOREIGN KEY / CHECK / UNIQUE) and
108
+ // `ADD PRIMARY KEY` are deliberately NOT here: each one VALIDATES every row already in the
109
+ // table and then RESTRICTS every write after it. An FK add fails on orphan rows, a CHECK add
110
+ // fails on any violating row, a UNIQUE add fails on existing duplicates, and ADD PRIMARY KEY
111
+ // fails on duplicates or NULLs *and* silently makes every included column NOT NULL. Calling
112
+ // those additive tells the reviewer the plan is safe when it can abort the apply outright —
113
+ // the same false zero described above. They read as `unclassified`, like `SET NOT NULL`.
114
+ /^ALTER\s+TABLE\s+[\s\S]+?\sADD\s+COLUMN\b/,
113
115
  // Enabling/forcing RLS only ever RESTRICTS; it cannot widen access or lose a row.
114
116
  /^ALTER\s+TABLE\s+[\s\S]+?\s(ENABLE|FORCE)\s+ROW\s+LEVEL\s+SECURITY\b/,
115
117
  // Relaxing nullability and setting a default add capability; they remove nothing.
@@ -148,12 +150,16 @@ export interface StatementPartition {
148
150
  unclassified: string[];
149
151
  }
150
152
 
151
- /** The first non-comment line, upper-cased — WARNING prologues must not hide the verb. */
152
- function statementHead(statement: string): string {
153
- return (statement.split('\n').find((l) => l.trim() !== '' && !l.trim().startsWith('--')) ?? '')
154
- .trim()
155
- .toUpperCase();
156
- }
153
+ /**
154
+ * The first non-comment line, upper-cased — WARNING prologues must not hide the verb.
155
+ *
156
+ * The reading is shared with the plain change list (sql-statement.ts); the upper-casing is
157
+ * this module's own, because every matcher below is written in upper case. Note this is the
158
+ * FIRST-LINE reading, deliberately: `partitionStatements` and `statementKind` bucket a statement
159
+ * by the verb it opens with. What DESTROYS is a different question and uses {@link statementBody}
160
+ * — see `classifyDestructive`.
161
+ */
162
+ const upperHead = (statement: string): string => statementHead(statement).toUpperCase();
157
163
 
158
164
  /**
159
165
  * Partition an executable stream into exactly one bucket per statement.
@@ -174,7 +180,7 @@ export function partitionStatements(
174
180
 
175
181
  for (const statement of executable) {
176
182
  if (destructive.has(statement)) continue;
177
- const head = statementHead(statement);
183
+ const head = upperHead(statement);
178
184
  if (AUTHZ_REMOVAL_MATCHERS.some((re) => re.test(head))) authzRemovals.push(statement);
179
185
  else if (ADDITIVE_MATCHERS.some((re) => re.test(head))) additive.push(statement);
180
186
  else unclassified.push(statement);
@@ -182,12 +188,22 @@ export function partitionStatements(
182
188
  return { additive, drops, narrowings, strips, authzRemovals, unclassified };
183
189
  }
184
190
 
185
- /** `REVOKE … ON <table> FROM <grantee>;` → the grantee, or null when it is not a revoke. */
191
+ /**
192
+ * `REVOKE … ON <table> FROM <grantee>;` → the grantee, or null when it is not a revoke.
193
+ *
194
+ * Read from {@link statementBody}, not the raw text. The regex is anchored at `^REVOKE`, and the
195
+ * emitter welds a `-- WARNING:` prologue onto statements it wants the operator to read — so the
196
+ * raw text of a revoke worth warning about starts with `--`, matched nothing, and returned null.
197
+ * Null here means "not a revoke", which UNDER-classifies a real privilege strip and skips the
198
+ * whole --confirm / snapshot / approver ceremony on it. That is the failure direction that
199
+ * matters: it removes a gate rather than adding one.
200
+ *
201
+ * Case is NOT touched. A quoted identifier can hold anything (`"Odd-Role"`, a reserved word,
202
+ * mixed case) and the grantee returned here is compared against the declared set, where
203
+ * `Odd-Role` and `ODD-ROLE` are two different roles.
204
+ */
186
205
  export function revokeTarget(statement: string): string | null {
187
- // A quoted identifier can hold anything (`"Odd-Role"`, a reserved word, mixed case), and
188
- // reading one as null would silently UNDER-classify a strip — the failure direction that
189
- // matters, since it skips the ceremony rather than adding one.
190
- const m = /^REVOKE\s+[\s\S]+?\sFROM\s+("[^"]*"|[A-Za-z_][A-Za-z0-9_$]*)\s*;?\s*$/i.exec(statement.trim());
206
+ const m = /^REVOKE\s+[\s\S]+?\sFROM\s+("[^"]*"|[A-Za-z_][A-Za-z0-9_$]*)\s*;?\s*$/i.exec(statementBody(statement));
191
207
  return m ? m[1].replace(/^"|"$/g, '') : null;
192
208
  }
193
209
 
@@ -199,10 +215,17 @@ export function classifyDestructive(
199
215
  const narrowings: string[] = [];
200
216
  const strips: string[] = [];
201
217
  for (const statement of executable) {
218
+ // Every matcher here reads the statement's BODY, never its raw text. The emitter's
219
+ // `-- WARNING:` prologue is prose written for the operator, and matching it both ways round
220
+ // was wrong: a comment that merely MENTIONS `DROP TABLE` ahead of an `ADD COLUMN` classified
221
+ // the addition as a drop, and a real `REVOKE` hiding under a prologue was not seen at all.
222
+ // The body preserves case, which these upper-case matchers rely on — the emitters write SQL
223
+ // keywords in upper case and nothing here lower-cases or upper-cases anything.
224
+ const body = statementBody(statement);
202
225
  // DROP IDENTITY is a drop: it takes the backing sequence and its counter with it, and a
203
226
  // counter is state no re-run reconstructs. The column's rows survive; its auto-value does not.
204
- if (/\bDROP\s+(TABLE|COLUMN|TYPE|IDENTITY)\b/.test(statement)) drops.push(statement);
205
- else if (/\bSET DATA TYPE\b/.test(statement) && /\bUSING\b/.test(statement)) narrowings.push(statement);
227
+ if (/\bDROP\s+(TABLE|COLUMN|TYPE|IDENTITY)\b/.test(body)) drops.push(statement);
228
+ else if (/\bSET DATA TYPE\b/.test(body) && /\bUSING\b/.test(body)) narrowings.push(statement);
206
229
  else if (opts.declaredGrantees) {
207
230
  // Only classified when the caller supplied the declared side — without it there is
208
231
  // no way to tell a strip from ordinary churn, and guessing would newly force
@@ -240,8 +263,7 @@ const KIND_MATCHERS: Array<[RegExp, string]> = [
240
263
  ];
241
264
 
242
265
  function statementKind(statement: string): string {
243
- const first = statement.split('\n').find((l) => l.trim() !== '' && !l.trim().startsWith('--')) ?? '';
244
- const s = first.trim().toUpperCase();
266
+ const s = upperHead(statement);
245
267
  for (const [re, kind] of KIND_MATCHERS) {
246
268
  if (re.test(s)) return kind;
247
269
  }
package/src/wire.ts ADDED
@@ -0,0 +1,30 @@
1
+ /**
2
+ * @everystack/cli/wire — the Model-derived wire contract, as the ops Lambda reaches it.
3
+ *
4
+ * The definitions live in `@everystack/model/wire` (a neutral package, so the shapes have ONE
5
+ * home rather than a CLI copy and a server copy that agree by convention). This file is a pure
6
+ * re-export, and it exists for resolution, not for taste: `@everystack/server` does not declare
7
+ * `@everystack/model` — under pnpm's strict layout it therefore cannot resolve
8
+ * `@everystack/model/wire` from its own files. It CAN resolve `@everystack/cli/*` (an optional
9
+ * peer it already loads `@everystack/cli/apply` from), and the CLI depends on `@everystack/model`
10
+ * outright, so the constant reaches the server through here.
11
+ *
12
+ * That routing is also what makes the guard measure the RIGHT thing. The server reads
13
+ * MODEL_WIRE_CONTRACT from the `@everystack/cli` bundled into its Lambda; the operator's CLI reads
14
+ * it from its own install. Those are the two versions that can drift, which is precisely the skew
15
+ * the handshake exists to refuse — the same axis `APPLY_CONTRACT_VERSION` already rides.
16
+ */
17
+
18
+ export { MODEL_WIRE_CONTRACT } from '@everystack/model/wire';
19
+ export type {
20
+ ModelWireEnvelope,
21
+ DeclaredOwner,
22
+ AdopteeKind,
23
+ Adoptee,
24
+ ProvisionWirePayload,
25
+ OwnershipWirePayload,
26
+ DerivedKind,
27
+ DerivedAttachment,
28
+ DerivedDescriptor,
29
+ ReconcileWirePayload,
30
+ } from '@everystack/model/wire';