@everystack/cli 0.3.21 → 0.3.23

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.
@@ -0,0 +1,251 @@
1
+ /**
2
+ * git-descent — a protected database is a git ref (brick 7 of the
3
+ * migration-authority design).
4
+ *
5
+ * The rule git already taught everyone: `db:apply` is a fast-forward push.
6
+ * The target's live fingerprint maps to the git commit whose committed
7
+ * models DECLARE that state; the deploying checkout must be a descendant
8
+ * of such a commit, or the apply refuses — "rebase first". This is what
9
+ * stops the multi-dev disaster case: A merges and deploys, B (branched
10
+ * before A merged) mints against the target — the concurrency lock passes,
11
+ * because B's plan was minted against current reality, but B's edge would
12
+ * REVERT A's merged work. The descent check catches exactly that: the
13
+ * commit declaring the target's state is not in B's history.
14
+ *
15
+ * The search runs over GIT ALONE, never `schema_log` (the ledger is a
16
+ * memoir, not a control surface — design decision 7): enumerate every
17
+ * historical state of the models directory across all refs, compile each
18
+ * unique tree, and ask whether its predicted live endpoint equals the
19
+ * target's live fingerprint (brick 4's merged prediction, so unmodeled
20
+ * brownfield tables ride through the test). A fingerprint no commit
21
+ * declares is a DRIFT report and an explicit operator decision — never a
22
+ * guess. Identity is the models-directory TREE, not the commit: a
23
+ * cherry-picked identical state is the same declared state.
24
+ */
25
+
26
+ import { execFileSync } from 'node:child_process';
27
+ import fs from 'node:fs';
28
+ import os from 'node:os';
29
+ import path from 'node:path';
30
+ import { pathToFileURL } from 'node:url';
31
+ import type { ModelDescriptor } from '@everystack/model';
32
+ import type { SchemaSnapshot } from './schema-introspect.js';
33
+ import type { AuthzContract } from './authz-contract.js';
34
+ import { fingerprintLive } from './schema-fingerprint.js';
35
+ import { predictLiveFingerprint } from './edge-plan.js';
36
+
37
+ /** Compile a models barrel on disk. The default rides the CLI's runtime (tsx). */
38
+ export type ModelsLoader = (barrel: string) => Promise<ModelDescriptor[]>;
39
+
40
+ export interface LiveState {
41
+ snapshot: SchemaSnapshot;
42
+ contract: AuthzContract;
43
+ }
44
+
45
+ export interface DescentOptions {
46
+ /** Where git commands run. Default: process.cwd(). */
47
+ cwd?: string;
48
+ /** The Postgres schema the Models default to. Default: `public`. */
49
+ schema?: string;
50
+ loader?: ModelsLoader;
51
+ /**
52
+ * Where historical trees materialize. Default: a fresh temp dir under the
53
+ * repo's node_modules — bare imports (`@everystack/model`) must resolve
54
+ * from the materialized files, and node walks parent directories.
55
+ */
56
+ materializeRoot?: string;
57
+ /** The ref that must descend from the declaring commit. Default: HEAD. */
58
+ headRef?: string;
59
+ }
60
+
61
+ export type DescentVerdict =
62
+ /** Not a git checkout (or no commits yet) — the rule cannot be verified here. */
63
+ | { status: 'no-git' }
64
+ /** The target has no tables — nothing to protect, first apply bootstraps it. */
65
+ | { status: 'fresh-target' }
66
+ /** Fast-forward: `commit` declares the target's live state and is an ancestor of HEAD. */
67
+ | { status: 'ok'; commit: string; tree: string; scannedTrees: number }
68
+ /** The declaring commits exist but none is an ancestor of HEAD — rebase first. */
69
+ | { status: 'diverged'; commits: string[]; tree: string; reason: string }
70
+ /** No committed models state declares the live fingerprint — operator decision. */
71
+ | { status: 'drift'; scannedTrees: number; compileFailures: string[]; reason: string };
72
+
73
+ export interface ModelTreeCandidate {
74
+ /** The models directory's tree oid — the identity of a declared state. */
75
+ tree: string;
76
+ /** Every enumerated commit carrying that tree, newest first. */
77
+ commits: string[];
78
+ }
79
+
80
+ const short = (ref: string): string => ref.slice(0, 12);
81
+
82
+ function git(args: string[], cwd: string): string | null {
83
+ try {
84
+ return execFileSync('git', args, { cwd, stdio: ['ignore', 'pipe', 'ignore'] }).toString();
85
+ } catch {
86
+ return null;
87
+ }
88
+ }
89
+
90
+ const defaultLoader: ModelsLoader = async (barrel) => {
91
+ const mod: any = await import(pathToFileURL(path.resolve(barrel)).href);
92
+ const models = mod.models ?? mod.default;
93
+ if (!Array.isArray(models)) throw new Error(`${barrel} does not export a \`models\` array`);
94
+ return models;
95
+ };
96
+
97
+ /**
98
+ * Every historical state of the models directory across ALL refs, newest
99
+ * first, grouped by tree oid. `--full-history` keeps every !TREESAME commit
100
+ * (no simplification dropping a state introduced on a side branch); one
101
+ * `cat-file --batch-check` resolves all `<sha>:<dir>` specs in a single
102
+ * child process, in input order.
103
+ */
104
+ export function enumerateModelTrees(modelsDir: string, cwd: string): ModelTreeCandidate[] {
105
+ const out = git(['rev-list', '--all', '--full-history', '--date-order', '--', modelsDir], cwd);
106
+ if (out === null) return [];
107
+ const shas = out.trim().split('\n').filter(Boolean);
108
+ if (shas.length === 0) return [];
109
+
110
+ const specs = shas.map((sha) => (modelsDir === '.' ? `${sha}^{tree}` : `${sha}:${modelsDir}`));
111
+ let batch: string;
112
+ try {
113
+ batch = execFileSync('git', ['cat-file', '--batch-check'], {
114
+ cwd,
115
+ input: specs.join('\n') + '\n',
116
+ stdio: ['pipe', 'pipe', 'ignore'],
117
+ }).toString();
118
+ } catch {
119
+ return [];
120
+ }
121
+
122
+ const byTree = new Map<string, string[]>();
123
+ const order: string[] = [];
124
+ batch.trim().split('\n').forEach((line, i) => {
125
+ const m = line.trim().match(/^([0-9a-f]{40,64}) tree /);
126
+ if (!m) return; // the directory is absent at that commit, or not a tree
127
+ if (!byTree.has(m[1])) {
128
+ byTree.set(m[1], []);
129
+ order.push(m[1]);
130
+ }
131
+ byTree.get(m[1])!.push(shas[i]);
132
+ });
133
+ return order.map((tree) => ({ tree, commits: byTree.get(tree)! }));
134
+ }
135
+
136
+ /** Extract a tree into `dest` — `git archive` piped to tar, no shell. */
137
+ export function materializeTree(tree: string, dest: string, cwd: string): void {
138
+ fs.mkdirSync(dest, { recursive: true });
139
+ const archive = execFileSync('git', ['archive', '--format=tar', tree], {
140
+ cwd,
141
+ maxBuffer: 512 * 1024 * 1024,
142
+ });
143
+ execFileSync('tar', ['-xf', '-', '-C', dest], { input: archive });
144
+ }
145
+
146
+ /**
147
+ * The fast-forward rule. Given the target's live state (already
148
+ * introspected — the apply path has it in hand), find the commit whose
149
+ * committed models declare that state and verify the checkout descends
150
+ * from it. Any declaring ancestor satisfies the rule; `diverged` is
151
+ * reported only when NO tree declaring the state has one.
152
+ */
153
+ export async function verifyDescent(
154
+ modelsPath: string,
155
+ live: LiveState,
156
+ opts: DescentOptions = {},
157
+ ): Promise<DescentVerdict> {
158
+ if (live.snapshot.tables.length === 0) return { status: 'fresh-target' };
159
+
160
+ const cwd = opts.cwd ?? process.cwd();
161
+ const rootOut = git(['rev-parse', '--show-toplevel'], cwd);
162
+ if (rootOut === null) return { status: 'no-git' };
163
+ const repoRoot = rootOut.trim();
164
+ const headOut = git(['rev-parse', opts.headRef ?? 'HEAD'], cwd);
165
+ if (headOut === null) return { status: 'no-git' };
166
+ const head = headOut.trim();
167
+
168
+ // realpath the cwd side: `--show-toplevel` resolves symlinks (macOS tmpdir
169
+ // is /var → /private/var), and a mismatched prefix would walk the models
170
+ // path right out of the repo.
171
+ const modelsAbs = path.resolve(fs.realpathSync(cwd), modelsPath);
172
+ const rel = path.relative(repoRoot, modelsAbs);
173
+ const modelsDir = path.dirname(rel) || '.';
174
+ const barrel = path.basename(modelsAbs);
175
+
176
+ const candidates = enumerateModelTrees(modelsDir, repoRoot);
177
+ const liveFingerprint = fingerprintLive(live.snapshot, live.contract).hash;
178
+
179
+ const nodeModules = path.join(repoRoot, 'node_modules');
180
+ const materializeRoot = opts.materializeRoot
181
+ ?? fs.mkdtempSync(path.join(fs.existsSync(nodeModules) ? nodeModules : os.tmpdir(), '.everystack-descent-'));
182
+ const ownsRoot = !opts.materializeRoot;
183
+ const loader = opts.loader ?? defaultLoader;
184
+
185
+ const compileFailures: string[] = [];
186
+ const declaringButDiverged: ModelTreeCandidate[] = [];
187
+ try {
188
+ for (const candidate of candidates) {
189
+ const dest = path.join(materializeRoot, candidate.tree);
190
+ let predicted: string;
191
+ try {
192
+ if (!fs.existsSync(dest)) materializeTree(candidate.tree, dest, repoRoot);
193
+ const models = await loader(path.join(dest, barrel));
194
+ predicted = predictLiveFingerprint(models, live.snapshot, live.contract, { schema: opts.schema });
195
+ } catch (err: any) {
196
+ compileFailures.push(`${short(candidate.tree)} (at ${short(candidate.commits[0])}): ${err.message}`);
197
+ continue;
198
+ }
199
+ if (predicted !== liveFingerprint) continue;
200
+
201
+ for (const commit of candidate.commits) {
202
+ try {
203
+ execFileSync('git', ['merge-base', '--is-ancestor', commit, head], {
204
+ cwd: repoRoot,
205
+ stdio: 'ignore',
206
+ });
207
+ return {
208
+ status: 'ok',
209
+ commit,
210
+ tree: candidate.tree,
211
+ scannedTrees: candidates.indexOf(candidate) + 1,
212
+ };
213
+ } catch {
214
+ // not an ancestor — keep looking; an identical tree elsewhere may be
215
+ }
216
+ }
217
+ declaringButDiverged.push(candidate);
218
+ }
219
+ } finally {
220
+ if (ownsRoot) fs.rmSync(materializeRoot, { recursive: true, force: true });
221
+ }
222
+
223
+ if (declaringButDiverged.length > 0) {
224
+ const commits = declaringButDiverged.flatMap((c) => c.commits);
225
+ return {
226
+ status: 'diverged',
227
+ commits,
228
+ tree: declaringButDiverged[0].tree,
229
+ reason:
230
+ `the target's state is declared by ${short(commits[0])}` +
231
+ (commits.length > 1 ? ` (+${commits.length - 1} more)` : '') +
232
+ ', which is NOT an ancestor of your checkout — your branch diverged before it. ' +
233
+ 'Rebase onto it and re-mint, so your edge moves the target forward instead of reverting merged work.',
234
+ };
235
+ }
236
+
237
+ const failureNote = compileFailures.length > 0
238
+ ? ` (${compileFailures.length} historical tree(s) no longer compile and were skipped)`
239
+ : '';
240
+ return {
241
+ status: 'drift',
242
+ scannedTrees: candidates.length,
243
+ compileFailures,
244
+ reason:
245
+ `no committed models state declares the target's live fingerprint ${short(liveFingerprint)} — ` +
246
+ `scanned ${candidates.length} historical tree(s) across all refs${failureNote}. ` +
247
+ 'Either the database was hand-edited (drift) or the declaring history was rewritten. ' +
248
+ 'Align the models with the live database first (db:pull, commit), ' +
249
+ 'or force with ceremony: --force-descent <snapshot-ref> --confirm.',
250
+ };
251
+ }
package/src/cli/index.ts CHANGED
@@ -17,6 +17,8 @@ import { dbSyncCommand } from './commands/db-sync.js';
17
17
  import { dbDiffCommand } from './commands/db-diff.js';
