@everystack/cli 0.4.93 → 0.4.96
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 +25 -0
- package/package.json +2 -2
- package/src/cli/apply-execute.ts +111 -19
- package/src/cli/authz-compile.ts +5 -3
- package/src/cli/authz-contract.ts +34 -0
- package/src/cli/authz-derive.ts +13 -3
- package/src/cli/commands/db-apply.ts +32 -18
- package/src/cli/commands/db-plan.ts +12 -18
- package/src/cli/commands/db-pull.ts +2 -2
- package/src/cli/commands/db-sync.ts +11 -1
- package/src/cli/derived-plan.ts +7 -1
- package/src/cli/exec-digest.ts +61 -16
- package/src/cli/exec-run.ts +81 -2
- package/src/cli/git-descent.ts +204 -628
- package/src/cli/index.ts +1 -1
- package/src/cli/plain-output.ts +12 -6
- package/src/cli/state-apply.ts +2 -2
- package/src/cli/state-function-deps.ts +95 -0
- package/src/exec.ts +1 -1
package/src/cli/index.ts
CHANGED
|
@@ -332,7 +332,7 @@ Usage:
|
|
|
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
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 — it also reads (never mints) whether the DERIVED layer (functions/views/matviews/triggers) is out of sync with the target and warns if so, since db:apply always reconciles that layer regardless of --plan: an empty edge here does not mean the target fully matches the checkout
|
|
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
|
|
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
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
|
|
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
|
package/src/cli/plain-output.ts
CHANGED
|
@@ -85,6 +85,12 @@ function oneLine(statement: string): string {
|
|
|
85
85
|
|
|
86
86
|
const unquote = (name: string): string => name.replace(/"/g, '').trim();
|
|
87
87
|
|
|
88
|
+
/** A GRANT's `TO …` / a REVOKE's `FROM …` list, every grantee: `admin, anon` — never `admin,`. */
|
|
89
|
+
const GRANTEES = String.raw`((?:"[^"]+"|[\w$]+)(?:\s*,\s*(?:"[^"]+"|[\w$]+))*)`;
|
|
90
|
+
const grantees = (list: string): string => list.split(',').map(unquote).join(', ');
|
|
91
|
+
const GRANT_RE = new RegExp(String.raw`^GRANT\s+([\s\S]+?)\s+ON\s+([\s\S]+?)\s+TO\s+` + GRANTEES, 'i');
|
|
92
|
+
const REVOKE_RE = new RegExp(String.raw`^REVOKE\s+([\s\S]+?)\s+ON\s+([\s\S]+?)\s+FROM\s+` + GRANTEES, 'i');
|
|
93
|
+
|
|
88
94
|
/** `public.events` → `events`; `stats.runs` → `stats.runs`. The default schema is noise. */
|
|
89
95
|
function rel(name: string): string {
|
|
90
96
|
const clean = unquote(name).replace(/;$/, '');
|
|
@@ -193,11 +199,11 @@ export function describeChange(statement: string, opts: DescribeOptions = {}): P
|
|
|
193
199
|
if ((m = /^DROP\s+POLICY\s+(?:IF\s+EXISTS\s+)?("[^"]+"|[\w$]+)\s+ON\s+([^\s;]+)/i.exec(s))) {
|
|
194
200
|
return { symbol: '-', text: `remove policy ${unquote(m[1])} on ${rel(m[2])}` };
|
|
195
201
|
}
|
|
196
|
-
if ((m =
|
|
197
|
-
return { symbol: '+', text: `grant ${
|
|
202
|
+
if ((m = GRANT_RE.exec(s))) {
|
|
203
|
+
return { symbol: '+', text: `grant ${grantees(m[3])}: ${privileges(m[1])} ${grantObject(m[2])}` };
|
|
198
204
|
}
|
|
199
|
-
if ((m =
|
|
200
|
-
return { symbol: '-', text: `revoke ${
|
|
205
|
+
if ((m = REVOKE_RE.exec(s))) {
|
|
206
|
+
return { symbol: '-', text: `revoke ${grantees(m[3])}: ${privileges(m[1])} ${grantObject(m[2])}` };
|
|
201
207
|
}
|
|
202
208
|
// A "rebuild" — the same function coming back with a new body — is ONE update line. The
|
|
203
209
|
// drop half is never shown: the object does not go away, its definition changes.
|
|
@@ -281,8 +287,8 @@ export function describeDestructive(statement: string): string {
|
|
|
281
287
|
if ((m = /^DROP\s+TYPE\s+(?:IF\s+EXISTS\s+)?([^\s;]+)/i.exec(s))) {
|
|
282
288
|
return `drops type ${rel(m[1])}`;
|
|
283
289
|
}
|
|
284
|
-
if ((m =
|
|
285
|
-
return `revokes ${privileges(m[1])} on ${grantObject(m[2])} from ${
|
|
290
|
+
if ((m = REVOKE_RE.exec(s))) {
|
|
291
|
+
return `revokes ${privileges(m[1])} on ${grantObject(m[2])} from ${grantees(m[3])} — nothing declares it, so it cannot be put back`;
|
|
286
292
|
}
|
|
287
293
|
return oneLine(statement);
|
|
288
294
|
}
|
package/src/cli/state-apply.ts
CHANGED
|
@@ -370,8 +370,8 @@ export interface StateApplyOptions {
|
|
|
370
370
|
now?: () => number;
|
|
371
371
|
/**
|
|
372
372
|
* What descent decided, folded into the schema_log outcome as `applied — <note>`: a honoured
|
|
373
|
-
* `--attest-lineage-lost` waiver, or a
|
|
374
|
-
*
|
|
373
|
+
* `--attest-lineage-lost` waiver, or a DRIFT origin (`descent: drift-origin
|
|
374
|
+
* never-declared (…) | hand-edited since … | applied from a DIRTY worktree at …`) — the record that makes this row the origin every
|
|
375
375
|
* later apply descends from. Read at WRITE time (a getter, because the decision is made in the
|
|
376
376
|
* descent step that runs before this). The witness query matches `applied%`, so an annotated
|
|
377
377
|
* row is still a witness.
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* state-function-deps — the declared functions a pending STATE batch calls, created first.
|
|
3
|
+
*
|
|
4
|
+
* Sync and apply run STATE (tables, RLS, policies, grants) before the DERIVED layer
|
|
5
|
+
* (functions, views, matviews, triggers). That order is right for views and triggers, which
|
|
6
|
+
* read tables state creates. It is wrong for one edge: an RLS policy's predicate is resolved
|
|
7
|
+
* when the policy is CREATED, so `can('read', { where: sql\`has_permission()\` })` over a
|
|
8
|
+
* declared `has_permission()` fails on a fresh database with `function has_permission() does
|
|
9
|
+
* not exist` — the function only arrives in the second half.
|
|
10
|
+
*
|
|
11
|
+
* So the edge is followed, not the layers swapped: a declared function whose name appears in
|
|
12
|
+
* a pending state statement, and which the target does not have yet, is created BEFORE the
|
|
13
|
+
* state batch. Everything else keeps the ordinary order.
|
|
14
|
+
*
|
|
15
|
+
* The reference test is TEXT, on purpose. Predicates are already compiled and diffed as text,
|
|
16
|
+
* and a false positive is harmless: creating a missing function early is exactly what the
|
|
17
|
+
* ordinary pass would do a moment later.
|
|
18
|
+
*
|
|
19
|
+
* A function whose SIGNATURE names a declared relation (`RETURNS SETOF some_view`) cannot go
|
|
20
|
+
* first — PostgreSQL resolves argument and return types at creation, and the relation may be
|
|
21
|
+
* created by state or by a view later in the derived pass. It stays behind, as in db:build.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import type { QueryRunner } from './authz-contract';
|
|
25
|
+
import type { SessionRunner } from './session';
|
|
26
|
+
import type { SourceObject } from './derived-source';
|
|
27
|
+
import { executeReconcile } from './commands/db-reconcile';
|
|
28
|
+
|
|
29
|
+
const escapeRe = (s: string): string => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
30
|
+
|
|
31
|
+
/** A call to `schema.name(` or `name(`, quoted or not, not preceded by another qualifier. */
|
|
32
|
+
function callPattern(schema: string, name: string): RegExp {
|
|
33
|
+
const q = (id: string) => `"?${escapeRe(id)}"?`;
|
|
34
|
+
return new RegExp(`(?:^|[^\\w$."])(?:${q(schema)}\\s*\\.\\s*)?${q(name)}\\s*\\(`, 'i');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The declared functions a state batch names by call — the pure half, no database.
|
|
39
|
+
* Only functions that CAN go first: no signature dependency on a declared relation.
|
|
40
|
+
*/
|
|
41
|
+
export function stateReferencedFunctions(statements: readonly string[], declared: readonly SourceObject[]): SourceObject[] {
|
|
42
|
+
if (statements.length === 0) return [];
|
|
43
|
+
const text = statements.join('\n');
|
|
44
|
+
return declared.filter((o) =>
|
|
45
|
+
o.kind === 'function'
|
|
46
|
+
&& !o.signatureDeps?.length
|
|
47
|
+
&& callPattern(o.schema, o.name).test(text));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const literal = (s: string): string => `'${s.replace(/'/g, "''")}'`;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Create, before the state batch, the declared functions it calls that the target lacks.
|
|
54
|
+
* Returns the identities created (empty when nothing needed to move).
|
|
55
|
+
*
|
|
56
|
+
* The reconcile runs with the FULL declared set and `only` scoped to these identities, never a
|
|
57
|
+
* subset: a subset would read every other managed object as "removed from source" and drop it.
|
|
58
|
+
* A function that already exists is left to the ordinary after-state pass, so an existing
|
|
59
|
+
* database's replace/drift handling is unchanged by this.
|
|
60
|
+
*
|
|
61
|
+
* A SQL-language body that reads a table this same state batch creates is a forward reference,
|
|
62
|
+
* not an error: the reconcile runs its batch under `SET LOCAL check_function_bodies = off`.
|
|
63
|
+
*/
|
|
64
|
+
export async function createStateReferencedFunctions(
|
|
65
|
+
runner: QueryRunner,
|
|
66
|
+
session: SessionRunner,
|
|
67
|
+
statements: readonly string[],
|
|
68
|
+
declared: readonly SourceObject[],
|
|
69
|
+
options: { actor?: string | null; gitRef?: string | null; now?: () => number } = {},
|
|
70
|
+
): Promise<string[]> {
|
|
71
|
+
const referenced = stateReferencedFunctions(statements, declared);
|
|
72
|
+
if (referenced.length === 0) return [];
|
|
73
|
+
|
|
74
|
+
const live = await runner(
|
|
75
|
+
`SELECT n.nspname AS schema, p.proname AS name FROM pg_proc p
|
|
76
|
+
JOIN pg_namespace n ON n.oid = p.pronamespace
|
|
77
|
+
WHERE p.proname IN (${[...new Set(referenced.map((o) => o.name))].map(literal).join(', ')})`,
|
|
78
|
+
);
|
|
79
|
+
const present = new Set(live.map((r: any) => `${r.schema}.${r.name}`));
|
|
80
|
+
const missing = referenced.filter((o) => !present.has(`${o.schema}.${o.name}`));
|
|
81
|
+
if (missing.length === 0) return [];
|
|
82
|
+
|
|
83
|
+
const run = await executeReconcile(runner, session, {
|
|
84
|
+
declared: [...declared],
|
|
85
|
+
only: missing.map((o) => o.identity),
|
|
86
|
+
apply: true,
|
|
87
|
+
actor: options.actor ?? null,
|
|
88
|
+
gitRef: options.gitRef ?? null,
|
|
89
|
+
now: options.now,
|
|
90
|
+
});
|
|
91
|
+
if (run.refusal) {
|
|
92
|
+
throw new Error(`could not create the functions the state batch calls (${missing.map((o) => o.identity).join(', ')}) before it: ${run.refusal}`);
|
|
93
|
+
}
|
|
94
|
+
return missing.map((o) => o.identity);
|
|
95
|
+
}
|
package/src/exec.ts
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
export { executeExec, assertNoTxnControl, execSha } from './cli/exec-execute';
|
|
12
12
|
export type { ExecIntent, ExecOutcome, ExecuteExecOptions, ExecResult } from './cli/exec-execute';
|
|
13
13
|
|
|
14
|
-
export { runExecInTx, SchemaChangedError, railStatements, EXEC_RAIL_DEFAULTS } from './cli/exec-run';
|
|
14
|
+
export { runExecInTx, SchemaChangedError, LedgerWriteError, railStatements, EXEC_RAIL_DEFAULTS } from './cli/exec-run';
|
|
15
15
|
export type { TxRunner, ExecStmtResult, ExecRails, RunExecInTxOptions } from './cli/exec-run';
|
|
16
16
|
|
|
17
17
|
export { catalogDigestQuery } from './cli/exec-digest';
|