@everystack/cli 0.3.22 → 0.3.25

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.
@@ -29,12 +29,22 @@ import type { ModelDescriptor } from '@everystack/model';
29
29
  import type { SchemaSnapshot } from './schema-introspect.js';
30
30
  import type { AuthzContract } from './authz-contract.js';
31
31
  import { generateMigrationSql, unmodeledTables } from './migration-generate.js';
32
- import { classifyGeneratedStatements } from './state-apply.js';
32
+ import { classifyGeneratedStatements, classifyDestructive } from './state-apply.js';
33
33
  import { fingerprintLive, fingerprintState, fingerprintModels, stableStringify } from './schema-fingerprint.js';
34
34
  import { compileDeclaredState } from './declared-diff.js';
35
35
  import { compileTableRenames } from './schema-compile.js';
36
36
 
37
- export const PLAN_VERSION = 1;
37
+ export const PLAN_VERSION = 2;
38
+
39
+ /** Classification counts (brick 9, decision 11): destructive = drops + narrowings. */
40
+ export interface PlanClassification {
41
+ /** Executable statements that lose no data. */
42
+ additive: number;
43
+ /** `DROP TABLE/COLUMN/TYPE` — the data is gone. */
44
+ drops: number;
45
+ /** Lossy/risky `ALTER COLUMN … TYPE` — data loss wearing an ALTER. */
46
+ narrowings: number;
47
+ }
38
48
 