18
18
  import { dbPlanCommand } from './commands/db-plan.js';
19
19
  import { dbApplyCommand } from './commands/db-apply.js';
20
+ import { dbCheckCommand } from './commands/db-check.js';
21
+ import { dbApproversCommand } from './commands/db-approvers.js';
20
22
  import { dbSnapshotCommand, dbSnapshotsCommand } from './commands/db-snapshot.js';
21
23
  import { dbBackupProbeCommand, dbBackupCommand, dbBackupsCommand, dbRestoreCommand, dbBackupDownloadCommand } from './commands/db-backup.js';
22
24
  import { consoleCommand } from './commands/console.js';
@@ -196,6 +198,12 @@ async function main() {
196
198
  case 'db:apply':
197
199
  await dbApplyCommand(flags);
198
200
  break;
201
+ case 'db:check':
202
+ await dbCheckCommand(flags);
203
+ break;
204
+ case 'db:approvers':
205
+ await dbApproversCommand(flags);
206
+ break;
199
207
  case 'db:authz:pull':
200
208
  await dbAuthzPullCommand(flags);
201
209
  break;
@@ -323,7 +331,9 @@ Usage:
323
331
  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
324
332
  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)
325
333
  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
326
- everystack db:apply --plan <file.plan.json> [--database-url <url>] Run a reviewed plan: verify the target is EXACTLY where the plan started (live fingerprint == plan.from, else refuse — the concurrency lock), apply as one transaction (schema_log w/ plan_ref), verify it landed exactly on plan.to. Idempotent when already at the target state; direct connection required
334
+ 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
335
+ 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
336
+ 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
327
337
  everystack db:authz:pull [--stage <name>] [--dir authz] Introspect live authz (rls/grants/policies/secdef) → reviewable contract files
