@everystack/cli 0.3.27 → 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.
@@ -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
- let outcome = 'applied';
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
- outcome = 'failed';
112
- // The batch rolled back; record the attempt, then surface the failure.
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.from === 'flag' ? 'Connecting via --database-url...' : 'Connecting via DATABASE_URL...');
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';
@@ -169,6 +170,16 @@ export function buildSyncReport(run: SyncRun): string[] {
169
170
  lines.push(`· ${state.classified.notices.length} notice(s) — manual follow-ups, nothing executed for them`);
170
171
  }
171
172
 
173
+ // The base fingerprint is a STATE-layer fact: both endpoints are sampled around
174
+ // the state apply, before compute runs. Print it here, adjacent to the state
175
+ // section, so an empty→full move never reads as if the compute step caused it —
176
+ // the derived layer (functions/views/matviews) is not fingerprinted at all.
177
+ lines.push(
178
+ state.toFingerprint === state.fromFingerprint
179
+ ? `base fingerprint: ${short(state.toFingerprint)} (unchanged)`
180
+ : `base fingerprint: ${short(state.fromFingerprint)} → ${short(state.toFingerprint)} (across the state apply)`,
181
+ );
182
+
172
183
  // Compute.
173
184
  if (compute === null) {
174
185
  lines.push('· compute: no db/sql directory — derived layer not declared, skipped');
@@ -181,12 +192,7 @@ export function buildSyncReport(run: SyncRun): string[] {
181
192
  }
182
193
  }
183
194
 
184
- // Fingerprint.
185
- lines.push(
186
- state.toFingerprint === state.fromFingerprint
187
- ? `fingerprint: ${short(state.toFingerprint)} (unchanged)`
188
- : `fingerprint: ${short(state.fromFingerprint)} → ${short(state.toFingerprint)}`,
189
- );
195
+ // Verdict.
190
196
  if (run.fingerprintMatch) {
191
197
  lines.push('MATCH — the database is the state your checkout declares');
192
198
  } else if (run.stateConverged) {
@@ -260,7 +266,7 @@ export async function dbSyncCommand(flags: Record<string, string>): Promise<void
260
266
  const sqlDir = flags['sql-dir'] || DEFAULT_SQL_DIR;
261
267
  const sources = await readSqlDirIfPresent(sqlDir);
262
268
 
263
- step(dbSource.from === 'flag' ? 'Connecting via --database-url...' : 'Connecting via DATABASE_URL...');
269
+ step(connectingVia(dbSource));
264
270
  const { runner, end } = await createUrlRunner(dbSource.url);
265
271
 
266
272
  try {
@@ -338,6 +344,7 @@ export async function dbSyncCommand(flags: Record<string, string>): Promise<void
338
344
  if (backfills && backfills.pending > 0) {
339
345
  info(`· ${backfills.pending} backfill(s) pending — data jobs run deliberately, never as a sync side effect: everystack db:backfill --apply`);
340
346
  }
347
+ await reportPipelineLastRun(runner);
341
348
  console.log('');
342
349
  if (!run.converged) {
343
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
+ }
@@ -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
- * which beats an inherited `DATABASE_URL`, which beats the default stage. An explicit
15
- * `--stage` deliberately wins over the env var so a shell with DATABASE_URL exported
16
- * still introspects the stage you named.
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
+ }