@everystack/cli 0.3.18 → 0.3.19

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/README.md CHANGED
@@ -219,6 +219,7 @@ everystack db:pull # introspect a live DB → field() Model
219
219
  everystack db:reconcile # deploy the derived layer (functions/views/matviews) from db/sql — no migrations
220
220
  everystack db:fingerprint # content-address the live base schema vs the Models — MATCH/MISMATCH
221
221
  everystack db:sync # make the database match your checkout — state + compute, one verb (dev DBs)
222
+ everystack db:diff # the state edge between two declared states — no DB, CI-pure; reverse = computed rollback
222
223
 
223
224
  # Security
224
225
  everystack db:doctor # is the DB least-privilege + RLS-subject?
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/cli",
3
- "version": "0.3.18",
3
+ "version": "0.3.19",
4
4
  "description": "CLI and OTA updates for Expo apps on everystack",
5
5
  "license": "AGPL-3.0-only",
6
6
  "author": "Scalable Technology, Inc. <licensing@scalable.technology>",
@@ -0,0 +1,87 @@
1
+ /**
2
+ * `everystack db:diff` — the state edge between two declared states, no
3
+ * database anywhere (brick 3's command surface).
4
+ *
5
+ * db:diff --from-models <barrel> [--to-models db/models/index.ts]
6
+ * [--allow-drops] [--check] [--json]
7
+ *
8
+ * Compiles both model sets and prints the SQL edge from one to the other —
9
+ * the same statement grammar db:generate produces (executable DDL, held
10
+ * drops, notices), computed with no connection, no network, no side
11
+ * effects. Deterministic: same two states, same edge, forever.
12
+ *
13
+ * The uses this unlocks:
14
+ * - CI plan preview: materialize the base branch's models, diff to HEAD's,
15
+ * attach the edge to the PR. `--check` exits 1 when the edge is non-empty.
16
+ * - Computed rollback: swap the flags. The reverse edge is derived from the
17
+ * same two states — nobody authors a down migration.
18
+ * - Review: the exact SQL a `db:sync` / `db:generate --apply` will run when
19
+ * the target database sits at the from-state.
20
+ *
21
+ * Between two declared states a removed table is intent (unlike the live
22
+ * diff, where an undeclared table is unmodeled and untouchable) — removals
23
+ * surface as drops, held back unless `--allow-drops`, like everywhere else.
24
+ */
25
+
26
+ import type { ModelDescriptor } from '@everystack/model';
27
+ import { diffDeclaredStates } from '../declared-diff.js';
28
+ import { classifyGeneratedStatements } from '../state-apply.js';
29
+ import { fingerprintModels } from '../schema-fingerprint.js';
30
+ import { resolveModelsPath } from '../models-path.js';
31
+ import { loadModels } from './db-generate.js';
32
+ import { step, success, fail, info, warn } from '../output.js';
33
+
34
+ export async function dbDiffCommand(flags: Record<string, string>): Promise<void> {
35
+ const fromPath = flags['from-models'];
36
+ if (!fromPath) {
37
+ fail('db:diff needs --from-models <barrel> — the declared state to diff FROM (--to-models defaults to the checkout\'s db/models).');
38
+ process.exit(1);
39
+ }
40
+ const toPath = resolveModelsPath(flags['to-models']);
41
+
42
+ let from: ModelDescriptor[];
43
+ let to: ModelDescriptor[];
44
+ try {
45
+ step(`Loading FROM models from ${fromPath}...`);
46
+ from = await loadModels(fromPath);
47
+ step(`Loading TO models from ${toPath}...`);
48
+ to = await loadModels(toPath);
49
+ info(`${from.length} → ${to.length} model(s).`);
50
+ } catch (err: any) {
51
+ fail(err.message);
52
+ process.exit(1);
53
+ }
54
+
55
+ const statements = diffDeclaredStates(from, to, { allowDrops: flags['allow-drops'] === 'true' });
56
+ const classified = classifyGeneratedStatements(statements);
57
+ const fromFingerprint = fingerprintModels(from).hash;
58
+ const toFingerprint = fingerprintModels(to).hash;
59
+
60
+ if (flags.json === 'true') {
61
+ console.log(JSON.stringify({
62
+ fromFingerprint,
63
+ toFingerprint,
64
+ statements,
65
+ executable: classified.executable.length,
66
+ heldDrops: classified.heldDrops.length,
67
+ notices: classified.notices.length,
68
+ empty: statements.length === 0,
69
+ }, null, 2));
70
+ } else {
71
+ console.log('');
72
+ if (statements.length === 0) {
73
+ success('The two states are identical — the edge is empty.');
74
+ } else {
75
+ info(`edge — ${statements.length} statement(s):`);
76
+ console.log('');
77
+ for (const s of statements) console.log(s.endsWith(';') ? s : `${s};`);
78
+ console.log('');
79
+ if (classified.heldDrops.length) warn(`${classified.heldDrops.length} destructive change(s) held back as comments (re-run with --allow-drops to emit them).`);
80
+ if (classified.notices.length) info(`${classified.notices.length} notice(s) — renames / manual follow-ups.`);
81
+ }
82
+ info(`fingerprint: ${fromFingerprint.slice(0, 12)} → ${toFingerprint.slice(0, 12)}`);
83
+ }
84
+
85
+ if (flags.check === 'true' && statements.length > 0) process.exit(1);
86
+ process.exit(0);
87
+ }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * declared-diff — the state edge between two DECLARED states, no live
3
+ * database anywhere (brick 3 of the migration-authority design).
4
+ *
5
+ * The live diff (db:generate) asks "what does the database need to become
6
+ * the models?" — one side is introspected. This module answers the same
7
+ * question between two model sets: compile the FROM models to the exact
8
+ * snapshot + contract shapes introspection produces, and run the one diff
9
+ * engine over them (`scope: 'full'` — between declared states the from-side
10
+ * is fully known, so a removed table is intent and becomes a drop, held by
11
+ * the same `allowDrops` gate as everywhere else).
12
+ *
13
+ * What this buys, per the design:
14
+ * - CI-pure plan previews: the edge between any two git states is
15
+ * `diffDeclaredStates(modelsAt(A), modelsAt(B))` — no database, no
16
+ * network, deterministic.
17
+ * - Computed rollback: the reverse edge is the same call with the arguments
18
+ * swapped — `diff(B, A)` — derived, never authored. Down migrations stop
19
+ * being a thing anyone writes.
20
+ * - The seed of plan minting (brick 4/7): a plan is this edge plus the two
21
+ * fingerprints naming its endpoints.
22
+ *
23
+ * The identity that makes it trustworthy, proven on real PostgreSQL in the
24
+ * integration suite: build a database at A, apply `diff(A→B)`, and
25
+ * `fingerprintLive` equals `fingerprintModels(B)` exactly — the pure edge
26
+ * and the live edge are the same edge.
27
+ */
28
+
29
+ import type { ModelDescriptor } from '@everystack/model';
30
+ import type { SchemaSnapshot } from './schema-introspect.js';
31
+ import type { AuthzContract } from './authz-contract.js';
32
+ import { compileTableSchema, compileEnums } from './schema-compile.js';
33
+ import { compileTableContract } from './authz-compile.js';
34
+ import { generateMigrationSql } from './migration-generate.js';
35
+
36
+ export interface DeclaredState {
37
+ snapshot: SchemaSnapshot;
38
+ contract: AuthzContract;
39
+ }
40
+
41
+ export interface DeclaredDiffOptions {
42
+ /** The Postgres schema the Models default to. Default: `public`. */
43
+ schema?: string;
44
+ /** Emit real drops for removals. Default false — held back as comments. */
45
+ allowDrops?: boolean;
46
+ }
47
+
48
+ /**
49
+ * Compile a model set to the shapes introspection produces — the same
50
+ * compilation `fingerprintModels` hashes, exposed as a state the diff
51
+ * engine can treat as "current".
52
+ */
53
+ export function compileDeclaredState(
54
+ models: ModelDescriptor[],
55
+ opts: { schema?: string } = {},
56
+ ): DeclaredState {
57
+ const schema = opts.schema ?? 'public';
58
+ return {
59
+ snapshot: {
60
+ tables: models.map((m) => compileTableSchema(m, { schema })),
61
+ enums: compileEnums(models),
62
+ },
63
+ contract: {
64
+ tables: models.map((m) => compileTableContract(m, { schema })),
65
+ functions: [],
66
+ },
67
+ };
68
+ }
69
+
70
+ /**
71
+ * The pure state edge `from → to`: the statement stream `db:generate` would
72
+ * produce against a live database at `from`, computed with no database.
73
+ * Same statement grammar throughout — executable DDL, held drops as
74
+ * comments, notices — so `classifyGeneratedStatements` and the apply path
75
+ * treat it identically. Swap the arguments for the reverse edge.
76
+ */
77
+ export function diffDeclaredStates(
78
+ from: ModelDescriptor[],
79
+ to: ModelDescriptor[],
80
+ opts: DeclaredDiffOptions = {},
81
+ ): string[] {
82
+ const a = compileDeclaredState(from, { schema: opts.schema });
83
+ return generateMigrationSql(to, a.snapshot, {
84
+ schema: opts.schema,
85
+ allowDrops: opts.allowDrops,
86
+ liveAuthz: a.contract,
87
+ scope: 'full',
88
+ });
89
+ }
package/src/cli/index.ts CHANGED
@@ -14,6 +14,7 @@ import { dbPullCommand } from './commands/db-pull.js';
14
14
  import { dbReconcileCommand } from './commands/db-reconcile.js';
