@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.
- package/package.json +5 -1
- package/src/cli/apply-execute.ts +185 -0
- package/src/cli/authz-lint.ts +48 -0
- package/src/cli/aws.ts +102 -4
- package/src/cli/commands/db-apply.ts +159 -169
- package/src/cli/commands/db-backfill.ts +2 -2
- package/src/cli/commands/db-backup.ts +34 -0
- package/src/cli/commands/db-check.ts +13 -1
- package/src/cli/commands/db-fingerprint.ts +2 -2
- package/src/cli/commands/db-generate.ts +2 -2
- package/src/cli/commands/db-plan.ts +7 -13
- package/src/cli/commands/db-pull.ts +2 -2
- package/src/cli/commands/db-reconcile.ts +93 -31
- package/src/cli/commands/db-sync.ts +15 -8
- package/src/cli/commands/deploy.ts +4 -0
- package/src/cli/commands/pipeline-run.ts +269 -0
- package/src/cli/db-source.ts +79 -7
- package/src/cli/derived-plan.ts +19 -3
- package/src/cli/discover.ts +16 -0
- package/src/cli/index.ts +22 -2
- package/src/cli/ops-diagnostics.ts +71 -0
- package/src/cli/pipeline-loader.ts +68 -0
- package/src/cli/pipeline-path.ts +25 -0
- package/src/cli/schema-fingerprint.ts +20 -4
package/src/cli/derived-plan.ts
CHANGED
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
* overwrite explicit, and the drift stays in the report so the operator sees
|
|
9
9
|
* what they overwrote). Never-reconciled matches are `needsBaseline`: the
|
|
10
10
|
* reconciler refuses to guess whether live matches source — `baseline: true`
|
|
11
|
-
* records the trust explicitly, once
|
|
11
|
+
* records the trust explicitly, once, or `rebuild: true` rebuilds them from
|
|
12
|
+
* source (drop+create) so the match is guaranteed rather than asserted.
|
|
12
13
|
*
|
|
13
14
|
* Graph work: replacing (or dropping) a relation rebuilds its live transitive
|
|
14
15
|
* dependents — dropped in reverse-topological order, recreated in source
|
|
@@ -72,6 +73,11 @@ export interface ReconcileOptions {
|
|
|
72
73
|
baseline?: boolean;
|
|
73
74
|
/** Rebuild drifted objects from source (the drift finding stays in the report). */
|
|
74
75
|
overwriteDrift?: boolean;
|
|
76
|
+
/**
|
|
77
|
+
* Rebuild never-reconciled source↔live matches from source (drop+create) instead of trusting
|
|
78
|
+
* them via baseline — the guarantee that live == db/sql when first contact can't verify it.
|
|
79
|
+
*/
|
|
80
|
+
rebuild?: boolean;
|
|
75
81
|
}
|
|
76
82
|
|
|
77
83
|
const isRelation = (kind: DerivedKind): boolean => kind !== 'function';
|
|
@@ -117,8 +123,18 @@ export function planReconcile(
|
|
|
117
123
|
continue;
|
|
118
124
|
}
|
|
119
125
|
if (!prov) {
|
|
120
|
-
|
|
121
|
-
|
|
126
|
+
// First contact: no provenance, and src-hash (source text) can't be compared to def-hash
|
|
127
|
+
// (live deparse), so the reconciler can't verify live == source. --baseline trusts it,
|
|
128
|
+
// --rebuild guarantees it by rebuilding from source (relations through the dependency graph
|
|
129
|
+
// below; functions replace in place), and the default flags it for a decision.
|
|
130
|
+
if (options.rebuild) {
|
|
131
|
+
if (isRelation(src.kind)) rebuild.set(src.identity, 'rebuilt from source (first contact, --rebuild)');
|
|
132
|
+
else fnReplace.set(src.identity, 'replaced from source (first contact, --rebuild)');
|
|
133
|
+
} else if (options.baseline) {
|
|
134
|
+
baseline.push(src.identity);
|
|
135
|
+
} else {
|
|
136
|
+
needsBaseline.push(src.identity);
|
|
137
|
+
}
|
|
122
138
|
continue;
|
|
123
139
|
}
|
|
124
140
|
|
package/src/cli/discover.ts
CHANGED
|
@@ -423,6 +423,22 @@ export async function invalidateCacheByFunctionName(staleName: string): Promise<
|
|
|
423
423
|
}
|
|
424
424
|
}
|
|
425
425
|
|
|
426
|
+
/**
|
|
427
|
+
* Delete the per-stage config cache for one stage, forcing the next stage command to
|
|
428
|
+
* re-discover live. Called after `everystack deploy` (a deploy can add a function the old
|
|
429
|
+
* cache never saw — the Ops function is the canonical case: its name stays absent from a
|
|
430
|
+
* config discovered before it existed, and the 24h TTL keeps that stale config valid, so
|
|
431
|
+
* db ops misroute to the Api function) and by `--refresh`. Best-effort; only touches the
|
|
432
|
+
* `outputs.{stage}.json` cache, never the deploy-written `outputs.json`.
|
|
433
|
+
*/
|
|
434
|
+
export async function invalidateCachedConfig(stage: string): Promise<void> {
|
|
435
|
+
try {
|
|
436
|
+
await fs.unlink(cachePath(stage));
|
|
437
|
+
} catch {
|
|
438
|
+
// No cache for this stage — nothing to invalidate.
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
426
442
|
export async function setCachedConfig(stage: string, config: DiscoveredConfig): Promise<void> {
|
|
427
443
|
const data: CachedOutputs = {
|
|
428
444
|
_cachedAt: Date.now(),
|
package/src/cli/index.ts
CHANGED
|
@@ -20,6 +20,7 @@ import { dbApplyCommand } from './commands/db-apply.js';
|
|
|
20
20
|
import { dbCheckCommand } from './commands/db-check.js';
|
|
21
21
|
import { dbApproversCommand } from './commands/db-approvers.js';
|
|
22
22
|
import { dbBackfillCommand } from './commands/db-backfill.js';
|
|
23
|
+
import { pipelineRunCommand, pipelineListCommand } from './commands/pipeline-run.js';
|
|
23
24
|
import { dbTemplateRefreshCommand, dbBranchCommand } from './commands/db-branch.js';
|
|
24
25
|
import { dbForkCommand } from './commands/db-fork.js';
|
|
25
26
|
import { dbSnapshotCommand, dbSnapshotsCommand } from './commands/db-snapshot.js';
|
|
@@ -114,6 +115,14 @@ async function main() {
|
|
|
114
115
|
process.exit(0);
|
|
115
116
|
}
|
|
116
117
|
|
|
118
|
+
// `--refresh` forces re-discovery: drop the stale per-stage cache before anything reads it
|
|
119
|
+
// (loadSstOutputs and resolveConfig both consult it). Use after a deploy the CLI didn't run,
|
|
120
|
+
// or whenever a stage command resolves to the wrong function.
|
|
121
|
+
if (flags.refresh && flags.stage) {
|
|
122
|
+
const { invalidateCachedConfig } = await import('./discover.js');
|
|
123
|
+
await invalidateCachedConfig(flags.stage);
|
|
124
|
+
}
|
|
125
|
+
|
|
117
126
|
// Auto-detect deployed URL from SST outputs
|
|
118
127
|
await loadSstOutputs(flags.stage);
|
|
119
128
|
|
|
@@ -210,6 +219,12 @@ async function main() {
|
|
|
210
219
|
case 'db:backfill':
|
|
211
220
|
await dbBackfillCommand(flags);
|
|
212
221
|
break;
|
|
222
|
+
case 'pipeline:run':
|
|
223
|
+
await pipelineRunCommand(flags);
|
|
224
|
+
break;
|
|
225
|
+
case 'pipeline:list':
|
|
226
|
+
await pipelineListCommand(flags);
|
|
227
|
+
break;
|
|
213
228
|
case 'db:template:refresh':
|
|
214
229
|
await dbTemplateRefreshCommand(flags);
|
|
215
230
|
break;
|
|
@@ -342,14 +357,16 @@ Usage:
|
|
|
342
357
|
everystack db:pull [--stage <name> | --database-url <url>] [--schema public] [--out <dir | file.ts>] Introspect the live DB → render field() Models (the brownfield on-ramp). --out <dir> writes one file per model + index.ts (the default shape); --out <file.ts> writes a single module; stdout otherwise
|
|
343
358
|
Both introspect via the deployed ops Lambda by default; --database-url (or an inherited DATABASE_URL) connects directly — for a schema that exists only on a local Postgres.
|
|
344
359
|
everystack db:fingerprint [--stage <name> | --database-url <url>] [--models <barrel>] [--json] Content-address the live base schema (tables+constraints+authz) and compare against the models — MATCH/MISMATCH (exit 1), plus the unfingerprinted-objects report
|
|
345
|
-
everystack db:reconcile [--sql-dir db/sql] [--stage <name> | --database-url <url>] [--apply] [--check] [--baseline] [--overwrite-drift] [--json] Reconcile the derived layer (functions/views/matviews) against db/sql sources: plan with rebuild-cost estimates by default; --check is the CI gate; --apply executes (direct connection only) and records provenance + schema_log. Hand-edits are drift (never overwritten silently)
|
|
360
|
+
everystack db:reconcile [--sql-dir db/sql] [--stage <name> | --database-url <url>] [--apply] [--check] [--baseline] [--rebuild] [--overwrite-drift] [--json] Reconcile the derived layer (functions/views/matviews) against db/sql sources: plan with rebuild-cost estimates by default; --check is the CI gate; --apply executes (direct connection only, atomic — DDL + provenance in one transaction) and records provenance + schema_log. Hand-edits are drift (never overwritten silently). First contact with existing objects: --baseline TRUSTS live == source (records provenance, verifies nothing), --rebuild GUARANTEES it (drop+create from source). They are mutually exclusive.
|
|
346
361
|
everystack db:sync [--database-url <url>] [--models <barrel>] [--sql-dir db/sql] [--schema-out <file.ts>] [--allow-drops] [--overwrite-drift] [--baseline] [--json] Make the database match your checkout — one verb, both layers: apply the state diff (tables+authz, one transaction, verified by re-diff), reconcile the derived layer against db/sql, report the resulting fingerprint vs the models' declared one. Dev databases only (direct connection required); DROPs held back unless --allow-drops; derived drift refuses unless --overwrite-drift; exit 1 when not converged
|
|
347
362
|
everystack db:diff --from-models <barrel> [--to-models db/models/index.ts] [--allow-drops] [--check] [--json] The state edge between two declared states, NO database: the SQL db:generate would produce, computed purely — CI plan previews (--check exits 1 on a non-empty edge) and computed rollbacks (swap the flags)
|
|
348
363
|
everystack db:plan [--stage <name> | --database-url <url>] [--models <barrel>] [--allow-drops] [--out db.plan.json | --out -] Mint a verified edge against a target: asks the TARGET its fingerprint, diffs the models, writes ONE reviewable plan (edge + both endpoint fingerprints). Held drops refuse the mint (--allow-drops carries destruction explicitly). Read-only — works via the ops Lambda; plans are ephemeral, never committed
|
|
349
364
|
everystack db:apply --plan <file.plan.json> [--database-url <url>] [--stage <name>] [--models <barrel>] [--confirm] [--snapshot-ref <ref>] [--force-descent <snapshot-ref> --confirm] Run a reviewed plan: verify the target is EXACTLY where the plan started (live fingerprint == plan.from, else refuse — the concurrency lock), verify the checkout DESCENDS from the commit declaring the target's state (the fast-forward rule, else refuse — "rebase first"), and for DESTRUCTIVE plans require --confirm always + a snapshot (automatic via db:backup with --stage, else --snapshot-ref) + the stage's approver set when declared (STS identity-verified). Every refusal is recorded in schema_log. Apply as one transaction (plan_ref stamped), verify it landed exactly on plan.to; idempotent when already there; direct connection required
|
|
350
|
-
everystack db:check [--models <barrel>] [--sql-dir db/sql] [--schema-out <file.ts>] [--database-url <url>] [--json] The CI gate, per PR: the merged declared state must COMPOSE (models load, no duplicate tables, db/sql parses) and generated artifacts must MATCH regeneration byte-for-byte; with a scratch PostgreSQL it builds the state from scratch on an ephemeral database (created + dropped) and requires fingerprint MATCH. Exit 1 on any failure; never touches a real target
|
|
365
|
+
everystack db:check [--models <barrel>] [--sql-dir db/sql] [--schema-out <file.ts>] [--database-url <url>] [--json] The CI gate, per PR: the merged declared state must COMPOSE (models load, no duplicate tables, db/sql parses), every exposed RLS-enabled table must declare a read path (no force-RLS-with-no-read landmine that goes dark on the superuser drop), and generated artifacts must MATCH regeneration byte-for-byte; with a scratch PostgreSQL it builds the state from scratch on an ephemeral database (created + dropped) and requires fingerprint MATCH. Exit 1 on any failure; never touches a real target
|
|
351
366
|
everystack db:approvers --stage <name> [--set "cto,arn:..."] [--remove] Declare who can DESTROY: the stage's destructive-approver set (SSM parameter, admin-writable). Destructive db:apply runs are then identity-verified (STS) against it; --set '' disables destructive applies; --remove returns the stage to ceremony-only
|
|
352
367
|
everystack db:backfill [--database-url <url>] [--dir db/backfills] [--apply] [--mark-applied <file.sql>] [--json] One-shot data jobs in their own lane: plan shows applied (by CONTENT identity — renames/comment edits are no-ops) / pending (in order, unbounded-pass advisories) / blocked (a name that already ran in a different form — one-shot jobs are immutable). --apply runs each pending job as its own transaction, recorded in everystack.backfill_log (a failure rolls back alone, is recorded, stops the run); --mark-applied records without running. Never runs as a schema side effect; direct connection required
|
|
368
|
+
everystack pipeline:run [--stage <name> | --database-url <url>] [--rebuild | --curate] [--only <substr> | --from <id> | --to <id>] [--resume [--run-id <id>]] [--dry-run] [--continue-on-error] [--json] Reproduce data from committed source: run the pipeline's stages in topological order, each idempotent and inside its own transaction, recorded in everystack.pipeline_log. Lanes: --rebuild (automated+frozen) / --curate (curated) / neither (all in order). --resume picks up the latest run, skipping applied stages. With --stage the orchestrator runs credential-free IN the ops Lambda (no URL held; heavy stages refused — run those local); --database-url is local-direct and unbounded
|
|
369
|
+
everystack pipeline:list [--rebuild | --curate] [--only <substr> | --from <id> | --to <id>] Show the pipeline's stages in run order (topological) with their lane and dependency edges — no execution, no database
|
|
353
370
|
everystack db:template:refresh [--database-url <url>] [--models <barrel>] [--sql-dir db/sql] [--seed "<cmd>"] [--no-seed] (Re)build the dev template <base>_tpl FROM THE DECLARED STATE (both layers, fingerprint MATCH bar) + seed-as-code (the app's db:seed script, run with DATABASE_URL pointed at the template; --seed overrides). All or nothing: a failed build/seed leaves NO template. Never a data copy
|
|
354
371
|
everystack db:branch [--list | --drop --confirm | --prune --confirm] [--database-url <url>] Mint (or find) the current git branch's database from the template (CREATE DATABASE … TEMPLATE — schema, authz, derived layer, and seed rows inherited), print its DATABASE_URL, then db:sync evolves it with the checkout. --list maps every branch DB to its branch; --prune drops the ones whose branch is gone (never guesses: unknown mappings are kept)
|
|
355
372
|
everystack db:fork --from-stage <src> --stage <target> --confirm [--backup <id>] Fork one DEPLOYED stage's database into another: back up the source (or reuse --backup <id>), presign the dump (the operator's credentials ARE the cross-stage authorization; expires in 1h), restore into the target via its ops Lambda. Production is never a target (that's db:restore); forking FROM production warns about PII; the branch's schema edge then lands via db:plan → db:apply (descent composes). Teardown: sst remove --stage <target>
|
|
@@ -398,6 +415,9 @@ Stage resolution:
|
|
|
398
415
|
--stage <name> Discover AWS resources for the given stage by querying AWS APIs.
|
|
399
416
|
Uses SST naming convention: {app}-{stage}-{Resource}-{hash}.
|
|
400
417
|
Results are cached in .sst/outputs.{stage}.json for 24 hours.
|
|
418
|
+
--refresh Drop the cached stage config and re-discover live. Use after a
|
|
419
|
+
deploy the CLI didn't run, or if a stage command resolves to the
|
|
420
|
+
wrong function. \`everystack deploy\` refreshes this cache for you.
|
|
401
421
|
|
|
402
422
|
Without --stage, reads .sst/outputs.json (written by the last \`sst deploy\`).
|
|
403
423
|
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Diagnostics for a crashed ops-Lambda invoke.
|
|
3
|
+
*
|
|
4
|
+
* A consumer's ops handler can fail to *boot* — a broken top-level import (a dependency missing
|
|
5
|
+
* from the deployed bundle, a version skew, a bad path). Lambda then returns a FunctionError with
|
|
6
|
+
* the opaque `Runtime exited with error: exit status 1` for EVERY action, giving the operator no
|
|
7
|
+
* signal that it's the handler failing to start (not their command) and no path to the cause. The
|
|
8
|
+
* CLI turns that into a report naming the real cause by reading the handler's CloudWatch logs; the
|
|
9
|
+
* pure detection/extraction/formatting lives here, the AWS fetch in aws.ts.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Whether a Lambda FunctionError message is the handler failing to boot / crashing the runtime — an
|
|
14
|
+
* uncaught init error worth chasing into CloudWatch — rather than an already-descriptive error. Only
|
|
15
|
+
* these opaque cases trigger the log fetch; a clear message passes through untouched.
|
|
16
|
+
*/
|
|
17
|
+
export function isOpsHandlerCrash(message: string): boolean {
|
|
18
|
+
return /Runtime exited|exited with error|Runtime\.(ImportModuleError|UserCodeSyntaxError|HandlerNotFound)|Cannot find (module|package)|ERR_MODULE_NOT_FOUND|\bMODULE_NOT_FOUND\b/i.test(
|
|
19
|
+
message,
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Log lines worth surfacing: the import error, a runtime error class, or a thrown Error/stack. */
|
|
24
|
+
const SIGNAL = /Cannot find (module|package)|ERR_MODULE_NOT_FOUND|\bMODULE_NOT_FOUND\b|Runtime\.\w+|(?:Reference|Type|Syntax|Range)?Error:/;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Pull the diagnostic lines from raw CloudWatch log text for a crashed ops invoke — the import
|
|
28
|
+
* error / stack frame that names the missing dependency — so an opaque "exit status 1" becomes the
|
|
29
|
+
* real cause. De-duplicates and caps output. Returns null when nothing diagnostic is present.
|
|
30
|
+
*/
|
|
31
|
+
export function extractCrashDiagnosis(logText: string): string | null {
|
|
32
|
+
const seen = new Set<string>();
|
|
33
|
+
const out: string[] = [];
|
|
34
|
+
for (const raw of logText.split('\n')) {
|
|
35
|
+
const line = raw.trim();
|
|
36
|
+
if (!line || !SIGNAL.test(line) || seen.has(line)) continue;
|
|
37
|
+
seen.add(line);
|
|
38
|
+
out.push(line);
|
|
39
|
+
if (out.length >= 6) break;
|
|
40
|
+
}
|
|
41
|
+
return out.length ? out.join('\n') : null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Assemble the operator-facing report for an ops-handler crash: the raw message, the "it's the
|
|
46
|
+
* handler, not your command" framing, and either the real cause from CloudWatch or a where-to-look
|
|
47
|
+
* hint when the logs couldn't be read.
|
|
48
|
+
*/
|
|
49
|
+
export function formatOpsCrashReport(
|
|
50
|
+
baseMessage: string,
|
|
51
|
+
logGroup?: string,
|
|
52
|
+
diagnosis?: string | null,
|
|
53
|
+
): string {
|
|
54
|
+
const lines = [
|
|
55
|
+
baseMessage,
|
|
56
|
+
'',
|
|
57
|
+
'The ops handler crashed at startup — usually a missing dependency in the deployed bundle, not a problem with this command.',
|
|
58
|
+
];
|
|
59
|
+
if (diagnosis) {
|
|
60
|
+
lines.push(
|
|
61
|
+
'',
|
|
62
|
+
`From CloudWatch${logGroup ? ` (${logGroup})` : ''}:`,
|
|
63
|
+
diagnosis,
|
|
64
|
+
'',
|
|
65
|
+
'Fix the import/dependency and redeploy.',
|
|
66
|
+
);
|
|
67
|
+
} else {
|
|
68
|
+
lines.push('Check its CloudWatch logs for the failing top-level import, then redeploy.');
|
|
69
|
+
}
|
|
70
|
+
return lines.join('\n');
|
|
71
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pipeline-loader — the CLI's window onto @everystack/server/pipeline.
|
|
3
|
+
*
|
|
4
|
+
* The CLI keeps NO compile-time dependency on @everystack/server (the two are
|
|
5
|
+
* mutual peers; hard-importing would be a project-reference cycle — the same
|
|
6
|
+
* reason plugin.ts inlines its types). So the orchestrator's types are mirrored
|
|
7
|
+
* structurally here and the runtime module is loaded lazily via a variable
|
|
8
|
+
* specifier. Every CLI verb that reaches the pipeline framework (pipeline:run,
|
|
9
|
+
* and the db:sync / db:plan visibility) goes through this one door.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export type PipelineLane = 'rebuild' | 'curate' | 'all';
|
|
13
|
+
|
|
14
|
+
export interface PipelineStage {
|
|
15
|
+
id: string;
|
|
16
|
+
lane: 'automated' | 'curated' | 'frozen';
|
|
17
|
+
dependsOn?: string[];
|
|
18
|
+
after?: string[];
|
|
19
|
+
heavy?: boolean;
|
|
20
|
+
}
|
|
21
|
+
export interface Pipeline {
|
|
22
|
+
stages: PipelineStage[];
|
|
23
|
+
}
|
|
24
|
+
export interface RunPipelineOptions {
|
|
25
|
+
runId: string;
|
|
26
|
+
lane?: PipelineLane;
|
|
27
|
+
only?: string;
|
|
28
|
+
from?: string;
|
|
29
|
+
to?: string;
|
|
30
|
+
resume?: boolean;
|
|
31
|
+
dryRun?: boolean;
|
|
32
|
+
continueOnError?: boolean;
|
|
33
|
+
actor?: string | null;
|
|
34
|
+
gitRef?: string | null;
|
|
35
|
+
}
|
|
36
|
+
export interface StageRunResult {
|
|
37
|
+
stageId: string;
|
|
38
|
+
outcome: 'applied' | 'skipped' | 'failed' | 'dry-run';
|
|
39
|
+
rows?: number;
|
|
40
|
+
error?: string;
|
|
41
|
+
}
|
|
42
|
+
export interface PipelineRunOutcome {
|
|
43
|
+
runId: string;
|
|
44
|
+
plan: string[];
|
|
45
|
+
results: StageRunResult[];
|
|
46
|
+
failed: boolean;
|
|
47
|
+
}
|
|
48
|
+
export interface PipelineRunSummary {
|
|
49
|
+
runId: string;
|
|
50
|
+
at: string | null;
|
|
51
|
+
applied: number;
|
|
52
|
+
failed: number;
|
|
53
|
+
failedStages: string[];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface ServerPipeline {
|
|
57
|
+
runPipeline(runner: unknown, pipeline: Pipeline, options: RunPipelineOptions): Promise<PipelineRunOutcome>;
|
|
58
|
+
latestRunId(query: (sql: string) => Promise<any[]>): Promise<string | null>;
|
|
59
|
+
readLastRun(query: (sql: string) => Promise<any[]>): Promise<PipelineRunSummary | null>;
|
|
60
|
+
topoSortStages(stages: PipelineStage[]): PipelineStage[];
|
|
61
|
+
selectStages(ordered: PipelineStage[], opts: Record<string, unknown>): PipelineStage[];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Lazily load the orchestrator from the @everystack/server peer (runtime only). */
|
|
65
|
+
export async function loadServerPipeline(): Promise<ServerPipeline> {
|
|
66
|
+
const spec = '@everystack/server/pipeline';
|
|
67
|
+
return (await import(spec)) as ServerPipeline;
|
|
68
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pipeline-path — where the consumer's pipeline definition lives.
|
|
3
|
+
*
|
|
4
|
+
* Beside the schema homes (`db/models/`, `db/sql/`, `db/backfills/`), the
|
|
5
|
+
* pipeline is authored at `db/pipeline.ts` (or `db/pipeline/index.ts`),
|
|
6
|
+
* exporting a `pipeline` built with definePipeline. An explicit `--pipeline`
|
|
7
|
+
* always wins; when neither home exists the primary is returned so error
|
|
8
|
+
* messages point at the current convention.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { existsSync } from 'node:fs';
|
|
12
|
+
|
|
13
|
+
/** Candidate pipeline entrypoints, in preference order. */
|
|
14
|
+
export const PIPELINE_HOMES = ['db/pipeline.ts', 'db/pipeline/index.ts'] as const;
|
|
15
|
+
|
|
16
|
+
export function resolvePipelinePath(
|
|
17
|
+
flag?: string,
|
|
18
|
+
exists: (p: string) => boolean = existsSync,
|
|
19
|
+
): string {
|
|
20
|
+
if (flag) return flag;
|
|
21
|
+
for (const home of PIPELINE_HOMES) {
|
|
22
|
+
if (exists(home)) return home;
|
|
23
|
+
}
|
|
24
|
+
return PIPELINE_HOMES[0];
|
|
25
|
+
}
|
|
@@ -27,6 +27,13 @@
|
|
|
27
27
|
* - FUNCTIONS are excluded — they belong to the derived layer, whose own
|
|
28
28
|
* content hashes (derived-introspect) cover them. Base fingerprint + derived
|
|
29
29
|
* manifest together are the full schema state.
|
|
30
|
+
* - Expressions (column defaults, check predicates, partial-index WHERE) hash
|
|
31
|
+
* through the SAME normalizers the diff uses (`normalizeDefault` /
|
|
32
|
+
* `normalizeCheck`), applied to both sides in `canonicalizeState`. Without
|
|
33
|
+
* this the model side hashes its source form (`'primary'`, `scope = ANY(...)`)
|
|
34
|
+
* while the live side hashes pg's deparse (`'primary'::text`,
|
|
35
|
+
* `(scope = ANY(...))`) — the identity below breaks on any schema with a text
|
|
36
|
+
* default, a check, or a partial index even when the diff is a no-op.
|
|
30
37
|
* - Deparse stability caveat, stated once: policy/check predicates hash as
|
|
31
38
|
* Postgres deparses them; a major-version deparser change can shift
|
|
32
39
|
* fingerprints. The version prefix plus the re-diff path keep that honest.
|
|
@@ -43,8 +50,12 @@ import type { SchemaSnapshot, TableSchema } from './schema-introspect.js';
|
|
|
43
50
|
import type { AuthzContract, TableContract } from './authz-contract.js';
|
|
44
51
|
import { compileTableSchema, compileEnums } from './schema-compile.js';
|
|
45
52
|
import { compileTableContract } from './authz-compile.js';
|
|
53
|
+
import { normalizeDefault, normalizeCheck } from './schema-diff.js';
|
|
46
54
|
|
|
47
|
-
|
|
55
|
+
// v2: expression fields (defaults, checks, partial-index WHERE) normalize through
|
|
56
|
+
// the diff's pg-deparse normalizers before hashing, so the model form and the live
|
|
57
|
+
// deparsed form agree — the identity `fingerprintModels === fingerprintLive` holds.
|
|
58
|
+
export const FINGERPRINT_VERSION = 2;
|
|
48
59
|
|
|
49
60
|
// ---------------------------------------------------------------------------
|
|
50
61
|
// Canonical form.
|
|
@@ -70,13 +81,17 @@ function canonicalTable(table: TableSchema): Record<string, unknown> {
|
|
|
70
81
|
return {
|
|
71
82
|
table: table.table,
|
|
72
83
|
columns: byKey(
|
|
73
|
-
|
|
84
|
+
// Defaults hash SEMANTICALLY, through the same normalizer the diff uses: the Model
|
|
85
|
+
// emits `'primary'`, pg deparses it to `'primary'::text` — the same state, one hash.
|
|
86
|
+
table.columns.map((c) => ({ name: c.name, type: c.type, notNull: c.notNull, default: normalizeDefault(c.default) })),
|
|
74
87
|
(c) => c.name,
|
|
75
88
|
),
|
|
76
89
|
primaryKey: table.primaryKey,
|
|
77
90
|
// Content, not names: a pg-default `_key` and a model-named unique are the same state.
|
|
78
91
|
uniques: byKey(table.uniques.map((u) => ({ columns: u.columns })), (u) => u.columns.join(',')),
|
|
79
|
-
|
|
92
|
+
// Predicates hash as the diff compares them — one layer of pg's wrapping parens dropped
|
|
93
|
+
// (`(scope = ANY (...))` == `scope = ANY (...)`), whitespace collapsed.
|
|
94
|
+
checks: byKey(table.checks.map((c) => ({ expr: normalizeCheck(c.expr) })), (c) => c.expr),
|
|
80
95
|
foreignKeys: byKey(
|
|
81
96
|
table.foreignKeys.map((f) => ({
|
|
82
97
|
columns: f.columns, refTable: f.refTable, refColumns: f.refColumns,
|
|
@@ -86,7 +101,8 @@ function canonicalTable(table: TableSchema): Record<string, unknown> {
|
|
|
86
101
|
(f) => stableStringify(f),
|
|
87
102
|
),
|
|
88
103
|
indexes: byKey(
|
|
89
|
-
|
|
104
|
+
// Partial-index WHERE is a predicate too — normalize it the same way as a check.
|
|
105
|
+
table.indexes.map((i) => ({ columns: i.columns, unique: i.unique, ...(i.where ? { where: normalizeCheck(i.where) } : {}) })),
|
|
90
106
|
(i) => stableStringify(i),
|
|
91
107
|
),
|
|
92
108
|
};
|