@everystack/cli 0.3.28 → 0.4.0

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 (47) hide show
  1. package/README.md +1 -1
  2. package/package.json +6 -2
  3. package/src/cli/apply-execute.ts +185 -0
  4. package/src/cli/authz-compile.ts +5 -2
  5. package/src/cli/authz-lint.ts +48 -0
  6. package/src/cli/aws.ts +102 -4
  7. package/src/cli/backfill.ts +1 -1
  8. package/src/cli/commands/db-apply.ts +159 -169
  9. package/src/cli/commands/db-backfill.ts +2 -2
  10. package/src/cli/commands/db-backup.ts +34 -0
  11. package/src/cli/commands/db-branch.ts +28 -4
  12. package/src/cli/commands/db-check.ts +97 -34
  13. package/src/cli/commands/db-fingerprint.ts +9 -4
  14. package/src/cli/commands/db-generate.ts +73 -22
  15. package/src/cli/commands/db-plan.ts +7 -13
  16. package/src/cli/commands/db-pull.ts +39 -7
  17. package/src/cli/commands/db-reconcile.ts +219 -66
  18. package/src/cli/commands/db-sync.ts +58 -21
  19. package/src/cli/commands/deploy.ts +4 -0
  20. package/src/cli/commands/pipeline-run.ts +269 -0
  21. package/src/cli/db-build.ts +9 -3
  22. package/src/cli/db-source.ts +83 -7
  23. package/src/cli/declared-derived.ts +136 -0
  24. package/src/cli/derived-apply.ts +40 -6
  25. package/src/cli/derived-compile.ts +327 -0
  26. package/src/cli/derived-grants.ts +96 -0
  27. package/src/cli/derived-introspect.ts +258 -21
  28. package/src/cli/derived-lint.ts +261 -0
  29. package/src/cli/derived-plan.ts +194 -12
  30. package/src/cli/derived-render.ts +320 -0
  31. package/src/cli/derived-source.ts +53 -125
  32. package/src/cli/discover.ts +16 -0
  33. package/src/cli/git-descent.ts +15 -2
  34. package/src/cli/index.ts +26 -6
  35. package/src/cli/migration-compile.ts +38 -7
  36. package/src/cli/migration-generate.ts +43 -4
  37. package/src/cli/model-render.ts +106 -13
  38. package/src/cli/models-path.ts +1 -1
  39. package/src/cli/ops-diagnostics.ts +71 -0
  40. package/src/cli/pipeline-loader.ts +68 -0
  41. package/src/cli/pipeline-path.ts +25 -0
  42. package/src/cli/schema-compile.ts +41 -10
  43. package/src/cli/schema-diff.ts +123 -17
  44. package/src/cli/schema-fingerprint.ts +28 -18
  45. package/src/cli/schema-introspect.ts +175 -8
  46. package/src/cli/schema-source.ts +75 -6
  47. package/src/cli/state-apply.ts +4 -1
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * `everystack db:pull` — generate `field()` Models from a live database (the brownfield on-ramp).
3
3
  *
4
- * db:pull [--stage <name> | --database-url <url>] [--schema public] [--out <dir | file.ts>]
4
+ * db:pull [--stage <name> | --database-url <url>] [--schema public] [--out <dir | file.ts>] [--abilities public-read]
5
5
  *
6
6
  * The reverse of db:generate: that writes migrations FROM Models; this writes Models FROM the
7
7
  * database. It introspects the deployed schema through the read-only ops Lambda `db:query`
@@ -23,9 +23,11 @@
23
23
  import fs from 'node:fs/promises';
24
24
  import path from 'node:path';
25
25
  import { introspectSchema } from '../schema-introspect.js';
26
- import { renderModelSource, renderModelFiles, pullableTables } from '../model-render.js';
26
+ import { introspectDerived, type DerivedCatalog } from '../derived-introspect.js';
27
+ import { renderDerivedSource, type DerivedRenderResult } from '../derived-render.js';
28
+ import { renderModelSource, renderModelFiles, pullableTables, modelVarName, ABILITY_PRESETS } from '../model-render.js';
27
29
  import type { QueryRunner } from '../authz-contract.js';
28
- import { resolveDbSource, createUrlRunner, type DbSource } from '../db-source.js';
30
+ import { resolveDbSource, createUrlRunner, connectingVia, type DbSource } from '../db-source.js';
29
31
  import { resolveConfig, opsFunction } from '../config.js';
