@everystack/cli 0.3.28 → 0.3.31
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/package.json +5 -1
- package/src/cli/apply-execute.ts +185 -0
- package/src/cli/authz-lint.ts +48 -0
- package/src/cli/aws.ts +102 -4
- 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-check.ts +13 -1
- package/src/cli/commands/db-fingerprint.ts +2 -2
- package/src/cli/commands/db-generate.ts +2 -2
- package/src/cli/commands/db-plan.ts +7 -13
- package/src/cli/commands/db-pull.ts +2 -2
- package/src/cli/commands/db-reconcile.ts +93 -31
- package/src/cli/commands/db-sync.ts +4 -2
- package/src/cli/commands/deploy.ts +4 -0
- package/src/cli/commands/pipeline-run.ts +269 -0
- package/src/cli/db-source.ts +79 -7
- package/src/cli/derived-plan.ts +19 -3
- package/src/cli/discover.ts +16 -0
- package/src/cli/index.ts +22 -2
- package/src/cli/ops-diagnostics.ts +71 -0
- package/src/cli/pipeline-loader.ts +68 -0
- package/src/cli/pipeline-path.ts +25 -0
|
@@ -35,7 +35,7 @@ import {
|
|
|
35
35
|
renderSchemaLogInsert,
|
|
36
36
|
ENSURE_RECONCILER_SQL,
|
|
37
37
|
} from '../derived-apply.js';
|
|
38
|
-
import { resolveDbSource, createUrlRunner, type DbSource } from '../db-source.js';
|
|
38
|
+
import { resolveDbSource, createUrlRunner, connectingVia, type DbSource } from '../db-source.js';
|
|
39
39
|
import { currentGitRef } from '../state-apply.js';
|
|
40
40
|
import { resolveConfig, opsFunction } from '../config.js';
|
|
41
41
|
import { invokeAction } from '../aws.js';
|
|
@@ -70,6 +70,18 @@ export interface ReconcileRun {
|
|
|
70
70
|
* Plan — and under `apply`, execute — one reconcile. Pure orchestration over
|
|
71
71
|
* an injected QueryRunner; the CLI shell below owns flags, files, and exits.
|
|
72
72
|
*/
|
|
73
|
+
/** Statements Postgres forbids inside a transaction block — they implicitly commit. */
|
|
74
|
+
export const NON_TRANSACTIONAL_RE = /\bCONCURRENTLY\b|\bVACUUM\b/i;
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Whether the whole batch can run inside one transaction. Almost always yes — the escape hatch
|
|
78
|
+
* is a consumer whose matview source carries a `CREATE INDEX CONCURRENTLY` (rendered verbatim) or
|
|
79
|
+
* a `VACUUM`, which self-commit and cannot live in a transaction block.
|
|
80
|
+
*/
|
|
81
|
+
export function isTransactionalBatch(statements: string[]): boolean {
|
|
82
|
+
return statements.every((s) => !NON_TRANSACTIONAL_RE.test(s));
|
|
83
|
+
}
|
|
84
|
+
|
|
73
85
|
export async function executeReconcile(
|
|
74
86
|
runner: QueryRunner,
|
|
75
87
|
sources: SourceFile[],
|
|
@@ -102,14 +114,52 @@ export async function executeReconcile(
|
|
|
102
114
|
await runner(ENSURE_RECONCILER_SQL.join(';\n'));
|
|
103
115
|
|
|
104
116
|
const started = now();
|
|
105
|
-
|
|
117
|
+
|
|
118
|
+
// Atomicity: the DDL and the provenance/schema_log that describe it go in ONE transaction, so a
|
|
119
|
+
// failure rolls the whole thing back — live and its bookkeeping can never diverge. That divergence
|
|
120
|
+
// (objects changed, provenance stale) is exactly the drift that strands a stage after a partial
|
|
121
|
+
// apply. The one exception is a batch with statements Postgres forbids inside a transaction block
|
|
122
|
+
// (CONCURRENTLY index builds, VACUUM); those self-commit, so we fall back to the unwrapped path.
|
|
123
|
+
const atomic = isTransactionalBatch(rendered.statements);
|
|
124
|
+
|
|
125
|
+
if (atomic) await runner('BEGIN');
|
|
106
126
|
try {
|
|
107
127
|
if (rendered.statements.length > 0) {
|
|
108
128
|
await runner(rendered.statements.join(';\n'));
|
|
109
129
|
}
|
|
130
|
+
|
|
131
|
+
// Re-read the catalog so provenance records the def hashes of what NOW exists, not what we hoped
|
|
132
|
+
// would exist. Inside the transaction this reads the batch's own not-yet-committed writes.
|
|
133
|
+
const after = await introspectDerived(runner);
|
|
134
|
+
const liveById = new Map(after.objects.map((o) => [o.identity, o]));
|
|
135
|
+
const srcById = new Map(parsed.objects.map((o) => [o.identity, o]));
|
|
136
|
+
|
|
137
|
+
const bookkeeping: string[] = [];
|
|
138
|
+
for (const identity of rendered.record) {
|
|
139
|
+
const src = srcById.get(identity);
|
|
140
|
+
const liveObj = liveById.get(identity);
|
|
141
|
+
if (!src || !liveObj) {
|
|
142
|
+
throw new Error(`reconcile applied but ${identity} is not introspectable afterwards — provenance not recorded, investigate`);
|
|
143
|
+
}
|
|
144
|
+
bookkeeping.push(renderProvenanceUpsert(identity, src.hash, liveObj.defHash));
|
|
145
|
+
}
|
|
146
|
+
for (const identity of rendered.remove) bookkeeping.push(renderProvenanceDelete(identity));
|
|
147
|
+
bookkeeping.push(renderSchemaLogInsert({
|
|
148
|
+
kind: 'compute reconcile',
|
|
149
|
+
sql: rendered.statements.join(';\n') || '-- provenance-only (baseline/prune)',
|
|
150
|
+
outcome: 'applied',
|
|
151
|
+
actor: options.actor, gitRef: options.gitRef, planRef: options.planRef,
|
|
152
|
+
durationMs: now() - started,
|
|
153
|
+
}));
|
|
154
|
+
await runner(bookkeeping.join(';\n'));
|
|
155
|
+
|
|
156
|
+
if (atomic) await runner('COMMIT');
|
|
110
157
|
} catch (err: any) {
|
|
111
|
-
|
|
112
|
-
//
|
|
158
|
+
// Roll the whole batch back — no half-applied DDL, no stale provenance — THEN record the failed
|
|
159
|
+
// attempt in its own transaction (the memoir must survive the rollback).
|
|
160
|
+
if (atomic) {
|
|
161
|
+
try { await runner('ROLLBACK'); } catch { /* the connection may already be aborted */ }
|
|
162
|
+
}
|
|
113
163
|
try {
|
|
114
164
|
await runner(renderSchemaLogInsert({
|
|
115
165
|
kind: 'compute reconcile', sql: rendered.statements.join(';\n'),
|
|
@@ -118,37 +168,31 @@ export async function executeReconcile(
|
|
|
118
168
|
durationMs: now() - started,
|
|
119
169
|
}));
|
|
120
170
|
} catch { /* the memoir is best-effort on failure */ }
|
|
121
|
-
throw err;
|
|
171
|
+
throw explainReconcileError(err);
|
|
122
172
|
}
|
|
123
173
|
|
|
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
174
|
return { plan, applied: true, statements: rendered.statements };
|
|
150
175
|
}
|
|
151
176
|
|
|
177
|
+
/**
|
|
178
|
+
* reconcile's `create` is a bare `CREATE` (no drop-first, no IF NOT EXISTS), so when its plan
|
|
179
|
+
* and reality disagree — a partial earlier run, a hand-created object — Postgres answers with a
|
|
180
|
+
* cryptic unique-violation on `pg_type`/`pg_class` instead of anything actionable. Turn that into
|
|
181
|
+
* a message that tells the operator what to do.
|
|
182
|
+
*/
|
|
183
|
+
export function explainReconcileError(err: unknown): Error {
|
|
184
|
+
const msg = String((err as any)?.message ?? err);
|
|
185
|
+
if (/pg_type_typname_nsp_index|pg_class_relname_nsp_index|already exists/i.test(msg)) {
|
|
186
|
+
return new Error(
|
|
187
|
+
`${msg}\n\nreconcile tried to CREATE a derived object whose name already exists — usually a partial `
|
|
188
|
+
+ `earlier run or a hand-created object (its create is not idempotent). Resolve by explicitly dropping `
|
|
189
|
+
+ `the colliding object (DROP MATERIALIZED VIEW / VIEW … CASCADE) and re-running, or, if the plan reports `
|
|
190
|
+
+ `it as drift, re-run with --overwrite-drift.`,
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
return err instanceof Error ? err : new Error(msg);
|
|
194
|
+
}
|
|
195
|
+
|
|
152
196
|
// ---------------------------------------------------------------------------
|
|
153
197
|
// Report rendering (pure).
|
|
154
198
|
// ---------------------------------------------------------------------------
|
|
@@ -241,6 +285,20 @@ export async function dbReconcileCommand(flags: Record<string, string>): Promise
|
|
|
241
285
|
process.exit(1);
|
|
242
286
|
}
|
|
243
287
|
|
|
288
|
+
// --baseline only writes under --apply (executeReconcile returns the plan and executes nothing
|
|
289
|
+
// when apply=false). Passing it alone used to print "recording provenance…" and exit 0 while
|
|
290
|
+
// persisting nothing — a silent no-op. Fail loudly instead; the plan already lists needsBaseline.
|
|
291
|
+
if (flags.baseline === 'true' && !apply) {
|
|
292
|
+
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).');
|
|
293
|
+
process.exit(1);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// --rebuild and --baseline are opposite answers to the same "can't verify first contact" question.
|
|
297
|
+
if (flags.rebuild === 'true' && flags.baseline === 'true') {
|
|
298
|
+
fail('--rebuild and --baseline are opposites: --baseline TRUSTS that live already matches source, --rebuild REBUILDS from source to guarantee it. Pick one.');
|
|
299
|
+
process.exit(1);
|
|
300
|
+
}
|
|
301
|
+
|
|
244
302
|
let sources: SourceFile[];
|
|
245
303
|
try {
|
|
246
304
|
sources = await readSqlDir(sqlDir);
|
|
@@ -252,7 +310,7 @@ export async function dbReconcileCommand(flags: Record<string, string>): Promise
|
|
|
252
310
|
let runner: QueryRunner;
|
|
253
311
|
let end: (() => Promise<void>) | undefined;
|
|
254
312
|
if (dbSource.kind === 'url') {
|
|
255
|
-
step(dbSource
|
|
313
|
+
step(connectingVia(dbSource));
|
|
256
314
|
({ runner, end } = await createUrlRunner(dbSource.url));
|
|
257
315
|
} else {
|
|
258
316
|
step('Resolving deployed config...');
|
|
@@ -264,6 +322,7 @@ export async function dbReconcileCommand(flags: Record<string, string>): Promise
|
|
|
264
322
|
const run = await executeReconcile(runner, sources, {
|
|
265
323
|
apply,
|
|
266
324
|
baseline: flags.baseline === 'true',
|
|
325
|
+
rebuild: flags.rebuild === 'true',
|
|
267
326
|
overwriteDrift: flags['overwrite-drift'] === 'true',
|
|
268
327
|
actor: process.env.USER ?? null,
|
|
269
328
|
gitRef: currentGitRef(),
|
|
@@ -277,6 +336,9 @@ export async function dbReconcileCommand(flags: Record<string, string>): Promise
|
|
|
277
336
|
fail(`not applied: ${run.refusal}`);
|
|
278
337
|
} else if (run.applied) {
|
|
279
338
|
success(`Applied ${run.statements.length} statement(s); provenance and schema_log recorded.`);
|
|
339
|
+
if (run.plan.actions.some((a) => a.action === 'baseline')) {
|
|
340
|
+
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 == db/sql, drop the object and let reconcile recreate it (or --overwrite-drift when the plan reports drift).');
|
|
341
|
+
}
|
|
280
342
|
} else if (apply) {
|
|
281
343
|
success('Nothing to apply — derived layer matches source.');
|
|
282
344
|
} else if (run.statements.length > 0) {
|
|
@@ -45,11 +45,12 @@ import {
|
|
|
45
45
|
import { fingerprintModels } from '../schema-fingerprint.js';
|
|
46
46
|
import { compileDrizzleSource } from '../schema-source.js';
|
|
47
47
|
import type { SourceFile } from '../derived-source.js';
|
|
48
|
-
import { resolveDbSource, createUrlRunner, type DbSource } from '../db-source.js';
|
|
48
|
+
import { resolveDbSource, createUrlRunner, connectingVia, type DbSource } from '../db-source.js';
|
|
49
49
|
import { planBackfills, readBackfillLog } from '../backfill.js';
|
|
50
50
|
import { resolveModelsPath } from '../models-path.js';
|
|
51
51
|
import { executeReconcile, buildReconcileReport, type ReconcileRun } from './db-reconcile.js';
|
|
52
52
|
import { loadModels } from './db-generate.js';
|
|
53
|
+
import { reportPipelineLastRun } from './pipeline-run.js';
|
|
53
54
|
import { step, success, fail, info, warn } from '../output.js';
|
|
54
55
|
|
|
55
56
|
const DEFAULT_SQL_DIR = 'db/sql';
|
|
@@ -265,7 +266,7 @@ export async function dbSyncCommand(flags: Record<string, string>): Promise<void
|
|
|
265
266
|
const sqlDir = flags['sql-dir'] || DEFAULT_SQL_DIR;
|
|
266
267
|
const sources = await readSqlDirIfPresent(sqlDir);
|
|
267
268
|
|
|
268
|
-
step(dbSource
|
|
269
|
+
step(connectingVia(dbSource));
|
|
269
270
|
const { runner, end } = await createUrlRunner(dbSource.url);
|
|
270
271
|
|
|
271
272
|
try {
|
|
@@ -343,6 +344,7 @@ export async function dbSyncCommand(flags: Record<string, string>): Promise<void
|
|
|
343
344
|
if (backfills && backfills.pending > 0) {
|
|
344
345
|
info(`· ${backfills.pending} backfill(s) pending — data jobs run deliberately, never as a sync side effect: everystack db:backfill --apply`);
|
|
345
346
|
}
|
|
347
|
+
await reportPipelineLastRun(runner);
|
|
346
348
|
console.log('');
|
|
347
349
|
if (!run.converged) {
|
|
348
350
|
fail('NOT converged — fix the findings above and re-run db:sync.');
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
|
|
13
13
|
import { spawn } from 'node:child_process';
|
|
14
14
|
import { step, success, fail, info } from '../output.js';
|
|
15
|
+
import { invalidateCachedConfig } from '../discover.js';
|
|
15
16
|
|
|
16
17
|
/** The `sst` argv `everystack deploy` runs. Pure, so the mapping is unit-tested. */
|
|
17
18
|
export function buildDeployArgs(flags: Record<string, string>): string[] {
|
|
@@ -42,5 +43,8 @@ export async function deployCommand(flags: Record<string, string>): Promise<void
|
|
|
42
43
|
});
|
|
43
44
|
});
|
|
44
45
|
success(`Deployed stage "${stage}".`);
|
|
46
|
+
// A deploy can change topology (a new Ops/Worker function, rotated Lambda hashes) that a
|
|
47
|
+
// stale per-stage cache would shadow — bust it so the next stage command re-discovers live.
|
|
48
|
+
await invalidateCachedConfig(stage);
|
|
45
49
|
info('Next: `everystack db:migrate --stage ' + stage + '` to apply migrations, `everystack update` to publish the app bundle.');
|
|
46
50
|
}
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `everystack pipeline:run` / `pipeline:list` — reproduce data from committed
|
|
3
|
+
* source (Phase 3, Brick 2, local-direct).
|
|
4
|
+
*
|
|
5
|
+
* pipeline:run [--database-url <url>] [--rebuild | --curate]
|
|
6
|
+
* [--only <substr> | --from <id> | --to <id>]
|
|
7
|
+
* [--resume [--run-id <id>]] [--dry-run] [--continue-on-error] [--json]
|
|
8
|
+
* pipeline:list [--rebuild | --curate] [--only | --from | --to]
|
|
9
|
+
*
|
|
10
|
+
* The orchestrator (topo order, the provenance-lane wall, resume, the memoir)
|
|
11
|
+
* lives in @everystack/server/pipeline and runs identically here (local, over a
|
|
12
|
+
* --database-url) and — later, Brick 5 — in the ops Lambda. This command loads
|
|
13
|
+
* the consumer's pipeline definition (as db:generate loads the models barrel),
|
|
14
|
+
* builds a transactional runner, and drives runPipeline.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { randomUUID } from 'node:crypto';
|
|
18
|
+
import { existsSync } from 'node:fs';
|
|
19
|
+
import path from 'node:path';
|
|
20
|
+
import { pathToFileURL } from 'node:url';
|
|
21
|
+
import { resolveDbSource, createUrlPipelineRunner, connectingVia, type DbSource } from '../db-source.js';
|
|
22
|
+
import { resolvePipelinePath, PIPELINE_HOMES } from '../pipeline-path.js';
|
|
23
|
+
import { resolveConfig, opsFunction } from '../config.js';
|
|
24
|
+
import { invokeAction } from '../aws.js';
|
|
25
|
+
import { currentGitRef } from '../state-apply.js';
|
|
26
|
+
import { step, success, fail, info, warn } from '../output.js';
|
|
27
|
+
import {
|
|
28
|
+
loadServerPipeline,
|
|
29
|
+
type Pipeline,
|
|
30
|
+
type PipelineLane,
|
|
31
|
+
type RunPipelineOptions,
|
|
32
|
+
type PipelineRunOutcome,
|
|
33
|
+
} from '../pipeline-loader.js';
|
|
34
|
+
|
|
35
|
+
/** --rebuild → rebuild, --curate → curate, neither → all. Both is a contradiction. */
|
|
36
|
+
export function pipelineLaneFromFlags(flags: Record<string, string>): PipelineLane {
|
|
37
|
+
const rebuild = flags.rebuild === 'true';
|
|
38
|
+
const curate = flags.curate === 'true';
|
|
39
|
+
if (rebuild && curate) {
|
|
40
|
+
throw new Error('--rebuild and --curate are mutually exclusive — a run targets one lane, or omit both to run all in order.');
|
|
41
|
+
}
|
|
42
|
+
if (rebuild) return 'rebuild';
|
|
43
|
+
if (curate) return 'curate';
|
|
44
|
+
return 'all';
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Map the CLI flags to the orchestrator's run options (pure; runId injected). */
|
|
48
|
+
export function pipelineRunOptions(flags: Record<string, string>, runId: string): RunPipelineOptions {
|
|
49
|
+
return {
|
|
50
|
+
runId,
|
|
51
|
+
lane: pipelineLaneFromFlags(flags),
|
|
52
|
+
...(flags.only ? { only: flags.only } : {}),
|
|
53
|
+
...(flags.from ? { from: flags.from } : {}),
|
|
54
|
+
...(flags.to ? { to: flags.to } : {}),
|
|
55
|
+
resume: flags.resume === 'true',
|
|
56
|
+
dryRun: flags['dry-run'] === 'true',
|
|
57
|
+
continueOnError: flags['continue-on-error'] === 'true',
|
|
58
|
+
actor: process.env.USER ?? null,
|
|
59
|
+
gitRef: currentGitRef(),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Load the consumer's pipeline definition — `pipeline` (or default) from the barrel. */
|
|
64
|
+
export async function loadPipeline(pipelinePath: string): Promise<Pipeline> {
|
|
65
|
+
const abs = path.resolve(pipelinePath);
|
|
66
|
+
let mod: any;
|
|
67
|
+
try {
|
|
68
|
+
mod = await import(pathToFileURL(abs).href);
|
|
69
|
+
} catch (err: any) {
|
|
70
|
+
throw new Error(`Could not load the pipeline from ${pipelinePath}: ${err.message}`);
|
|
71
|
+
}
|
|
72
|
+
const pipeline = mod.pipeline ?? mod.default;
|
|
73
|
+
if (!pipeline || !Array.isArray(pipeline.stages)) {
|
|
74
|
+
throw new Error(`${pipelinePath} must export a \`pipeline\` built with definePipeline (got ${typeof pipeline}).`);
|
|
75
|
+
}
|
|
76
|
+
return pipeline;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function renderOutcome(outcome: PipelineRunOutcome, json: boolean, dryRun: boolean): void {
|
|
80
|
+
if (json) {
|
|
81
|
+
console.log(JSON.stringify(outcome, null, 2));
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
if (dryRun) {
|
|
85
|
+
info(`plan — ${outcome.plan.length} stage(s) in run order:`);
|
|
86
|
+
for (const id of outcome.plan) info(` · ${id}`);
|
|
87
|
+
success('dry run — nothing executed.');
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
for (const r of outcome.results) {
|
|
91
|
+
if (r.outcome === 'applied') info(` ✓ ${r.stageId}${r.rows != null ? ` (${r.rows} row(s))` : ''}`);
|
|
92
|
+
else if (r.outcome === 'skipped') info(` · ${r.stageId} (skipped)`);
|
|
93
|
+
else if (r.outcome === 'failed') fail(` ✗ ${r.stageId}: ${r.error}`);
|
|
94
|
+
}
|
|
95
|
+
const applied = outcome.results.filter((r) => r.outcome === 'applied').length;
|
|
96
|
+
if (outcome.failed) fail('pipeline run FAILED — fix the stage and re-run with --resume.');
|
|
97
|
+
else success(`pipeline run complete — ${applied} stage(s) applied.`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Surface the pipeline's most recent run — the analog of the pending-backfill
|
|
102
|
+
* nudge in db:sync / db:plan. Gated on a pipeline existing (non-pipeline
|
|
103
|
+
* consumers pay nothing) and best-effort (a missing memoir is silent).
|
|
104
|
+
*/
|
|
105
|
+
export async function reportPipelineLastRun(query: (sql: string) => Promise<any[]>): Promise<void> {
|
|
106
|
+
if (!PIPELINE_HOMES.some((home) => existsSync(home))) return;
|
|
107
|
+
try {
|
|
108
|
+
const { readLastRun } = await loadServerPipeline();
|
|
109
|
+
const last = await readLastRun(query);
|
|
110
|
+
if (!last) return;
|
|
111
|
+
if (last.failed > 0) {
|
|
112
|
+
warn(`pipeline: last run left ${last.failed} stage(s) failed (${last.failedStages.join(', ')}) — resume: everystack pipeline:run --resume`);
|
|
113
|
+
} else {
|
|
114
|
+
info(`· pipeline: last run applied ${last.applied} stage(s)${last.at ? ` at ${last.at}` : ''}.`);
|
|
115
|
+
}
|
|
116
|
+
} catch {
|
|
117
|
+
// Visibility is best-effort — never let it break the schema verb.
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* pipeline:run --stage: the orchestrator runs in the ops Lambda, credential-free.
|
|
123
|
+
* The Lambda owns the bundled pipeline (registered via dbPlugin, like `seed`);
|
|
124
|
+
* the CLI passes only the run options and renders the outcome. Resume with no
|
|
125
|
+
* explicit run id is resolved Lambda-side against everystack.pipeline_log.
|
|
126
|
+
*/
|
|
127
|
+
async function pipelineRunViaStage(flags: Record<string, string>): Promise<void> {
|
|
128
|
+
step('Resolving deployed config...');
|
|
129
|
+
const config = await resolveConfig(flags.stage);
|
|
130
|
+
const { region } = config;
|
|
131
|
+
const fn = opsFunction(config);
|
|
132
|
+
|
|
133
|
+
const resume = flags.resume === 'true';
|
|
134
|
+
const dryRun = flags['dry-run'] === 'true';
|
|
135
|
+
const options: RunPipelineOptions = {
|
|
136
|
+
// Fresh runs get a client-minted id; resume with no --run-id is resolved
|
|
137
|
+
// in the Lambda (it reads the latest run on the stage).
|
|
138
|
+
runId: flags['run-id'] ?? (resume ? '' : randomUUID()),
|
|
139
|
+
lane: pipelineLaneFromFlags(flags),
|
|
140
|
+
...(flags.only ? { only: flags.only } : {}),
|
|
141
|
+
...(flags.from ? { from: flags.from } : {}),
|
|
142
|
+
...(flags.to ? { to: flags.to } : {}),
|
|
143
|
+
resume,
|
|
144
|
+
dryRun,
|
|
145
|
+
continueOnError: flags['continue-on-error'] === 'true',
|
|
146
|
+
actor: process.env.USER ?? null,
|
|
147
|
+
gitRef: currentGitRef(),
|
|
148
|
+
};
|
|
149
|
+
// Drop an empty runId so the Lambda's resume resolution kicks in.
|
|
150
|
+
const payload: Record<string, unknown> = { ...options };
|
|
151
|
+
if (!payload.runId) delete payload.runId;
|
|
152
|
+
|
|
153
|
+
step('Running the pipeline in the ops Lambda (credential-free — no database URL held)...');
|
|
154
|
+
const outcome: any = await invokeAction(region, fn, 'pipeline:run', payload);
|
|
155
|
+
if (outcome?.error) {
|
|
156
|
+
fail(`pipeline:run failed: ${outcome.error}`);
|
|
157
|
+
process.exit(1);
|
|
158
|
+
}
|
|
159
|
+
renderOutcome(outcome as PipelineRunOutcome, flags.json === 'true', dryRun);
|
|
160
|
+
if (outcome.failed) process.exit(1);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export async function pipelineRunCommand(flags: Record<string, string>): Promise<void> {
|
|
164
|
+
let dbSource: DbSource;
|
|
165
|
+
try {
|
|
166
|
+
dbSource = resolveDbSource(flags);
|
|
167
|
+
} catch (err: any) {
|
|
168
|
+
fail(err.message);
|
|
169
|
+
process.exit(1);
|
|
170
|
+
}
|
|
171
|
+
// Validate the lane before anything.
|
|
172
|
+
try {
|
|
173
|
+
pipelineLaneFromFlags(flags);
|
|
174
|
+
} catch (err: any) {
|
|
175
|
+
fail(err.message);
|
|
176
|
+
process.exit(1);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Deployed stage: the orchestrator runs credential-free in the ops Lambda —
|
|
180
|
+
// no URL held here. The Lambda owns the bundled pipeline; we pass options only.
|
|
181
|
+
if (dbSource.kind === 'stage') {
|
|
182
|
+
await pipelineRunViaStage(flags);
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const pipelinePath = resolvePipelinePath(flags.pipeline);
|
|
187
|
+
let pipeline: Pipeline;
|
|
188
|
+
try {
|
|
189
|
+
step(`Loading pipeline from ${pipelinePath}...`);
|
|
190
|
+
pipeline = await loadPipeline(pipelinePath);
|
|
191
|
+
info(`${pipeline.stages.length} stage(s).`);
|
|
192
|
+
} catch (err: any) {
|
|
193
|
+
fail(err.message);
|
|
194
|
+
process.exit(1);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
step(connectingVia(dbSource));
|
|
198
|
+
const runner = await createUrlPipelineRunner(dbSource.url);
|
|
199
|
+
try {
|
|
200
|
+
const { runPipeline, latestRunId } = await loadServerPipeline();
|
|
201
|
+
|
|
202
|
+
let runId = flags['run-id'];
|
|
203
|
+
const resume = flags.resume === 'true';
|
|
204
|
+
if (!runId) {
|
|
205
|
+
if (resume) {
|
|
206
|
+
const latest = await latestRunId(runner.query);
|
|
207
|
+
if (!latest) {
|
|
208
|
+
fail('nothing to resume — this pipeline has not run against this database yet.');
|
|
209
|
+
process.exit(1);
|
|
210
|
+
}
|
|
211
|
+
runId = latest;
|
|
212
|
+
info(`resuming run ${runId.slice(0, 8)}.`);
|
|
213
|
+
} else {
|
|
214
|
+
runId = randomUUID();
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const options = pipelineRunOptions(flags, runId);
|
|
219
|
+
if (options.dryRun) info('dry run — computing the plan, executing nothing.');
|
|
220
|
+
const outcome = await runPipeline(runner, pipeline, options);
|
|
221
|
+
renderOutcome(outcome, flags.json === 'true', options.dryRun === true);
|
|
222
|
+
if (outcome.failed) process.exit(1);
|
|
223
|
+
} catch (err: any) {
|
|
224
|
+
fail(`pipeline:run failed: ${err.message}`);
|
|
225
|
+
process.exit(1);
|
|
226
|
+
} finally {
|
|
227
|
+
await runner.end();
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export async function pipelineListCommand(flags: Record<string, string>): Promise<void> {
|
|
232
|
+
const pipelinePath = resolvePipelinePath(flags.pipeline);
|
|
233
|
+
let pipeline: Pipeline;
|
|
234
|
+
try {
|
|
235
|
+
pipeline = await loadPipeline(pipelinePath);
|
|
236
|
+
} catch (err: any) {
|
|
237
|
+
fail(err.message);
|
|
238
|
+
process.exit(1);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
let lane: PipelineLane;
|
|
242
|
+
try {
|
|
243
|
+
lane = pipelineLaneFromFlags(flags);
|
|
244
|
+
} catch (err: any) {
|
|
245
|
+
fail(err.message);
|
|
246
|
+
process.exit(1);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const { topoSortStages, selectStages } = await loadServerPipeline();
|
|
250
|
+
let selected;
|
|
251
|
+
try {
|
|
252
|
+
const ordered = topoSortStages(pipeline.stages);
|
|
253
|
+
selected = selectStages(ordered, {
|
|
254
|
+
lane,
|
|
255
|
+
...(flags.only ? { only: flags.only } : {}),
|
|
256
|
+
...(flags.from ? { from: flags.from } : {}),
|
|
257
|
+
...(flags.to ? { to: flags.to } : {}),
|
|
258
|
+
});
|
|
259
|
+
} catch (err: any) {
|
|
260
|
+
fail(err.message);
|
|
261
|
+
process.exit(1);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
info(`${selected.length} stage(s) in run order:`);
|
|
265
|
+
for (const s of selected) {
|
|
266
|
+
const edges = [...(s.dependsOn ?? []), ...(s.after ?? [])];
|
|
267
|
+
info(` ${s.id} [${s.lane}]${edges.length ? ` ← ${edges.join(', ')}` : ''}${s.heavy ? ' (heavy)' : ''}`);
|
|
268
|
+
}
|
|
269
|
+
}
|
package/src/cli/db-source.ts
CHANGED
|
@@ -10,10 +10,14 @@
|
|
|
10
10
|
* the target schema exists only on a local Postgres (migrations applied locally, no
|
|
11
11
|
* interim deploy), there is no Lambda to ask.
|
|
12
12
|
*
|
|
13
|
-
* Precedence is explicit-over-ambient: `--database-url` beats an explicit `--stage`,
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
13
|
+
* Precedence is explicit-over-ambient: `--database-url` beats an explicit `--stage`, which beats
|
|
14
|
+
* an inherited env URL, which beats the default stage. An explicit `--stage` deliberately wins
|
|
15
|
+
* over the env var so a shell with a URL exported still introspects the stage you named.
|
|
16
|
+
*
|
|
17
|
+
* Between the two env credentials, `ADMIN_DATABASE_URL` (the migrator/owner) wins over
|
|
18
|
+
* `DATABASE_URL` (the least-privilege authenticator). These verbs run DDL — the authenticator
|
|
19
|
+
* can't — so the CLI's direct path is an operator path. Mirrors the server's
|
|
20
|
+
* `getAdminDatabaseUrl() ?? getDatabaseUrl()`.
|
|
17
21
|
*
|
|
18
22
|
* The driver is loaded lazily: `postgres` ships with @everystack/server (every deployed
|
|
19
23
|
* consumer has it hoisted), so the deployed path never pays the import and a missing
|
|
@@ -23,7 +27,7 @@
|
|
|
23
27
|
import type { QueryRunner } from './authz-contract.js';
|
|
24
28
|
|
|
25
29
|
export type DbSource =
|
|
26
|
-
| { kind: 'url'; url: string; from: 'flag' | 'env' }
|
|
30
|
+
| { kind: 'url'; url: string; from: 'flag' | 'env' | 'admin-env' }
|
|
27
31
|
| { kind: 'stage' };
|
|
28
32
|
|
|
29
33
|
/** Decide which path serves this invocation. Pure; env injectable for tests. */
|
|
@@ -36,16 +40,26 @@ export function resolveDbSource(
|
|
|
36
40
|
// parseFlags encodes a value-less flag as 'true'
|
|
37
41
|
if (flagUrl === 'true') {
|
|
38
42
|
throw new Error(
|
|
39
|
-
'--database-url needs a connection string (or omit the flag and set DATABASE_URL in the environment).',
|
|
43
|
+
'--database-url needs a connection string (or omit the flag and set ADMIN_DATABASE_URL / DATABASE_URL in the environment).',
|
|
40
44
|
);
|
|
41
45
|
}
|
|
42
46
|
return { kind: 'url', url: flagUrl, from: 'flag' };
|
|
43
47
|
}
|
|
44
48
|
if (flags.stage) return { kind: 'stage' };
|
|
49
|
+
// Operator/schema verbs run DDL; prefer the admin (migrator/owner) credential over the
|
|
50
|
+
// least-privilege authenticator, which cannot. Mirrors getAdminDatabaseUrl() ?? getDatabaseUrl().
|
|
51
|
+
if (env.ADMIN_DATABASE_URL) return { kind: 'url', url: env.ADMIN_DATABASE_URL, from: 'admin-env' };
|
|
45
52
|
if (env.DATABASE_URL) return { kind: 'url', url: env.DATABASE_URL, from: 'env' };
|
|
46
53
|
return { kind: 'stage' };
|
|
47
54
|
}
|
|
48
55
|
|
|
56
|
+
/** The "Connecting via …" line, honest about which credential source was chosen. */
|
|
57
|
+
export function connectingVia(source: Extract<DbSource, { kind: 'url' }>): string {
|
|
58
|
+
if (source.from === 'flag') return 'Connecting via --database-url...';
|
|
59
|
+
if (source.from === 'admin-env') return 'Connecting via ADMIN_DATABASE_URL...';
|
|
60
|
+
return 'Connecting via DATABASE_URL...';
|
|
61
|
+
}
|
|
62
|
+
|
|
49
63
|
export interface UrlRunner {
|
|
50
64
|
runner: QueryRunner;
|
|
51
65
|
/** Close the client so the process can exit cleanly. */
|
|
@@ -69,9 +83,67 @@ export async function createUrlRunner(
|
|
|
69
83
|
);
|
|
70
84
|
}
|
|
71
85
|
const postgres = mod.default ?? mod;
|
|
72
|
-
const sql = postgres(url, { max: 1, onnotice: () => {} });
|
|
86
|
+
const sql = postgres(url, { max: 1, onnotice: () => {}, ...sslDefaults(url) });
|
|
73
87
|
return {
|
|
74
88
|
runner: async (query: string) => Array.from(await sql.unsafe(query)),
|
|
75
89
|
end: () => sql.end({ timeout: 5 }),
|
|
76
90
|
};
|
|
77
91
|
}
|
|
92
|
+
|
|
93
|
+
export interface UrlPipelineRunner {
|
|
94
|
+
query: (sql: string) => Promise<any[]>;
|
|
95
|
+
transaction: <T>(fn: (tx: (sql: string) => Promise<any[]>) => Promise<T>) => Promise<T>;
|
|
96
|
+
/** Close the client so the process can exit cleanly. */
|
|
97
|
+
end: () => Promise<void>;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* A pipeline runner over a direct postgres.js connection — the local-direct
|
|
102
|
+
* path for `pipeline:run`. Unlike the introspection runner it carries a real
|
|
103
|
+
* `transaction` (postgres.js `sql.begin`), so each stage's writes and its
|
|
104
|
+
* memoir row commit or roll back together.
|
|
105
|
+
*/
|
|
106
|
+
export async function createUrlPipelineRunner(
|
|
107
|
+
url: string,
|
|
108
|
+
load: () => Promise<any> = () => import('postgres'),
|
|
109
|
+
): Promise<UrlPipelineRunner> {
|
|
110
|
+
let mod: any;
|
|
111
|
+
try {
|
|
112
|
+
mod = await load();
|
|
113
|
+
} catch {
|
|
114
|
+
throw new Error(
|
|
115
|
+
'The direct-connection path needs the "postgres" driver. It ships with @everystack/server; in a project without it: pnpm add -D postgres',
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
const postgres = mod.default ?? mod;
|
|
119
|
+
const sql = postgres(url, { max: 1, onnotice: () => {}, ...sslDefaults(url) });
|
|
120
|
+
return {
|
|
121
|
+
query: async (query: string) => Array.from(await sql.unsafe(query)),
|
|
122
|
+
transaction: async (fn) =>
|
|
123
|
+
sql.begin((tx: any) => fn(async (query: string) => Array.from(await tx.unsafe(query)))),
|
|
124
|
+
end: () => sql.end({ timeout: 5 }),
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Managed Postgres (RDS / Supabase / Neon) requires TLS — a direct connection without it
|
|
130
|
+
* is rejected with pg_hba "no encryption". Default `ssl: 'require'` for remote hosts so the
|
|
131
|
+
* direct-connection path just works, and leave local dev DBs plaintext (they don't serve TLS).
|
|
132
|
+
* An explicit `?sslmode=` in the URL always wins — postgres.js parses it — so we never override
|
|
133
|
+
* the operator's choice; we only fill the gap when they said nothing.
|
|
134
|
+
*/
|
|
135
|
+
export function sslDefaults(url: string): { ssl?: 'require' } {
|
|
136
|
+
let host = '';
|
|
137
|
+
let hasSslMode = false;
|
|
138
|
+
try {
|
|
139
|
+
const u = new URL(url);
|
|
140
|
+
host = u.hostname;
|
|
141
|
+
hasSslMode = u.searchParams.has('sslmode');
|
|
142
|
+
} catch {
|
|
143
|
+
return {};
|
|
144
|
+
}
|
|
145
|
+
const isLocal =
|
|
146
|
+
host === 'localhost' || host === '127.0.0.1' || host === '::1' || host.endsWith('.local');
|
|
147
|
+
if (isLocal || hasSslMode) return {};
|
|
148
|
+
return { ssl: 'require' };
|
|
149
|
+
}
|
package/src/cli/derived-plan.ts
CHANGED
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
* overwrite explicit, and the drift stays in the report so the operator sees
|
|
9
9
|
* what they overwrote). Never-reconciled matches are `needsBaseline`: the
|
|
10
10
|
* reconciler refuses to guess whether live matches source — `baseline: true`
|
|
11
|
-
* records the trust explicitly, once
|
|
11
|
+
* records the trust explicitly, once, or `rebuild: true` rebuilds them from
|
|
12
|
+
* source (drop+create) so the match is guaranteed rather than asserted.
|
|
12
13
|
*
|
|
13
14
|
* Graph work: replacing (or dropping) a relation rebuilds its live transitive
|
|
14
15
|
* dependents — dropped in reverse-topological order, recreated in source
|
|
@@ -72,6 +73,11 @@ export interface ReconcileOptions {
|
|
|
72
73
|
baseline?: boolean;
|
|
73
74
|
/** Rebuild drifted objects from source (the drift finding stays in the report). */
|
|
74
75
|
overwriteDrift?: boolean;
|
|
76
|
+
/**
|
|
77
|
+
* Rebuild never-reconciled source↔live matches from source (drop+create) instead of trusting
|
|
78
|
+
* them via baseline — the guarantee that live == db/sql when first contact can't verify it.
|
|
79
|
+
*/
|
|
80
|
+
rebuild?: boolean;
|
|
75
81
|
}
|
|
76
82
|
|
|
77
83
|
const isRelation = (kind: DerivedKind): boolean => kind !== 'function';
|
|
@@ -117,8 +123,18 @@ export function planReconcile(
|
|
|
117
123
|
continue;
|
|
118
124
|
}
|
|
119
125
|
if (!prov) {
|
|
120
|
-
|
|
121
|
-
|
|
126
|
+
// First contact: no provenance, and src-hash (source text) can't be compared to def-hash
|
|
127
|
+
// (live deparse), so the reconciler can't verify live == source. --baseline trusts it,
|
|
128
|
+
// --rebuild guarantees it by rebuilding from source (relations through the dependency graph
|
|
129
|
+
// below; functions replace in place), and the default flags it for a decision.
|
|
130
|
+
if (options.rebuild) {
|
|
131
|
+
if (isRelation(src.kind)) rebuild.set(src.identity, 'rebuilt from source (first contact, --rebuild)');
|
|
132
|
+
else fnReplace.set(src.identity, 'replaced from source (first contact, --rebuild)');
|
|
133
|
+
} else if (options.baseline) {
|
|
134
|
+
baseline.push(src.identity);
|
|
135
|
+
} else {
|
|
136
|
+
needsBaseline.push(src.identity);
|
|
137
|
+
}
|
|
122
138
|
continue;
|
|
123
139
|
}
|
|
124
140
|
|