@developer-ayushanand/dbgit 0.1.0-beta.1
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/.github/ISSUE_TEMPLATE/bug_report.yml +33 -0
- package/.github/PULL_REQUEST_TEMPLATE.md +20 -0
- package/.github-issue-bug.md +57 -0
- package/.github-workflows-ci.yml +41 -0
- package/CODE_OF_CONDUCT.md +45 -0
- package/CONTRIBUTING.md +29 -0
- package/LICENSE +21 -0
- package/README.md +115 -0
- package/SECURITY.md +15 -0
- package/dbgit-0.1.0-beta.1.tgz +0 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +173 -0
- package/dist/commands/branch.d.ts +3 -0
- package/dist/commands/branch.js +34 -0
- package/dist/commands/checkout.d.ts +1 -0
- package/dist/commands/checkout.js +59 -0
- package/dist/commands/commit.d.ts +3 -0
- package/dist/commands/commit.js +86 -0
- package/dist/commands/diff.d.ts +1 -0
- package/dist/commands/diff.js +91 -0
- package/dist/commands/doctor.d.ts +1 -0
- package/dist/commands/doctor.js +115 -0
- package/dist/commands/init.d.ts +4 -0
- package/dist/commands/init.js +84 -0
- package/dist/commands/log.d.ts +1 -0
- package/dist/commands/log.js +18 -0
- package/dist/commands/merge.d.ts +1 -0
- package/dist/commands/merge.js +101 -0
- package/dist/commands/purge.d.ts +1 -0
- package/dist/commands/purge.js +85 -0
- package/dist/commands/restore-backup.d.ts +1 -0
- package/dist/commands/restore-backup.js +14 -0
- package/dist/commands/restore.d.ts +1 -0
- package/dist/commands/restore.js +33 -0
- package/dist/commands/rollback.d.ts +6 -0
- package/dist/commands/rollback.js +187 -0
- package/dist/core/backup.d.ts +5 -0
- package/dist/core/backup.js +40 -0
- package/dist/core/connector.d.ts +13 -0
- package/dist/core/connector.js +39 -0
- package/dist/core/dependencyGraph.d.ts +5 -0
- package/dist/core/dependencyGraph.js +87 -0
- package/dist/core/differ.d.ts +3 -0
- package/dist/core/differ.js +218 -0
- package/dist/core/generator.d.ts +3 -0
- package/dist/core/generator.js +104 -0
- package/dist/core/ignore.d.ts +3 -0
- package/dist/core/ignore.js +20 -0
- package/dist/core/schemaLock.d.ts +3 -0
- package/dist/core/schemaLock.js +18 -0
- package/dist/core/snapshot.d.ts +4 -0
- package/dist/core/snapshot.js +121 -0
- package/dist/core/store.d.ts +19 -0
- package/dist/core/store.js +102 -0
- package/dist/core/transaction.d.ts +2 -0
- package/dist/core/transaction.js +17 -0
- package/dist/core/validator.d.ts +4 -0
- package/dist/core/validator.js +18 -0
- package/dist/types/changes.d.ts +27 -0
- package/dist/types/changes.js +14 -0
- package/dist/types/commits.d.ts +21 -0
- package/dist/types/commits.js +1 -0
- package/dist/types/schema.d.ts +40 -0
- package/dist/types/schema.js +1 -0
- package/package.json +38 -0
- package/tsconfig.json +16 -0
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import ora from 'ora';
|
|
3
|
+
import prompts from 'prompts';
|
|
4
|
+
import crypto from 'crypto';
|
|
5
|
+
import boxen from 'boxen';
|
|
6
|
+
import { isInitialized, getHead, loadCommit, loadSnapshot, setHead, loadConfig, saveCommit, saveSnapshot, saveBranch, loadBranch } from '../core/store.js';
|
|
7
|
+
import { createPool, getConnectionConfig, query } from '../core/connector.js';
|
|
8
|
+
import { captureSnapshot } from '../core/snapshot.js';
|
|
9
|
+
import { diffSnapshots } from '../core/differ.js';
|
|
10
|
+
import { validateSchemaNotDrifted } from '../core/validator.js';
|
|
11
|
+
import { acquireLock, releaseLock } from '../core/schemaLock.js';
|
|
12
|
+
import { createBackup } from '../core/backup.js';
|
|
13
|
+
import { orderChanges } from '../core/dependencyGraph.js';
|
|
14
|
+
import { generateForwardSQL } from '../core/generator.js';
|
|
15
|
+
import { runInTransaction } from '../core/transaction.js';
|
|
16
|
+
import { loadIgnoreList } from '../core/ignore.js';
|
|
17
|
+
import { ChangeType } from '../types/changes.js';
|
|
18
|
+
export async function rollbackCommand(hash, options) {
|
|
19
|
+
if (!isInitialized()) {
|
|
20
|
+
console.error(chalk.red("Not a DBGit repository. Run 'dbgit init' first."));
|
|
21
|
+
process.exit(1);
|
|
22
|
+
}
|
|
23
|
+
const head = getHead();
|
|
24
|
+
if (!head.commit) {
|
|
25
|
+
console.error(chalk.red("No commits to rollback from."));
|
|
26
|
+
process.exit(1);
|
|
27
|
+
}
|
|
28
|
+
const ignoreList = loadIgnoreList();
|
|
29
|
+
const config = getConnectionConfig();
|
|
30
|
+
const pool = createPool(config);
|
|
31
|
+
const dbConfig = loadConfig();
|
|
32
|
+
const isProd = dbConfig.DBGIT_MODE === 'prod';
|
|
33
|
+
try {
|
|
34
|
+
const targetCommit = loadCommit(hash);
|
|
35
|
+
const headCommit = loadCommit(head.commit);
|
|
36
|
+
const targetSnapshot = loadSnapshot(targetCommit.snapshotHash);
|
|
37
|
+
const spinner = ora('Validating schema...').start();
|
|
38
|
+
try {
|
|
39
|
+
await validateSchemaNotDrifted(pool, headCommit, ignoreList);
|
|
40
|
+
}
|
|
41
|
+
catch (e) {
|
|
42
|
+
spinner.fail(chalk.red(e.message));
|
|
43
|
+
await pool.end();
|
|
44
|
+
process.exit(1);
|
|
45
|
+
}
|
|
46
|
+
spinner.succeed('Schema validation passed.');
|
|
47
|
+
const liveSnapshot = await captureSnapshot(pool, ignoreList);
|
|
48
|
+
const changeset = diffSnapshots(liveSnapshot, targetSnapshot);
|
|
49
|
+
if (changeset.changes.length === 0) {
|
|
50
|
+
console.log(chalk.gray("ℹ Database schema is already at target state."));
|
|
51
|
+
await pool.end();
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
if (changeset.hasDestructive) {
|
|
55
|
+
const destructiveChanges = changeset.changes.filter(c => c.isDestructive);
|
|
56
|
+
const allFks = Object.values(liveSnapshot.tables).flatMap(t => t.foreignKeys);
|
|
57
|
+
let impactReport = chalk.yellow('⚠ DESTRUCTIVE CHANGES DETECTED\n\n');
|
|
58
|
+
for (const change of destructiveChanges) {
|
|
59
|
+
let impact = '';
|
|
60
|
+
let dependencies = [];
|
|
61
|
+
if (change.type === ChangeType.DROP_TABLE) {
|
|
62
|
+
const rows = await query(pool, `SELECT count(*) FROM "${change.table}"`);
|
|
63
|
+
impact = `${rows[0].count} rows`;
|
|
64
|
+
dependencies = allFks
|
|
65
|
+
.filter(fk => fk.targetTable === change.table)
|
|
66
|
+
.map(fk => `${fk.sourceTable}.${fk.sourceColumn}`);
|
|
67
|
+
}
|
|
68
|
+
else if (change.type === ChangeType.DROP_COLUMN) {
|
|
69
|
+
const rows = await query(pool, `SELECT count(*) FROM "${change.table}" WHERE "${change.objectName}" IS NOT NULL`);
|
|
70
|
+
impact = `${rows[0].count} non-null rows`;
|
|
71
|
+
dependencies = allFks
|
|
72
|
+
.filter(fk => fk.targetTable === change.table && fk.targetColumn === change.objectName)
|
|
73
|
+
.map(fk => `${fk.sourceTable}.${fk.sourceColumn}`);
|
|
74
|
+
}
|
|
75
|
+
impactReport += chalk.red(`✗ ${change.type}: ${change.table}${change.objectName ? '.' + change.objectName : ''}\n`);
|
|
76
|
+
impactReport += chalk.gray(` Impact: ${impact}\n`);
|
|
77
|
+
if (dependencies.length > 0) {
|
|
78
|
+
impactReport += chalk.red(` Referenced by: ${dependencies.join(', ')}\n`);
|
|
79
|
+
}
|
|
80
|
+
impactReport += '\n';
|
|
81
|
+
}
|
|
82
|
+
console.log(boxen(impactReport.trim(), { padding: 1, borderColor: 'red', title: 'Rollback Risk: HIGH' }));
|
|
83
|
+
if (isProd) {
|
|
84
|
+
if (!options.safe) {
|
|
85
|
+
console.error(chalk.red("\nError: Production mode. Backup is mandatory for destructive operations. Use --safe."));
|
|
86
|
+
process.exit(1);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
else {
|
|
90
|
+
if (!options.force && !options.safe) {
|
|
91
|
+
const response = await prompts({
|
|
92
|
+
type: 'confirm',
|
|
93
|
+
name: 'value',
|
|
94
|
+
message: 'Continue with destructive rollback?',
|
|
95
|
+
initial: false
|
|
96
|
+
});
|
|
97
|
+
if (!response.value) {
|
|
98
|
+
console.log(chalk.gray('Rollback aborted.'));
|
|
99
|
+
process.exit(1);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
if (options.force && !isProd) {
|
|
104
|
+
const response = await prompts({
|
|
105
|
+
type: 'text',
|
|
106
|
+
name: 'confirm',
|
|
107
|
+
message: 'Type DESTROY DATA to continue:'
|
|
108
|
+
});
|
|
109
|
+
if (response.confirm !== 'DESTROY DATA') {
|
|
110
|
+
console.log(chalk.gray('Rollback aborted.'));
|
|
111
|
+
process.exit(1);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
if (options.safe) {
|
|
115
|
+
const backupSpinner = ora('Creating backup...').start();
|
|
116
|
+
const backupPath = await createBackup(head.commit, config);
|
|
117
|
+
if (backupPath) {
|
|
118
|
+
backupSpinner.succeed(`Backup created: ${backupPath}`);
|
|
119
|
+
}
|
|
120
|
+
else {
|
|
121
|
+
backupSpinner.warn('Backup skipped or failed.');
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
const orderedChanges = orderChanges(changeset.changes, liveSnapshot);
|
|
126
|
+
let sqlStatements = generateForwardSQL({ ...changeset, changes: orderedChanges });
|
|
127
|
+
if (options.softDelete) {
|
|
128
|
+
const timestamp = Date.now();
|
|
129
|
+
sqlStatements = sqlStatements.map(sql => {
|
|
130
|
+
if (sql.startsWith('DROP TABLE')) {
|
|
131
|
+
const tableName = sql.replace('DROP TABLE ', '').trim();
|
|
132
|
+
return `ALTER TABLE ${tableName} RENAME TO _dbgit_deleted_${tableName.replace(/"/g, '')}_${timestamp}`;
|
|
133
|
+
}
|
|
134
|
+
if (sql.includes('DROP COLUMN')) {
|
|
135
|
+
const match = sql.match(/ALTER TABLE (.*) DROP COLUMN (.*)/);
|
|
136
|
+
if (match) {
|
|
137
|
+
return `ALTER TABLE ${match[1].trim()} RENAME COLUMN ${match[2].trim()} TO _dbgit_deleted_${match[2].trim().replace(/"/g, '')}_${timestamp}`;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return sql;
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
if (options.dryRun) {
|
|
144
|
+
console.log(chalk.blue('\nDry run: SQL statements that would be executed:'));
|
|
145
|
+
sqlStatements.forEach(sql => console.log(chalk.gray(sql)));
|
|
146
|
+
console.log(chalk.green('\nDry run complete. Nothing executed.'));
|
|
147
|
+
await pool.end();
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
await acquireLock(pool);
|
|
151
|
+
const applySpinner = ora('Applying rollback...').start();
|
|
152
|
+
try {
|
|
153
|
+
await runInTransaction(pool, sqlStatements);
|
|
154
|
+
// Create a NEW commit for the rollback
|
|
155
|
+
const newCommitHash = crypto.createHash('sha256').update(Date.now().toString() + hash).digest('hex').substring(0, 7);
|
|
156
|
+
const rollbackCommit = {
|
|
157
|
+
commitHash: newCommitHash,
|
|
158
|
+
snapshotHash: targetCommit.snapshotHash,
|
|
159
|
+
parent: head.commit,
|
|
160
|
+
timestamp: new Date().toISOString(),
|
|
161
|
+
message: `rollback: restore schema state from ${hash.substring(0, 7)}`,
|
|
162
|
+
branch: head.branch,
|
|
163
|
+
schemaHash: targetCommit.schemaHash,
|
|
164
|
+
type: 'rollback',
|
|
165
|
+
targetCommit: hash
|
|
166
|
+
};
|
|
167
|
+
saveSnapshot(targetCommit.snapshotHash, targetSnapshot); // Ensure snapshot is saved for the new commit
|
|
168
|
+
saveCommit(rollbackCommit);
|
|
169
|
+
const newHead = { ...head, commit: newCommitHash };
|
|
170
|
+
setHead(newHead);
|
|
171
|
+
if (head.branch) {
|
|
172
|
+
const branch = loadBranch(head.branch);
|
|
173
|
+
branch.headCommit = newCommitHash;
|
|
174
|
+
saveBranch(branch);
|
|
175
|
+
}
|
|
176
|
+
applySpinner.succeed(chalk.green(`Rolled back to ${hash.substring(0, 7)}. New commit ${newCommitHash} created.`));
|
|
177
|
+
}
|
|
178
|
+
finally {
|
|
179
|
+
await releaseLock(pool);
|
|
180
|
+
}
|
|
181
|
+
await pool.end();
|
|
182
|
+
}
|
|
183
|
+
catch (e) {
|
|
184
|
+
console.error(chalk.red(`Rollback failed: ${e.message}`));
|
|
185
|
+
process.exit(1);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { ConnectionConfig } from './connector.js';
|
|
2
|
+
export declare function createBackup(hash: string, config: ConnectionConfig): Promise<string>;
|
|
3
|
+
export declare function listBackups(): string[];
|
|
4
|
+
export declare function backupExists(hash: string): boolean;
|
|
5
|
+
export declare function getRestoreCommand(hash: string, config: ConnectionConfig): string;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { execSync } from 'child_process';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import fs from 'fs';
|
|
4
|
+
import { getBackupsDir } from './store.js';
|
|
5
|
+
export async function createBackup(hash, config) {
|
|
6
|
+
const backupDir = path.join(getBackupsDir(), hash);
|
|
7
|
+
if (!fs.existsSync(backupDir)) {
|
|
8
|
+
fs.mkdirSync(backupDir, { recursive: true });
|
|
9
|
+
}
|
|
10
|
+
const backupFile = path.join(backupDir, 'backup.sql');
|
|
11
|
+
const env = {
|
|
12
|
+
...process.env,
|
|
13
|
+
PGPASSWORD: config.password
|
|
14
|
+
};
|
|
15
|
+
const command = `pg_dump -h ${config.host} -p ${config.port} -U ${config.user} -d ${config.database} -f "${backupFile}"`;
|
|
16
|
+
try {
|
|
17
|
+
execSync(command, { env, stdio: 'pipe' });
|
|
18
|
+
return backupFile;
|
|
19
|
+
}
|
|
20
|
+
catch (error) {
|
|
21
|
+
if (error.message.includes('pg_dump: not found') || error.message.includes('command not found')) {
|
|
22
|
+
console.warn("pg_dump not found. Install PostgreSQL client tools and ensure pg_dump is in your PATH. Skipping backup.");
|
|
23
|
+
return '';
|
|
24
|
+
}
|
|
25
|
+
throw error;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
export function listBackups() {
|
|
29
|
+
const backupsDir = getBackupsDir();
|
|
30
|
+
if (!fs.existsSync(backupsDir))
|
|
31
|
+
return [];
|
|
32
|
+
return fs.readdirSync(backupsDir);
|
|
33
|
+
}
|
|
34
|
+
export function backupExists(hash) {
|
|
35
|
+
return fs.existsSync(path.join(getBackupsDir(), hash, 'backup.sql'));
|
|
36
|
+
}
|
|
37
|
+
export function getRestoreCommand(hash, config) {
|
|
38
|
+
const backupFile = path.join(getBackupsDir(), hash, 'backup.sql');
|
|
39
|
+
return `psql -h ${config.host} -p ${config.port} -U ${config.user} -d ${config.database} -f "${backupFile}"`;
|
|
40
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import pg from 'pg';
|
|
2
|
+
export interface ConnectionConfig {
|
|
3
|
+
host?: string;
|
|
4
|
+
port?: number;
|
|
5
|
+
database?: string;
|
|
6
|
+
user?: string;
|
|
7
|
+
password?: string;
|
|
8
|
+
ssl?: boolean;
|
|
9
|
+
connectionString?: string;
|
|
10
|
+
}
|
|
11
|
+
export declare function getConnectionConfig(): ConnectionConfig;
|
|
12
|
+
export declare function createPool(config: ConnectionConfig): pg.Pool;
|
|
13
|
+
export declare function query<T>(pool: pg.Pool, sql: string, params?: unknown[]): Promise<T[]>;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import pg from 'pg';
|
|
2
|
+
const { Pool } = pg;
|
|
3
|
+
import { loadConfig } from './store.js';
|
|
4
|
+
export function getConnectionConfig() {
|
|
5
|
+
const storedConfig = loadConfig();
|
|
6
|
+
// 1. DATABASE_URL
|
|
7
|
+
if (process.env.DATABASE_URL) {
|
|
8
|
+
return { connectionString: process.env.DATABASE_URL };
|
|
9
|
+
}
|
|
10
|
+
// 2. DBGIT_DATABASE_URL
|
|
11
|
+
if (process.env.DBGIT_DATABASE_URL) {
|
|
12
|
+
return { connectionString: process.env.DBGIT_DATABASE_URL };
|
|
13
|
+
}
|
|
14
|
+
// 3. .dbgit/config databaseUrl (if we support it there in the future, for now it might be in individual fields)
|
|
15
|
+
if (storedConfig.databaseUrl) {
|
|
16
|
+
return { connectionString: storedConfig.databaseUrl };
|
|
17
|
+
}
|
|
18
|
+
// 4. Legacy individual variables (Env first, then config)
|
|
19
|
+
const host = process.env.DBGIT_HOST || storedConfig.DBGIT_HOST || 'localhost';
|
|
20
|
+
const port = parseInt(process.env.DBGIT_PORT || storedConfig.DBGIT_PORT || '5432', 10);
|
|
21
|
+
const database = process.env.DBGIT_DATABASE || storedConfig.DBGIT_DATABASE || '';
|
|
22
|
+
const user = process.env.DBGIT_USER || storedConfig.DBGIT_USER || '';
|
|
23
|
+
const password = process.env.DBGIT_PASSWORD || storedConfig.DBGIT_PASSWORD;
|
|
24
|
+
const ssl = (process.env.DBGIT_SSL === 'true') || (storedConfig.DBGIT_SSL === 'true') || false;
|
|
25
|
+
return { host, port, database, user, password, ssl };
|
|
26
|
+
}
|
|
27
|
+
export function createPool(config) {
|
|
28
|
+
if (config.connectionString) {
|
|
29
|
+
return new Pool({ connectionString: config.connectionString });
|
|
30
|
+
}
|
|
31
|
+
if (!config.database || !config.user) {
|
|
32
|
+
throw new Error('Database name and user are required. Set DATABASE_URL or individual DBGIT_* env vars.');
|
|
33
|
+
}
|
|
34
|
+
return new Pool(config);
|
|
35
|
+
}
|
|
36
|
+
export async function query(pool, sql, params) {
|
|
37
|
+
const res = await pool.query(sql, params);
|
|
38
|
+
return res.rows;
|
|
39
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { ForeignKey, SchemaSnapshot } from '../types/schema.js';
|
|
2
|
+
import { Change } from '../types/changes.js';
|
|
3
|
+
export declare function orderForDeletion(tables: string[], foreignKeys: ForeignKey[]): string[];
|
|
4
|
+
export declare function orderForCreation(tables: string[], foreignKeys: ForeignKey[]): string[];
|
|
5
|
+
export declare function orderChanges(changes: Change[], snapshot: SchemaSnapshot): Change[];
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { ChangeType } from '../types/changes.js';
|
|
2
|
+
export function orderForDeletion(tables, foreignKeys) {
|
|
3
|
+
// Kahn's algorithm for topological sort
|
|
4
|
+
// For deletion, we want to delete tables that are NOT referenced by others first.
|
|
5
|
+
// Dependencies: targetTable -> sourceTable (target must exist for source to exist)
|
|
6
|
+
// For deletion: sourceTable must be deleted before targetTable if there is an FK from source to target.
|
|
7
|
+
const adj = new Map();
|
|
8
|
+
const inDegree = new Map();
|
|
9
|
+
tables.forEach(t => {
|
|
10
|
+
adj.set(t, []);
|
|
11
|
+
inDegree.set(t, 0);
|
|
12
|
+
});
|
|
13
|
+
foreignKeys.forEach(fk => {
|
|
14
|
+
if (adj.has(fk.sourceTable) && adj.has(fk.targetTable)) {
|
|
15
|
+
adj.get(fk.sourceTable).push(fk.targetTable);
|
|
16
|
+
inDegree.set(fk.targetTable, (inDegree.get(fk.targetTable) || 0) + 1);
|
|
17
|
+
}
|
|
18
|
+
});
|
|
19
|
+
const queue = [];
|
|
20
|
+
inDegree.forEach((degree, table) => {
|
|
21
|
+
if (degree === 0)
|
|
22
|
+
queue.push(table);
|
|
23
|
+
});
|
|
24
|
+
const result = [];
|
|
25
|
+
while (queue.length > 0) {
|
|
26
|
+
const u = queue.shift();
|
|
27
|
+
result.push(u);
|
|
28
|
+
adj.get(u)?.forEach(v => {
|
|
29
|
+
inDegree.set(v, inDegree.get(v) - 1);
|
|
30
|
+
if (inDegree.get(v) === 0)
|
|
31
|
+
queue.push(v);
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
// Any tables not in result are part of a cycle (shouldn't happen with FKs normally)
|
|
35
|
+
const remaining = tables.filter(t => !result.includes(t));
|
|
36
|
+
return [...result, ...remaining];
|
|
37
|
+
}
|
|
38
|
+
export function orderForCreation(tables, foreignKeys) {
|
|
39
|
+
return orderForDeletion(tables, foreignKeys).reverse();
|
|
40
|
+
}
|
|
41
|
+
export function orderChanges(changes, snapshot) {
|
|
42
|
+
// Order:
|
|
43
|
+
// 1. DROP_FOREIGN_KEY
|
|
44
|
+
// 2. DROP_CONSTRAINT
|
|
45
|
+
// 3. DROP_INDEX
|
|
46
|
+
// 4. DROP_COLUMN
|
|
47
|
+
// 5. DROP_TABLE (topologically ordered)
|
|
48
|
+
// 6. MODIFY_COLUMN
|
|
49
|
+
// 7. ADD_TABLE (topologically ordered)
|
|
50
|
+
// 8. ADD_COLUMN
|
|
51
|
+
// 9. ADD_INDEX
|
|
52
|
+
// 10. ADD_CONSTRAINT
|
|
53
|
+
// 11. ADD_FOREIGN_KEY
|
|
54
|
+
const dropFks = changes.filter(c => c.type === ChangeType.DROP_FOREIGN_KEY);
|
|
55
|
+
const dropConstraints = changes.filter(c => c.type === ChangeType.DROP_CONSTRAINT);
|
|
56
|
+
const dropIndexes = changes.filter(c => c.type === ChangeType.DROP_INDEX);
|
|
57
|
+
const dropColumns = changes.filter(c => c.type === ChangeType.DROP_COLUMN);
|
|
58
|
+
const dropTables = changes.filter(c => c.type === ChangeType.DROP_TABLE);
|
|
59
|
+
const modifyColumns = changes.filter(c => c.type === ChangeType.MODIFY_COLUMN);
|
|
60
|
+
const addTables = changes.filter(c => c.type === ChangeType.ADD_TABLE);
|
|
61
|
+
const addColumns = changes.filter(c => c.type === ChangeType.ADD_COLUMN);
|
|
62
|
+
const addIndexes = changes.filter(c => c.type === ChangeType.ADD_INDEX);
|
|
63
|
+
const addConstraints = changes.filter(c => c.type === ChangeType.ADD_CONSTRAINT);
|
|
64
|
+
const addFks = changes.filter(c => c.type === ChangeType.ADD_FOREIGN_KEY);
|
|
65
|
+
// Topologically sort dropTables
|
|
66
|
+
const allFks = Object.values(snapshot.tables).flatMap(t => t.foreignKeys);
|
|
67
|
+
const dropTableNames = dropTables.map(c => c.table);
|
|
68
|
+
const sortedDropTableNames = orderForDeletion(dropTableNames, allFks);
|
|
69
|
+
const sortedDropTables = sortedDropTableNames.map(name => dropTables.find(c => c.table === name));
|
|
70
|
+
// Topologically sort addTables
|
|
71
|
+
const addTableNames = addTables.map(c => c.table);
|
|
72
|
+
const sortedAddTableNames = orderForCreation(addTableNames, allFks);
|
|
73
|
+
const sortedAddTables = sortedAddTableNames.map(name => addTables.find(c => c.table === name));
|
|
74
|
+
return [
|
|
75
|
+
...dropFks,
|
|
76
|
+
...dropConstraints,
|
|
77
|
+
...dropIndexes,
|
|
78
|
+
...dropColumns,
|
|
79
|
+
...sortedDropTables,
|
|
80
|
+
...modifyColumns,
|
|
81
|
+
...sortedAddTables,
|
|
82
|
+
...addColumns,
|
|
83
|
+
...addIndexes,
|
|
84
|
+
...addConstraints,
|
|
85
|
+
...addFks
|
|
86
|
+
];
|
|
87
|
+
}
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import { ChangeType } from '../types/changes.js';
|
|
2
|
+
export function diffSnapshots(from, to) {
|
|
3
|
+
const changes = [];
|
|
4
|
+
const fromTables = Object.keys(from.tables);
|
|
5
|
+
const toTables = Object.keys(to.tables);
|
|
6
|
+
// Tables to drop
|
|
7
|
+
for (const tableName of fromTables) {
|
|
8
|
+
if (!to.tables[tableName]) {
|
|
9
|
+
changes.push({
|
|
10
|
+
type: ChangeType.DROP_TABLE,
|
|
11
|
+
table: tableName,
|
|
12
|
+
before: from.tables[tableName],
|
|
13
|
+
isDestructive: true
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
// Tables to add or modify
|
|
18
|
+
for (const tableName of toTables) {
|
|
19
|
+
const fromTable = from.tables[tableName];
|
|
20
|
+
const toTable = to.tables[tableName];
|
|
21
|
+
if (!fromTable) {
|
|
22
|
+
changes.push({
|
|
23
|
+
type: ChangeType.ADD_TABLE,
|
|
24
|
+
table: tableName,
|
|
25
|
+
after: toTable,
|
|
26
|
+
isDestructive: false
|
|
27
|
+
});
|
|
28
|
+
// All columns, indexes etc in this table are part of ADD_TABLE
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
// Diff columns
|
|
32
|
+
diffColumns(fromTable, toTable, changes);
|
|
33
|
+
// Diff indexes
|
|
34
|
+
diffIndexes(fromTable, toTable, changes);
|
|
35
|
+
// Diff constraints
|
|
36
|
+
diffConstraints(fromTable, toTable, changes);
|
|
37
|
+
// Diff foreign keys
|
|
38
|
+
diffForeignKeys(fromTable, toTable, changes);
|
|
39
|
+
}
|
|
40
|
+
return {
|
|
41
|
+
changes,
|
|
42
|
+
hasDestructive: changes.some(c => c.isDestructive)
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
function diffColumns(from, to, changes) {
|
|
46
|
+
const fromCols = new Map(from.columns.map(c => [c.name, c]));
|
|
47
|
+
const toCols = new Map(to.columns.map(c => [c.name, c]));
|
|
48
|
+
for (const [name, col] of fromCols) {
|
|
49
|
+
if (!toCols.has(name)) {
|
|
50
|
+
changes.push({
|
|
51
|
+
type: ChangeType.DROP_COLUMN,
|
|
52
|
+
table: from.name,
|
|
53
|
+
objectName: name,
|
|
54
|
+
before: col,
|
|
55
|
+
isDestructive: true
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
for (const [name, col] of toCols) {
|
|
60
|
+
const fromCol = fromCols.get(name);
|
|
61
|
+
if (!fromCol) {
|
|
62
|
+
changes.push({
|
|
63
|
+
type: ChangeType.ADD_COLUMN,
|
|
64
|
+
table: to.name,
|
|
65
|
+
objectName: name,
|
|
66
|
+
after: col,
|
|
67
|
+
isDestructive: false
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
else if (fromCol.type !== col.type || fromCol.nullable !== col.nullable || fromCol.default !== col.default) {
|
|
71
|
+
changes.push({
|
|
72
|
+
type: ChangeType.MODIFY_COLUMN,
|
|
73
|
+
table: to.name,
|
|
74
|
+
objectName: name,
|
|
75
|
+
before: fromCol,
|
|
76
|
+
after: col,
|
|
77
|
+
isDestructive: false // Usually not destructive, but can be
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
function diffIndexes(from, to, changes) {
|
|
83
|
+
const fromIdx = new Map(from.indexes.map(i => [i.name, i]));
|
|
84
|
+
const toIdx = new Map(to.indexes.map(i => [i.name, i]));
|
|
85
|
+
for (const [name, idx] of fromIdx) {
|
|
86
|
+
if (!toIdx.has(name)) {
|
|
87
|
+
changes.push({
|
|
88
|
+
type: ChangeType.DROP_INDEX,
|
|
89
|
+
table: from.name,
|
|
90
|
+
objectName: name,
|
|
91
|
+
before: idx,
|
|
92
|
+
isDestructive: false
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
for (const [name, idx] of toIdx) {
|
|
97
|
+
const fromIdxObj = fromIdx.get(name);
|
|
98
|
+
if (!fromIdxObj) {
|
|
99
|
+
changes.push({
|
|
100
|
+
type: ChangeType.ADD_INDEX,
|
|
101
|
+
table: to.name,
|
|
102
|
+
objectName: name,
|
|
103
|
+
after: idx,
|
|
104
|
+
isDestructive: false
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
else if (JSON.stringify(fromIdxObj) !== JSON.stringify(idx)) {
|
|
108
|
+
changes.push({
|
|
109
|
+
type: ChangeType.DROP_INDEX,
|
|
110
|
+
table: from.name,
|
|
111
|
+
objectName: name,
|
|
112
|
+
before: fromIdxObj,
|
|
113
|
+
isDestructive: false
|
|
114
|
+
});
|
|
115
|
+
changes.push({
|
|
116
|
+
type: ChangeType.ADD_INDEX,
|
|
117
|
+
table: to.name,
|
|
118
|
+
objectName: name,
|
|
119
|
+
after: idx,
|
|
120
|
+
isDestructive: false
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
function diffConstraints(from, to, changes) {
|
|
126
|
+
const fromCons = new Map(from.constraints.map(c => [c.name, c]));
|
|
127
|
+
const toCons = new Map(to.constraints.map(c => [c.name, c]));
|
|
128
|
+
for (const [name, con] of fromCons) {
|
|
129
|
+
if (!toCons.has(name)) {
|
|
130
|
+
changes.push({
|
|
131
|
+
type: ChangeType.DROP_CONSTRAINT,
|
|
132
|
+
table: from.name,
|
|
133
|
+
objectName: name,
|
|
134
|
+
before: con,
|
|
135
|
+
beforeTable: from,
|
|
136
|
+
isDestructive: false
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
for (const [name, con] of toCons) {
|
|
141
|
+
const fromCon = fromCons.get(name);
|
|
142
|
+
if (!fromCon) {
|
|
143
|
+
changes.push({
|
|
144
|
+
type: ChangeType.ADD_CONSTRAINT,
|
|
145
|
+
table: to.name,
|
|
146
|
+
objectName: name,
|
|
147
|
+
after: con,
|
|
148
|
+
afterTable: to,
|
|
149
|
+
isDestructive: false
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
else if (JSON.stringify(fromCon) !== JSON.stringify(con)) {
|
|
153
|
+
changes.push({
|
|
154
|
+
type: ChangeType.DROP_CONSTRAINT,
|
|
155
|
+
table: from.name,
|
|
156
|
+
objectName: name,
|
|
157
|
+
before: fromCon,
|
|
158
|
+
beforeTable: from,
|
|
159
|
+
isDestructive: false
|
|
160
|
+
});
|
|
161
|
+
changes.push({
|
|
162
|
+
type: ChangeType.ADD_CONSTRAINT,
|
|
163
|
+
table: to.name,
|
|
164
|
+
objectName: name,
|
|
165
|
+
after: con,
|
|
166
|
+
afterTable: to,
|
|
167
|
+
isDestructive: false
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
function diffForeignKeys(from, to, changes) {
|
|
173
|
+
const fromFks = new Map(from.foreignKeys.map(f => [f.name, f]));
|
|
174
|
+
const toFks = new Map(to.foreignKeys.map(f => [f.name, f]));
|
|
175
|
+
for (const [name, fk] of fromFks) {
|
|
176
|
+
if (!toFks.has(name)) {
|
|
177
|
+
changes.push({
|
|
178
|
+
type: ChangeType.DROP_FOREIGN_KEY,
|
|
179
|
+
table: from.name,
|
|
180
|
+
objectName: name,
|
|
181
|
+
before: fk,
|
|
182
|
+
beforeTable: from,
|
|
183
|
+
isDestructive: true
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
for (const [name, fk] of toFks) {
|
|
188
|
+
const fromFk = fromFks.get(name);
|
|
189
|
+
if (!fromFk) {
|
|
190
|
+
changes.push({
|
|
191
|
+
type: ChangeType.ADD_FOREIGN_KEY,
|
|
192
|
+
table: to.name,
|
|
193
|
+
objectName: name,
|
|
194
|
+
after: fk,
|
|
195
|
+
afterTable: to,
|
|
196
|
+
isDestructive: false
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
else if (JSON.stringify(fromFk) !== JSON.stringify(fk)) {
|
|
200
|
+
changes.push({
|
|
201
|
+
type: ChangeType.DROP_FOREIGN_KEY,
|
|
202
|
+
table: from.name,
|
|
203
|
+
objectName: name,
|
|
204
|
+
before: fromFk,
|
|
205
|
+
beforeTable: from,
|
|
206
|
+
isDestructive: true
|
|
207
|
+
});
|
|
208
|
+
changes.push({
|
|
209
|
+
type: ChangeType.ADD_FOREIGN_KEY,
|
|
210
|
+
table: to.name,
|
|
211
|
+
objectName: name,
|
|
212
|
+
after: fk,
|
|
213
|
+
afterTable: to,
|
|
214
|
+
isDestructive: false
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|