@everystack/cli 0.3.13 → 0.3.15
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 -0
- package/package.json +1 -1
- package/src/cli/commands/db-authz.ts +2 -1
- package/src/cli/commands/db-generate.ts +2 -2
- package/src/cli/commands/db-reconcile.ts +300 -0
- package/src/cli/derived-apply.ts +162 -0
- package/src/cli/derived-introspect.ts +314 -0
- package/src/cli/derived-plan.ts +279 -0
- package/src/cli/derived-source.ts +373 -0
- package/src/cli/index.ts +7 -2
- package/src/cli/models-path.ts +25 -0
- package/src/cli/runbook/detect.ts +4 -2
package/README.md
CHANGED
|
@@ -216,6 +216,7 @@ everystack db:psql --stage dev [-c SQL] # interactive psql / one-off query via L
|
|
|
216
216
|
# Model-driven schema (@everystack/model)
|
|
217
217
|
everystack db:generate # diff Models vs the live DB → next migration
|
|
218
218
|
everystack db:pull # introspect a live DB → field() Models (brownfield on-ramp)
|
|
219
|
+
everystack db:reconcile # deploy the derived layer (functions/views/matviews) from db/sql — no migrations
|
|
219
220
|
|
|
220
221
|
# Security
|
|
221
222
|
everystack db:doctor # is the DB least-privilege + RLS-subject?
|
package/package.json
CHANGED
|
@@ -44,6 +44,7 @@ import { writeContract, loadContract } from '../authz-contract-io.js';
|
|
|
44
44
|
import { renderContractMarkdown } from '../authz-render.js';
|
|
45
45
|
import { FUNCTIONS_SQL, catalogFunctionToDescriptor } from '../security-catalog.js';
|
|
46
46
|
import { resolveConfig, opsFunction } from '../config.js';
|
|
47
|
+
import { resolveModelsPath } from '../models-path.js';
|
|
47
48
|
import { invokeAction } from '../aws.js';
|
|
48
49
|
import { step, success, fail, info, warn } from '../output.js';
|
|
49
50
|
|
|
@@ -239,7 +240,7 @@ async function loadModels(modelsPath: string): Promise<ModelDescriptor[]> {
|
|
|
239
240
|
* exit on any leak or vacuous policy; an `unprobed` table (one identity) is a warning, not a fail.
|
|
240
241
|
*/
|
|
241
242
|
export async function dbAuthzOwnerCommand(flags: Record<string, string>): Promise<void> {
|
|
242
|
-
const modelsPath = flags.models
|
|
243
|
+
const modelsPath = resolveModelsPath(flags.models);
|
|
243
244
|
const schema = flags.schema || 'public';
|
|
244
245
|
|
|
245
246
|
let probes: OwnerProbe[];
|
|
@@ -28,11 +28,11 @@ import { generateMigrationSql, unmodeledTables, formatMigrationFile, planMigrati
|
|
|
28
28
|
import { introspectContract, type QueryRunner } from '../authz-contract.js';
|
|
29
29
|
import { FUNCTIONS_SQL, catalogFunctionToDescriptor } from '../security-catalog.js';
|
|
30
30
|
import { resolveDbSource, createUrlRunner, type DbSource } from '../db-source.js';
|
|
31
|
+
import { resolveModelsPath } from '../models-path.js';
|
|
31
32
|
import { resolveConfig, opsFunction } from '../config.js';
|
|
32
33
|
import { invokeAction } from '../aws.js';
|
|
33
34
|
import { step, success, fail, info, warn } from '../output.js';
|
|
34
35
|
|
|
35
|
-
const DEFAULT_MODELS = 'models/index.ts';
|
|
36
36
|
const DEFAULT_MIGRATIONS = 'drizzle';
|
|
37
37
|
const DEFAULT_SCHEMA_OUT = 'db/schema.generated.ts';
|
|
38
38
|
|
|
@@ -123,7 +123,7 @@ async function dbGenerateInit(modelsPath: string, migrationsDir: string, schemaO
|
|
|
123
123
|
}
|
|
124
124
|
|
|
125
125
|
export async function dbGenerateCommand(flags: Record<string, string>): Promise<void> {
|
|
126
|
-
const modelsPath = flags.models
|
|
126
|
+
const modelsPath = resolveModelsPath(flags.models);
|
|
127
127
|
const migrationsDir = path.resolve(flags.dir || DEFAULT_MIGRATIONS);
|
|
128
128
|
const schemaOut = path.resolve(flags['schema-out'] || DEFAULT_SCHEMA_OUT);
|
|
129
129
|
const name = flags.name || 'generated';
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `everystack db:reconcile` — deploy the derived layer (functions, views,
|
|
3
|
+
* matviews) by reconciling the live database against the db/sql sources.
|
|
4
|
+
*
|
|
5
|
+
* db:reconcile [--sql-dir db/sql] [--stage <name> | --database-url <url>]
|
|
6
|
+
* [--apply] [--check] [--baseline] [--overwrite-drift] [--json]
|
|
7
|
+
*
|
|
8
|
+
* Derived objects do not migrate; they deploy. The plan is computed fresh
|
|
9
|
+
* every run: source objects (hashed, comment/whitespace-insensitive) joined
|
|
10
|
+
* against the live catalog and the reconciler's provenance claims. Default
|
|
11
|
+
* mode prints the plan with rebuild-cost estimates. `--check` is the CI gate
|
|
12
|
+
* (exit 1 when anything differs). `--apply` executes: the DDL goes as ONE
|
|
13
|
+
* multi-statement batch (one implicit transaction — a failed create rolls
|
|
14
|
+
* back its drops), then provenance is re-recorded from fresh introspection,
|
|
15
|
+
* and the full applied SQL is appended to `everystack.schema_log`.
|
|
16
|
+
*
|
|
17
|
+
* Hand-edited live objects are DRIFT — reported, applied over only with
|
|
18
|
+
* `--overwrite-drift`. Never-reconciled matches need `--baseline` once (the
|
|
19
|
+
* explicit trust that live == source, recorded). `--apply` needs a direct
|
|
20
|
+
* connection (`--database-url` / DATABASE_URL): the ops Lambda `db:query`
|
|
21
|
+
* path is read-only by design, so plan/check work anywhere but writes go
|
|
22
|
+
* through a credentialed connection you chose to hold.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import fs from 'node:fs/promises';
|
|
26
|
+
import path from 'node:path';
|
|
27
|
+
import { execSync } from 'node:child_process';
|
|
28
|
+
import type { QueryRunner } from '../authz-contract.js';
|
|
29
|
+
import { parseSqlSources, type SourceFile } from '../derived-source.js';
|
|
30
|
+
import { introspectDerived } from '../derived-introspect.js';
|
|
31
|
+
import { planReconcile, type ReconcilePlan, type ReconcileOptions } from '../derived-plan.js';
|
|
32
|
+
import {
|
|
33
|
+
renderReconcileSql,
|
|
34
|
+
renderProvenanceUpsert,
|
|
35
|
+
renderProvenanceDelete,
|
|
36
|
+
renderSchemaLogInsert,
|
|
37
|
+
ENSURE_RECONCILER_SQL,
|
|
38
|
+
} from '../derived-apply.js';
|
|
39
|
+
import { resolveDbSource, createUrlRunner, type DbSource } from '../db-source.js';
|
|
40
|
+
import { resolveConfig, opsFunction } from '../config.js';
|
|
41
|
+
import { invokeAction } from '../aws.js';
|
|
42
|
+
import { step, success, fail, info, warn } from '../output.js';
|
|
43
|
+
|
|
44
|
+
const DEFAULT_SQL_DIR = 'db/sql';
|
|
45
|
+
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
// The testable core.
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
export interface ExecuteOptions extends ReconcileOptions {
|
|
51
|
+
apply?: boolean;
|
|
52
|
+
actor?: string | null;
|
|
53
|
+
gitRef?: string | null;
|
|
54
|
+
planRef?: string | null;
|
|
55
|
+
/** Injectable clock for tests. */
|
|
56
|
+
now?: () => number;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface ReconcileRun {
|
|
60
|
+
plan: ReconcilePlan;
|
|
61
|
+
/** True when DDL/bookkeeping was actually executed. */
|
|
62
|
+
applied: boolean;
|
|
63
|
+
/** The DDL batch that was (or would be) sent. */
|
|
64
|
+
statements: string[];
|
|
65
|
+
/** Why apply was refused, when it was. */
|
|
66
|
+
refusal?: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Plan — and under `apply`, execute — one reconcile. Pure orchestration over
|
|
71
|
+
* an injected QueryRunner; the CLI shell below owns flags, files, and exits.
|
|
72
|
+
*/
|
|
73
|
+
export async function executeReconcile(
|
|
74
|
+
runner: QueryRunner,
|
|
75
|
+
sources: SourceFile[],
|
|
76
|
+
options: ExecuteOptions = {},
|
|
77
|
+
): Promise<ReconcileRun> {
|
|
78
|
+
const live = await introspectDerived(runner);
|
|
79
|
+
const parsed = parseSqlSources(sources);
|
|
80
|
+
const plan = planReconcile(parsed, live, options);
|
|
81
|
+
const rendered = renderReconcileSql(plan, parsed.objects);
|
|
82
|
+
|
|
83
|
+
if (!options.apply) return { plan, applied: false, statements: rendered.statements };
|
|
84
|
+
|
|
85
|
+
if (plan.blocked.length > 0) {
|
|
86
|
+
return {
|
|
87
|
+
plan, applied: false, statements: rendered.statements,
|
|
88
|
+
refusal: `blocked chains: ${plan.blocked.map((b) => b.identity).join(', ')} — resolve the unmanaged dependents first`,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
if (plan.drift.length > 0 && !options.overwriteDrift) {
|
|
92
|
+
return {
|
|
93
|
+
plan, applied: false, statements: rendered.statements,
|
|
94
|
+
refusal: `drift on ${plan.drift.map((d) => d.identity).join(', ')} — inspect, then re-run with --overwrite-drift to rebuild from source`,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
if (rendered.statements.length === 0 && rendered.record.length === 0 && rendered.remove.length === 0) {
|
|
98
|
+
return { plan, applied: false, statements: [] };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const now = options.now ?? Date.now;
|
|
102
|
+
await runner(ENSURE_RECONCILER_SQL.join(';\n'));
|
|
103
|
+
|
|
104
|
+
const started = now();
|
|
105
|
+
let outcome = 'applied';
|
|
106
|
+
try {
|
|
107
|
+
if (rendered.statements.length > 0) {
|
|
108
|
+
await runner(rendered.statements.join(';\n'));
|
|
109
|
+
}
|
|
110
|
+
} catch (err: any) {
|
|
111
|
+
outcome = 'failed';
|
|
112
|
+
// The batch rolled back; record the attempt, then surface the failure.
|
|
113
|
+
try {
|
|
114
|
+
await runner(renderSchemaLogInsert({
|
|
115
|
+
kind: 'compute reconcile', sql: rendered.statements.join(';\n'),
|
|
116
|
+
outcome: `failed: ${String(err?.message ?? err).slice(0, 500)}`,
|
|
117
|
+
actor: options.actor, gitRef: options.gitRef, planRef: options.planRef,
|
|
118
|
+
durationMs: now() - started,
|
|
119
|
+
}));
|
|
120
|
+
} 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));
|
|
138
|
+
}
|
|
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
|
+
|
|
149
|
+
return { plan, applied: true, statements: rendered.statements };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ---------------------------------------------------------------------------
|
|
153
|
+
// Report rendering (pure).
|
|
154
|
+
// ---------------------------------------------------------------------------
|
|
155
|
+
|
|
156
|
+
export function formatBytes(bytes: number): string {
|
|
157
|
+
if (bytes >= 1024 ** 3) return `${(bytes / 1024 ** 3).toFixed(1)} GB`;
|
|
158
|
+
if (bytes >= 1024 ** 2) return `${(bytes / 1024 ** 2).toFixed(1)} MB`;
|
|
159
|
+
if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
160
|
+
return `${bytes} B`;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const VERB_GLYPH: Record<string, string> = {
|
|
164
|
+
create: '+', replace: '~', drop: '-', refresh: '↻', baseline: '◦', prune: '·',
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
export function buildReconcileReport(plan: ReconcilePlan): string[] {
|
|
168
|
+
const lines: string[] = [];
|
|
169
|
+
if (plan.skipped.length > 0) lines.push(`= ${plan.skipped.length} up to date`);
|
|
170
|
+
for (const a of plan.actions) {
|
|
171
|
+
const cost = a.bytes !== undefined ? `; ${formatBytes(a.bytes)}${a.rows ? `, ~${a.rows} rows` : ''}` : '';
|
|
172
|
+
lines.push(`${VERB_GLYPH[a.action] ?? '?'} ${a.action} ${a.identity} (${a.reason}${cost})`);
|
|
173
|
+
}
|
|
174
|
+
const rebuilt = plan.actions.filter((a) => (a.action === 'create' || a.action === 'refresh') && a.bytes !== undefined);
|
|
175
|
+
if (plan.totalRebuildBytes > 0) {
|
|
176
|
+
lines.push(`estimated rebuild: ${formatBytes(plan.totalRebuildBytes)} across ${rebuilt.length} matview(s)`);
|
|
177
|
+
}
|
|
178
|
+
for (const d of plan.drift) lines.push(`! drift ${d.identity} — ${d.detail}`);
|
|
179
|
+
if (plan.needsBaseline.length > 0) {
|
|
180
|
+
lines.push(`! needs baseline (${plan.needsBaseline.length}): ${plan.needsBaseline.join(', ')} — run with --baseline to record trust, once`);
|
|
181
|
+
}
|
|
182
|
+
if (plan.unmanaged.length > 0) {
|
|
183
|
+
lines.push(`! unmanaged (${plan.unmanaged.length}): ${plan.unmanaged.join(', ')} — not in source, never touched`);
|
|
184
|
+
}
|
|
185
|
+
for (const b of plan.blocked) lines.push(`! blocked ${b.identity} — ${b.reason}`);
|
|
186
|
+
for (const w of plan.warnings) lines.push(`! ${w}`);
|
|
187
|
+
if (lines.length === 0) lines.push('nothing to do — derived layer matches source');
|
|
188
|
+
return lines;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Anything that makes `--check` fail: pending work or unresolved findings. */
|
|
192
|
+
export function checkFails(plan: ReconcilePlan): boolean {
|
|
193
|
+
return plan.actions.some((a) => a.action !== 'baseline')
|
|
194
|
+
|| plan.drift.length > 0
|
|
195
|
+
|| plan.needsBaseline.length > 0
|
|
196
|
+
|| plan.blocked.length > 0;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// ---------------------------------------------------------------------------
|
|
200
|
+
// The CLI shell.
|
|
201
|
+
// ---------------------------------------------------------------------------
|
|
202
|
+
|
|
203
|
+
/** A QueryRunner backed by the ops Lambda `db:query` action (read-only). */
|
|
204
|
+
function lambdaRunner(region: string, fn: string): QueryRunner {
|
|
205
|
+
return async (sql: string) => {
|
|
206
|
+
const result: any = await invokeAction(region, fn, 'db:query', { sql });
|
|
207
|
+
if (result?.error) throw new Error(`Query failed: ${result.error}`);
|
|
208
|
+
return result?.rows ?? [];
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
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
|
+
function currentGitRef(): string | null {
|
|
227
|
+
try {
|
|
228
|
+
return execSync('git rev-parse HEAD', { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim();
|
|
229
|
+
} catch {
|
|
230
|
+
return null;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export async function dbReconcileCommand(flags: Record<string, string>): Promise<void> {
|
|
235
|
+
const sqlDir = flags['sql-dir'] || DEFAULT_SQL_DIR;
|
|
236
|
+
const apply = flags.apply === 'true';
|
|
237
|
+
const check = flags.check === 'true';
|
|
238
|
+
|
|
239
|
+
let dbSource: DbSource;
|
|
240
|
+
try {
|
|
241
|
+
dbSource = resolveDbSource(flags);
|
|
242
|
+
} catch (err: any) {
|
|
243
|
+
fail(err.message);
|
|
244
|
+
process.exit(1);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (apply && dbSource.kind !== 'url') {
|
|
248
|
+
fail('--apply needs a direct connection (--database-url or DATABASE_URL) — the ops Lambda query path is read-only by design.');
|
|
249
|
+
process.exit(1);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
let sources: SourceFile[];
|
|
253
|
+
try {
|
|
254
|
+
sources = await readSqlDir(sqlDir);
|
|
255
|
+
} catch (err: any) {
|
|
256
|
+
fail(err.message);
|
|
257
|
+
process.exit(1);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
let runner: QueryRunner;
|
|
261
|
+
let end: (() => Promise<void>) | undefined;
|
|
262
|
+
if (dbSource.kind === 'url') {
|
|
263
|
+
step(dbSource.from === 'flag' ? 'Connecting via --database-url...' : 'Connecting via DATABASE_URL...');
|
|
264
|
+
({ runner, end } = await createUrlRunner(dbSource.url));
|
|
265
|
+
} else {
|
|
266
|
+
step('Resolving deployed config...');
|
|
267
|
+
const config = await resolveConfig(flags.stage);
|
|
268
|
+
runner = lambdaRunner(config.region, opsFunction(config));
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
try {
|
|
272
|
+
const run = await executeReconcile(runner, sources, {
|
|
273
|
+
apply,
|
|
274
|
+
baseline: flags.baseline === 'true',
|
|
275
|
+
overwriteDrift: flags['overwrite-drift'] === 'true',
|
|
276
|
+
actor: process.env.USER ?? null,
|
|
277
|
+
gitRef: currentGitRef(),
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
if (flags.json === 'true') {
|
|
281
|
+
console.log(JSON.stringify({ ...run.plan, applied: run.applied, refusal: run.refusal ?? null }, null, 2));
|
|
282
|
+
} else {
|
|
283
|
+
for (const line of buildReconcileReport(run.plan)) info(line);
|
|
284
|
+
if (run.refusal) {
|
|
285
|
+
fail(`not applied: ${run.refusal}`);
|
|
286
|
+
} else if (run.applied) {
|
|
287
|
+
success(`Applied ${run.statements.length} statement(s); provenance and schema_log recorded.`);
|
|
288
|
+
} else if (apply) {
|
|
289
|
+
success('Nothing to apply — derived layer matches source.');
|
|
290
|
+
} else if (run.statements.length > 0) {
|
|
291
|
+
warn('Plan only — re-run with --apply to execute.');
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
if (run.refusal) process.exit(1);
|
|
296
|
+
if (check && checkFails(run.plan)) process.exit(1);
|
|
297
|
+
} finally {
|
|
298
|
+
await end?.();
|
|
299
|
+
}
|
|
300
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* derived-apply — renders a ReconcilePlan into executable SQL.
|
|
3
|
+
*
|
|
4
|
+
* Pure: no database here. The command sends the DDL batch as ONE
|
|
5
|
+
* multi-statement string (postgres simple-query protocol = one implicit
|
|
6
|
+
* transaction, so a failed create rolls back the drops that preceded it),
|
|
7
|
+
* then — after the batch commits — re-introspects the touched objects and
|
|
8
|
+
* records provenance with the fresh live def hashes, and appends the
|
|
9
|
+
* schema_log row carrying the full SQL text that was applied.
|
|
10
|
+
*
|
|
11
|
+
* The bookkeeping tables live in the `everystack` schema with no grants
|
|
12
|
+
* beyond the owner: admin-only by construction. schema_log is a memoir —
|
|
13
|
+
* the reconciler writes it and never reads it.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { normalizeSql, type SourceObject } from './derived-source.js';
|
|
17
|
+
import type { ReconcilePlan } from './derived-plan.js';
|
|
18
|
+
import { quoteQualified } from './pg-ident.js';
|
|
19
|
+
|
|
20
|
+
/** SQL string literal with '' doubling (standard_conforming_strings). */
|
|
21
|
+
export function escapeLiteral(value: string): string {
|
|
22
|
+
return `'${value.replace(/'/g, "''")}'`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const nullable = (value: string | null | undefined): string =>
|
|
26
|
+
value == null ? 'NULL' : escapeLiteral(value);
|
|
27
|
+
|
|
28
|
+
/** Bookkeeping DDL — idempotent, safe to send before every apply. */
|
|
29
|
+
export const ENSURE_RECONCILER_SQL: string[] = [
|
|
30
|
+
`CREATE SCHEMA IF NOT EXISTS everystack`,
|
|
31
|
+
`CREATE TABLE IF NOT EXISTS everystack.derived_provenance (
|
|
32
|
+
identity text PRIMARY KEY,
|
|
33
|
+
src_hash text NOT NULL,
|
|
34
|
+
def_hash text NOT NULL,
|
|
35
|
+
applied_at timestamptz NOT NULL DEFAULT now()
|
|
36
|
+
)`,
|
|
37
|
+
`CREATE TABLE IF NOT EXISTS everystack.schema_log (
|
|
38
|
+
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
|
39
|
+
applied_at timestamptz NOT NULL DEFAULT now(),
|
|
40
|
+
kind text NOT NULL,
|
|
41
|
+
actor text,
|
|
42
|
+
git_ref text,
|
|
43
|
+
plan_ref text,
|
|
44
|
+
from_fingerprint text,
|
|
45
|
+
to_fingerprint text,
|
|
46
|
+
sql text NOT NULL,
|
|
47
|
+
duration_ms integer,
|
|
48
|
+
outcome text NOT NULL
|
|
49
|
+
)`,
|
|
50
|
+
];
|
|
51
|
+
|
|
52
|
+
export interface RenderedReconcile {
|
|
53
|
+
/** DDL in execution order — send as one batch (one implicit transaction). */
|
|
54
|
+
statements: string[];
|
|
55
|
+
/** Identities to (re)record in provenance after the batch commits. */
|
|
56
|
+
record: string[];
|
|
57
|
+
/** Identities whose provenance rows must be deleted (drops + prunes). */
|
|
58
|
+
remove: string[];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const DROP_KEYWORD: Record<string, string> = {
|
|
62
|
+
'materialized view': 'MATERIALIZED VIEW',
|
|
63
|
+
view: 'VIEW',
|
|
64
|
+
function: 'FUNCTION',
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/** Rewrite `CREATE FUNCTION` to `CREATE OR REPLACE FUNCTION` (replace-in-place). */
|
|
68
|
+
export function ensureOrReplace(sql: string): string {
|
|
69
|
+
return sql.replace(/^(\s*)CREATE\s+FUNCTION/i, '$1CREATE OR REPLACE FUNCTION');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const WITH_NO_DATA_RE = /WITH\s+NO\s+DATA\s*$/i;
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* An object's full creation SQL: the CREATE statement plus its attachments, in
|
|
76
|
+
* source order. A matview whose source says `WITH NO DATA` gets a trailing
|
|
77
|
+
* REFRESH: the reconciler dropped a populated matview to rebuild it, and
|
|
78
|
+
* executing the source verbatim would leave it unpopulated — matching the
|
|
79
|
+
* text but regressing the database. (Found by a consumer whose extracted
|
|
80
|
+
* sources carry pg_dump's WITH NO DATA ordering.)
|
|
81
|
+
*/
|
|
82
|
+
function objectSql(obj: SourceObject): string[] {
|
|
83
|
+
const statements = [obj.sql, ...obj.attachments.map((a) => a.sql)];
|
|
84
|
+
if (obj.kind === 'materialized view' && WITH_NO_DATA_RE.test(normalizeSql(obj.sql))) {
|
|
85
|
+
statements.push(`REFRESH MATERIALIZED VIEW ${quoteQualified(obj.identity)}`);
|
|
86
|
+
}
|
|
87
|
+
return statements;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function renderReconcileSql(plan: ReconcilePlan, source: SourceObject[]): RenderedReconcile {
|
|
91
|
+
const srcById = new Map(source.map((o) => [o.identity, o]));
|
|
92
|
+
const statements: string[] = [];
|
|
93
|
+
const record: string[] = [];
|
|
94
|
+
const remove: string[] = [];
|
|
95
|
+
|
|
96
|
+
for (const action of plan.actions) {
|
|
97
|
+
switch (action.action) {
|
|
98
|
+
case 'drop': {
|
|
99
|
+
statements.push(`DROP ${DROP_KEYWORD[action.kind]} IF EXISTS ${quoteQualified(action.identity)}`);
|
|
100
|
+
// A rebuild's drop is followed by its create; only a true removal loses provenance.
|
|
101
|
+
if (!plan.actions.some((a) => a.action === 'create' && a.identity === action.identity)) {
|
|
102
|
+
remove.push(action.identity);
|
|
103
|
+
}
|
|
104
|
+
break;
|
|
105
|
+
}
|
|
106
|
+
case 'replace': {
|
|
107
|
+
const obj = srcById.get(action.identity);
|
|
108
|
+
if (!obj) throw new Error(`replace action for ${action.identity} has no source object`);
|
|
109
|
+
statements.push(...objectSql(obj).map(ensureOrReplace));
|
|
110
|
+
record.push(action.identity);
|
|
111
|
+
break;
|
|
112
|
+
}
|
|
113
|
+
case 'create': {
|
|
114
|
+
const obj = srcById.get(action.identity);
|
|
115
|
+
if (!obj) throw new Error(`create action for ${action.identity} has no source object`);
|
|
116
|
+
statements.push(...objectSql(obj));
|
|
117
|
+
record.push(action.identity);
|
|
118
|
+
break;
|
|
119
|
+
}
|
|
120
|
+
case 'refresh': {
|
|
121
|
+
statements.push(`REFRESH MATERIALIZED VIEW ${quoteQualified(action.identity)}`);
|
|
122
|
+
break;
|
|
123
|
+
}
|
|
124
|
+
case 'baseline': {
|
|
125
|
+
record.push(action.identity);
|
|
126
|
+
break;
|
|
127
|
+
}
|
|
128
|
+
case 'prune': {
|
|
129
|
+
remove.push(action.identity);
|
|
130
|
+
break;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return { statements, record, remove };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function renderProvenanceUpsert(identity: string, srcHash: string, defHash: string): string {
|
|
139
|
+
return `INSERT INTO everystack.derived_provenance (identity, src_hash, def_hash, applied_at)
|
|
140
|
+
VALUES (${escapeLiteral(identity)}, ${escapeLiteral(srcHash)}, ${escapeLiteral(defHash)}, now())
|
|
141
|
+
ON CONFLICT (identity) DO UPDATE SET src_hash = EXCLUDED.src_hash, def_hash = EXCLUDED.def_hash, applied_at = now()`;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function renderProvenanceDelete(identity: string): string {
|
|
145
|
+
return `DELETE FROM everystack.derived_provenance WHERE identity = ${escapeLiteral(identity)}`;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export interface SchemaLogEntry {
|
|
149
|
+
kind: string;
|
|
150
|
+
sql: string;
|
|
151
|
+
outcome: string;
|
|
152
|
+
actor?: string | null;
|
|
153
|
+
gitRef?: string | null;
|
|
154
|
+
planRef?: string | null;
|
|
155
|
+
durationMs?: number;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function renderSchemaLogInsert(entry: SchemaLogEntry): string {
|
|
159
|
+
const duration = entry.durationMs === undefined ? 'NULL' : String(Math.round(entry.durationMs));
|
|
160
|
+
return `INSERT INTO everystack.schema_log (kind, actor, git_ref, plan_ref, sql, duration_ms, outcome)
|
|
161
|
+
VALUES (${escapeLiteral(entry.kind)}, ${nullable(entry.actor)}, ${nullable(entry.gitRef)}, ${nullable(entry.planRef)}, ${escapeLiteral(entry.sql)}, ${duration}, ${escapeLiteral(entry.outcome)})`;
|
|
162
|
+
}
|