30
32
  import { invokeAction } from '../aws.js';
31
33
  import { fail } from '../output.js';
@@ -49,6 +51,14 @@ function lambdaRunner(region: string, fn: string): QueryRunner {
49
51
  export async function dbPullCommand(flags: Record<string, string>): Promise<void> {
50
52
  const schema = flags.schema || 'public';
51
53
 
54
+ // The read-model scaffold: default 'commented' surfaces the authz decision in every
55
+ // model; a preset stamps it. Validated up front so a typo fails before introspection.
56
+ const abilities = flags.abilities || 'commented';
57
+ if (abilities !== 'commented' && !ABILITY_PRESETS[abilities]) {
58
+ fail(`Unknown --abilities preset '${abilities}'. Known: ${Object.keys(ABILITY_PRESETS).join(', ')} (omit the flag to scaffold the decision as comments).`);
59
+ process.exit(1);
60
+ }
61
+
52
62
  let dbSource: DbSource;
53
63
  try {
54
64
  dbSource = resolveDbSource(flags);
@@ -58,11 +68,12 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
58
68
  }
59
69
 
60
70
  let current;
71
+ let derivedCatalog: DerivedCatalog | undefined;
61
72
  try {
62
73
  let runner: QueryRunner;
63
74
  let end: (() => Promise<void>) | undefined;
64
75
  if (dbSource.kind === 'url') {
65
- note(dbSource.from === 'flag' ? 'Connecting via --database-url...' : 'Connecting via DATABASE_URL...');
76
+ note(connectingVia(dbSource));
66
77
  ({ runner, end } = await createUrlRunner(dbSource.url));
67
78
  } else {
68
79
  note('Resolving deployed config...');
@@ -72,6 +83,9 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
72
83
  }
73
84
  note(`Introspecting live database (schema: ${schema})...`);
74
85
  current = await introspectSchema(runner);
86
+ // The derived layer rides the same pull (B5) — views/matviews/functions/sequences
87
+ // render as descriptors; adoption is pull → commit → --baseline → clean reconcile.
88
+ derivedCatalog = await introspectDerived(runner);
75
89
  await end?.();
76
90
  } catch (err: any) {
77
91
  fail(err.message);
@@ -90,11 +104,24 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
90
104
  process.exit(1);
91
105
  }
92
106
 
107
+ // The derived layer (B5): rendered from the live catalog with dependsOn from the real
108
+ // edge graph. Everything inexpressible is a stderr warning AND an inline FIXME.
109
+ const knownTables = new Map(pulled.map((t) => [t.table, modelVarName(t.table)]));
110
+ const derived: DerivedRenderResult = renderDerivedSource(derivedCatalog ?? { objects: [], edges: [], provenance: [] }, {
111
+ knownTables,
112
+ sequences: current.sequences,
113
+ });
114
+ for (const w of derived.warnings) caution(w);
115
+ const derivedCount = derived.names.length + derived.sequenceNames.length;
116
+ if (derivedCount > 0) {
117
+ detail(`Derived layer: ${derived.names.length} descriptor(s) + ${derived.sequenceNames.length} sequence(s) rendered (adopt with db:reconcile --baseline after committing).`);
118
+ }
119
+
93
120
  let source: string;
94
121
  if (flags.out && !flags.out.endsWith('.ts')) {
95
122
  // A directory: one file per model + index.ts — the default shape for a real app.
96
123
  const dir = path.resolve(flags.out);
97
- const files = renderModelFiles(current, { schema });
124
+ const files = renderModelFiles(current, { schema, abilities, derived });
98
125
  try {
99
126
  await fs.mkdir(dir, { recursive: true });
100
127
  const written = new Set(files.map((f) => f.file));
@@ -111,7 +138,7 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
111
138
  source = files.map((f) => f.source).join('\n');
112
139
  } else if (flags.out) {
113
140
  const outPath = path.resolve(flags.out);
114
- source = renderModelSource(current, { schema });
141
+ source = renderModelSource(current, { schema, abilities, derived });
115
142
  try {
116
143
  await fs.mkdir(path.dirname(outPath), { recursive: true });
117
144
  await fs.writeFile(outPath, source, 'utf8');
@@ -121,12 +148,17 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
121
148
  }
122
149
  ok(`Wrote ${path.relative(process.cwd(), outPath)} — ${pulled.length} model(s).`);
123
150
  } else {
124
- source = renderModelSource(current, { schema });
151
+ source = renderModelSource(current, { schema, abilities, derived });
125
152
  process.stdout.write(source);
126
153
  ok(`Rendered ${pulled.length} model(s) from schema "${schema}" (stdout — redirect or pass --out to save).`);
127
154
  }
128
155
 
129
156
  const flagged = (source.match(/\/\/ (FIXME|TODO|composite|CHECK|FK →)/g) ?? []).length;
130
157
  if (flagged) caution(`${flagged} inline comment(s) flag things to review (unmapped types, checks, cross-schema FKs).`);
158
+ if (abilities === 'commented') {
159
+ note(`Each model scaffolds its authz decision as comments — author them (db:check fails until every model declares), or stamp the common case: db:pull --abilities public-read.`);
160
+ } else {
161
+ note(`Stamped '${abilities}' abilities into every model — review the generated stanzas; they are code, not defaults.`);
162
+ }
131
163
  process.exit(0);
132
164
  }
@@ -1,9 +1,13 @@
1
1
  /**
2
2
  * `everystack db:reconcile` — deploy the derived layer (functions, views,
3
- * matviews) by reconciling the live database against the db/sql sources.
3
+ * matviews, triggers, escape-hatch objects) by reconciling the live database
4
+ * against the DECLARED descriptors (defineView / defineMaterializedView /
5
+ * defineFunction / defineSql / trigger() on the models). Descriptors are the
6
+ * single home — a checkout still carrying db/sql *.sql files fails loudly with
7
+ * the migration path (B7).
4
8
  *
5
- * db:reconcile [--sql-dir db/sql] [--stage <name> | --database-url <url>]
6
- * [--apply] [--check] [--baseline] [--overwrite-drift] [--json]
9
+ * db:reconcile [--stage <name> | --database-url <url>]
10
+ * [--apply] [--check] [--baseline] [--rebaseline] [--overwrite-drift] [--json]
7
11
  *
8
12
  * Derived objects do not migrate; they deploy. The plan is computed fresh
9
13
  * every run: source objects (hashed, comment/whitespace-insensitive) joined
@@ -16,33 +20,35 @@
16
20
  *
17
21
  * Hand-edited live objects are DRIFT — reported, applied over only with
18
22
  * `--overwrite-drift`. Never-reconciled matches need `--baseline` once (the
19
- * explicit trust that live == source, recorded). `--apply` needs a direct
23
+ * explicit trust that live == source, recorded). A source RE-RENDER over an
24
+ * untouched live object — the db/sql-era → descriptor migration — needs
25
+ * `--rebaseline` once: live is verified (defHash match), the new source hash
26
+ * is re-recorded, nothing rebuilds. `--apply` needs a direct
20
27
  * connection (`--database-url` / DATABASE_URL): the ops Lambda `db:query`
21
28
  * path is read-only by design, so plan/check work anywhere but writes go
22
29
  * through a credentialed connection you chose to hold.
23
30
  */
24
31
 
25
- import fs from 'node:fs/promises';
26
- import path from 'node:path';
27
32
  import type { QueryRunner } from '../authz-contract.js';
28
- import { parseSqlSources, type SourceFile } from '../derived-source.js';
33
+ import type { SourceObject } from '../derived-source.js';
29
34
  import { introspectDerived } from '../derived-introspect.js';
30
35
  import { planReconcile, type ReconcilePlan, type ReconcileOptions } from '../derived-plan.js';
31
36
  import {
32
37
  renderReconcileSql,
33
38
  renderProvenanceUpsert,
39
+ renderProvenanceMigrate,
40
+ triggerDropSql,
34
41
  renderProvenanceDelete,
35
42
  renderSchemaLogInsert,
36
43
  ENSURE_RECONCILER_SQL,
37
44
  } from '../derived-apply.js';
38
- import { resolveDbSource, createUrlRunner, type DbSource } from '../db-source.js';
45
+ import { resolveDbSource, createUrlRunner, connectingVia, type DbSource } from '../db-source.js';
46
+ import { loadDeclaredDerived, retiredSqlDirAnywhere, retiredSqlDirFlagRefusal, type DeclaredDerived } from '../declared-derived.js';
39
47
  import { currentGitRef } from '../state-apply.js';
40
48
  import { resolveConfig, opsFunction } from '../config.js';
41
49
  import { invokeAction } from '../aws.js';
42
50
  import { step, success, fail, info, warn } from '../output.js';
43
51
 
44
- const DEFAULT_SQL_DIR = 'db/sql';
45
-
46
52
  // ---------------------------------------------------------------------------
47
53
  // The testable core.
48
54
  // ---------------------------------------------------------------------------
@@ -52,6 +58,8 @@ export interface ExecuteOptions extends ReconcileOptions {
52
58
  actor?: string | null;
53
59
  gitRef?: string | null;
54
60
  planRef?: string | null;
61
+ /** Descriptor-compiled objects (the declared derived layer) — the ONE source stream. */
62
+ declared?: SourceObject[];
55
63
  /** Injectable clock for tests. */
56
64
  now?: () => number;
57
65
  }
@@ -70,13 +78,38 @@ export interface ReconcileRun {
70
78
  * Plan — and under `apply`, execute — one reconcile. Pure orchestration over
71
79
  * an injected QueryRunner; the CLI shell below owns flags, files, and exits.
72
80
  */
81
+ /** Statements Postgres forbids inside a transaction block — they implicitly commit. */
82
+ export const NON_TRANSACTIONAL_RE = /\bCONCURRENTLY\b|\bVACUUM\b/i;
83
+
84
+ /** Session identity — the guard against postgres-js's silent reconnect (see the apply path). */
85
+ export const BACKEND_PID_SQL = 'SELECT pg_backend_pid() AS pid';
86
+
87
+ // Dollar-quoted bodies and comments are CONTENT, not batch shape — a plpgsql helper whose
88
+ // body says `REFRESH MATERIALIZED VIEW CONCURRENTLY …` is perfectly transactional to CREATE.
89
+ // Stripping errs loud, never silent: over-stripping would wrongly WRAP a truly-concurrent
90
+ // statement, and PostgreSQL then refuses it with its own clear error at apply time.
91
+ const DOLLAR_BODY_RE = /\$([A-Za-z_][A-Za-z0-9_]*)?\$[\s\S]*?\$\1\$/g;
92
+ const LINE_COMMENT_RE = /--[^\n]*/g;
93
+ const BLOCK_COMMENT_RE = /\/\*[\s\S]*?\*\//g;
94
+
95
+ /**
96
+ * Whether the whole batch can run inside one transaction. Almost always yes — the escape hatch
97
+ * is a consumer whose matview source carries a `CREATE INDEX CONCURRENTLY` (rendered verbatim) or
98
+ * a `VACUUM`, which self-commit and cannot live in a transaction block. Only the statement's own
99
+ * shape decides: dollar-quoted bodies and comments are stripped before the test.
100
+ */
101
+ export function isTransactionalBatch(statements: string[]): boolean {
102
+ return statements.every((s) => !NON_TRANSACTIONAL_RE.test(
103
+ s.replace(DOLLAR_BODY_RE, '').replace(BLOCK_COMMENT_RE, '').replace(LINE_COMMENT_RE, ''),
104
+ ));
105
+ }
106
+
73
107
  export async function executeReconcile(
74
108
  runner: QueryRunner,
75
- sources: SourceFile[],
76
109
  options: ExecuteOptions = {},
77
110
  ): Promise<ReconcileRun> {
78
111
  const live = await introspectDerived(runner);
79
- const parsed = parseSqlSources(sources);
112
+ const parsed = { objects: options.declared ?? [], warnings: [] as string[] };
80
113
  const plan = planReconcile(parsed, live, options);
81
114
  const rendered = renderReconcileSql(plan, parsed.objects);
82
115
 
@@ -94,7 +127,7 @@ export async function executeReconcile(
94
127
  refusal: `drift on ${plan.drift.map((d) => d.identity).join(', ')} — inspect, then re-run with --overwrite-drift to rebuild from source`,
95
128
  };
96
129
  }
97
- if (rendered.statements.length === 0 && rendered.record.length === 0 && rendered.remove.length === 0) {
130
+ if (rendered.statements.length === 0 && rendered.record.length === 0 && rendered.remove.length === 0 && rendered.migrate.length === 0) {
98
131
  return { plan, applied: false, statements: [] };
99
132
  }
100
133
 
@@ -102,14 +135,92 @@ export async function executeReconcile(
102
135
  await runner(ENSURE_RECONCILER_SQL.join(';\n'));
103
136
 
104
137
  const started = now();
105
- let outcome = 'applied';
138
+
139
+ // Atomicity: the DDL and the provenance/schema_log that describe it go in ONE transaction, so a
140
+ // failure rolls the whole thing back — live and its bookkeeping can never diverge. That divergence
141
+ // (objects changed, provenance stale) is exactly the drift that strands a stage after a partial
142
+ // apply. The one exception is a batch with statements Postgres forbids inside a transaction block
143
+ // (CONCURRENTLY index builds, VACUUM); those self-commit, so we fall back to the unwrapped path.
144
+ const atomic = isTransactionalBatch(rendered.statements);
145
+
146
+ if (atomic) await runner('BEGIN');
106
147
  try {
148
+ // The session guard: postgres-js `max: 1` is a pool of one, not a session lease — a
149
+ // connection that dies between runner calls silently reconnects, the open transaction
150
+ // aborts server-side, and every later call runs in autocommit on a fresh session. The
151
+ // bookkeeping would then record a state the DDL never reached, and the object would read
152
+ // as converged forever (the red-team's false-convergence finding). Pin the backend pid
153
+ // at BEGIN; refuse the bookkeeping if it moved. createUrlRunner also disables the
154
+ // driver's max_lifetime recycling — the deterministic trigger during long applies.
155
+ const pidAtBegin = atomic ? Number((await runner(BACKEND_PID_SQL))[0]?.pid) : null;
156
+
107
157
  if (rendered.statements.length > 0) {
108
- await runner(rendered.statements.join(';\n'));
158
+ if (atomic) {
159
+ await runner(rendered.statements.join(';\n'));
160
+ } else {
161
+ // Self-committing statements (CONCURRENTLY, VACUUM) refuse ANY transaction block —
162
+ // including the implicit one a joined multi-statement simple query runs in, so the
163
+ // batch MUST go one statement per protocol message. A failure mid-way leaves the
164
+ // earlier statements committed: the documented cost of the unwrapped path; the next
165
+ // plan re-converges from what actually landed.
166
+ for (const statement of rendered.statements) await runner(statement);
167
+ }
109
168
  }
169
+
170
+ // Re-read the catalog so provenance records the def hashes of what NOW exists, not what we hoped
171
+ // would exist. Inside the transaction this reads the batch's own not-yet-committed writes.
172
+ const after = await introspectDerived(runner);
173
+ const liveById = new Map(after.objects.map((o) => [o.identity, o]));
174
+ const srcById = new Map(parsed.objects.map((o) => [o.identity, o]));
175
+
176
+ const bookkeeping: string[] = [];
177
+ for (const identity of rendered.record) {
178
+ const src = srcById.get(identity);
179
+ if (!src) {
180
+ throw new Error(`reconcile applied but ${identity} has no source object — provenance not recorded, investigate`);
181
+ }
182
+ // 'sql'-kind objects are invisible to the catalog: provenance records the source
183
+ // hash on both sides plus HOW TO REMOVE it (the escape hatch's whole contract).
184
+ if (src.kind === 'sql') {
185
+ bookkeeping.push(renderProvenanceUpsert(identity, src.hash, src.hash, 'sql', src.drop));
186
+ continue;
187
+ }
188
+ const liveObj = liveById.get(identity);
189
+ if (!liveObj) {
190
+ throw new Error(`reconcile applied but ${identity} is not introspectable afterwards — provenance not recorded, investigate`);
191
+ }
192
+ const dropSql = src.kind === 'trigger' && src.table ? triggerDropSql(src.name, src.table) : undefined;
193
+ bookkeeping.push(renderProvenanceUpsert(identity, src.hash, liveObj.defHash, src.kind, dropSql));
194
+ }
195
+ for (const m of rendered.migrate) bookkeeping.push(renderProvenanceMigrate(m.from, m.to));
196
+ for (const identity of rendered.remove) bookkeeping.push(renderProvenanceDelete(identity));
197
+ bookkeeping.push(renderSchemaLogInsert({
198
+ kind: 'compute reconcile',
199
+ sql: rendered.statements.join(';\n') || '-- provenance-only (baseline/rebaseline/prune)',
200
+ outcome: 'applied',
201
+ actor: options.actor, gitRef: options.gitRef, planRef: options.planRef,
202
+ durationMs: now() - started,
203
+ }));
204
+
205
+ if (atomic) {
206
+ const pidNow = Number((await runner(BACKEND_PID_SQL))[0]?.pid);
207
+ if (pidNow !== pidAtBegin) {
208
+ throw new Error(
209
+ `the session changed mid-transaction (backend pid ${pidAtBegin} → ${pidNow}) — the connection `
210
+ + `was swapped or recycled under the open transaction (pooler restart? server restart?), PostgreSQL `
211
+ + `aborted it, and NOTHING from this batch is applied. No bookkeeping was written; re-run the apply.`,
212
+ );
213
+ }
214
+ }
215
+ await runner(bookkeeping.join(';\n'));
216
+
217
+ if (atomic) await runner('COMMIT');
110
218
  } catch (err: any) {
111
- outcome = 'failed';
112
- // The batch rolled back; record the attempt, then surface the failure.
219
+ // Roll the whole batch back — no half-applied DDL, no stale provenance — THEN record the failed
220
+ // attempt in its own transaction (the memoir must survive the rollback).
221
+ if (atomic) {
222
+ try { await runner('ROLLBACK'); } catch { /* the connection may already be aborted */ }
223
+ }
113
224
  try {
114
225
  await runner(renderSchemaLogInsert({
115
226
  kind: 'compute reconcile', sql: rendered.statements.join(';\n'),
@@ -118,37 +229,31 @@ export async function executeReconcile(
118
229
  durationMs: now() - started,
119
230
  }));
120
231
  } catch { /* the memoir is best-effort on failure */ }
121
- throw err;
122
- }
123
-
124
- // The batch committed — re-read the catalog so provenance records the def
125
- // hashes of what NOW exists, not what we hoped would exist.
126
- const after = await introspectDerived(runner);
127
- const liveById = new Map(after.objects.map((o) => [o.identity, o]));
128
- const srcById = new Map(parsed.objects.map((o) => [o.identity, o]));
129
-
130
- const bookkeeping: string[] = [];
131
- for (const identity of rendered.record) {
132
- const src = srcById.get(identity);
133
- const liveObj = liveById.get(identity);
134
- if (!src || !liveObj) {
135
- throw new Error(`reconcile applied but ${identity} is not introspectable afterwards — provenance not recorded, investigate`);
136
- }
137
- bookkeeping.push(renderProvenanceUpsert(identity, src.hash, liveObj.defHash));
232
+ throw explainReconcileError(err);
138
233
  }
139
- for (const identity of rendered.remove) bookkeeping.push(renderProvenanceDelete(identity));
140
- bookkeeping.push(renderSchemaLogInsert({
141
- kind: 'compute reconcile',
142
- sql: rendered.statements.join(';\n') || '-- provenance-only (baseline/prune)',
143
- outcome,
144
- actor: options.actor, gitRef: options.gitRef, planRef: options.planRef,
145
- durationMs: now() - started,
146
- }));
147
- await runner(bookkeeping.join(';\n'));
148
234
 
149
235
  return { plan, applied: true, statements: rendered.statements };
150
236
  }
151
237
 
238
+ /**
239
+ * reconcile's `create` is a bare `CREATE` (no drop-first, no IF NOT EXISTS), so when its plan
240
+ * and reality disagree — a partial earlier run, a hand-created object — Postgres answers with a
241
+ * cryptic unique-violation on `pg_type`/`pg_class` instead of anything actionable. Turn that into
242
+ * a message that tells the operator what to do.
243
+ */
244
+ export function explainReconcileError(err: unknown): Error {
245
+ const msg = String((err as any)?.message ?? err);
246
+ if (/pg_type_typname_nsp_index|pg_class_relname_nsp_index|already exists/i.test(msg)) {
247
+ return new Error(
248
+ `${msg}\n\nreconcile tried to CREATE a derived object whose name already exists — usually a partial `
249
+ + `earlier run or a hand-created object (its create is not idempotent). Resolve by explicitly dropping `
250
+ + `the colliding object (DROP MATERIALIZED VIEW / VIEW … CASCADE) and re-running, or, if the plan reports `
251
+ + `it as drift, re-run with --overwrite-drift.`,
252
+ );
253
+ }
254
+ return err instanceof Error ? err : new Error(msg);
255
+ }
256
+
152
257
  // ---------------------------------------------------------------------------
153
258
  // Report rendering (pure).
154
259
  // ---------------------------------------------------------------------------
@@ -161,7 +266,7 @@ export function formatBytes(bytes: number): string {
161
266
  }
162
267
 
163
268
  const VERB_GLYPH: Record<string, string> = {
164
- create: '+', replace: '~', drop: '-', refresh: '↻', baseline: '◦', prune: '·',
269
+ create: '+', replace: '~', drop: '-', refresh: '↻', baseline: '◦', rebaseline: '≈', prune: '·',
165
270
  };
166
271
 
167
272
  export function buildReconcileReport(plan: ReconcilePlan): string[] {
@@ -176,6 +281,16 @@ export function buildReconcileReport(plan: ReconcilePlan): string[] {
176
281
  lines.push(`estimated rebuild: ${formatBytes(plan.totalRebuildBytes)} across ${rebuilt.length} matview(s)`);
177
282
  }
178
283
  for (const d of plan.drift) lines.push(`! drift ${d.identity} — ${d.detail}`);
284
+ for (const r of plan.regrants) {
285
+ lines.push(`≠ regrant ${r.identity} — live grants differ from source (${r.statements.join('; ')})`);
286
+ }
287
+ for (const d of plan.dependencyDrift) {
288
+ const parts = [
289
+ ...(d.missing.length ? [`reads ${d.missing.join(', ')} undeclared`] : []),
290
+ ...(d.stale.length ? [`declares ${d.stale.join(', ')} but never reads it`] : []),
291
+ ];
292
+ lines.push(`! depends ${d.identity} — ${parts.join('; ')} (fix dependsOn in the descriptor)`);
293
+ }
179
294
  if (plan.needsBaseline.length > 0) {
180
295
  lines.push(`! needs baseline (${plan.needsBaseline.length}): ${plan.needsBaseline.join(', ')} — run with --baseline to record trust, once`);
181
296
  }
@@ -188,12 +303,15 @@ export function buildReconcileReport(plan: ReconcilePlan): string[] {
188
303
  return lines;
189
304
  }
190
305
 
191
- /** Anything that makes `--check` fail: pending work or unresolved findings. */
306
+ /** Anything that makes `--check` fail: pending work or unresolved findings.
307
+ * Baseline and rebaseline are explicit adoption the operator asked for, not findings. */
192
308
  export function checkFails(plan: ReconcilePlan): boolean {
193
- return plan.actions.some((a) => a.action !== 'baseline')
309
+ return plan.actions.some((a) => a.action !== 'baseline' && a.action !== 'rebaseline')
194
310
  || plan.drift.length > 0
195
311
  || plan.needsBaseline.length > 0
196
- || plan.blocked.length > 0;
312
+ || plan.blocked.length > 0
313
+ || plan.regrants.length > 0
314
+ || plan.dependencyDrift.length > 0;
197
315
  }
198
316
 
199
317
  // ---------------------------------------------------------------------------
@@ -209,25 +327,27 @@ function lambdaRunner(region: string, fn: string): QueryRunner {
209
327
  };
210
328
  }
211
329
 
212
- async function readSqlDir(dir: string): Promise<SourceFile[]> {
213
- let entries: string[];
214
- try {
215
- entries = await fs.readdir(dir);
216
- } catch {
217
- throw new Error(`No SQL source directory at ${dir} (set --sql-dir).`);
218
- }
219
- const files = entries.filter((f) => f.endsWith('.sql')).sort();
220
- return Promise.all(files.map(async (file) => ({
221
- file: path.join(dir, file),
222
- sql: await fs.readFile(path.join(dir, file), 'utf-8'),
223
- })));
224
- }
225
-
226
330
  export async function dbReconcileCommand(flags: Record<string, string>): Promise<void> {
227
- const sqlDir = flags['sql-dir'] || DEFAULT_SQL_DIR;
228
331
  const apply = flags.apply === 'true';
229
332
  const check = flags.check === 'true';
230
333
 
334
+ // B7 tombstone — db/sql is retired and no longer read. Failing beats silently
335
+ // skipping: skipped files would leave live objects unmanaged.
336
+ try {
337
+ if (flags['sql-dir']) {
338
+ fail(await retiredSqlDirFlagRefusal(flags['sql-dir']));
339
+ process.exit(1);
340
+ }
341
+ const retired = await retiredSqlDirAnywhere(flags.models);
342
+ if (retired) {
343
+ fail(retired);
344
+ process.exit(1);
345
+ }
346
+ } catch (err: any) {
347
+ fail(err.message);
348
+ process.exit(1);
349
+ }
350
+
231
351
  let dbSource: DbSource;
232
352
  try {
233
353
  dbSource = resolveDbSource(flags);
@@ -241,18 +361,41 @@ export async function dbReconcileCommand(flags: Record<string, string>): Promise
241
361
  process.exit(1);
242
362
  }
243
363
 
244
- let sources: SourceFile[];
364
+ // --baseline only writes under --apply (executeReconcile returns the plan and executes nothing
365
+ // when apply=false). Passing it alone used to print "recording provenance…" and exit 0 while
366
+ // persisting nothing — a silent no-op. Fail loudly instead; the plan already lists needsBaseline.
367
+ if (flags.baseline === 'true' && !apply) {
368
+ fail('--baseline only persists with --apply — on its own it records nothing (it silently no-op\'d before). Re-run with --apply, or drop --baseline to just see the plan (it already lists what needs baseline).');
369
+ process.exit(1);
370
+ }
371
+ if (flags.rebaseline === 'true' && !apply) {
372
+ fail('--rebaseline only persists with --apply — on its own it records nothing. Re-run with --apply, or drop --rebaseline to see the plan (source re-renders show as drop+create with reason "source changed").');
373
+ process.exit(1);
374
+ }
375
+
376
+ // --rebuild and --baseline are opposite answers to the same "can't verify first contact" question.
377
+ if (flags.rebuild === 'true' && flags.baseline === 'true') {
378
+ fail('--rebuild and --baseline are opposites: --baseline TRUSTS that live already matches source, --rebuild REBUILDS from source to guarantee it. Pick one.');
379
+ process.exit(1);
380
+ }
381
+
382
+ // The DECLARED derived layer (descriptors from the models barrel) — the one source
383
+ // stream — plus the pending table renames, so trigger provenance migrates.
384
+ let declared: DeclaredDerived | null = null;
245
385
  try {
246
- sources = await readSqlDir(sqlDir);
386
+ declared = await loadDeclaredDerived(flags.models);
247
387
  } catch (err: any) {
248
388
  fail(err.message);
249
389
  process.exit(1);
250
390
  }
391
+ if (declared && declared.objects.length > 0) {
392
+ info(`${declared.objects.length} declared derived object(s) from the models barrel.`);
393
+ }
251
394
 
252
395
  let runner: QueryRunner;
253
396
  let end: (() => Promise<void>) | undefined;
254
397
  if (dbSource.kind === 'url') {
255
- step(dbSource.from === 'flag' ? 'Connecting via --database-url...' : 'Connecting via DATABASE_URL...');
398
+ step(connectingVia(dbSource));
256
399
  ({ runner, end } = await createUrlRunner(dbSource.url));
257
400
  } else {
258
401
  step('Resolving deployed config...');
@@ -261,9 +404,13 @@ export async function dbReconcileCommand(flags: Record<string, string>): Promise
261
404
  }
262
405
 
263
406
  try {
264
- const run = await executeReconcile(runner, sources, {
407
+ const run = await executeReconcile(runner, {
265
408
  apply,
409
+ declared: declared?.objects,
410
+ renamedTables: declared?.renamedTables,
266
411
  baseline: flags.baseline === 'true',
412
+ rebaseline: flags.rebaseline === 'true',
413
+ rebuild: flags.rebuild === 'true',
267
414
  overwriteDrift: flags['overwrite-drift'] === 'true',
268
415
  actor: process.env.USER ?? null,
269
416
  gitRef: currentGitRef(),
@@ -277,6 +424,12 @@ export async function dbReconcileCommand(flags: Record<string, string>): Promise
277
424
  fail(`not applied: ${run.refusal}`);
278
425
  } else if (run.applied) {
279
426
  success(`Applied ${run.statements.length} statement(s); provenance and schema_log recorded.`);
427
+ if (run.plan.actions.some((a) => a.action === 'baseline')) {
428
+ warn('baseline recorded trust WITHOUT verifying live matches source — on first contact it CANNOT compare a source hash to a live deparse, so it trusts your assertion, it does not check. If you need a guarantee that live == the declared source, drop the object and let reconcile recreate it (or --overwrite-drift when the plan reports drift).');
429
+ }
430
+ if (run.plan.actions.some((a) => a.action === 'rebaseline')) {
431
+ warn('rebaseline VERIFIED the live side (each object is byte-identical to what the reconciler last applied) and TRUSTED the source side (that your re-rendered source produces the same object). If a descriptor actually differs — a changed body, a renamed index — that difference is now recorded as applied and will NOT deploy until the source changes again. When unsure, rebuild instead: drop the flag and let the plan drop+create.');
432
+ }
280
433
  } else if (apply) {
281
434
  success('Nothing to apply — derived layer matches source.');
282
435
  } else if (run.statements.length > 0) {