39
49
  export interface EdgePlan {
40
50
  v: number;
@@ -47,8 +57,9 @@ export interface EdgePlan {
47
57
  /** The edge, in db:generate's statement grammar (no held drops — mint refuses them). */
48
58
  statements: string[];
49
59
  executable: number;
50
- /** Executable statements that DROP somethingshown red, explicit by construction. */
60
+ /** Executable statements that LOSE DATA (drops + narrowing type changes) — red, gated at apply. */
51
61
  destructive: number;
62
+ classification: PlanClassification;
52
63
  notices: number;
53
64
  /** Live tables no model declares — untouched by the edge, riding into `to` as-is. */
54
65
  unmodeled: string[];
@@ -135,6 +146,13 @@ export function mintEdgePlan(
135
146
  );
136
147
  }
137
148
 
149
+ // Data-destructive only: dropping a policy/grant is recoverable metadata
150
+ // (the authz phase is data-safe by construction); dropping a table,
151
+ // column, or type is not — and neither is a narrowing type change, which
152
+ // is data loss wearing an ALTER.
153
+ const breakdown = classifyDestructive(classified.executable);
154
+ const destructive = breakdown.drops.length + breakdown.narrowings.length;
155
+
138
156
  return {
139
157
  v: PLAN_VERSION,
140
158
  fromFingerprint: fingerprintLive(snapshot, contract).hash,
@@ -142,10 +160,12 @@ export function mintEdgePlan(
142
160
  declaredFingerprint: fingerprintModels(models, { schema: opts.schema }).hash,
143
161
  statements,
144
162
  executable: classified.executable.length,
145
- // Data-destructive only: dropping a policy/grant is recoverable metadata
146
- // (the authz phase is data-safe by construction); dropping a table,
147
- // column, or type is not.
148
- destructive: classified.executable.filter((s) => /\bDROP\s+(TABLE|COLUMN|TYPE)\b/.test(s)).length,
163
+ destructive,
164
+ classification: {
165
+ additive: classified.executable.length - destructive,
166
+ drops: breakdown.drops.length,
167
+ narrowings: breakdown.narrowings.length,
168
+ },
149
169
  notices: classified.notices.length,
150
170
  unmodeled: unmodeledTables(models, snapshot),
151
171
  gitRef: opts.gitRef ?? null,
@@ -183,7 +203,14 @@ export function buildPlanSummary(plan: EdgePlan): string[] {
183
203
  }
184
204
  lines.push(`${plan.executable} executable statement(s), ${plan.notices} notice(s)`);
185
205
  if (plan.destructive > 0) {
186
- lines.push(`! ${plan.destructive} DESTRUCTIVE statement(s) — drops carried explicitly, review them`);
206
+ const { drops, narrowings } = classifyDestructive(classifyGeneratedStatements(plan.statements).executable);
207
+ lines.push(
208
+ `! ${plan.destructive} DESTRUCTIVE statement(s) — ${drops.length} drop(s), ${narrowings.length} narrowing type change(s). Data loss carried explicitly; the apply is gated (--confirm + snapshot, approver set when declared):`,
209
+ );
210
+ for (const statement of [...drops, ...narrowings]) {
211
+ const ddl = statement.split('\n').find((l) => l.trim() !== '' && !l.trim().startsWith('--')) ?? statement;
212
+ lines.push(`! ${ddl.trim()}`);
213
+ }
187
214
  }
188
215
  if (plan.unmodeled.length > 0) {
189
216
  lines.push(`· ${plan.unmodeled.length} unmodeled table(s) ride through untouched: ${plan.unmodeled.join(', ')}`);
package/src/cli/index.ts CHANGED
@@ -18,6 +18,9 @@ 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
20
  import { dbCheckCommand } from './commands/db-check.js';
21
+ import { dbApproversCommand } from './commands/db-approvers.js';
22
+ import { dbBackfillCommand } from './commands/db-backfill.js';
23
+ import { dbTemplateRefreshCommand, dbBranchCommand } from './commands/db-branch.js';
21
24
  import { dbSnapshotCommand, dbSnapshotsCommand } from './commands/db-snapshot.js';
22
25
  import { dbBackupProbeCommand, dbBackupCommand, dbBackupsCommand, dbRestoreCommand, dbBackupDownloadCommand } from './commands/db-backup.js';
23
26
  import { consoleCommand } from './commands/console.js';
@@ -200,6 +203,18 @@ async function main() {
200
203
  case 'db:check':
201
204
  await dbCheckCommand(flags);
202
205
  break;
206
+ case 'db:approvers':
207
+ await dbApproversCommand(flags);
208
+ break;
209
+ case 'db:backfill':
210
+ await dbBackfillCommand(flags);
211
+ break;
212
+ case 'db:template:refresh':
213
+ await dbTemplateRefreshCommand(flags);
214
+ break;
215
+ case 'db:branch':
216
+ await dbBranchCommand(flags);
217
+ break;
203
218
  case 'db:authz:pull':
204
219
  await dbAuthzPullCommand(flags);
205
220
  break;
@@ -327,8 +342,12 @@ Usage:
327
342
  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
328
343
  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)
329
344
  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
330
- everystack db:apply --plan <file.plan.json> [--database-url <url>] [--models <barrel>] [--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"; force needs the snapshot you took + --confirm), 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
345
+ 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
331
346
  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
347
+ 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
348
+ 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
349
+ 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
350
+ 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)
332
351
  everystack db:authz:pull [--stage <name>] [--dir authz] Introspect live authz (rls/grants/policies/secdef) → reviewable contract files
333
352
  everystack db:authz:diff [--stage <name>] [--dir authz] Validate the live DB against the committed contract (non-zero exit on drift)
334
353
  everystack db:authz:test [--stage <name>] [--dir authz] Red-team enforcement: SET ROLE + attempt per role/table/command (non-zero exit on a hole)
@@ -34,3 +34,10 @@ export * from './authz-owner-probe.js';
34
34
 
35
35
  // --- the SECDEF function catalog the contract introspection needs -----------
36
36
  export { FUNCTIONS_SQL, catalogFunctionToDescriptor } from './security-catalog.js';
37
+
38
+ // --- databases FROM the declared state: ephemeral test DBs + the builder ----
39
+ // `createEphemeralDatabase(adminUrl, models, { sources })` is the packaged
40
+ // create-sync-drop shape for consumer test suites: a fresh database at the
41
+ // declared state (state + authz + derived), verified to fingerprint MATCH,
42
+ // dropped when you're done — never long-lived, never poisoned.
43
+ export * from './db-build.js';
@@ -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 {