@everystack/cli 0.3.28 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/README.md +1 -1
  2. package/package.json +6 -2
  3. package/src/cli/apply-execute.ts +185 -0
  4. package/src/cli/authz-compile.ts +5 -2
  5. package/src/cli/authz-lint.ts +48 -0
  6. package/src/cli/aws.ts +102 -4
  7. package/src/cli/backfill.ts +1 -1
  8. package/src/cli/commands/db-apply.ts +159 -169
  9. package/src/cli/commands/db-backfill.ts +2 -2
  10. package/src/cli/commands/db-backup.ts +34 -0
  11. package/src/cli/commands/db-branch.ts +28 -4
  12. package/src/cli/commands/db-check.ts +97 -34
  13. package/src/cli/commands/db-fingerprint.ts +9 -4
  14. package/src/cli/commands/db-generate.ts +73 -22
  15. package/src/cli/commands/db-plan.ts +7 -13
  16. package/src/cli/commands/db-pull.ts +39 -7
  17. package/src/cli/commands/db-reconcile.ts +219 -66
  18. package/src/cli/commands/db-sync.ts +58 -21
  19. package/src/cli/commands/deploy.ts +4 -0
  20. package/src/cli/commands/pipeline-run.ts +269 -0
  21. package/src/cli/db-build.ts +9 -3
  22. package/src/cli/db-source.ts +83 -7
  23. package/src/cli/declared-derived.ts +136 -0
  24. package/src/cli/derived-apply.ts +40 -6
  25. package/src/cli/derived-compile.ts +327 -0
  26. package/src/cli/derived-grants.ts +96 -0
  27. package/src/cli/derived-introspect.ts +258 -21
  28. package/src/cli/derived-lint.ts +261 -0
  29. package/src/cli/derived-plan.ts +194 -12
  30. package/src/cli/derived-render.ts +320 -0
  31. package/src/cli/derived-source.ts +53 -125
  32. package/src/cli/discover.ts +16 -0
  33. package/src/cli/git-descent.ts +15 -2
  34. package/src/cli/index.ts +26 -6
  35. package/src/cli/migration-compile.ts +38 -7
  36. package/src/cli/migration-generate.ts +43 -4
  37. package/src/cli/model-render.ts +106 -13
  38. package/src/cli/models-path.ts +1 -1
  39. package/src/cli/ops-diagnostics.ts +71 -0
  40. package/src/cli/pipeline-loader.ts +68 -0
  41. package/src/cli/pipeline-path.ts +25 -0
  42. package/src/cli/schema-compile.ts +41 -10
  43. package/src/cli/schema-diff.ts +123 -17
  44. package/src/cli/schema-fingerprint.ts +28 -18
  45. package/src/cli/schema-introspect.ts +175 -8
  46. package/src/cli/schema-source.ts +75 -6
  47. package/src/cli/state-apply.ts +4 -1
@@ -2,15 +2,15 @@
2
2
  * `everystack db:sync` — make the database match your checkout. One verb,
