@everystack/cli 0.3.28 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/package.json +6 -2
- package/src/cli/apply-execute.ts +185 -0
- package/src/cli/authz-compile.ts +5 -2
- package/src/cli/authz-lint.ts +48 -0
- package/src/cli/aws.ts +102 -4
- package/src/cli/backfill.ts +1 -1
- package/src/cli/commands/db-apply.ts +159 -169
- package/src/cli/commands/db-backfill.ts +2 -2
- package/src/cli/commands/db-backup.ts +34 -0
- package/src/cli/commands/db-branch.ts +28 -4
- package/src/cli/commands/db-check.ts +97 -34
- package/src/cli/commands/db-fingerprint.ts +9 -4
- package/src/cli/commands/db-generate.ts +73 -22
- package/src/cli/commands/db-plan.ts +7 -13
- package/src/cli/commands/db-pull.ts +39 -7
- package/src/cli/commands/db-reconcile.ts +219 -66
- package/src/cli/commands/db-sync.ts +58 -21
- package/src/cli/commands/deploy.ts +4 -0
- package/src/cli/commands/pipeline-run.ts +269 -0
- package/src/cli/db-build.ts +9 -3
- package/src/cli/db-source.ts +83 -7
- package/src/cli/declared-derived.ts +136 -0
- package/src/cli/derived-apply.ts +40 -6
- package/src/cli/derived-compile.ts +327 -0
- package/src/cli/derived-grants.ts +96 -0
- package/src/cli/derived-introspect.ts +258 -21
- package/src/cli/derived-lint.ts +261 -0
- package/src/cli/derived-plan.ts +194 -12
- package/src/cli/derived-render.ts +320 -0
- package/src/cli/derived-source.ts +53 -125
- package/src/cli/discover.ts +16 -0
- package/src/cli/git-descent.ts +15 -2
- package/src/cli/index.ts +26 -6
- package/src/cli/migration-compile.ts +38 -7
- package/src/cli/migration-generate.ts +43 -4
- package/src/cli/model-render.ts +106 -13
- package/src/cli/models-path.ts +1 -1
- package/src/cli/ops-diagnostics.ts +71 -0
- package/src/cli/pipeline-loader.ts +68 -0
- package/src/cli/pipeline-path.ts +25 -0
- package/src/cli/schema-compile.ts +41 -10
- package/src/cli/schema-diff.ts +123 -17
- package/src/cli/schema-fingerprint.ts +28 -18
- package/src/cli/schema-introspect.ts +175 -8
- package/src/cli/schema-source.ts +75 -6
- package/src/cli/state-apply.ts +4 -1
|
@@ -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>]
|
|
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
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
266
|
-
|
|
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
|
|
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 (
|
|
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
|
+
}
|
package/src/cli/db-build.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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,
|
|
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
|
});
|
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,71 @@ export async function createUrlRunner(
|
|
|
69
83
|
);
|
|
70
84
|
}
|
|
71
85
|
const postgres = mod.default ?? mod;
|
|
72
|
-
|
|
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
|
+
}
|