@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 +1 -0
- package/package.json +1 -1
- package/src/cli/commands/db-diff.ts +87 -0
- package/src/cli/declared-diff.ts +89 -0
- package/src/cli/index.ts +5 -0
- package/src/cli/migration-generate.ts +18 -5
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
|
@@ -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
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
|