328
338
  everystack db:authz:diff [--stage <name>] [--dir authz] Validate the live DB against the committed contract (non-zero exit on drift)
329
339
  everystack db:authz:test [--stage <name>] [--dir authz] Red-team enforcement: SET ROLE + attempt per role/table/command (non-zero exit on a hole)
@@ -304,6 +304,11 @@ export function compileDrizzleSource(allModels: ModelDescriptor[], _opts: Compil
304
304
  const { call, builder: pgBuilder } = baseColumnSource(toSnakeCase(key), spec);
305
305
  pgCoreBuilders.add(pgBuilder);
306
306
  const mods = columnModifiers(spec, isComposite, modelsByDescriptor);
307
+ if (spec.isDeprecated) {
308
+ // The contract phase (decision 13): the column stays in the database and
309
+ // stays readable; the strikethrough warns new code away at author time.
310
+ colLines.push(` /** @deprecated contract phase — readable, not writable; the physical drop is a later, gated act. */`);
311
+ }
307
312
  colLines.push(` ${key}: ${call}${mods},`);
308
313
  }
309
314
 
@@ -56,6 +56,36 @@ export function classifyGeneratedStatements(statements: string[]): ClassifiedSta
56
56
  return { executable, heldDrops, notices };
57
57
  }
58
58
 