3
3
  * both layers, for dev-stage databases (brick 5's core).
4
4
  *
5
- * db:sync [--database-url <url>] [--models <barrel>] [--sql-dir db/sql]
6
- * [--allow-drops] [--overwrite-drift] [--baseline] [--json]
5
+ * db:sync [--database-url <url>] [--models <barrel>]
6
+ * [--allow-drops] [--overwrite-drift] [--baseline] [--rebaseline] [--json]
7
7
  *
8
8
  * The edit loop's verb: fingerprint the live base state, apply the state
9
9
  * diff (tables + authz, one transaction, verified by re-diff), reconcile
10
- * the derived layer (functions/views/matviews) against db/sql, and report
11
- * where the database now is — "at fingerprint X, matching your checkout"
12
- * when everything converged. Hot-reload for the database; no artifact
13
- * produced, every act recorded in `everystack.schema_log`.
10
+ * the derived layer against the declared descriptors, and report where the
11
+ * database now is — "at fingerprint X, matching your checkout" when
12
+ * everything converged. Hot-reload for the database; no artifact produced,
13
+ * every act recorded in `everystack.schema_log`.
14
14
  *
15
15
  * The verdict is two-tier, honestly: CONVERGED means the state re-diff is
16
16
  * empty and the compute layer is clean — that is the exit-0 bar. The
@@ -34,6 +34,8 @@ import { introspectContract, type QueryRunner } from '../authz-contract.js';
34
34
  import { introspectSchema } from '../schema-introspect.js';
35
35
  import { FUNCTIONS_SQL, contractFunctionRow } from '../security-catalog.js';
36
36
  import { generateMigrationSql, unmodeledTables } from '../migration-generate.js';
37
+ import type { SequenceDescriptor } from '@everystack/model';
38
+ import type { SourceObject } from '../derived-source.js';
37
39
  import {
38
40
  applyStateAndVerify,
39
41
  classifyGeneratedStatements,
@@ -45,14 +47,15 @@ import {
45
47
  import { fingerprintModels } from '../schema-fingerprint.js';
46
48
  import { compileDrizzleSource } from '../schema-source.js';
47
49
  import type { SourceFile } from '../derived-source.js';
48
- import { resolveDbSource, createUrlRunner, type DbSource } from '../db-source.js';
50
+ import { resolveDbSource, createUrlRunner, connectingVia, type DbSource } from '../db-source.js';
49
51
  import { planBackfills, readBackfillLog } from '../backfill.js';
50
52
  import { resolveModelsPath } from '../models-path.js';
51
53
  import { executeReconcile, buildReconcileReport, type ReconcileRun } from './db-reconcile.js';
52
54
  import { loadModels } from './db-generate.js';
55
+ import { loadDeclaredDerived, retiredSqlDirAnywhere, retiredSqlDirFlagRefusal, type DeclaredDerived } from '../declared-derived.js';
56
+ import { reportPipelineLastRun } from './pipeline-run.js';
53
57
  import { step, success, fail, info, warn } from '../output.js';
54
58
 
55
- const DEFAULT_SQL_DIR = 'db/sql';
56
59
  const DEFAULT_SCHEMA_OUT = 'db/schema.generated.ts';
57
60
 
58
61
  // ---------------------------------------------------------------------------
@@ -63,8 +66,17 @@ export interface SyncOptions {
63
66
  allowDrops?: boolean;
64
67
  overwriteDrift?: boolean;
65
68
  baseline?: boolean;
69
+ /** Re-record provenance for source re-renders over verifiably-untouched live objects
70
+ * (the db/sql-era migration) — see ReconcileOptions.rebaseline. */
71
+ rebaseline?: boolean;
66
72
  actor?: string | null;
67
73
  gitRef?: string | null;
74
+ /** Descriptor-compiled derived objects (the declared layer) — the one compute stream. */
75
+ declared?: SourceObject[];
76
+ /** Standalone sequences the modules declare (state — created before tables, fingerprinted). */
77
+ sequences?: SequenceDescriptor[];
78
+ /** Pending table renames — trigger provenance migrates instead of drop+create. */
79
+ renamedTables?: Record<string, string>;
68
80
  /** Injectable clock for tests. */
69
81
  now?: () => number;
70
82
  }
@@ -78,7 +90,7 @@ export interface SyncHooks {
78
90
 
79
91
  export interface SyncRun {
80
92
  state: StateSyncOutcome;
81
- /** null = no db/sql sources — the compute layer is not declared, skipped. */
93
+ /** null = no declared derived layer — the compute step is skipped. */
82
94
  compute: ReconcileRun | null;
83
95
  declaredFingerprint: string;
84
96
  fingerprintMatch: boolean;
@@ -99,31 +111,34 @@ export interface SyncRun {
99
111
  export async function executeSync(
100
112
  runner: QueryRunner,
101
113
  models: ModelDescriptor[],
102
- sources: SourceFile[] | null,
103
114
  options: SyncOptions = {},
104
115
  hooks: SyncHooks = {},
105
116
  ): Promise<SyncRun> {
106
117
  const current = await introspectSchema(runner);
107
118
  const liveAuthz = await introspectContract(runner, contractFunctionRow, FUNCTIONS_SQL);
108
119
  const statements = generateMigrationSql(models, current, {
109
- allowDrops: options.allowDrops, liveAuthz,
120
+ allowDrops: options.allowDrops, liveAuthz, sequences: options.sequences,
110
121
  });
111
122
  hooks.onStatePlan?.(statements, classifyGeneratedStatements(statements));
112
123
 
113
124
  const state = await applyStateAndVerify(runner, models, statements, current, liveAuthz, {
114
- allowDrops: options.allowDrops,
125
+ allowDrops: options.allowDrops, sequences: options.sequences,
115
126
  actor: options.actor, gitRef: options.gitRef, now: options.now,
116
127
  });
117
128
  hooks.onStateDone?.(state);
118
129
 
119
- const compute = sources === null ? null : await executeReconcile(runner, sources, {
130
+ // The declared derived layer is the one compute stream; nothing declared = skipped.
131
+ const compute = !options.declared?.length ? null : await executeReconcile(runner, {
120
132
  apply: true,
121
133
  baseline: options.baseline,
134
+ rebaseline: options.rebaseline,
122
135
  overwriteDrift: options.overwriteDrift,
136
+ declared: options.declared,
137
+ renamedTables: options.renamedTables,
123
138
  actor: options.actor, gitRef: options.gitRef, now: options.now,
124
139
  });
125
140
 
126
- const declaredFingerprint = fingerprintModels(models).hash;
141
+ const declaredFingerprint = fingerprintModels(models, { sequences: options.sequences }).hash;
127
142
  const stateConverged = state.remaining.length === 0;
128
143
  // The compute bar, judged AFTER the apply: no refusal (drift/blocked stop the
129
144
  // whole batch) and nothing left unadopted (needsBaseline objects were not
@@ -181,7 +196,7 @@ export function buildSyncReport(run: SyncRun): string[] {
181
196
 
182
197
  // Compute.
183
198
  if (compute === null) {
184
- lines.push('· compute: no db/sql directoryderived layer not declared, skipped');
199
+ lines.push('· compute: no derived layer declared nothing to reconcile');
185
200
  } else {
186
201
  lines.push('compute:');
187
202
  for (const line of buildReconcileReport(compute.plan)) lines.push(` ${line}`);
@@ -249,34 +264,55 @@ export async function dbSyncCommand(flags: Record<string, string>): Promise<void
249
264
  const modelsPath = resolveModelsPath(flags.models);
250
265
  const schemaOut = path.resolve(flags['schema-out'] || DEFAULT_SCHEMA_OUT);
251
266
  let models: ModelDescriptor[];
267
+ // The declared derived layer + sequences + renames — the same stream db:reconcile
268
+ // consumes. Loaded before the schema write: typed derived relations emit into it.
269
+ let declaredDb: DeclaredDerived | null = null;
252
270
  try {
253
271
  step(`Loading models from ${modelsPath}...`);
254
272
  models = await loadModels(modelsPath);
273
+ declaredDb = await loadDeclaredDerived(flags.models);
255
274
  info(`${models.length} model(s).`);
256
275
  // Same contract as db:generate: the drizzle runtime schema is a derived
257
276
  // artifact, refreshed from the models on every run.
258
277
  step(`Writing the drizzle schema → ${path.relative(process.cwd(), schemaOut)}...`);
259
- await fs.writeFile(schemaOut, compileDrizzleSource(models), 'utf8');
278
+ await fs.writeFile(schemaOut, compileDrizzleSource(models, { derived: declaredDb?.derived }), 'utf8');
260
279
  } catch (err: any) {
261
280
  fail(err.message);
262
281
  process.exit(1);
263
282
  }
264
283
 
265
- const sqlDir = flags['sql-dir'] || DEFAULT_SQL_DIR;
266
- const sources = await readSqlDirIfPresent(sqlDir);
284
+ // B7 tombstone — db/sql is retired and no longer read; failing beats silently
285
+ // skipping (skipped files would leave live objects unmanaged).
286
+ try {
287
+ if (flags['sql-dir']) {
288
+ fail(await retiredSqlDirFlagRefusal(flags['sql-dir']));
289
+ process.exit(1);
290
+ }
291
+ const retired = await retiredSqlDirAnywhere(flags.models);
292
+ if (retired) {
293
+ fail(retired);
294
+ process.exit(1);
295
+ }
296
+ } catch (err: any) {
297
+ fail(err.message);
298
+ process.exit(1);
299
+ }
267
300
 
268
- step(dbSource.from === 'flag' ? 'Connecting via --database-url...' : 'Connecting via DATABASE_URL...');
301
+ step(connectingVia(dbSource));
269
302
  const { runner, end } = await createUrlRunner(dbSource.url);
270
303
 
271
304
  try {
272
305
  const run = await executeSync(
273
306
  runner,
274
307
  models,
275
- sources,
276
308
  {
309
+ declared: declaredDb?.objects,
310
+ sequences: declaredDb?.sequences,
311
+ renamedTables: declaredDb?.renamedTables,
277
312
  allowDrops: flags['allow-drops'] === 'true',
278
313
  overwriteDrift: flags['overwrite-drift'] === 'true',
279
314
  baseline: flags.baseline === 'true',
315
+ rebaseline: flags.rebaseline === 'true',
280
316
  actor: process.env.USER ?? null,
281
317
  gitRef: currentGitRef(),
282
318
  },
@@ -295,7 +331,7 @@ export async function dbSyncCommand(flags: Record<string, string>): Promise<void
295
331
  }
296
332
  },
297
333
  onStateDone: () => {
298
- if (sources !== null) step(`Reconciling the derived layer against ${sqlDir}...`);
334
+ if (declaredDb?.objects.length) step('Reconciling the declared derived layer...');
299
335
  },
300
336
  },
301
337
  );
@@ -343,6 +379,7 @@ export async function dbSyncCommand(flags: Record<string, string>): Promise<void
343
379
  if (backfills && backfills.pending > 0) {
344
380
  info(`· ${backfills.pending} backfill(s) pending — data jobs run deliberately, never as a sync side effect: everystack db:backfill --apply`);
345
381
  }
382
+ await reportPipelineLastRun(runner);
346
383
  console.log('');
347
384
  if (!run.converged) {
348
385
  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
+ }
@@ -12,7 +12,8 @@
12
12
 
13
13
  import type { ModelDescriptor } from '@everystack/model';
14
14
  import type { TableContract, QueryRunner } from './authz-contract.js';
15
- import type { SourceFile } from './derived-source.js';
15
+ import type { SourceObject } from './derived-source.js';
16
+ import type { SequenceDescriptor } from '@everystack/model';
16
17
  import { compileDeclaredState } from './declared-diff.js';
17
18
  import { createUrlRunner } from './db-source.js';
18
19
  import { executeSync, buildSyncReport } from './commands/db-sync.js';
@@ -88,7 +89,10 @@ export interface BuildResult {
88
89
  }
89
90
 
90
91
  export interface BuildOptions {
91
- sources?: SourceFile[] | null;
92
+ /** Descriptor-compiled derived objects (the declared layer). */
93
+ declared?: SourceObject[];
94
+ /** Standalone sequences (state — created before tables, in the fingerprint bar). */
95
+ sequences?: SequenceDescriptor[];
92
96
  actor?: string | null;
93
97
  gitRef?: string | null;
94
98
  }
@@ -106,7 +110,9 @@ export async function buildIntoDatabase(
106
110
  const { runner, end } = await createUrlRunner(url);
107
111
  try {
108
112
  const createdRoles = await ensureContractRoles(runner, models);
109
- const run = await executeSync(runner, models, options.sources ?? null, {
113
+ const run = await executeSync(runner, models, {
114
+ declared: options.declared,
115
+ sequences: options.sequences,
110
116
  actor: options.actor ?? 'db-build',
111
117
  gitRef: options.gitRef ?? null,
112
118
  });
@@ -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,71 @@ export async function createUrlRunner(
69
83
  );
70
84
  }
71
85
  const postgres = mod.default ?? mod;
72
- const sql = postgres(url, { max: 1, onnotice: () => {} });
86
+ // max_lifetime: null the driver's default recycles a connection after a random 30–60
87
+ // minutes, resolving the in-flight query and THEN killing the session. Under reconcile's
88
+ // BEGIN-across-calls transaction that is a silent session swap mid-batch (the reconcile
89
+ // apply path also pins the backend pid and refuses bookkeeping if it moved).
90
+ const sql = postgres(url, { max: 1, max_lifetime: null, onnotice: () => {}, ...sslDefaults(url) });
73
91
  return {
74
92
  runner: async (query: string) => Array.from(await sql.unsafe(query)),
75
93
  end: () => sql.end({ timeout: 5 }),
76
94
  };
77
95
  }
96
+
97
+ export interface UrlPipelineRunner {
98
+ query: (sql: string) => Promise<any[]>;
99
+ transaction: <T>(fn: (tx: (sql: string) => Promise<any[]>) => Promise<T>) => Promise<T>;
100
+ /** Close the client so the process can exit cleanly. */
101
+ end: () => Promise<void>;
102
+ }
103
+
104
+ /**
105
+ * A pipeline runner over a direct postgres.js connection — the local-direct
106
+ * path for `pipeline:run`. Unlike the introspection runner it carries a real
107
+ * `transaction` (postgres.js `sql.begin`), so each stage's writes and its
108
+ * memoir row commit or roll back together.
109
+ */
110
+ export async function createUrlPipelineRunner(
111
+ url: string,
112
+ load: () => Promise<any> = () => import('postgres'),
113
+ ): Promise<UrlPipelineRunner> {
114
+ let mod: any;
115
+ try {
116
+ mod = await load();
117
+ } catch {
118
+ throw new Error(
119
+ 'The direct-connection path needs the "postgres" driver. It ships with @everystack/server; in a project without it: pnpm add -D postgres',
120
+ );
121
+ }
122
+ const postgres = mod.default ?? mod;
123
+ const sql = postgres(url, { max: 1, onnotice: () => {}, ...sslDefaults(url) });
124
+ return {
125
+ query: async (query: string) => Array.from(await sql.unsafe(query)),
126
+ transaction: async (fn) =>
127
+ sql.begin((tx: any) => fn(async (query: string) => Array.from(await tx.unsafe(query)))),
128
+ end: () => sql.end({ timeout: 5 }),
129
+ };
130
+ }
131
+
132
+ /**
133
+ * Managed Postgres (RDS / Supabase / Neon) requires TLS — a direct connection without it
134
+ * is rejected with pg_hba "no encryption". Default `ssl: 'require'` for remote hosts so the
135
+ * direct-connection path just works, and leave local dev DBs plaintext (they don't serve TLS).
136
+ * An explicit `?sslmode=` in the URL always wins — postgres.js parses it — so we never override
137
+ * the operator's choice; we only fill the gap when they said nothing.
138
+ */
139
+ export function sslDefaults(url: string): { ssl?: 'require' } {
140
+ let host = '';
141
+ let hasSslMode = false;
142
+ try {
143
+ const u = new URL(url);
144
+ host = u.hostname;
145
+ hasSslMode = u.searchParams.has('sslmode');
146
+ } catch {
147
+ return {};
148
+ }
149
+ const isLocal =
150
+ host === 'localhost' || host === '127.0.0.1' || host === '::1' || host.endsWith('.local');
151
+ if (isLocal || hasSslMode) return {};
152
+ return { ssl: 'require' };
153
+ }