@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.
- package/README.md +1 -1
- package/package.json +6 -2
- package/src/cli/apply-execute.ts +185 -0
- package/src/cli/authz-compile.ts +5 -2
- package/src/cli/authz-lint.ts +48 -0
- package/src/cli/aws.ts +102 -4
- package/src/cli/backfill.ts +1 -1
- package/src/cli/commands/db-apply.ts +159 -169
- package/src/cli/commands/db-backfill.ts +2 -2
- package/src/cli/commands/db-backup.ts +34 -0
- package/src/cli/commands/db-branch.ts +28 -4
- package/src/cli/commands/db-check.ts +97 -34
- package/src/cli/commands/db-fingerprint.ts +9 -4
- package/src/cli/commands/db-generate.ts +73 -22
- package/src/cli/commands/db-plan.ts +7 -13
- package/src/cli/commands/db-pull.ts +39 -7
- package/src/cli/commands/db-reconcile.ts +219 -66
- package/src/cli/commands/db-sync.ts +58 -21
- package/src/cli/commands/deploy.ts +4 -0
- package/src/cli/commands/pipeline-run.ts +269 -0
- package/src/cli/db-build.ts +9 -3
- package/src/cli/db-source.ts +83 -7
- package/src/cli/declared-derived.ts +136 -0
- package/src/cli/derived-apply.ts +40 -6
- package/src/cli/derived-compile.ts +327 -0
- package/src/cli/derived-grants.ts +96 -0
- package/src/cli/derived-introspect.ts +258 -21
- package/src/cli/derived-lint.ts +261 -0
- package/src/cli/derived-plan.ts +194 -12
- package/src/cli/derived-render.ts +320 -0
- package/src/cli/derived-source.ts +53 -125
- package/src/cli/discover.ts +16 -0
- package/src/cli/git-descent.ts +15 -2
- package/src/cli/index.ts +26 -6
- package/src/cli/migration-compile.ts +38 -7
- package/src/cli/migration-generate.ts +43 -4
- package/src/cli/model-render.ts +106 -13
- package/src/cli/models-path.ts +1 -1
- package/src/cli/ops-diagnostics.ts +71 -0
- package/src/cli/pipeline-loader.ts +68 -0
- package/src/cli/pipeline-path.ts +25 -0
- package/src/cli/schema-compile.ts +41 -10
- package/src/cli/schema-diff.ts +123 -17
- package/src/cli/schema-fingerprint.ts +28 -18
- package/src/cli/schema-introspect.ts +175 -8
- package/src/cli/schema-source.ts +75 -6
- 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 {
|
|
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
|
|
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
|
|
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 [--
|
|
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).
|
|
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 {
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
112
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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,
|
|
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) {
|