59
+ /**
60
+ * The destructive subset of an executable statement stream — destructive
61
+ * means DATA LOSS (brick 9's classification, design decision 11):
62
+ *
63
+ * drops — `DROP TABLE/COLUMN/TYPE`: the data is gone.
64
+ * narrowings — lossy/risky `ALTER COLUMN … SET DATA TYPE`: data loss
65
+ * wearing an ALTER. Detection rides the emitter's structural
66
+ * contract, not prose: a SAFE type change is a bare
67
+ * `SET DATA TYPE`; anything lossy or risky carries a `USING`
68
+ * cast (schema-diff's `classifyTypeChange`, conservative by
69
+ * default — the contract is pinned by test).
70
+ *
71
+ * Recoverable metadata is never destructive: policies, grants, constraints,
72
+ * defaults, nullability all re-declare from the models without touching a row.
73
+ */
74
+ export interface DestructiveBreakdown {
75
+ drops: string[];
76
+ narrowings: string[];
77
+ }
78
+
79
+ export function classifyDestructive(executable: string[]): DestructiveBreakdown {
80
+ const drops: string[] = [];
81
+ const narrowings: string[] = [];
82
+ for (const statement of executable) {
83
+ if (/\bDROP\s+(TABLE|COLUMN|TYPE)\b/.test(statement)) drops.push(statement);
84
+ else if (/\bSET DATA TYPE\b/.test(statement) && /\bUSING\b/.test(statement)) narrowings.push(statement);
85
+ }
86
+ return { drops, narrowings };
87
+ }
88
+
59
89
  /** The current git HEAD, for schema_log's intent pointer. Null outside a repo. */
60
90
  export function currentGitRef(): string | null {
61
91
  try {