From 35cb76768ade6fbf0661a385553912c4a1c2f039 Mon Sep 17 00:00:00 2001 From: coji Date: Mon, 16 Mar 2026 13:14:33 +0900 Subject: [PATCH 1/4] improve: speed up pull-db and add restore-db command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pull-db: リモートで sqlite3 .backup + tar.gz を1回のSSHで実行し、 1回のSFTPで取得する方式に変更。SSH接続17回→3回に削減。 .backup によりアトミックで一貫性のあるコピーを保証。 restore-db: バックアップ一覧表示・指定バックアップからのリストア機能を追加。 db:setup 後でも pull したデータに即座に戻せるようになった。 Co-Authored-By: Claude Opus 4.6 (1M context) --- Dockerfile | 1 + ops/cli.ts | 23 ++++- ops/commands/pull-db.ts | 183 +++++++++++++++---------------------- ops/commands/restore-db.ts | 83 +++++++++++++++++ ops/remote/prepare-pull.sh | 28 ++++++ 5 files changed, 210 insertions(+), 108 deletions(-) create mode 100644 ops/commands/restore-db.ts create mode 100755 ops/remote/prepare-pull.sh diff --git a/Dockerfile b/Dockerfile index e700ec6a..dbcb4617 100644 --- a/Dockerfile +++ b/Dockerfile @@ -68,6 +68,7 @@ COPY --from=build /upflow/tsconfig.json /upflow/tsconfig.json COPY --from=build /upflow/start.sh /upflow/start.sh COPY --from=build /upflow/app /upflow/app COPY --from=build /upflow/batch /upflow/batch +COPY --from=build /upflow/ops/remote /upflow/ops/remote COPY --from=build /upflow/server.mjs /upflow/server.mjs CMD [ "sh", "./start.sh" ] \ No newline at end of file diff --git a/ops/cli.ts b/ops/cli.ts index 66a9f9b5..f548e1a5 100644 --- a/ops/cli.ts +++ b/ops/cli.ts @@ -1,5 +1,6 @@ import { cli, command } from 'cleye' import { pullDbCommand } from './commands/pull-db' +import { restoreDbCommand } from './commands/restore-db' const pullDb = command( { @@ -32,7 +33,27 @@ const pullDb = command( }, ) +const restoreDb = command( + { + name: 'restore-db', + flags: { + name: { + type: String, + description: + 'Backup name to restore (e.g. backup_2026-03-15T14-18-50-365Z). Omit to list available backups.', + }, + }, + help: { + description: 'Restore database files from a previous backup.', + }, + }, + (argv) => { + const { help, ...flags } = argv.flags + restoreDbCommand(flags) + }, +) + cli({ name: 'ops', - commands: [pullDb], + commands: [pullDb, restoreDb], }) diff --git a/ops/commands/pull-db.ts b/ops/commands/pull-db.ts index cc3b5617..740e8e44 100644 --- a/ops/commands/pull-db.ts +++ b/ops/commands/pull-db.ts @@ -1,13 +1,11 @@ import Database from 'better-sqlite3' import consola from 'consola' -import { execSync } from 'node:child_process' +import { execFileSync } from 'node:child_process' import fs from 'node:fs' import path from 'node:path' const DATA_DIR = 'data' -const REMOTE_DATA_DIR = '/upflow/data' const VALID_APP_NAME = /^[a-z0-9-]+$/ -const VALID_DB_FILENAME = /^[\w.-]+\.db$/ interface PullDbOptions { app: string @@ -28,94 +26,97 @@ function backupExistingData() { return } - const timestamp = new Date().toISOString().replace(/[:.]/g, '-') - const backupDir = path.join(DATA_DIR, `backup_${timestamp}`) - fs.mkdirSync(backupDir, { recursive: true }) - - const files = fs + const dbFiles = fs .readdirSync(DATA_DIR) .filter( (f) => f.endsWith('.db') || f.endsWith('.db-wal') || f.endsWith('.db-shm'), ) - for (const file of files) { - const src = path.join(DATA_DIR, file) - const dest = path.join(backupDir, file) - fs.copyFileSync(src, dest) - consola.debug(`Backed up ${file}`) + if (dbFiles.length === 0) { + consola.info('No database files to back up') + return } - consola.success(`Backed up ${files.length} files to ${backupDir}`) -} -function listRemoteTenantDbs(app: string): string[] { - consola.start('Listing remote tenant databases...') - try { - const output = execSync( - `fly ssh console -a ${app} -C "sh -c 'ls ${REMOTE_DATA_DIR}/tenant_*.db'"`, - { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }, - ) - return output - .trim() - .split('\n') - .filter(Boolean) - .map((line) => path.basename(line.trim())) - .filter((name) => VALID_DB_FILENAME.test(name)) - } catch (error) { - const stderr = - error instanceof Error && 'stderr' in error - ? String((error as { stderr: unknown }).stderr) - : '' - consola.warn( - `No tenant databases found or command failed${stderr ? `: ${stderr.trim()}` : ''}`, - ) - return [] + const timestamp = new Date().toISOString().replace(/[:.]/g, '-') + const backupDir = path.join(DATA_DIR, `backup_${timestamp}`) + fs.mkdirSync(backupDir, { recursive: true }) + + for (const file of dbFiles) { + fs.copyFileSync(path.join(DATA_DIR, file), path.join(backupDir, file)) } + consola.success(`Backed up ${dbFiles.length} files to ${backupDir}`) } /** - * リモート DB の WAL をメイン DB ファイルにフラッシュする。 - * WAL モードで動作中の DB は -wal ファイルに未コミットの変更を持つため、 - * .db ファイルだけ pull すると不整合(orphan index 等)が発生する。 + * リモートの prepare-pull.sh を実行して tar.gz を作成し、 + * 1回の SFTP で取得して展開する。 */ -function checkpointRemoteDbs(app: string, dbFiles: string[]) { - consola.start('Checkpointing remote databases (flushing WAL)...') - let succeeded = 0 - for (const f of dbFiles) { - const dbPath = `${REMOTE_DATA_DIR}/${f}` - try { - execSync( - `fly ssh console -a ${app} -C 'sqlite3 ${dbPath} "PRAGMA wal_checkpoint(TRUNCATE);"'`, - { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }, - ) - succeeded++ - } catch { - consola.warn( - `WAL checkpoint failed for ${f}, will pull WAL files instead`, - ) - } - } - if (succeeded > 0) { - consola.success(`Checkpointed ${succeeded}/${dbFiles.length} database(s)`) +function pullAllDbs(app: string) { + const remoteTar = '/tmp/upflow-data.tar.gz' + const localTar = path.join(DATA_DIR, '_pull.tar.gz') + + // Step 1: リモートで checkpoint + tar.gz 作成 + consola.start('Remote: checkpointing databases and creating archive...') + const output = execFileSync( + 'fly', + [ + 'ssh', + 'console', + '-a', + app, + '-C', + 'sh /upflow/ops/remote/prepare-pull.sh', + ], + { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }, + ) + + // Parse file list from output between ---FILES--- and ---DONE--- + const lines = output.split('\n').map((l) => l.trim()) + const startIdx = lines.indexOf('---FILES---') + const endIdx = lines.indexOf('---DONE---') + const dbFiles = + startIdx >= 0 && endIdx > startIdx + ? lines.slice(startIdx + 1, endIdx).filter(Boolean) + : [] + + if (dbFiles.length === 0) { + consola.error('No database files found on remote') + process.exit(1) } -} + consola.success( + `Remote archive ready: ${dbFiles.length} database(s) — ${dbFiles.join(', ')}`, + ) -function pullFile(app: string, remoteFile: string, localFile: string) { - // fly ssh sftp get refuses to overwrite existing files - if (fs.existsSync(localFile)) { - fs.unlinkSync(localFile) + // Step 2: 1回の SFTP で tar.gz を取得 + consola.start('Pulling archive...') + if (fs.existsSync(localTar)) { + fs.unlinkSync(localTar) } - consola.start(`Pulling ${remoteFile}...`) - execSync(`fly ssh sftp get -a ${app} ${remoteFile} ${localFile}`, { + execFileSync('fly', ['ssh', 'sftp', 'get', '-a', app, remoteTar, localTar], { stdio: 'inherit', }) -} -function tryPullFile(app: string, remoteFile: string, localFile: string) { + // Step 3: ローカルで展開 + consola.start('Extracting archive...') + execFileSync('tar', ['xzf', path.resolve(localTar)], { + cwd: DATA_DIR, + stdio: 'inherit', + }) + fs.unlinkSync(localTar) + + // Step 4: リモートの一時ファイルを削除 try { - pullFile(app, remoteFile, localFile) + execFileSync( + 'fly', + ['ssh', 'console', '-a', app, '-C', `rm -f ${remoteTar}`], + { stdio: ['pipe', 'pipe', 'pipe'] }, + ) } catch { - // WAL/SHM files may not exist (e.g. after checkpoint), ignore + // non-critical } + + consola.success(`Pulled ${dbFiles.length} database(s)`) + return dbFiles } function sanitizeExportSettings(dbPath: string) { @@ -158,45 +159,13 @@ export function pullDbCommand(options: PullDbOptions) { // Ensure data directory exists fs.mkdirSync(DATA_DIR, { recursive: true }) - // Step 2: List tenant databases - const tenantDbs = listRemoteTenantDbs(app) - const filesToPull = ['data.db', ...tenantDbs] - - consola.info( - `Found ${filesToPull.length} database(s): ${filesToPull.join(', ')}`, - ) - - // Step 3: Checkpoint WAL on remote before pulling - checkpointRemoteDbs(app, filesToPull) - - // Step 4: Pull each file (+ WAL/SHM as fallback) - let pulledCount = 0 - for (const file of filesToPull) { - try { - pullFile(app, `${REMOTE_DATA_DIR}/${file}`, path.join(DATA_DIR, file)) - // Also pull WAL and SHM files if they exist (safety net) - tryPullFile( - app, - `${REMOTE_DATA_DIR}/${file}-wal`, - path.join(DATA_DIR, `${file}-wal`), - ) - tryPullFile( - app, - `${REMOTE_DATA_DIR}/${file}-shm`, - path.join(DATA_DIR, `${file}-shm`), - ) - pulledCount++ - } catch (error) { - consola.error(`Failed to pull ${file}:`, error) - } - } - - consola.success(`Pulled ${pulledCount}/${filesToPull.length} database(s)`) + // Step 2: Remote checkpoint + tar + pull + extract + const dbFiles = pullAllDbs(app) - // Step 5: Sanitize export settings in all pulled databases + // Step 3: Sanitize export settings if (!noSanitize) { consola.start('Sanitizing export settings...') - for (const file of filesToPull) { + for (const file of dbFiles) { const dbPath = path.join(DATA_DIR, file) if (fs.existsSync(dbPath)) { sanitizeExportSettings(dbPath) @@ -207,7 +176,7 @@ export function pullDbCommand(options: PullDbOptions) { consola.box( [ - `Pull complete: ${pulledCount} file(s)`, + `Pull complete: ${dbFiles.length} database(s)`, noSanitize ? 'Sanitization: skipped' : 'Sanitization: done', noBackup ? 'Backup: skipped' : 'Backup: done', ].join('\n'), diff --git a/ops/commands/restore-db.ts b/ops/commands/restore-db.ts new file mode 100644 index 00000000..a05e1c6e --- /dev/null +++ b/ops/commands/restore-db.ts @@ -0,0 +1,83 @@ +import consola from 'consola' +import fs from 'node:fs' +import path from 'node:path' + +const DATA_DIR = 'data' + +interface RestoreDbOptions { + name?: string +} + +function listBackups(): string[] { + if (!fs.existsSync(DATA_DIR)) return [] + return fs + .readdirSync(DATA_DIR) + .filter( + (f) => + f.startsWith('backup_') && + fs.statSync(path.join(DATA_DIR, f)).isDirectory(), + ) + .sort() + .reverse() +} + +export function restoreDbCommand(options: RestoreDbOptions) { + const backups = listBackups() + if (backups.length === 0) { + consola.error('No backups found in data/') + process.exit(1) + } + + const target = options.name + + if (!target) { + consola.info('Available backups (newest first):') + for (const b of backups) { + const files = fs + .readdirSync(path.join(DATA_DIR, b)) + .filter((f) => f.endsWith('.db')) + consola.log(` ${b} (${files.length} database(s))`) + } + consola.info('\nUsage: pnpm ops restore-db --name ') + return + } + + const backupDir = path.join(DATA_DIR, target) + if (!fs.existsSync(backupDir) || !fs.statSync(backupDir).isDirectory()) { + consola.error(`Backup not found: ${target}`) + consola.info('Run without --name to list available backups') + process.exit(1) + } + + const filesToRestore = fs + .readdirSync(backupDir) + .filter( + (f) => + f.endsWith('.db') || f.endsWith('.db-wal') || f.endsWith('.db-shm'), + ) + + if (filesToRestore.length === 0) { + consola.error(`No database files in backup: ${target}`) + process.exit(1) + } + + // Remove current db files + const currentDbFiles = fs + .readdirSync(DATA_DIR) + .filter( + (f) => + (f.endsWith('.db') || f.endsWith('.db-wal') || f.endsWith('.db-shm')) && + !f.startsWith('backup_'), + ) + for (const f of currentDbFiles) { + fs.unlinkSync(path.join(DATA_DIR, f)) + } + + // Copy backup files to data/ + for (const f of filesToRestore) { + fs.copyFileSync(path.join(backupDir, f), path.join(DATA_DIR, f)) + } + + const dbCount = filesToRestore.filter((f) => f.endsWith('.db')).length + consola.success(`Restored ${dbCount} database(s) from ${target}`) +} diff --git a/ops/remote/prepare-pull.sh b/ops/remote/prepare-pull.sh new file mode 100755 index 00000000..5c84b201 --- /dev/null +++ b/ops/remote/prepare-pull.sh @@ -0,0 +1,28 @@ +#!/bin/sh +# リモートサーバー上で実行: 全DBのアトミックバックアップを作成し tar.gz にまとめる +# Usage: fly ssh console -C /upflow/ops/remote/prepare-pull.sh +set -e + +DATA_DIR="/upflow/data" +STAGING_DIR="/tmp/upflow-pull" +OUTPUT="/tmp/upflow-data.tar.gz" + +rm -rf "$STAGING_DIR" +mkdir -p "$STAGING_DIR" + +cd "$DATA_DIR" + +# sqlite3 .backup でアトミックにコピー(WAL書き込み中でも一貫性が保証される) +for f in *.db; do + sqlite3 "$f" ".backup $STAGING_DIR/$f" +done + +# Create archive from staging +tar czf "$OUTPUT" -C "$STAGING_DIR" . + +rm -rf "$STAGING_DIR" + +# Output file list for caller to parse +echo "---FILES---" +ls -1 *.db +echo "---DONE---" From 7b287d66ca627032a8ee5b670e716a8e26326b9b Mon Sep 17 00:00:00 2001 From: coji Date: Mon, 16 Mar 2026 13:20:58 +0900 Subject: [PATCH 2/4] refactor: extract shared constants and simplify ops commands - Extract DATA_DIR, BACKUP_PREFIX, isDbFile() to ops/lib/data-dir.ts - Remove remote cleanup SSH call (prepare-pull.sh self-cleans) - Use withFileTypes in listBackups() to avoid per-entry statSync - Use statSync with throwIfNoEntry instead of existsSync+statSync - Remove dead !startsWith('backup_') guard (db files never match) - Remove redundant existsSync before sanitization Co-Authored-By: Claude Opus 4.6 (1M context) --- ops/commands/pull-db.ts | 37 ++++++++++--------------------------- ops/commands/restore-db.ts | 30 ++++++++---------------------- ops/lib/data-dir.ts | 10 ++++++++++ ops/remote/prepare-pull.sh | 3 ++- 4 files changed, 30 insertions(+), 50 deletions(-) create mode 100644 ops/lib/data-dir.ts diff --git a/ops/commands/pull-db.ts b/ops/commands/pull-db.ts index 740e8e44..5e8490eb 100644 --- a/ops/commands/pull-db.ts +++ b/ops/commands/pull-db.ts @@ -3,8 +3,8 @@ import consola from 'consola' import { execFileSync } from 'node:child_process' import fs from 'node:fs' import path from 'node:path' +import { BACKUP_PREFIX, DATA_DIR, isDbFile } from '../lib/data-dir' -const DATA_DIR = 'data' const VALID_APP_NAME = /^[a-z0-9-]+$/ interface PullDbOptions { @@ -26,19 +26,14 @@ function backupExistingData() { return } - const dbFiles = fs - .readdirSync(DATA_DIR) - .filter( - (f) => - f.endsWith('.db') || f.endsWith('.db-wal') || f.endsWith('.db-shm'), - ) + const dbFiles = fs.readdirSync(DATA_DIR).filter(isDbFile) if (dbFiles.length === 0) { consola.info('No database files to back up') return } const timestamp = new Date().toISOString().replace(/[:.]/g, '-') - const backupDir = path.join(DATA_DIR, `backup_${timestamp}`) + const backupDir = path.join(DATA_DIR, `${BACKUP_PREFIX}${timestamp}`) fs.mkdirSync(backupDir, { recursive: true }) for (const file of dbFiles) { @@ -55,8 +50,8 @@ function pullAllDbs(app: string) { const remoteTar = '/tmp/upflow-data.tar.gz' const localTar = path.join(DATA_DIR, '_pull.tar.gz') - // Step 1: リモートで checkpoint + tar.gz 作成 - consola.start('Remote: checkpointing databases and creating archive...') + // Step 1: リモートで .backup + tar.gz 作成 + consola.start('Remote: creating atomic backups and archive...') const output = execFileSync( 'fly', [ @@ -89,8 +84,10 @@ function pullAllDbs(app: string) { // Step 2: 1回の SFTP で tar.gz を取得 consola.start('Pulling archive...') - if (fs.existsSync(localTar)) { + try { fs.unlinkSync(localTar) + } catch { + // file may not exist } execFileSync('fly', ['ssh', 'sftp', 'get', '-a', app, remoteTar, localTar], { stdio: 'inherit', @@ -104,17 +101,6 @@ function pullAllDbs(app: string) { }) fs.unlinkSync(localTar) - // Step 4: リモートの一時ファイルを削除 - try { - execFileSync( - 'fly', - ['ssh', 'console', '-a', app, '-C', `rm -f ${remoteTar}`], - { stdio: ['pipe', 'pipe', 'pipe'] }, - ) - } catch { - // non-critical - } - consola.success(`Pulled ${dbFiles.length} database(s)`) return dbFiles } @@ -159,17 +145,14 @@ export function pullDbCommand(options: PullDbOptions) { // Ensure data directory exists fs.mkdirSync(DATA_DIR, { recursive: true }) - // Step 2: Remote checkpoint + tar + pull + extract + // Step 2: Remote backup + tar + pull + extract const dbFiles = pullAllDbs(app) // Step 3: Sanitize export settings if (!noSanitize) { consola.start('Sanitizing export settings...') for (const file of dbFiles) { - const dbPath = path.join(DATA_DIR, file) - if (fs.existsSync(dbPath)) { - sanitizeExportSettings(dbPath) - } + sanitizeExportSettings(path.join(DATA_DIR, file)) } consola.success('Sanitization complete') } diff --git a/ops/commands/restore-db.ts b/ops/commands/restore-db.ts index a05e1c6e..a7db0452 100644 --- a/ops/commands/restore-db.ts +++ b/ops/commands/restore-db.ts @@ -1,8 +1,7 @@ import consola from 'consola' import fs from 'node:fs' import path from 'node:path' - -const DATA_DIR = 'data' +import { BACKUP_PREFIX, DATA_DIR, isDbFile } from '../lib/data-dir' interface RestoreDbOptions { name?: string @@ -11,12 +10,9 @@ interface RestoreDbOptions { function listBackups(): string[] { if (!fs.existsSync(DATA_DIR)) return [] return fs - .readdirSync(DATA_DIR) - .filter( - (f) => - f.startsWith('backup_') && - fs.statSync(path.join(DATA_DIR, f)).isDirectory(), - ) + .readdirSync(DATA_DIR, { withFileTypes: true }) + .filter((d) => d.isDirectory() && d.name.startsWith(BACKUP_PREFIX)) + .map((d) => d.name) .sort() .reverse() } @@ -43,18 +39,14 @@ export function restoreDbCommand(options: RestoreDbOptions) { } const backupDir = path.join(DATA_DIR, target) - if (!fs.existsSync(backupDir) || !fs.statSync(backupDir).isDirectory()) { + const stat = fs.statSync(backupDir, { throwIfNoEntry: false }) + if (!stat?.isDirectory()) { consola.error(`Backup not found: ${target}`) consola.info('Run without --name to list available backups') process.exit(1) } - const filesToRestore = fs - .readdirSync(backupDir) - .filter( - (f) => - f.endsWith('.db') || f.endsWith('.db-wal') || f.endsWith('.db-shm'), - ) + const filesToRestore = fs.readdirSync(backupDir).filter(isDbFile) if (filesToRestore.length === 0) { consola.error(`No database files in backup: ${target}`) @@ -62,13 +54,7 @@ export function restoreDbCommand(options: RestoreDbOptions) { } // Remove current db files - const currentDbFiles = fs - .readdirSync(DATA_DIR) - .filter( - (f) => - (f.endsWith('.db') || f.endsWith('.db-wal') || f.endsWith('.db-shm')) && - !f.startsWith('backup_'), - ) + const currentDbFiles = fs.readdirSync(DATA_DIR).filter(isDbFile) for (const f of currentDbFiles) { fs.unlinkSync(path.join(DATA_DIR, f)) } diff --git a/ops/lib/data-dir.ts b/ops/lib/data-dir.ts new file mode 100644 index 00000000..5dd8f6ca --- /dev/null +++ b/ops/lib/data-dir.ts @@ -0,0 +1,10 @@ +export const DATA_DIR = 'data' +export const BACKUP_PREFIX = 'backup_' + +export function isDbFile(filename: string): boolean { + return ( + filename.endsWith('.db') || + filename.endsWith('.db-wal') || + filename.endsWith('.db-shm') + ) +} diff --git a/ops/remote/prepare-pull.sh b/ops/remote/prepare-pull.sh index 5c84b201..19b20584 100755 --- a/ops/remote/prepare-pull.sh +++ b/ops/remote/prepare-pull.sh @@ -7,7 +7,8 @@ DATA_DIR="/upflow/data" STAGING_DIR="/tmp/upflow-pull" OUTPUT="/tmp/upflow-data.tar.gz" -rm -rf "$STAGING_DIR" +# 前回の残りがあれば削除 +rm -rf "$STAGING_DIR" "$OUTPUT" mkdir -p "$STAGING_DIR" cd "$DATA_DIR" From 2516472354ffd926f06b3648b29273d4e587044a Mon Sep 17 00:00:00 2001 From: coji Date: Mon, 16 Mar 2026 13:22:45 +0900 Subject: [PATCH 3/4] fix: show help when ops is run without a command Co-Authored-By: Claude Opus 4.6 (1M context) --- ops/cli.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/ops/cli.ts b/ops/cli.ts index f548e1a5..53154bf8 100644 --- a/ops/cli.ts +++ b/ops/cli.ts @@ -53,7 +53,11 @@ const restoreDb = command( }, ) -cli({ +const argv = cli({ name: 'ops', commands: [pullDb, restoreDb], }) + +if (!argv.command) { + argv.showHelp() +} From fc5a888e89570f6f2383e381fc3bdede1b4d246d Mon Sep 17 00:00:00 2001 From: coji Date: Mon, 16 Mar 2026 13:28:01 +0900 Subject: [PATCH 4/4] fix: address CodeRabbit review feedback - prepare-pull.sh: guard against empty *.db glob with [ -e ] check - restore-db: add confirmation prompt before destructive restore Co-Authored-By: Claude Opus 4.6 (1M context) --- ops/commands/restore-db.ts | 18 ++++++++++++++++-- ops/remote/prepare-pull.sh | 8 ++++++++ 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/ops/commands/restore-db.ts b/ops/commands/restore-db.ts index a7db0452..a13357ce 100644 --- a/ops/commands/restore-db.ts +++ b/ops/commands/restore-db.ts @@ -17,7 +17,7 @@ function listBackups(): string[] { .reverse() } -export function restoreDbCommand(options: RestoreDbOptions) { +export async function restoreDbCommand(options: RestoreDbOptions) { const backups = listBackups() if (backups.length === 0) { consola.error('No backups found in data/') @@ -53,6 +53,21 @@ export function restoreDbCommand(options: RestoreDbOptions) { process.exit(1) } + const dbCount = filesToRestore.filter((f) => f.endsWith('.db')).length + consola.warn( + `This will replace all current database files with ${dbCount} database(s) from ${target}.`, + ) + consola.warn( + 'Ensure no batch jobs or dev server are accessing the databases.', + ) + const confirmed = await consola.prompt('Continue with restore?', { + type: 'confirm', + }) + if (!confirmed) { + consola.info('Restore cancelled') + return + } + // Remove current db files const currentDbFiles = fs.readdirSync(DATA_DIR).filter(isDbFile) for (const f of currentDbFiles) { @@ -64,6 +79,5 @@ export function restoreDbCommand(options: RestoreDbOptions) { fs.copyFileSync(path.join(backupDir, f), path.join(DATA_DIR, f)) } - const dbCount = filesToRestore.filter((f) => f.endsWith('.db')).length consola.success(`Restored ${dbCount} database(s) from ${target}`) } diff --git a/ops/remote/prepare-pull.sh b/ops/remote/prepare-pull.sh index 19b20584..b389c16e 100755 --- a/ops/remote/prepare-pull.sh +++ b/ops/remote/prepare-pull.sh @@ -14,10 +14,18 @@ mkdir -p "$STAGING_DIR" cd "$DATA_DIR" # sqlite3 .backup でアトミックにコピー(WAL書き込み中でも一貫性が保証される) +db_count=0 for f in *.db; do + [ -e "$f" ] || continue sqlite3 "$f" ".backup $STAGING_DIR/$f" + db_count=$((db_count + 1)) done +if [ "$db_count" -eq 0 ]; then + echo "No database files found in $DATA_DIR" >&2 + exit 1 +fi + # Create archive from staging tar czf "$OUTPUT" -C "$STAGING_DIR" .