@everystack/cli 0.4.99 → 0.4.102
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/CHANGELOG.md +105 -0
- package/package.json +3 -3
- package/src/cli/apply-execute.ts +15 -1
- package/src/cli/authz-baseline.ts +8 -4
- package/src/cli/authz-compile.ts +61 -7
- package/src/cli/authz-contract.ts +23 -14
- package/src/cli/authz-derive.ts +282 -30
- package/src/cli/authz-reconcile.ts +6 -4
- package/src/cli/catalog-rename.ts +115 -0
- package/src/cli/commands/db-check.ts +40 -0
- package/src/cli/commands/db-generate.ts +10 -9
- package/src/cli/commands/db-plan.ts +30 -13
- package/src/cli/commands/db-pull.ts +153 -63
- package/src/cli/commands/db-reconcile.ts +22 -3
- package/src/cli/commands/db-sync.ts +6 -5
- package/src/cli/commands/db.ts +91 -8
- package/src/cli/commands/update.ts +3 -2
- package/src/cli/db-build.ts +1 -0
- package/src/cli/declared-derived.ts +3 -1
- package/src/cli/declared-diff.ts +4 -1
- package/src/cli/derived-apply.ts +116 -10
- package/src/cli/derived-grants.ts +4 -2
- package/src/cli/derived-introspect.ts +23 -3
- package/src/cli/derived-plan.ts +37 -1
- package/src/cli/derived-render.ts +33 -34
- package/src/cli/edge-plan.ts +57 -9
- package/src/cli/generated-artifacts.ts +22 -0
- package/src/cli/generated-names.ts +108 -0
- package/src/cli/host-url.ts +48 -27
- package/src/cli/index.ts +5 -5
- package/src/cli/lifecycle-canon.ts +37 -0
- package/src/cli/lifecycle-derived.ts +721 -0
- package/src/cli/lifecycle-pull.ts +177 -0
- package/src/cli/lifecycle-verify.ts +232 -0
- package/src/cli/migration-compile.ts +8 -3
- package/src/cli/migration-generate.ts +23 -3
- package/src/cli/model-map.ts +255 -0
- package/src/cli/model-render.ts +243 -77
- package/src/cli/plain-output.ts +198 -10
- package/src/cli/runbook/model-section.ts +8 -5
- package/src/cli/schema-compile.ts +57 -18
- package/src/cli/schema-diff.ts +184 -41
- package/src/cli/schema-fingerprint.ts +39 -6
- package/src/cli/schema-introspect.ts +66 -2
- package/src/cli/schema-source.ts +40 -9
- package/src/cli/schema-swap.ts +3 -2
- package/src/cli/state-apply.ts +10 -4
- package/src/cli/ts-template.ts +52 -0
package/src/cli/host-url.ts
CHANGED
|
@@ -13,44 +13,65 @@ import fs from 'node:fs/promises';
|
|
|
13
13
|
import path from 'node:path';
|
|
14
14
|
import { createRequire } from 'node:module';
|
|
15
15
|
|
|
16
|
-
|
|
17
|
-
|
|
16
|
+
/**
|
|
17
|
+
* Sets HOST_URL from the SST outputs when it can, and returns what it read when it could not: one
|
|
18
|
+
* plain line per file, for missingHostUrlError. Empty when HOST_URL was already set or was found.
|
|
19
|
+
*/
|
|
20
|
+
export async function loadSstOutputs(stage?: string): Promise<string[]> {
|
|
21
|
+
if (process.env.HOST_URL) return [];
|
|
22
|
+
const trace: string[] = [];
|
|
18
23
|
|
|
19
24
|
// When --stage is provided, try stage-specific cache first
|
|
20
25
|
if (stage) {
|
|
21
|
-
const
|
|
26
|
+
const file = `.sst/outputs.${stage}.json`;
|
|
22
27
|
try {
|
|
23
|
-
const
|
|
24
|
-
const cached = JSON.parse(raw);
|
|
28
|
+
const cached = JSON.parse(await fs.readFile(path.resolve('.sst', `outputs.${stage}.json`), 'utf8'));
|
|
25
29
|
if (cached.routerUrl) {
|
|
26
30
|
process.env.HOST_URL = cached.routerUrl;
|
|
27
|
-
return;
|
|
31
|
+
return [];
|
|
28
32
|
}
|
|
29
|
-
|
|
30
|
-
|
|
33
|
+
trace.push(`${file} has an empty routerUrl (CDN_URL is not set on the stage's API function)`);
|
|
34
|
+
} catch (err) {
|
|
35
|
+
trace.push(`${file} ${(err as NodeJS.ErrnoException).code === 'ENOENT' ? 'was not found' : 'could not be read'}`);
|
|
31
36
|
}
|
|
32
37
|
}
|
|
33
38
|
|
|
34
|
-
const outputsPath = path.resolve('.sst', 'outputs.json');
|
|
35
39
|
try {
|
|
36
|
-
const
|
|
37
|
-
const
|
|
38
|
-
if (
|
|
39
|
-
|
|
40
|
-
|
|
40
|
+
const outputs = JSON.parse(await fs.readFile(path.resolve('.sst', 'outputs.json'), 'utf8'));
|
|
41
|
+
const verdict = stage ? await outputsStageVerdict(outputs, stage) : 'this';
|
|
42
|
+
if (verdict === 'this') {
|
|
43
|
+
if (outputs.routerUrl) {
|
|
44
|
+
process.env.HOST_URL = outputs.routerUrl;
|
|
45
|
+
return [];
|
|
46
|
+
}
|
|
47
|
+
trace.push(stage ? `.sst/outputs.json is from stage "${stage}" but has an empty routerUrl` : '.sst/outputs.json has an empty routerUrl');
|
|
48
|
+
} else if (verdict === 'other') {
|
|
49
|
+
trace.push(`.sst/outputs.json is from a deploy of ${JSON.stringify(String(outputs.apiFunctionName).slice(0, 120))}, which is not stage "${stage}"`);
|
|
50
|
+
} else if (verdict === 'no-name') {
|
|
51
|
+
trace.push('.sst/outputs.json has no apiFunctionName, so the stage it describes is unknown');
|
|
52
|
+
} else {
|
|
53
|
+
trace.push(`.sst/outputs.json cannot be matched to stage "${stage}": the app name could not be read from sst.config.ts`);
|
|
41
54
|
}
|
|
42
|
-
} catch {
|
|
43
|
-
|
|
55
|
+
} catch (err) {
|
|
56
|
+
trace.push(`.sst/outputs.json ${(err as NodeJS.ErrnoException).code === 'ENOENT' ? 'was not found' : 'could not be read'}`);
|
|
44
57
|
}
|
|
58
|
+
return trace;
|
|
45
59
|
}
|
|
46
60
|
|
|
47
|
-
/**
|
|
48
|
-
|
|
61
|
+
/**
|
|
62
|
+
* Provenance gate for the --stage fall-through. Only 'this' lets outputs.json set HOST_URL; every
|
|
63
|
+
* other answer, including an unreadable app name, means unknown, so no.
|
|
64
|
+
*/
|
|
65
|
+
async function outputsStageVerdict(
|
|
66
|
+
outputs: { apiFunctionName?: string },
|
|
67
|
+
stage: string,
|
|
68
|
+
): Promise<'this' | 'other' | 'no-name' | 'no-app'> {
|
|
69
|
+
if (!outputs.apiFunctionName) return 'no-name';
|
|
49
70
|
try {
|
|
50
71
|
const { parseAppName, outputsDescribeStage } = await import('./discover');
|
|
51
|
-
return outputsDescribeStage(outputs, await parseAppName(), stage);
|
|
72
|
+
return outputsDescribeStage(outputs, await parseAppName(), stage) ? 'this' : 'other';
|
|
52
73
|
} catch {
|
|
53
|
-
return
|
|
74
|
+
return 'no-app';
|
|
54
75
|
}
|
|
55
76
|
}
|
|
56
77
|
|
|
@@ -117,9 +138,9 @@ async function dotenvSetsHostUrl(
|
|
|
117
138
|
*/
|
|
118
139
|
export async function missingHostUrlError(
|
|
119
140
|
stage: string | undefined,
|
|
120
|
-
opts: { env?: Record<string, string | undefined>; cwd?: string; siteUrl?: string; resolveFrom?: string } = {},
|
|
141
|
+
opts: { env?: Record<string, string | undefined>; cwd?: string; siteUrl?: string; resolveFrom?: string; trace?: string[] } = {},
|
|
121
142
|
): Promise<string | undefined> {
|
|
122
|
-
const { env = process.env, cwd = process.cwd(), siteUrl, resolveFrom = cwd } = opts;
|
|
143
|
+
const { env = process.env, cwd = process.cwd(), siteUrl, resolveFrom = cwd, trace } = opts;
|
|
123
144
|
if (env.HOST_URL || (await dotenvSetsHostUrl(cwd, env, resolveFrom))) return undefined;
|
|
124
145
|
if (!stage) {
|
|
125
146
|
return (
|
|
@@ -131,10 +152,10 @@ export async function missingHostUrlError(
|
|
|
131
152
|
);
|
|
132
153
|
}
|
|
133
154
|
const offer = siteUrl ? ` This stage's site URL is ${siteUrl}; export it as HOST_URL if that is its public URL.` : '';
|
|
134
|
-
|
|
135
|
-
|
|
155
|
+
const head = `No HOST_URL for stage "${stage}", so the bundle would ship the app's default URL as updates.url.${offer}`;
|
|
156
|
+
const fix =
|
|
136
157
|
'Otherwise export HOST_URL=<the stage\'s public URL>, or set CDN_URL on the API function and re-run with --refresh. ' +
|
|
137
|
-
'(A HOST_URL in a dotenv file counts only when the project\'s Expo can be found to read it the way `expo export` does.)
|
|
138
|
-
|
|
139
|
-
)
|
|
158
|
+
'(A HOST_URL in a dotenv file counts only when the project\'s Expo can be found to read it the way `expo export` does.)';
|
|
159
|
+
if (trace?.length) return `${head}\nRead:\n${trace.map(line => `- ${line}`).join('\n')}\n${fix}`;
|
|
160
|
+
return `${head} ${fix} (.sst/outputs.json is used only when its apiFunctionName is "<app>-${stage}-ApiFunction-...".)`;
|
|
140
161
|
}
|
package/src/cli/index.ts
CHANGED
|
@@ -312,7 +312,7 @@ Usage:
|
|
|
312
312
|
everystack db:reset [--stage <name>] Drop all schemas + re-run migrations (dev only)
|
|
313
313
|
everystack db:psql --stage <name> Interactive ADMIN psql (IAM-gated; resolves the admin URL in-process)
|
|
314
314
|
everystack db:psql [--stage <name>] -c <command> Run one query via Lambda (read-only). Runs as the SELECT-only 'reader' role when the stage has one (db:provision --with-reader + QUERY_DATABASE_URL wired to dbPlugin's queryDatabaseUrl); WITHOUT one it runs on the OPERATOR connection, fenced only by a read-only transaction — see docs/cli.md
|
|
315
|
-
everystack db:doctor [--stage <name>] [--direct | --database-url <api> [--admin-database-url <ops>]] Check the DB is least-privilege + RLS-subject (api vs operator connection). No flag = ops-Lambda venue (auto-falls to direct via the stage secrets if the handler has no dbPlugin). --direct = probe both connections from the stage's DATABASE_URL + ADMIN_DATABASE_URL secrets; --database-url = explicit local venue (a lone URL is probed as both)
|
|
315
|
+
everystack db:doctor [--purge-after <days>] [--models <barrel>] [--stage <name>] [--direct | --database-url <api> [--admin-database-url <ops>]] Check the DB is least-privilege + RLS-subject (api vs operator connection). No flag = ops-Lambda venue (auto-falls to direct via the stage secrets if the handler has no dbPlugin). --direct = probe both connections from the stage's DATABASE_URL + ADMIN_DATABASE_URL secrets; --database-url = explicit local venue (a lone URL is probed as both)
|
|
316
316
|
everystack db:provision --stage <name> [--direct | --database-url <url>] [--rotation-pairs] [--with-reader] Create the least-privilege role chain (idempotent). --rotation-pairs creates A/B pairs for live rotation. --with-reader adds the SELECT-only 'reader' login db:query runs as, grants it read on the declared schemas, and writes QUERY_DATABASE_URL (opt-in once, then kept converged on every re-provision)
|
|
317
317
|
everystack db:provision --rotate --stage <name> [--slot api|ops|both] [--grace 60] Rotate credentials live (A/B role swap, no deploy, no downtime)
|
|
318
318
|
everystack db:snapshot [--stage <name>] [--instance <id>] Take a physical RDS snapshot (instant DR point; RDS only — use db:backup for portable logical backups)
|
|
@@ -323,18 +323,18 @@ Usage:
|
|
|
323
323
|
everystack db:backup:download <id> [--stage <name>] Presigned URL to download a backup's dump (valid 1h)
|
|
324
324
|
everystack db:export --schema <name> [--stage <name> | --database-url <url> [--out <file.dump>]] [--models <barrel>] Schema-scoped pg_dump artifact, stamped with the DECLARED schema fingerprint (the canonical-sync export; db:swap gates on that stamp). --stage dumps the stage's private DB via the ops Lambda → S3; --database-url (explicit flag, never the env) dumps a reachable DB to a local .dump + .meta.json — the build-locally → publish → swap on-ramp
|
|
325
325
|
everystack db:swap --schema <name> --from <artifact.dump | artifact-id> [--stage <name> --direct | --database-url <url>] --confirm [--fingerprint <hash>] [--snapshot physical|logical|none] [--snapshot-ref <id>] [--rebuild-derived] [--dump-build <file.json>] Land a schema artifact atomically: fingerprint gate → pre-flight refusals → CONFIRMED snapshot → restore into <schema>_incoming (COPY-safe rewrite) → build the paired derived layer → one txn (drop+rename+recreate app→schema FKs, re-apply authz + schema USAGE) → assertions → drop retiring. Refresh-free; app.* untouched; the derived layer is never absent. DESTRUCTIVE. The venue is EXPLICIT — DATABASE_URL in the env is refused, and --stage requires --direct (a multi-GB restore exceeds the ops-Lambda 900s clock). The rollback point defaults to a PHYSICAL RDS snapshot on a stage that exposes databaseInstanceId (no locks, no pg_dump contending with the restore) and a WAITED logical db:backup otherwise; a bare --database-url refuses without --snapshot-ref <id> or --snapshot none. docs/schema-swap.md
|
|
326
|
-
everystack db:generate [--stage <name> | --database-url <url>] [--name <label>] [--models db/models/index.ts] [--schema-out db/schema.generated.ts] [--allow-drops] [--apply] [--dry-run] Diff models vs the live DB → next migration file, or with --apply execute it directly (one transaction, schema_log recorded, verified by re-diff — no drizzle folder needed; direct connection only; DROPs held back unless --allow-drops). --dry-run prints the edge and writes NOTHING (no migration, no journal entry, no schema refresh) — the preview verb; db:diff computes a models-vs-models edge with no database at all. The resolved --schema-out is recorded in the migration journal: later flag-less runs reuse it (flag > recorded > default), a differing flag updates the record and says so
|
|
327
|
-
everystack db:pull [--stage <name> | --database-url <url>] [--schema public] [--out <dir | file.ts>] [--derived-out <file.ts | dir>] [--abilities live|public-read] Introspect the live DB → render field() Models — THE on-ramp for an existing database: commit what it renders, then db:plan --stage <s>. EMPTY plan = adopted as it is (that commit declares the database; every later db:apply descends from it). NON-EMPTY plan = the hardening edge: review it, db:apply --plan <f> --stage <s> — no commit declares the database yet, so the reviewed plan is the ORIGIN. Render from the stage, never from a local restore of a dump. --out <dir> writes one file per model + index.ts (the default shape); --out <file.ts> writes a single module; stdout otherwise. --derived-out <file.ts> extracts the derived layer (descriptors + sequences) as its own self-contained module — alone it leaves the models untouched (the hand-maintained-barrel splice); with --out the models render omits the now-external derived layer. --derived-out <dir> (anything not ending in .ts) splits that module by kind and schema instead — views.ts, functions-<schema>.ts, triggers.ts, index.ts barrel — for a real schema whose derived layer is otherwise one unreviewable file; refuses loudly, before writing anything, if the split files would import each other. Every model scaffolds its authz decision as comments (db:check fails until authored). **--abilities live is the brownfield mode**: derive each model's authz from the grants and policies the database ALREADY has, and write the foreign-grantee baseline (db/authz-baseline.json) — this is what you want when adopting an existing schema. --abilities public-read stamps the common stanza (public read, admin write) uncommented — greenfield only, since on an existing database it declares public read of every table. Both are explicit generated code, never a runtime default. --govern-roles <r1,r2> (with --abilities live)
|
|
326
|
+
everystack db:generate [--stage <name> | --database-url <url>] [--name <label>] [--models db/models/index.ts] [--schema-out db/schema.generated.ts] [--allow-drops] [--apply] [--dry-run] Diff models vs the live DB → next migration file, or with --apply execute it directly (one transaction, schema_log recorded, verified by re-diff — no drizzle folder needed; direct connection only; DROPs held back unless --allow-drops). --dry-run prints the edge and writes NOTHING (no migration, no journal entry, no schema refresh) — the preview verb; db:diff computes a models-vs-models edge with no database at all. The resolved --schema-out is recorded in the migration journal: later flag-less runs reuse it (flag > recorded > default), a differing flag updates the record and says so. Every run but --dry-run also writes model-map.generated.json beside the schema (the credential-free association map; db:check guards it)
|
|
327
|
+
everystack db:pull [--stage <name> | --database-url <url>] [--schema public] [--out <dir | file.ts>] [--derived-out <file.ts | dir>] [--abilities live|public-read] Introspect the live DB → render field() Models — THE on-ramp for an existing database: commit what it renders, then db:plan --stage <s>. EMPTY plan = adopted as it is (that commit declares the database; every later db:apply descends from it). NON-EMPTY plan = the hardening edge: review it, db:apply --plan <f> --stage <s> — no commit declares the database yet, so the reviewed plan is the ORIGIN. Render from the stage, never from a local restore of a dump. --out <dir> writes one file per model + index.ts (the default shape); --out <file.ts> writes a single module; stdout otherwise. --derived-out <file.ts> extracts the derived layer (descriptors + sequences) as its own self-contained module — alone it leaves the models untouched (the hand-maintained-barrel splice); with --out the models render omits the now-external derived layer. --derived-out <dir> (anything not ending in .ts) splits that module by kind and schema instead — views.ts, functions-<schema>.ts, triggers.ts, index.ts barrel — for a real schema whose derived layer is otherwise one unreviewable file; refuses loudly, before writing anything, if the split files would import each other. Every model scaffolds its authz decision as comments (db:check fails until authored). **--abilities live is the brownfield mode**: derive each model's authz from the grants and policies the database ALREADY has, and write the foreign-grantee baseline (db/authz-baseline.json) — this is what you want when adopting an existing schema. --abilities public-read stamps the common stanza (public read, admin write) uncommented — greenfield only, since on an existing database it declares public read of every table. Both are explicit generated code, never a runtime default. --govern-roles <r1,r2> (with --abilities live) makes the models OWN the named FOREIGN roles (defineModule governedRoles): a grant and its exactly-paired policy render as a role-scoped can() (a column-scoped UPDATE as can('update', { role, columns })), any other grant as privileges with a WARNING naming table and role; a fresh build recreates them; required for migration deletion when the migrations created grants to an ops/connection role. Explicit only (a re-pull must not silently govern), complete-or-refuse (a restrictive policy, or a column-scoped grant with no exactly-paired policy, fails the pull before anything is written). --matviews-as-tables renders every matview as defineMaterializedTable with INTROSPECTED fields (the canonical-sync flip: a pipeline-owned table everystack migrates but never refreshes) — names land in an exported materializedTables array to spread into your models; fields come back nullable/unkeyed (matviews carry no PK/NOT NULL) — tighten on review; add --suggest-keys to probe the LIVE rows for functionally-unique columns (one scan per matview) and surface each as a commented .primaryKey() suggestion. docs/derived-objects.md#flipping-a-matview-to-a-materialized-table---matviews-as-tables
|
|
328
328
|
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.
|
|
329
329
|
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
|
|
330
330
|
everystack db:reconcile [--stage <name> | --database-url <url>] [--apply] [--check] [--baseline] [--rebuild] [--rebaseline] [--overwrite-drift] [--only a,b] [--json] ADVANCED — you do not run this for a deploy: db:apply runs it for you (after a plan, and alone when given no --plan), and db:check runs its checkout-side gates. It is fully supported, not deprecated — scripts and CI jobs calling it directly keep working unchanged. Reach for it yourself for a surgical fix (--only <schema.name> rebuilds one object without minting a plan), an adoption move (--baseline / --rebaseline), or a hand-edit overwrite (--overwrite-drift). Reconcile the derived layer (functions/views/matviews/triggers) against the DECLARED descriptors (defineView/defineMaterializedView/defineFunction/defineSql/trigger() on models, from the barrel) — the single home (db/sql is retired; leftover .sql files fail with the migration path): plan with rebuild-cost estimates by default; --check is the CI gate; --apply executes (atomic — DDL + provenance in one transaction) and records provenance + schema_log; --apply --stage runs credential-free in the ops Lambda (no admin URL on the deployer, the db:apply twin), --apply --database-url runs direct. 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. --only <schema.name,…> restricts the run to the named objects (surgical); with --rebuild it FORCES those to rebuild from source even when the hashes show no diff — the recovery exit when a mistaken --rebaseline left a self-consistent-but-wrong provenance row (the dependency cascade rebuilds their live dependents).
|
|
331
331
|
everystack db:refresh [--stage <name> | --database-url <url> | --direct] [--only a,b] [--verify-nonempty] [--list] Refresh the declared materialized views in dependency order, credential-free. --stage runs the whole refresh in the ops Lambda on the operator connection (no URL on the operator's machine — the data-lane twin of the reconcile lane); --database-url refreshes over a local connection (dev); --stage <name> --direct resolves the stage's operator connection from its ops Lambda (credential never on argv) and refreshes CLI-side on an UNBOUNDED clock, for a matview set that exceeds the ops-Lambda 900s limit; --only refreshes a named subset (full identity or bare name); --verify-nonempty gates on populated-but-zero-rows matviews (the dark-panel check — a scoped EXISTS on just what was refreshed, so a promotion script gets the gate with no read authority) and FAILS naming any empty; --list previews the order without connecting. Plain REFRESH (ACCESS EXCLUSIVE); fail-fast, idempotent to re-run.
|
|
332
332
|
everystack db:sync [--database-url <url>] [--models <barrel>] [--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 the declared descriptors, 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
|
|
333
333
|
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)
|
|
334
|
-
everystack db:plan [--stage <name> [--direct] | --database-url <url>] [--models <barrel>] [--allow-drops] [--out db.plan.json | --out -] [--verbose] 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; plans are ephemeral, never committed. VENUES: --stage runs via the ops Lambda, which reads TWICE and refuses on disagreement — agreement there is a DETECTOR, not a verification. --stage --direct resolves the stage's operator connection from its IAM-gated ops Lambda, holds it in memory only, and reads ONCE over one session: the lane for a fingerprint you intend to trust, with the credential never on argv. --database-url is the local-dev venue (same read guarantee, but against a deployed stage it puts a privileged DSN on the command line) DEFAULT OUTPUT is the plain change list: '+' something is now true, '-' something is no longer true, and a '⚠ DESTRUCTIVE' line of its own when real data can be lost. Nothing else. --verbose restores the full internal report (edge summary, kind histogram, adoption and ownership reports, plan ref). The edge covers tables/columns/grants ONLY
|
|
334
|
+
everystack db:plan [--stage <name> [--direct] | --database-url <url>] [--models <barrel>] [--allow-drops] [--out db.plan.json | --out -] [--verbose] 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; plans are ephemeral, never committed. VENUES: --stage runs via the ops Lambda, which reads TWICE and refuses on disagreement — agreement there is a DETECTOR, not a verification. --stage --direct resolves the stage's operator connection from its IAM-gated ops Lambda, holds it in memory only, and reads ONCE over one session: the lane for a fingerprint you intend to trust, with the credential never on argv. --database-url is the local-dev venue (same read guarantee, but against a deployed stage it puts a privileged DSN on the command line) DEFAULT OUTPUT is the plain change list: '+' something is now true, '-' something is no longer true, and a '⚠ DESTRUCTIVE' line of its own when real data can be lost. Nothing else. --verbose restores the full internal report (edge summary, kind histogram, adoption and ownership reports, plan ref). The edge covers tables/columns/grants ONLY. db:apply always reconciles the DERIVED layer (functions/views/matviews/triggers) regardless of --plan, so the default output also lists each of those objects it will change, under 'Also on db:apply (from the checkout, not in <file>)' — read-only, never minted into the plan file. A plan that removes a policy, revokes a grant or turns row security off prints a '⚠ ACCESS' line and never says 'Safe to apply' (it is not DESTRUCTIVE: no --confirm)
|
|
335
335
|
everystack db:apply [--plan <file.plan.json>] [--database-url <url>] [--stage <name>] [--models <barrel>] [--confirm] [--state-only] [--snapshot-ref <ref>] [--attest-lineage-lost <sha>] [--verbose] THE deploy verb — it does the WHOLE job: the reviewed plan (tables, columns, grants), then ALWAYS the functions, views, matviews and triggers, against the same target, reported as one receipt. With NO --plan it does the catch-up alone (this is what replaces running db:reconcile --apply by hand). --state-only skips the catch-up for the narrow case that wants the tables alone. A brownfield derived object the reconciler has never touched before (no provenance row) is SKIPPED, never guessed at, and that skip prints by default (not only under --verbose): 'N derived object(s) skipped ... db:reconcile --baseline' — a plain apply that only showed this under --verbose read as full success while such objects sat permanently unreconciled. If the plan lands and the catch-up then fails, the exit is non-zero and the message says the table/column changes are committed and safe and gives the rerun, which retries only the catch-up. 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 that last deployed the target (the fast-forward rule, read from schema_log and git ancestry — no historical tree is compiled: a deploying commit that is not an ancestor of HEAD refuses by name, escaped git-natively by fetch + merge/rebase + re-plan; --attest-lineage-lost <sha> waives it ONLY for a deploying commit proven gone from every ref. When no recorded apply matches the live state and nothing deployed is outside HEAD's history — a database everystack never wrote, or one hand-edited since its last apply — that is DRIFT: the reviewed plan is the ORIGIN, it applies with a loud warning, and the origin is recorded in schema_log so every later apply descends from it. A deploying commit this clone lacks refuses with a fetch runbook; an unreadable schema_log is UNVERIFIABLE and refuses), and for DESTRUCTIVE plans require --confirm always + a confirmed rollback point + the stage's approver set when declared (STS identity-verified). The STAGE lane (--stage without --direct) runs every catalog query in its own ops-Lambda invoke, so one read can be assembled from several containers: it reads the target TWICE and REFUSES when the two disagree (inconsistent containers), reports agreement as a NON-DETECTION (it cannot verify read consistency), and REFUSES a DESTRUCTIVE plan outright — destructive applies go over --database-url (direct) with the full ceremony. Every refusal that reaches the ops Lambda is recorded in schema_log. Apply as one transaction (plan_ref stamped), verify it landed exactly on plan.to; idempotent when already there DEFAULT OUTPUT is the receipt: the same change lines back, then 'Applied N changes.'. --verbose restores the endpoint and plan-ref line
|
|
336
336
|
everystack db:build --database-url <url> [--models <barrel>] Build a database FROM the models and KEEP it — the fresh-database bootstrap a deleted migration folder is replaced BY. Runs the same compose db:check proves buildability with (extensions -> schemas -> contract roles -> declared functions -> state -> derived layer), against a real target, then reports MATCH. Venue is EXPLICIT: --database-url only, never the ambient environment, because this writes a whole schema. REFUSES a database that already holds objects — evolving an existing one is db:sync (dev) or db:plan -> db:apply (a stage). Contract roles are created NOLOGIN if absent, and roles are cluster-level
|
|
337
|
-
everystack db:check [--models <barrel>] [--schema-out <file.ts>] [--database-url <url>] [--json] [--verbose] The CI gate, per PR: the merged declared state must COMPOSE (models load, no duplicate tables, descriptors compile), 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 DEFAULT OUTPUT is ONE line: 'Up to date.' or 'N changes pending. Run db:plan to review.'. --verbose restores every finding and the from-scratch build report
|
|
337
|
+
everystack db:check [--models <barrel>] [--schema-out <file.ts>] [--database-url <url>] [--json] [--verbose] The CI gate, per PR: the merged declared state must COMPOSE (models load, no duplicate tables, descriptors compile), 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 (the drizzle schema and the model map beside it) 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 DEFAULT OUTPUT is ONE line: 'Up to date.' or 'N changes pending. Run db:plan to review.'. --verbose restores every finding and the from-scratch build report
|
|
338
338
|
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
|
|
339
339
|
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
|
|
340
340
|
everystack db:exec <file.sql> [--stage <name> | --database-url <url>] [--schemas a,b] [--confirm] Credential-free write SQL — the psql-piped-file replacement (or pipe SQL on stdin). Runs the whole file as ONE transaction, DML-only enforced SEMANTICALLY: a catalog digest before vs after rejects ANY schema change (however caused — a function, a DO block, dynamic SQL) by rolling the txn back. Schema changes go through db:apply / db:reconcile. Every run is recorded crash-truthfully in everystack.exec_log (intent before, per-statement rows-affected after). --stage runs in the ops Lambda (operator holds no URL); prod-tier writes require --confirm; --database-url is local-direct (--schemas scopes the digest, default public)
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/** Structured lifecycle definitions, independent of manifest comments and SQL deparse layout. */
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import type { FunctionDescriptor } from '@everystack/model';
|
|
4
|
+
import { functionIdentity, normalizeArgType } from './pg-argtypes';
|
|
5
|
+
import { parseQualified } from './derived-source';
|
|
6
|
+
|
|
7
|
+
function canonJson(value: unknown): string {
|
|
8
|
+
if (Array.isArray(value)) return `[${value.map(canonJson).join(',')}]`;
|
|
9
|
+
if (value && typeof value === 'object') return `{${Object.keys(value).sort().map(k => `${JSON.stringify(k)}:${canonJson((value as Record<string, unknown>)[k])}`).join(',')}}`;
|
|
10
|
+
return JSON.stringify(value ?? null);
|
|
11
|
+
}
|
|
12
|
+
export function canonHash(value: unknown): string {
|
|
13
|
+
return createHash('sha256').update(canonJson(value)).digest('hex');
|
|
14
|
+
}
|
|
15
|
+
export interface FunctionCanon {
|
|
16
|
+
kind: 'function'; identity: string; args: string; returns: string; language: string;
|
|
17
|
+
secdef: boolean; volatility: string; strict: boolean; leakproof: boolean;
|
|
18
|
+
config: string[]; owner: string; execute: string[]; body: string;
|
|
19
|
+
}
|
|
20
|
+
export function canonOfFunction(fn: FunctionDescriptor): FunctionCanon {
|
|
21
|
+
const { schema, name } = parseQualified(fn.name);
|
|
22
|
+
const execute = new Set(fn.abilities.flatMap(a => a.condition.role ? [String(a.condition.role)] : []));
|
|
23
|
+
for (const [role, privileges] of Object.entries(fn.privileges ?? {})) if (privileges.includes('EXECUTE')) execute.add(role);
|
|
24
|
+
if (fn.owner) execute.delete(fn.owner);
|
|
25
|
+
return {
|
|
26
|
+
kind: 'function', identity: functionIdentity(schema, name, fn.args.map(a => a.type)),
|
|
27
|
+
args: fn.args.map(a => `${a.name} ${normalizeArgType(a.type)}${a.default == null ? '' : ` DEFAULT ${a.default}`}`).join(', '),
|
|
28
|
+
returns: typeof fn.returns === 'string' ? normalizeArgType(fn.returns) : JSON.stringify(fn.returns),
|
|
29
|
+
language: fn.language, secdef: fn.security === 'definer',
|
|
30
|
+
volatility: { immutable: 'i', stable: 's', volatile: 'v' }[fn.volatility],
|
|
31
|
+
strict: false, leakproof: false,
|
|
32
|
+
config: Array.isArray(fn.searchPath) ? [`search_path=${fn.searchPath.join(', ')}`] : [],
|
|
33
|
+
owner: fn.owner ?? '(ambient)', execute: [...execute].sort(),
|
|
34
|
+
// renderFunction adds exactly one newline on each side of the authored body.
|
|
35
|
+
body: `\n${fn.body}\n`,
|
|
36
|
+
};
|
|
37
|
+
}
|