d1-sync-local
Version:
Sync Cloudflare D1 remote databases to local development environment
363 lines (304 loc) • 11.8 kB
JavaScript
const fs = require('fs');
const path = require('path');
const { exec } = require('child_process');
const { promisify } = require('util');
const prompts = require('prompts');
const toml = require('toml');
const chalk = require('chalk');
const ora = require('ora');
const execAsync = promisify(exec);
const packageJson = require('./package.json');
const { detectLanguage, t } = require('./locales');
// Detect user language
const lang = detectLanguage();
// Check for version flag
if (process.argv.includes('--version') || process.argv.includes('-v')) {
console.log(packageJson.version);
process.exit(0);
}
// Check for help flag
if (process.argv.includes('--help') || process.argv.includes('-h')) {
console.log(`
${chalk.bold('d1-sync-local')} - ${t('description', lang)}
${chalk.bold(t('usage', lang) + ':')}
d1-sync-local Sync D1 database to local
d1sl ${t('shortAlias', lang)}
${chalk.bold(t('options', lang) + ':')}
-v, --version ${t('showVersion', lang)}
-h, --help ${t('showHelp', lang)}
${chalk.bold(t('examples', lang) + ':')}
$ d1-sync-local
$ d1sl
$ npx d1-sync-local
`);
process.exit(0);
}
class D1SyncLocal {
constructor() {
this.wranglerConfigPath = path.join(process.cwd(), 'wrangler.toml');
this.exportDir = path.join(process.cwd(), '.d1-sync-exports');
this.lang = lang;
}
async readWranglerConfig() {
if (!fs.existsSync(this.wranglerConfigPath)) {
throw new Error(t('configNotFound', this.lang));
}
const configContent = fs.readFileSync(this.wranglerConfigPath, 'utf-8');
return toml.parse(configContent);
}
parseD1Databases(config) {
const databases = {};
// Parse default/local database
if (config.d1_databases) {
config.d1_databases.forEach(db => {
databases.local = {
name: 'Local/Default',
binding: db.binding,
database_name: db.database_name,
database_id: db.database_id,
envFlag: ''
};
});
}
// Parse environment-specific databases
if (config.env) {
Object.entries(config.env).forEach(([envName, envConfig]) => {
if (envConfig.d1_databases) {
envConfig.d1_databases.forEach(db => {
databases[envName] = {
name: envName.charAt(0).toUpperCase() + envName.slice(1),
binding: db.binding,
database_name: db.database_name,
database_id: db.database_id,
envFlag: `--env ${envName}`
};
});
}
});
}
return databases;
}
async selectDatabase(databases) {
const choices = Object.entries(databases).map(([key, db]) => ({
title: `${db.name} (${db.database_name})`,
value: key,
description: `${t('binding', this.lang)}: ${db.binding}`
}));
const { source } = await prompts({
type: 'select',
name: 'source',
message: t('selectSource', this.lang),
choices
});
return source ? databases[source] : null;
}
async executeCommand(command, spinner, successMsg) {
try {
const { stdout, stderr } = await execAsync(command, { maxBuffer: 50 * 1024 * 1024 });
if (stderr && !stderr.includes('warning')) {
throw new Error(stderr);
}
spinner.succeed(successMsg);
return stdout;
} catch (error) {
spinner.fail();
throw error;
}
}
fixD1Export(inputFile, outputFile) {
const content = fs.readFileSync(inputFile, 'utf8');
// Remove or modify PRAGMA defer_foreign_keys
let fixedContent = content.replace(/PRAGMA defer_foreign_keys=TRUE;/g, 'PRAGMA foreign_keys=OFF;');
// Add DROP TABLE IF EXISTS before CREATE TABLE
fixedContent = fixedContent.replace(/CREATE TABLE (\w+)/g, 'DROP TABLE IF EXISTS $1;\nCREATE TABLE $1');
// Fix INSERT statements with missing quotes
fixedContent = fixedContent.replace(/INSERT INTO (\w+) VALUES\((.*?)\);/gs, (match, tableName, values) => {
const fixedValues = values.split(/,(?![^()]*\))/).map(value => {
value = value.trim();
if (value === 'NULL') return value;
if (value.startsWith("'") && value.endsWith("'")) return value;
if (/^-?\d+(\.\d+)?$/.test(value)) return value;
return `'${value.replace(/'/g, "''")}'`;
});
return `INSERT INTO ${tableName} VALUES(${fixedValues.join(',')});`;
});
// Add PRAGMA foreign_keys=ON at the end if not present
if (!fixedContent.includes('PRAGMA foreign_keys=ON;')) {
fixedContent += '\nPRAGMA foreign_keys=ON;';
}
fs.writeFileSync(outputFile, fixedContent);
}
async deleteLocalDatabase(databaseName) {
// First try to drop all tables using SQL
try {
console.log(chalk.gray(` ${t('cleaningDatabase', this.lang)}`));
// Get all tables
const result = await execAsync(`npx wrangler d1 execute ${databaseName} --local --command="SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' AND name NOT LIKE '_cf_%'"`);
const tables = [];
const matches = result.stdout.match(/"name":\s*"([^"]+)"/g);
if (matches) {
matches.forEach(match => {
const tableName = match.match(/"name":\s*"([^"]+)"/)[1];
tables.push(tableName);
});
}
if (tables.length > 0) {
// Create SQL to drop all tables
const dropSQL = `
PRAGMA foreign_keys = OFF;
${tables.map(table => `DROP TABLE IF EXISTS ${table};`).join('\n')}
PRAGMA foreign_keys = ON;
`;
// Write to temp file and execute
const tempFile = path.join(require('os').tmpdir(), `d1-sync-drop-${Date.now()}.sql`);
fs.writeFileSync(tempFile, dropSQL);
try {
await execAsync(`npx wrangler d1 execute ${databaseName} --local --file="${tempFile}"`);
} finally {
if (fs.existsSync(tempFile)) {
fs.unlinkSync(tempFile);
}
}
}
} catch (error) {
// If SQL cleanup fails, try file deletion
}
// Also try to delete database files
const wranglerDir = path.join(process.cwd(), '.wrangler');
let deletedCount = 0;
if (fs.existsSync(wranglerDir)) {
const findDbFiles = (dir) => {
const files = fs.readdirSync(dir);
for (const file of files) {
const fullPath = path.join(dir, file);
const stat = fs.statSync(fullPath);
if (stat.isDirectory()) {
findDbFiles(fullPath);
} else if (file.includes(databaseName) && (file.endsWith('.db') || file.endsWith('.sqlite'))) {
try {
fs.unlinkSync(fullPath);
deletedCount++;
} catch (e) {
// Ignore errors
}
}
}
};
findDbFiles(wranglerDir);
}
return deletedCount;
}
async validateSync(databaseName) {
try {
const result = await execAsync(`npx wrangler d1 execute ${databaseName} --local --command="SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' AND name NOT LIKE '_cf_%'"`);
const tables = [];
const matches = result.stdout.match(/"name":\s*"([^"]+)"/g);
if (matches) {
matches.forEach(match => {
const tableName = match.match(/"name":\s*"([^"]+)"/)[1];
tables.push(tableName);
});
}
console.log(chalk.cyan(`\n📊 ${t('syncedTables', this.lang, { count: tables.length })}`));
for (const table of tables) {
try {
const countResult = await execAsync(`npx wrangler d1 execute ${databaseName} --local --command="SELECT COUNT(*) as count FROM ${table}"`);
const match = countResult.stdout.match(/"count":\s*(\d+)/);
const count = match ? match[1] : '0';
console.log(chalk.green(` ✓ ${table}: ${count} ${t('records', this.lang)}`));
} catch (e) {
console.log(chalk.yellow(` ⚠️ ${table}: ${t('unableToCount', this.lang)}`));
}
}
return true;
} catch (error) {
console.warn(chalk.yellow(`\n⚠️ ${t('validationFailed', this.lang)}`), error.message);
return false;
}
}
async run() {
console.log(chalk.bold.cyan('\n' + t('title', this.lang) + '\n'));
try {
// Read wrangler.toml
const spinner = ora(t('readingConfig', this.lang)).start();
const config = await this.readWranglerConfig();
const databases = this.parseD1Databases(config);
spinner.succeed(t('foundConfig', this.lang));
if (Object.keys(databases).length === 0) {
console.error(chalk.red(t('noDatabases', this.lang)));
process.exit(1);
}
// Let user select local database
const localDb = databases.local;
if (!localDb) {
console.error(chalk.red(t('noLocalDb', this.lang)));
process.exit(1);
}
console.log(chalk.gray(`${t('localDatabase', this.lang)}: ${localDb.database_name}`));
// Select source database
const sourceDb = await this.selectDatabase(databases);
if (!sourceDb) {
console.log(chalk.red(t('noDbSelected', this.lang)));
return;
}
// Confirm
console.log(chalk.yellow(`\n⚠️ ${t('syncWarning', this.lang, { source: sourceDb.database_name })}`));
const { confirm } = await prompts({
type: 'confirm',
name: 'confirm',
message: t('confirmReplace', this.lang),
initial: false
});
if (!confirm) {
console.log(chalk.red(t('cancelled', this.lang)));
return;
}
// Create export directory
if (!fs.existsSync(this.exportDir)) {
fs.mkdirSync(this.exportDir, { recursive: true });
}
// Export source database
const exportSpinner = ora(t('exportingDb', this.lang)).start();
const timestamp = new Date().toISOString().replace(/[:]/g, '-').split('.')[0];
const exportFile = path.join(this.exportDir, `export_${timestamp}.sql`);
await this.executeCommand(
`npx wrangler d1 export ${sourceDb.database_name} ${sourceDb.envFlag} --output="${exportFile}"`,
exportSpinner,
t('exportComplete', this.lang)
);
// Delete local database files
const deleteSpinner = ora(t('cleaningLocal', this.lang)).start();
const deletedCount = await this.deleteLocalDatabase(localDb.database_name);
if (deletedCount > 0) {
deleteSpinner.succeed(t('cleanedLocalWithCount', this.lang, { count: deletedCount }));
} else {
deleteSpinner.succeed(t('cleanedLocal', this.lang));
}
// Fix export file
const fixSpinner = ora(t('processingExport', this.lang)).start();
const fixedFile = exportFile.replace('.sql', '_fixed.sql');
this.fixD1Export(exportFile, fixedFile);
fixSpinner.succeed(t('exportProcessed', this.lang));
// Import to local
const importSpinner = ora(t('importingLocal', this.lang)).start();
await this.executeCommand(
`npx wrangler d1 execute ${localDb.database_name} --local --file="${fixedFile}"`,
importSpinner,
t('importComplete', this.lang)
);
// Clean up
fs.unlinkSync(exportFile);
fs.unlinkSync(fixedFile);
// Validate
await this.validateSync(localDb.database_name);
console.log(chalk.green.bold('\n' + t('syncSuccess', this.lang) + '\n'));
} catch (error) {
console.error(chalk.red(`\n❌ ${t('error', this.lang)}`), error.message);
process.exit(1);
}
}
}
// Run the tool
const sync = new D1SyncLocal();
sync.run();