15
15
  import { dbFingerprintCommand } from './commands/db-fingerprint.js';
16
16
  import { dbSyncCommand } from './commands/db-sync.js';
17
+ import { dbDiffCommand } from './commands/db-diff.js';
17
18
  import { dbSnapshotCommand, dbSnapshotsCommand } from './commands/db-snapshot.js';
18
19
  import { dbBackupProbeCommand, dbBackupCommand, dbBackupsCommand, dbRestoreCommand, dbBackupDownloadCommand } from './commands/db-backup.js';
19
20
  import { consoleCommand } from './commands/console.js';
@@ -184,6 +185,9 @@ async function main() {
184
185
  case 'db:sync':
185
186
  await dbSyncCommand(flags);
186
187
  break;
188
+ case 'db:diff':
189
+ await dbDiffCommand(flags);
190
+ break;
187
191
  case 'db:authz:pull':
188
192
  await dbAuthzPullCommand(flags);
189
193
  break;
@@ -309,6 +313,7 @@ Usage:
309
313
  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
310
314
  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); first contact with existing objects wants --baseline, once.
311
315
  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
316
+ 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)
312
317
  everystack db:authz:pull [--stage <name>] [--dir authz] Introspect live authz (rls/grants/policies/secdef) → reviewable contract files
313
318
  everystack db:authz:diff [--stage <name>] [--dir authz] Validate the live DB against the committed contract (non-zero exit on drift)
