@everystack/cli 0.4.44 → 0.4.46

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.
Files changed (42) hide show
  1. package/package.json +2 -2
  2. package/src/cli/alter-type-dependents.ts +96 -0
  3. package/src/cli/apply-execute.ts +22 -8
  4. package/src/cli/authz-adoption-class.ts +314 -0
  5. package/src/cli/authz-baseline.ts +25 -3
  6. package/src/cli/authz-canonical.ts +178 -0
  7. package/src/cli/authz-compile.ts +130 -40
  8. package/src/cli/authz-contract.ts +212 -44
  9. package/src/cli/authz-derive.ts +244 -34
  10. package/src/cli/authz-identity.ts +222 -0
  11. package/src/cli/authz-ownership.ts +193 -0
  12. package/src/cli/authz-reconcile.ts +61 -27
  13. package/src/cli/aws.ts +32 -0
  14. package/src/cli/commands/db-apply.ts +60 -14
  15. package/src/cli/commands/db-authz.ts +9 -14
  16. package/src/cli/commands/db-fingerprint.ts +54 -18
  17. package/src/cli/commands/db-generate.ts +59 -15
  18. package/src/cli/commands/db-plan.ts +89 -9
  19. package/src/cli/commands/db-pull.ts +36 -19
  20. package/src/cli/commands/db-reconcile.ts +18 -20
  21. package/src/cli/commands/db-swap.ts +5 -4
  22. package/src/cli/commands/db-sync.ts +8 -5
  23. package/src/cli/db-build.ts +2 -2
  24. package/src/cli/db-source.ts +56 -0
  25. package/src/cli/derived-introspect.ts +27 -26
  26. package/src/cli/derived-lint.ts +7 -8
  27. package/src/cli/edge-plan.ts +125 -17
  28. package/src/cli/git-descent.ts +16 -9
  29. package/src/cli/index.ts +2 -18
  30. package/src/cli/model-render.ts +75 -52
  31. package/src/cli/output.ts +25 -3
  32. package/src/cli/parse-flags.ts +39 -0
  33. package/src/cli/schema-compile.ts +6 -1
  34. package/src/cli/schema-diff.ts +1 -1
  35. package/src/cli/schema-fingerprint.ts +154 -42
  36. package/src/cli/schema-introspect.ts +44 -17
  37. package/src/cli/schema-source.ts +9 -0
  38. package/src/cli/session.ts +184 -0
  39. package/src/cli/stage-read-consistency.ts +128 -0
  40. package/src/cli/state-apply.ts +4 -2
  41. package/src/cli/swap-execute.ts +4 -3
  42. package/src/cli/search-path.ts +0 -51
@@ -32,7 +32,8 @@ import type { ModelDescriptor } from '@everystack/model';
32
32
  import type { SchemaSnapshot } from './schema-introspect.js';
33
33
  import type { AuthzContract } from './authz-contract.js';
34
34
  import { fingerprintLive } from './schema-fingerprint.js';
35
- import { predictLiveFingerprint } from './edge-plan.js';
35
+ import { predictLiveFingerprint, governedLiveFingerprint } from './edge-plan.js';
36
+ import { governedRolesForModels } from './schema-fingerprint.js';
36
37
 
37
38
  /** Compile a models barrel on disk. The default rides the CLI's runtime (tsx). */
38
39
  export type ModelsLoader = (barrel: string) => Promise<ModelDescriptor[]>;
@@ -174,7 +175,6 @@ export async function verifyDescent(
174
175
  const barrel = path.basename(modelsAbs);
175
176
 
176
177
  const candidates = enumerateModelTrees(modelsDir, repoRoot);
177
- const liveFingerprint = fingerprintLive(live.snapshot, live.contract).hash;
178
178
 
179
179
  const nodeModules = path.join(repoRoot, 'node_modules');
180
180
  const materializeRoot = opts.materializeRoot
@@ -187,29 +187,36 @@ export async function verifyDescent(
187
187
  try {
188
188
  for (const candidate of candidates) {
189
189
  const dest = path.join(materializeRoot, candidate.tree);
190
- let predicted: string;
190
+ let declares: boolean;
191
191
  // A swallowed error here skips the candidate — and if it was the DECLARING tree, the
192
192
  // verdict silently falls through to 'drift'. Real compile failures are deterministic;
193
193
  // a transient materialize/import failure (fd pressure, module-loader contention under
194
194
  // a parallel test run or a busy CI box) is not — so a failed candidate gets exactly
195
195
  // one retry, from a clean materialization, before it is recorded as a compile failure.
196
- const attempt = async (): Promise<string> => {
196
+ //
197
+ // The declares-test is GOVERNED on both sides, per candidate: the commit's
198
+ // models define the governed set, and the live hash is filtered through the
199
+ // SAME set as the prediction — comparing a governed prediction against the
200
+ // raw live hash can never match on a brownfield target with foreign roles.
201
+ const attempt = async (): Promise<boolean> => {
197
202
  if (!fs.existsSync(dest)) materializeTree(candidate.tree, dest, repoRoot);
198
203
  const models = await loader(path.join(dest, barrel));
199
- return predictLiveFingerprint(models, live.snapshot, live.contract, { schema: opts.schema });
204
+ const governedRoles = governedRolesForModels(models);
205
+ const predicted = predictLiveFingerprint(models, live.snapshot, live.contract, { schema: opts.schema, governedRoles });
206
+ return predicted === governedLiveFingerprint(live.snapshot, live.contract, governedRoles);
200
207
  };
201
208
  try {
202
209
  try {
203
- predicted = await attempt();
210
+ declares = await attempt();
204
211
  } catch {
205
212
  fs.rmSync(dest, { recursive: true, force: true });
206
- predicted = await attempt();
213
+ declares = await attempt();
207
214
  }
208
215
  } catch (err: any) {
209
216
  compileFailures.push(`${short(candidate.tree)} (at ${short(candidate.commits[0])}): ${err.message}`);
210
217
  continue;
211
218
  }
212
- if (predicted !== liveFingerprint) continue;
219
+ if (!declares) continue;
213
220
 
214
221
  for (const commit of candidate.commits) {
215
222
  try {
@@ -255,7 +262,7 @@ export async function verifyDescent(
255
262
  scannedTrees: candidates.length,
256
263
  compileFailures,
257
264
  reason:
258
- `no committed models state declares the target's live fingerprint ${short(liveFingerprint)} — ` +
265
+ `no committed models state declares the target's live fingerprint ${short(fingerprintLive(live.snapshot, live.contract).hash)} — ` +
259
266
  `scanned ${candidates.length} historical tree(s) across all refs${failureNote}. ` +
260
267
  'Either the database was hand-edited (drift) or the declaring history was rewritten. ' +
261
268
  'Align the models with the live database first (db:pull, commit), ' +
package/src/cli/index.ts CHANGED
@@ -45,27 +45,11 @@ import { auditCommand } from './commands/audit.js';
45
45
  import { uiAuditCommand } from './commands/ui-audit.js';
46
46
  import { runbookCommand } from './commands/runbook.js';
47
47
  import { fail } from './output.js';
48
+ import { parseFlags } from './parse-flags.js';
48
49
 
49
50
  const args = process.argv.slice(2);
50
51
  const command = args[0];
51
52
 
52
- function parseFlags(args: string[]): Record<string, string> {
53
- const flags: Record<string, string> = {};
54
- for (let i = 0; i < args.length; i++) {
55
- // Handle both --flag and -flag
56
- if (args[i].startsWith('--')) {
57
- const key = args[i].slice(2);
58
- const value = args[i + 1] && !args[i + 1].startsWith('-') ? args[++i] : 'true';
59
- flags[key] = value;
60
- } else if (args[i].startsWith('-') && args[i].length > 1) {
61
- const key = args[i].slice(1);
62
- const value = args[i + 1] && !args[i + 1].startsWith('-') ? args[++i] : 'true';
63
- flags[key] = value;
64
- }
65
- }
66
- return flags;
67
- }
68
-
69
53
  /**
70
54
  * Auto-detect HOST_URL from SST outputs.
71
55
  * SST writes .sst/outputs.json after every deploy with { routerUrl, apiUrl, ... }.
@@ -383,7 +367,7 @@ Usage:
383
367
  everystack db:sync [--database-url <url>] [--models <barrel>] [--schema-out <file.ts>] [--allow-drops] [--overwrite-drift] [--baseline] [--json] Make the database match your checkout — one verb, both layers: apply the state diff (tables+authz, one transaction, verified by re-diff), reconcile the derived layer against the declared descriptors, report the resulting fingerprint vs the models' declared one. Dev databases only (direct connection required); DROPs held back unless --allow-drops; derived drift refuses unless --overwrite-drift; exit 1 when not converged
384
368
  everystack db:diff --from-models <barrel> [--to-models db/models/index.ts] [--allow-drops] [--check] [--json] The state edge between two declared states, NO database: the SQL db:generate would produce, computed purely — CI plan previews (--check exits 1 on a non-empty edge) and computed rollbacks (swap the flags)
385
369
  everystack db:plan [--stage <name> | --database-url <url>] [--models <barrel>] [--allow-drops] [--out db.plan.json | --out -] Mint a verified edge against a target: asks the TARGET its fingerprint, diffs the models, writes ONE reviewable plan (edge + both endpoint fingerprints). Held drops refuse the mint (--allow-drops carries destruction explicitly). Read-only — works via the ops Lambda; plans are ephemeral, never committed
386
- everystack db:apply --plan <file.plan.json> [--database-url <url>] [--stage <name>] [--models <barrel>] [--confirm] [--snapshot-ref <ref>] [--force-descent <snapshot-ref> --confirm] Run a reviewed plan: verify the target is EXACTLY where the plan started (live fingerprint == plan.from, else refuse — the concurrency lock), verify the checkout DESCENDS from the commit declaring the target's state (the fast-forward rule, else refuse — "rebase first"), and for DESTRUCTIVE plans require --confirm always + a snapshot (automatic via db:backup with --stage, else --snapshot-ref) + the stage's approver set when declared (STS identity-verified). Every refusal is recorded in schema_log. Apply as one transaction (plan_ref stamped), verify it landed exactly on plan.to; idempotent when already there; direct connection required
370
+ everystack db:apply --plan <file.plan.json> [--database-url <url>] [--stage <name>] [--models <barrel>] [--confirm] [--snapshot-ref <ref>] [--force-descent <snapshot-ref> --confirm] Run a reviewed plan: verify the target is EXACTLY where the plan started (live fingerprint == plan.from, else refuse — the concurrency lock), verify the checkout DESCENDS from the commit declaring the target's state (the fast-forward rule, else refuse — "rebase first"), and for DESTRUCTIVE plans require --confirm always + an attested --snapshot-ref + the stage's approver set when declared (STS identity-verified). The STAGE lane (--stage without --direct) runs every catalog query in its own ops-Lambda invoke, so one read can be assembled from several containers: it reads the target TWICE and REFUSES when the two disagree (inconsistent containers), reports agreement as a NON-DETECTION (it cannot verify read consistency), and REFUSES a DESTRUCTIVE plan outright — destructive applies go over --database-url (direct) with the full ceremony. Every refusal that reaches the ops Lambda is recorded in schema_log. Apply as one transaction (plan_ref stamped), verify it landed exactly on plan.to; idempotent when already there
387
371
  everystack db:check [--models <barrel>] [--schema-out <file.ts>] [--database-url <url>] [--json] The CI gate, per PR: the merged declared state must COMPOSE (models load, no duplicate tables, descriptors compile), every exposed RLS-enabled table must declare a read path (no force-RLS-with-no-read landmine that goes dark on the superuser drop), and generated artifacts must MATCH regeneration byte-for-byte; with a scratch PostgreSQL it builds the state from scratch on an ephemeral database (created + dropped) and requires fingerprint MATCH. Exit 1 on any failure; never touches a real target
388
372
  everystack db:approvers --stage <name> [--set "cto,arn:..."] [--remove] Declare who can DESTROY: the stage's destructive-approver set (SSM parameter, admin-writable). Destructive db:apply runs are then identity-verified (STS) against it; --set '' disables destructive applies; --remove returns the stage to ceremony-only
389
373
  everystack db:backfill [--database-url <url>] [--dir db/backfills] [--apply] [--mark-applied <file.sql>] [--json] One-shot data jobs in their own lane: plan shows applied (by CONTENT identity — renames/comment edits are no-ops) / pending (in order, unbounded-pass advisories) / blocked (a name that already ran in a different form — one-shot jobs are immutable). --apply runs each pending job as its own transaction, recorded in everystack.backfill_log (a failure rolls back alone, is recorded, stops the run); --mark-applied records without running. Never runs as a schema side effect; direct connection required
@@ -97,13 +97,24 @@ export const ABILITY_PRESETS: Record<string, string[]> = {
97
97
  };
98
98
 
99
99
  /**
100
- * A rendered ability that is a PUBLIC read — anon-visible, the only shape the soft-delete
101
- * guard ever applied to. Matches `defineModel`'s own rule: action `read`, with no `role`,
102
- * `owner`, or `via` narrowing it. Tested per-ability (never against the whole joined stanza)
103
- * so one ability's `role:` can never mask another's public read.
100
+ * A rendered ability that is a PUBLIC read — anon-visible, the shape the soft-delete guard
101
+ * applies to. Tested per-ability (never against the whole joined stanza) so one ability's
102
+ * `role:` can never mask another's public read.
103
+ *
104
+ * The TEXT form of `isPublicReadAbility` from `@everystack/model`, which is the structured
105
+ * one `defineModel`'s soft-delete refusal uses. Two representations because this renderer only
106
+ * ever holds rendered source (derive emits text, not descriptors), one meaning — pinned by a
107
+ * differential test over the ability matrix, the same way `tableReaches` is pinned against the
108
+ * grant compiler. They used to disagree: this counted `role: 'anon'`, the refusal did not, and
109
+ * a table whose authenticated read diverges renders exactly that shape. The refusal now fires
110
+ * on the anonymous audience however it is spelled, so both surfaces answer alike.
104
111
  */
105
- function isPublicReadAbility(expr: string): boolean {
106
- return /^can\('read'/.test(expr.trim()) && !/\b(role|owner|via)\s*:/.test(expr);
112
+ export function isPublicReadAbility(expr: string): boolean {
113
+ const e = expr.trim();
114
+ if (!/^can\('read'/.test(e)) return false;
115
+ if (/\b(owner|via)\s*:/.test(e)) return false;
116
+ const role = /\brole\s*:\s*'([^']+)'/.exec(e);
117
+ return !role || role[1] === 'anon';
107
118
  }
108
119
 
109
120
  /**
@@ -156,56 +167,47 @@ function abilitiesStanza(mode: string, table?: TableSchema, liveAuthz?: Map<stri
156
167
  * nobody wrote. The comment says how to get the other one, so the decision is visible in the
157
168
  * file rather than buried in a compiler convention.
158
169
  */
170
+ /**
171
+ * `writtenBy`, rendered ONLY when the live table is not FORCEd.
172
+ *
173
+ * `writtenBy` is not documentation — it is the sole input to RLS FORCE (`forced: writtenBy ===
174
+ * 'app'`, the compiler's one use of it), and it defaults to `'app'`. So a pulled model that
175
+ * omits it silently declares FORCE on every table, and the next plan carries an
176
+ * `ALTER TABLE … FORCE ROW LEVEL SECURITY` against a table someone deliberately left unforced
177
+ * — a real change to who bypasses row security, arriving in a plan as though it were adoption.
178
+ * The reference schema happens to be FORCEd throughout, which is exactly why this went unseen.
179
+ *
180
+ * `'worker'` is not a guess about the writer; it is the value whose COMPILED CONSEQUENCE
181
+ * matches the live bit. The comment says so, because a reader who takes it as a claim about
182
+ * the application would be misled — and this is a line they must consciously keep or change.
183
+ */
184
+ function writtenByStanza(table: TableSchema, liveAuthz?: Map<string, TableContract>): string {
185
+ const live = liveAuthz?.get(table.table);
186
+ if (!live || live.rls.forced) return '';
187
+ return ` writtenBy: 'worker', // live reality: RLS is not FORCEd here, and only writtenBy controls that. `
188
+ + `'app' (the default) would FORCE it — a change to who bypasses row security, not an adoption.\n`;
189
+ }
190
+
159
191
  function softDeleteStanza(table: TableSchema, publicRead: boolean): string {
160
192
  const hasColumn = table.columns.some((c) => c.name === 'deleted_at');
161
193
  if (!hasColumn || !publicRead) return '';
162
194
  return ` softDelete: false, // live reality: no policy filters deleted_at. true excludes soft-deleted rows from public reads.\n`;
163
195
  }
164
196
 
165
- /** `format_type` the `field.*()` factory that produces it (the non-parameterized types). */
166
- const FIELD_FACTORY: Record<string, string> = {
167
- uuid: 'uuid',
168
- text: 'text',
169
- integer: 'integer',
170
- bigint: 'bigint',
171
- real: 'real',
172
- 'double precision': 'doublePrecision',
173
- boolean: 'boolean',
174
- json: 'json',
175
- jsonb: 'jsonb',
176
- date: 'date',
177
- 'timestamp with time zone': 'timestamptz',
178
- 'timestamp without time zone': 'timestamp',
179
- timestamp: 'timestamp',
180
- };
197
+ // The inverse type map lives with the vocabulary it inverts (`@everystack/model`),
198
+ // where `field.pgType` refuses the spellings a first-class field owns — one
199
+ // definition, two callers, so render and refusal can never disagree.
200
+ import { fieldFactoryCall } from '@everystack/model';
201
+ export { fieldFactoryCall };
181
202
 
182
203
  /**
183
- * The full `field.*()` call for a `format_type` string, or null when no field maps it.
184
- * Handles the parameterized types `format_type` spells out `numeric(p,s)` and
185
- * `character varying(n)` — so a pulled column round-trips back to the same DDL. A
186
- * scale-0 numeric renders as the cleaner `field.numeric(p)` (identical to `(p, 0)`).
204
+ * The verbatim fallback for a type no first-class field owns: carry the exact
205
+ * `format_type` spelling with `field.pgType(...)`. An array carries its scalar
206
+ * plus `.array()` — the factory refuses a bracketed spelling by design.
187
207
  */
188
- export function fieldFactoryCall(type: string): string | null {
189
- // An array is its base type + `.array()` — exactly how the compiler emits it
190
- // (`format_type` spells arrays `text[]`). An array of an unmapped base stays null.
191
- if (type.endsWith('[]')) {
192
- const base = fieldFactoryCall(type.slice(0, -2));
193
- return base ? `${base}.array()` : null;
194
- }
195
-
196
- const direct = FIELD_FACTORY[type];
197
- if (direct) return `field.${direct}()`;
198
-
199
- const num = type.match(/^numeric(?:\((\d+),(\d+)\))?$/);
200
- if (num) {
201
- if (num[1] == null) return 'field.numeric()';
202
- return num[2] === '0' ? `field.numeric(${num[1]})` : `field.numeric(${num[1]}, ${num[2]})`;
203
- }
204
-
205
- const vc = type.match(/^character varying(?:\((\d+)\))?$/);
206
- if (vc) return vc[1] != null ? `field.varchar(${vc[1]})` : 'field.varchar()';
207
-
208
- return null;
208
+ function verbatimFieldCall(type: string): string {
209
+ if (type.endsWith('[]')) return `${verbatimFieldCall(type.slice(0, -2))}.array()`;
210
+ return `field.pgType(${tsLiteral(type)})`;
209
211
  }
210
212
 
211
213
  /** `public.image_variants` / `image_variants` → `image_variants` (the bare table name). */
@@ -213,8 +215,25 @@ function bareName(table: string): string {
213
215
  return table.replace(/^[^.]+\./, '');
214
216
  }
215
217
 
216
- /** Migration infrastructure a pull must never render as a model, whatever schema it landed in. */
217
- const INFRASTRUCTURE_TABLES = new Set(['__drizzle_migrations']);
218
+ /**
219
+ * Migration infrastructure a pull must never render as a model, whatever schema it landed in.
220
+ *
221
+ * A brownfield pull adopts a database another migration tool still owns, and that tool's
222
+ * bookkeeping is not the app's data. Rendering it as a model makes everystack GOVERN it: the
223
+ * compiled contract declares an admin policy, and the adopter's first plan carries a
224
+ * `CREATE POLICY` on a table nobody asked us to manage. The journal belongs to whoever writes
225
+ * it — drizzle-kit's `__drizzle_migrations`, Rails' `schema_migrations` +
226
+ * `ar_internal_metadata`.
227
+ *
228
+ * Deliberately a SHORT list of names that are unambiguously a migration tool's own state. It
229
+ * is not a heuristic and must not become one: a real app table wrongly matched here silently
230
+ * loses its declared authorization.
231
+ */
232
+ const INFRASTRUCTURE_TABLES = new Set([
233
+ '__drizzle_migrations', // drizzle-kit
234
+ 'schema_migrations', // Rails / ActiveRecord
235
+ 'ar_internal_metadata', // Rails / ActiveRecord
236
+ ]);
218
237
 
219
238
  /**
220
239
  * The tables a pull renders: the requested schema, minus migration infrastructure.
@@ -374,8 +393,11 @@ function renderField(table: TableSchema, col: ColumnSchema, known: Set<string>,
374
393
  ?? (enumValues
375
394
  ? `field.enum(${tsLiteral(col.type)}, [${enumValues.map(tsLiteral).join(', ')}])`
376
395
  : fieldFactoryCall(col.type));
377
- let expr = call ?? 'field.text()';
378
- let comment = call ? '' : ` // FIXME: column type '${col.type}' has no field mapping`;
396
+ // No first-class field owns the type → carry it VERBATIM. The column's exact
397
+ // type is the identity, so the round trip is clean: no coercion, no FIXME, no
398
+ // plan statement. The note stays — a verbatim type is a fact worth seeing.
399
+ let expr = call ?? verbatimFieldCall(col.type);
400
+ let comment = call ? '' : ` // verbatim: no first-class field for '${col.type}' — carried as-is`;
379
401
 
380
402
  const isPk = table.primaryKey.includes(col.name);
381
403
  if (isPk) expr += '.primaryKey()';
@@ -486,9 +508,10 @@ export function renderModelBlock(table: TableSchema, known: Set<string>, enums:
486
508
  // reviewer must resolve about a model (and where the field-report consumer's codemod
487
509
  // put it, proving the position is mechanical-edit-friendly).
488
510
  const stanza = abilitiesStanza(abilities, table, liveAuthz);
511
+ const writtenBy = writtenByStanza(table, liveAuthz);
489
512
  const softDelete = softDeleteStanza(table, stanza.publicRead);
490
513
 
491
- return `export const ${modelVarName(table.table)} = defineModel('${bareName(table.table)}', {\n${stanza.text}\n${softDelete} fields: {\n${fields}\n },${constraints}\n});`;
514
+ return `export const ${modelVarName(table.table)} = defineModel('${bareName(table.table)}', {\n${stanza.text}\n${writtenBy}${softDelete} fields: {\n${fields}\n },${constraints}\n});`;
492
515
  }
493
516
 
494
517
  /** The `import` lines a rendered body needs — only what it actually uses, so the file reads clean. */
package/src/cli/output.ts CHANGED
@@ -11,11 +11,11 @@ export function step(msg: string): void {
11
11
  }
12
12
 
13
13
  export function success(msg: string): void {
14
- console.log(` \u2713 ${msg}`);
14
+ say(` \u2713 ${msg}`);
15
15
  }
16
16
 
17
17
  export function warn(msg: string): void {
18
- console.log(` ! ${msg}`);
18
+ say(` ! ${msg}`);
19
19
  }
20
20
 
21
21
  export function fail(msg: string): void {
@@ -23,5 +23,27 @@ export function fail(msg: string): void {
23
23
  }
24
24
 
25
25
  export function info(msg: string): void {
26
- console.log(` ${msg}`);
26
+ say(` ${msg}`);
27
+ }
28
+
29
+ /**
30
+ * When a command makes stdout the DATA channel (`db:plan --out -`), every human-readable
31
+ * line has to move to stderr \u2014 otherwise the report is interleaved with the artifact and
32
+ * the stdout form cannot be piped to anything. `step` and `fail` already write to stderr
33
+ * on that principle; this extends it to the rest, on demand.
34
+ *
35
+ * Off by default, so no command's output moves unless it asks. One-way and process-wide:
36
+ * a CLI process runs exactly one command, and the choice is made once, before it prints.
37
+ */
38
+ let stdoutReserved = false;
39
+
40
+ /** Declare stdout the data channel: human-readable output moves to stderr from here on. */
41
+ export function reserveStdoutForData(): void {
42
+ stdoutReserved = true;
43
+ }
44
+
45
+ /** Human-readable output \u2014 stdout normally, stderr once stdout is reserved for data. */
46
+ function say(line: string): void {
47
+ if (stdoutReserved) console.error(line);
48
+ else console.log(line);
27
49
  }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * The CLI's flag parser.
3
+ *
4
+ * `--key value` / `-k value`, with a value-less flag defaulting to the string `'true'`.
5
+ * Extracted from the entry point so it can be tested without executing the CLI.
6
+ *
7
+ * The subtlety is what counts as "the next argument is a VALUE, not the next flag".
8
+ * Two legitimate values look like flags, or like nothing, to a naive check:
9
+ *
10
+ * - A bare `-` is the conventional stdout sentinel. `db:plan --out -` documents itself
11
+ * as "print to stdout"; read as a value-less flag it became `out=true` and the
12
+ * command wrote a file literally named `true`.
13
+ * - The EMPTY string is a value. `db:approvers --set ''` is the documented way to
14
+ * declare an empty approver set and DISABLE destructive applies on a stage; read as
15
+ * value-less it became `set=true`, and the command refused with "--set needs a
16
+ * value" — so the documented way to turn the gate off could not be typed.
17
+ *
18
+ * Both are the same mistake: treating an unusual-looking value as an absent one. A flag
19
+ * is an argument that starts with `-` AND is longer than one character; everything else,
20
+ * including `''`, is a value.
21
+ */
22
+
23
+ /** Is this argument a flag (as opposed to a value)? A bare `-` is a value. */
24
+ function isFlag(arg: string): boolean {
25
+ return arg.startsWith('-') && arg.length > 1;
26
+ }
27
+
28
+ /** Parse `--key value` / `-k value` pairs; a flag with no value becomes `'true'`. */
29
+ export function parseFlags(args: string[]): Record<string, string> {
30
+ const flags: Record<string, string> = {};
31
+ for (let i = 0; i < args.length; i++) {
32
+ const arg = args[i];
33
+ if (!isFlag(arg)) continue;
34
+ const key = arg.startsWith('--') ? arg.slice(2) : arg.slice(1);
35
+ const next = args[i + 1];
36
+ flags[key] = next !== undefined && !isFlag(next) ? args[++i] : 'true';
37
+ }
38
+ return flags;
39
+ }
@@ -124,9 +124,14 @@ function scalarPgType(spec: FieldSpec): string {
124
124
  if (!spec.enumName) throw new Error('field bridge: an enum field needs a type name — field.enum(name, values)');
125
125
  return spec.enumName;
126
126
  }
127
+ if (spec.type === 'pgType') {
128
+ // A verbatim type: the declared string IS the canonical `format_type` spelling
129
+ // (the factory refused anything else), so it round-trips byte-for-byte.
130
+ if (!spec.pgTypeName) throw new Error('field bridge: a pgType field needs its type name — field.pgType(name)');
131
+ return spec.pgTypeName;
132
+ }
127
133
  const t = PG_TYPE[spec.type];
128
134
  if (!t) {
129
- // geometry needs PostGIS — a deliberate follow-up, not a silent wrong column.
130
135
  throw new Error(`field bridge: no DDL mapping for field type '${spec.type}' yet`);
131
136
  }
132
137
  return t;
@@ -520,7 +520,7 @@ function createIndexChange(table: string, ix: IndexSchema): SchemaChange {
520
520
  * the consumer's 31 phantom drop/creates). Plain btree indexes key exactly as before
521
521
  * plus a constant tail — both sides compute the same key, so nothing churns.
522
522
  */
523
- function indexKey(ix: IndexSchema): string {
523
+ export function indexKey(ix: IndexSchema): string {
524
524
  const entries = ix.columns.map((c) => normalizeCheck(c)).join(',');
525
525
  return `${ix.unique ? 'u' : ''}|${entries}|${normalizeCheck(ix.where ?? '')}|${ix.using ?? 'btree'}|${(ix.include ?? []).join(',')}`;
526
526
  }
@@ -46,12 +46,14 @@
46
46
  */
47
47
 
48
48
  import { createHash } from 'node:crypto';
49
+ import { canonicalAuthz } from './authz-canonical.js';
49
50
  import type { ModelDescriptor, SequenceDescriptor } from '@everystack/model';
50
51
  import type { SchemaSnapshot, TableSchema } from './schema-introspect.js';
51
52
  import type { AuthzContract, TableContract } from './authz-contract.js';
52
53
  import { compileTableSchema, compileEnums, compileSequences } from './schema-compile.js';
53
54
  import { compileTableContract } from './authz-compile.js';
54
- import { normalizeDefault, normalizeCheck } from './schema-diff.js';
55
+ import { governedRoleSet } from './authz-reconcile.js';
56
+ import { normalizeDefault, normalizeCheck, indexKey } from './schema-diff.js';
55
57
 
56
58
  // v2: expression fields (defaults, checks, partial-index WHERE) normalize through
57
59
  // the diff's pg-deparse normalizers before hashing, so the model form and the live
@@ -62,7 +64,53 @@ import { normalizeDefault, normalizeCheck } from './schema-diff.js';
62
64
  // STANDALONE SEQUENCES enter the canonical form (declared via defineSequence,
63
65
  // introspected from pg_sequence minus serial-owned) — a coverage expansion; the
64
66
  // `sequences` key appears only when any exist, so sequence-free states hash unchanged.
65
- export const FINGERPRINT_VERSION = 3;
67
+ // v4: the AUTHZ canonical form became the reconciler's equivalence relation instead of a
68
+ // name-keyed transcript of the catalog. Policy NAMES leave the hash (a brownfield database
69
+ // names its policies whatever its previous migration tool named them, so hashing the name made
70
+ // a policy carrying the declared authorization read as a different state); policies expand
71
+ // across their roles as a MULTISET (so one policy TO a,b hashes equal to two identical ones TO
72
+ // a and TO b, and duplicates never collapse); PUBLIC stays a single sentinel and is never
73
+ // enumerated; and grants are filtered to the GOVERNED grantees, because the reconciler leaves
74
+ // an ungoverned migrator/ETL role alone and a hash that counts it can never converge.
75
+ // Together these restore the identity the format exists for: fingerprints match exactly when
76
+ // db:generate is a no-op.
77
+ // v5 — a redundant DEFAULT PRECISION is no longer part of a column's identity.
78
+ // `timestamp(6) without time zone` and `timestamp without time zone` are the same type (6 is
79
+ // the family's default), so they now canonicalize to one spelling in
80
+ // `canonicalColumnType`. Two columns that always stored identical values used to hash
81
+ // differently, which meant a schema carrying the explicit spelling could never reach MATCH.
82
+ // Only precision 6 and only the timestamp family: `timestamp(3)` is a real difference.
83
+ // Consequence, and the reason this is a VERSION and not a silent fix: a plan or baseline
84
+ // minted under v4 against such a schema refuses as a FORMAT CHANGE and must be re-minted.
85
+ // No DDL, no data movement — the fingerprint is a content address.
86
+ // v6 — an INDEX'S IDENTITY is the differ's content key, verbatim. The v5 form hashed
87
+ // {columns, unique, where} only, so a GIN and a btree index on the same column hashed
88
+ // EQUAL while `db:generate` emitted a DROP + CREATE — MATCH could lie exactly where it
89
+ // matters for tsvector/geometry (GIN/GiST). The v5 form also hashed indexes as a
90
+ // multiset while the diff dedupes by content, so a physically duplicate index (two
91
+ // identical indexes under different names — a common migration-era artifact) made
92
+ // MATCH unreachable even though the diff was, correctly, a no-op. Both fixed the same
93
+ // way: the canonical index entry is now `indexKey` from schema-diff — access method and
94
+ // INCLUDE in, key entries normalized, names out, duplicates collapsed — ONE definition
95
+ // for "are these the same index", shared by the differ and the hash. Same consequence
96
+ // as every bump: plans/baselines minted under v5 refuse as FORMAT CHANGE and re-mint.
97
+ // v7 — a DEAD policy is not part of the state. A policy governing a privilege none of its
98
+ // roles holds authorizes nothing: Postgres refuses at the GRANT before ever consulting it.
99
+ // The reconciler now leaves such a policy alone (dropping it changes no access, so it was
100
+ // pure churn an adopter had to read and approve), and the canonical form stops counting it —
101
+ // the same choice from the same function, `isPolicyDead`, which also backs the adoption
102
+ // classifier's `dead` class and db:pull's notes. Without both halves the state could not
103
+ // converge on any brownfield database carrying policies whose grants were revoked long ago.
104
+ // Deadness is recomputed against live grants every time and never stored, so a policy
105
+ // RE-ENTERS the state the moment a grant makes it effective.
106
+ // v8 — a SUBSUMED policy is not part of the state either. Permissive policies OR together and
107
+ // a PUBLIC policy applies to every role, so a role-scoped policy whose rule is identical to a
108
+ // PUBLIC one admits no session the other does not: it changes no access, so it is churn, not
109
+ // state. Same treatment and same lockstep as v7's dead policies — `isPolicySubsumed` excludes
110
+ // it from the canonical form AND the reconciler leaves it alone, before and after. Restrictive
111
+ // policies are never subsumed (they AND, so removing one would WIDEN), and the rule must match
112
+ // exactly including the effective WITH CHECK.
113
+ export const FINGERPRINT_VERSION = 8;
66
114
 
67
115
  // ---------------------------------------------------------------------------
68
116
  // Canonical form.
@@ -107,48 +155,28 @@ function canonicalTable(table: TableSchema): Record<string, unknown> {
107
155
  })),
108
156
  (f) => stableStringify(f),
109
157
  ),
110
- indexes: byKey(
111
- // Partial-index WHERE is a predicate too normalize it the same way as a check.
112
- table.indexes.map((i) => ({ columns: i.columns, unique: i.unique, ...(i.where ? { where: normalizeCheck(i.where) } : {}) })),
113
- (i) => stableStringify(i),
114
- ),
158
+ // v6: an index's canonical form IS the differ's content key — one definition,
159
+ // both surfaces. That brings the access method (`using`) and INCLUDE set into
160
+ // the identity (a GIN and a btree on the same column are different states),
161
+ // normalizes key entries the way the diff compares them, and DEDUPES: two
162
+ // physically duplicate indexes are one state, exactly as the diff (which can
163
+ // neither create nor drop the second copy) already treats them.
164
+ indexes: [...new Set(table.indexes.map(indexKey))].sort(),
115
165
  };
116
166
  }
117
167
 
118
- function canonicalAuthzTable(contract: TableContract): Record<string, unknown> {
119
- return {
120
- table: contract.table,
121
- rls: { enabled: contract.rls.enabled, forced: contract.rls.forced },
122
- grants: Object.fromEntries(
123
- Object.entries(contract.grants)
124
- .map(([role, privs]) => [role, [...privs].sort()] as const)
125
- .sort(([a], [b]) => (a < b ? -1 : 1)),
126
- ),
127
- ...(contract.columnGrants && Object.keys(contract.columnGrants).length > 0
128
- ? {
129
- columnGrants: Object.fromEntries(
130
- Object.entries(contract.columnGrants)
131
- .map(([role, byPriv]) => [
132
- role,
133
- Object.fromEntries(
134
- Object.entries(byPriv)
135
- .map(([priv, cols]) => [priv, [...cols].sort()] as const)
136
- .sort(([a], [b]) => (a < b ? -1 : 1)),
137
- ),
138
- ] as const)
139
- .sort(([a], [b]) => (a < b ? -1 : 1)),
140
- ),
141
- }
142
- : {}),
143
- policies: byKey(
144
- contract.policies.map((p) => ({
145
- name: p.name, command: p.command, roles: [...p.roles].sort(),
146
- permissive: p.permissive, using: p.using, check: p.check,
147
- })),
148
- (p) => p.name,
149
- ),
150
- // The handler-side columns exposure block is NOT a database fact — excluded.
151
- };
168
+ /**
169
+ * The authorization slice of the canonical form.
170
+ *
171
+ * Delegates to `authz-canonical`, which the reconciler and the differ read too. Keeping a
172
+ * private copy here is what let the fingerprint drift out of step with the reconciler and
173
+ * report permanent drift on a database that had nothing to reconcile.
174
+ */
175
+ function canonicalAuthzTable(
176
+ contract: TableContract,
177
+ governed?: ReadonlySet<string>,
178
+ ): Record<string, unknown> {
179
+ return canonicalAuthz(contract, governed);
152
180
  }
153
181
 
154
182
  export interface CanonicalState {
@@ -171,6 +199,17 @@ export interface CanonicalizeOptions {
171
199
  /** Restrict the state to these schemas — the content address of ONE schema (e.g. a
172
200
  * schema-scoped artifact) instead of the whole database. Omitted = every schema. */
173
201
  schemas?: string[];
202
+ /**
203
+ * The grantees the models govern. PASS THIS WHENEVER HASHING A LIVE CONTRACT.
204
+ *
205
+ * The reconciler leaves an ungoverned grantee alone by design, so a live grant to a
206
+ * migrator or ETL role is never reconciled. A hash that counts it describes a state the
207
+ * models can never reach: the operator gets "nothing to do" from the plan and "you have
208
+ * drifted" from the gate, with no action in between that resolves it. Omitted means "hash
209
+ * every grantee", which is correct only for a contract compiled FROM the models, where
210
+ * every grantee is governed by construction.
211
+ */
212
+ governedRoles?: ReadonlySet<string>;
174
213
  }
175
214
 
176
215
  export function canonicalizeState(
@@ -208,7 +247,10 @@ export function canonicalizeState(
208
247
  .map((e) => ({ name: e.name, values: e.values })),
209
248
  (e) => e.name,
210
249
  ),
211
- authz: byKey(authzTables.filter((t) => keepTable(t.table)).map(canonicalAuthzTable), (t) => String(t.table)),
250
+ authz: byKey(
251
+ authzTables.filter((t) => keepTable(t.table)).map((t) => canonicalAuthzTable(t, opts.governedRoles)),
252
+ (t) => String(t.table),
253
+ ),
212
254
  ...(sequences.length ? { sequences } : {}),
213
255
  };
214
256
  }
@@ -248,6 +290,27 @@ export function fingerprintModels(
248
290
  return fingerprintState(snapshot, authzTables, opts.schemas ? { schemas: opts.schemas } : {});
249
291
  }
250
292
 
293
+ /**
294
+ * The governed-role set a model checkout implies — THE set every declared-vs-live
295
+ * comparison must filter the live side through. One derivation, all callers:
296
+ * db:fingerprint's MATCH, the mint's `to` endpoint, the apply's verify-after and
297
+ * already-applied checks, and git-descent's declares-test. (v4 introduced the
298
+ * filter in the canonical layer; a live grant nothing will ever reconcile is not
299
+ * part of the state the models describe. Leaving a caller unfiltered re-creates
300
+ * the exact defect v4 fixed: zero statements, MATCH unreachable.)
301
+ * `extra` is the modules' widened `governedRoles` declaration.
302
+ */
303
+ export function governedRolesForModels(
304
+ models: ModelDescriptor[],
305
+ extra?: readonly string[],
306
+ ): ReadonlySet<string> {
307
+ const declared: AuthzContract = {
308
+ tables: models.map((m) => compileTableContract(m, {})),
309
+ functions: [],
310
+ };
311
+ return governedRoleSet(declared, extra ?? []);
312
+ }
313
+
251
314
  /** Convenience: the live side, from the two existing introspections. */
252
315
  export function fingerprintLive(
253
316
  snapshot: SchemaSnapshot,
@@ -305,3 +368,52 @@ export interface UnfingerprintedObject {
305
368
  export function mapUnfingerprintedRows(rows: Array<{ kind: unknown; identity: unknown }>): UnfingerprintedObject[] {
306
369
  return rows.map((r) => ({ kind: String(r.kind), identity: String(r.identity) }));
307
370
  }
371
+
372
+
373
+ // ---------------------------------------------------------------------------
374
+ // Comparing a STORED fingerprint against live reality.
375
+ // ---------------------------------------------------------------------------
376
+
377
+ /**
378
+ * A fingerprint as recorded in an artifact — a plan, a baseline, an export stamp.
379
+ *
380
+ * `v` is the format it was computed under. It must be stored ALONGSIDE the hash, not merely
381
+ * mixed into it: a hash alone cannot say why it differs, and the difference between "you have
382
+ * drifted" and "the format changed" is the difference between an operator hunting a phantom
383
+ * change and an operator running one re-baseline.
384
+ */
385
+ export interface StoredFingerprint {
386
+ hash: string;
387
+ /** The FINGERPRINT_VERSION in force when the hash was computed. Absent = pre-v4 artifact. */
388
+ v?: number;
389
+ }
390
+
391
+ export type FingerprintComparison =
392
+ | { kind: 'match' }
393
+ | { kind: 'drift' }
394
+ | { kind: 'format-changed'; stored: number | 'unstamped'; current: number };
395
+
396
+ /**
397
+ * Compare a stored fingerprint to one computed now.
398
+ *
399
+ * A raw hash compare across formats reports DRIFT, which is a lie — the state may be
400
+ * untouched while the way we describe it changed. Every hash comparison that spans an artifact
401
+ * boundary must come through here so a format change reads as a format change.
402
+ */
403
+ export function compareStoredFingerprint(
404
+ stored: StoredFingerprint,
405
+ currentHash: string,
406
+ currentVersion: number = FINGERPRINT_VERSION,
407
+ ): FingerprintComparison {
408
+ if (stored.v !== currentVersion) {
409
+ return { kind: 'format-changed', stored: stored.v ?? 'unstamped', current: currentVersion };
410
+ }
411
+ return stored.hash === currentHash ? { kind: 'match' } : { kind: 'drift' };
412
+ }
413
+
414
+ /** The operator-facing sentence for a format change. Names the fix, never the phantom drift. */
415
+ export function formatChangedMessage(c: Extract<FingerprintComparison, { kind: 'format-changed' }>): string {
416
+ return `fingerprint format changed (recorded v${c.stored}, current v${c.current}) — this is NOT drift. `
417
+ + 'The artifact predates the current canonical form, so its hash cannot be compared. '
418
+ + 'Re-mint the plan (db:plan) or re-baseline the stage (db:reconcile --rebaseline); no DDL is involved.';
419
+ }