314
319
  everystack db:authz:test [--stage <name>] [--dir authz] Red-team enforcement: SET ROLE + attempt per role/table/command (non-zero exit on a hole)
@@ -33,6 +33,15 @@ export interface GenerateOptions {
33
33
  * never silently loses data. Destruction needs intent, the same rule renames are built on.
34
34
  */
35
35
  allowDrops?: boolean;
36
+ /**
37
+ * How much of `current` the diff may see. `'declared'` (default) scopes it to the
38
+ * tables/enums the Models declare — right against a LIVE database, where an
39
+ * undeclared table is unmodeled (auth, jobs, another team's) and must be invisible,
40
+ * never dropped. `'full'` diffs everything — right between two DECLARED states
41
+ * (declared-diff), where the from-side is fully known and absence is intent: a
42
+ * removed table/enum becomes a drop (still held by the `allowDrops` gate).
43
+ */
44
+ scope?: 'declared' | 'full';
36
45
  /**
37
46
  * The live authorization contract (`introspectContract`). When provided, the migration
38
47
  * carries the authz layer too — the Models' `abilities` compiled to RLS/policies/grants,
@@ -94,11 +103,15 @@ export function generateMigrationSql(models: ModelDescriptor[], current: SchemaS
94
103
  const declaredTables = new Set(desired.tables.map((t) => t.table));
95
104
  const declaredEnums = new Set(desiredEnums.map((e) => e.name));
96
105
  // Scope both tables and enums to what the Models declare — an undeclared table OR enum
97
- // (auth, jobs, observability ship their own) is invisible to the diff, so it is never dropped.
98
- const scopedCurrent: SchemaSnapshot = {
99
- tables: current.tables.filter((t) => declaredTables.has(t.table)),
100
- enums: (current.enums ?? []).filter((e) => declaredEnums.has(e.name)),
101
- };
106
+ // (auth, jobs, observability ship their own) is invisible to the diff, so it is never
107
+ // dropped. `scope: 'full'` (declared-diff, model→model) skips the filter: there the
108
+ // from-side is fully declared too, so absence is intent and removals must surface.
109
+ const scopedCurrent: SchemaSnapshot = opts.scope === 'full'
110
+ ? { tables: current.tables, enums: current.enums ?? [] }
111
+ : {
112
+ tables: current.tables.filter((t) => declaredTables.has(t.table)),
113
+ enums: (current.enums ?? []).filter((e) => declaredEnums.has(e.name)),
114
+ };
102
115
  const renames = compileRenames(models, { schema });
103
116
  const changes = diffSchema(desired, scopedCurrent, renames);
104
117