Spaces:
Paused
Paused
| const fs = require('node:fs'); | |
| const fsp = require('node:fs/promises'); | |
| const path = require('node:path'); | |
| const { spawn } = require('node:child_process'); | |
| function normalizeRepo(repo) { | |
| const value = String(repo || '').trim(); | |
| if (!value) return ''; | |
| if (/^https?:\/\//i.test(value)) return value.replace(/\/+$/, ''); | |
| return `https://github.com/${value.replace(/^\/+/, '').replace(/\.git$/i, '')}.git`; | |
| } | |
| function sqliteLiteral(value) { | |
| return String(value || '').replace(/'/g, "''"); | |
| } | |
| function delay(ms) { | |
| return new Promise((resolve) => setTimeout(resolve, ms)); | |
| } | |
| function isLikelySqliteFile(filePath) { | |
| try { | |
| const handle = fs.openSync(filePath, 'r'); | |
| const header = Buffer.alloc(16); | |
| fs.readSync(handle, header, 0, 16, 0); | |
| fs.closeSync(handle); | |
| return header.toString('utf8') === 'SQLite format 3\u0000'; | |
| } catch { | |
| return false; | |
| } | |
| } | |
| class SqliteGitHubBackup { | |
| constructor({ config, dbPath }) { | |
| this.config = config || {}; | |
| this.dbPath = path.resolve(dbPath); | |
| this.repoUrl = normalizeRepo(this.config.repo); | |
| this.repoDir = path.resolve(this.config.repoDir || path.dirname(this.dbPath)); | |
| this.snapshotPath = String(this.config.snapshotPath || 'backups/k-vault.db').replace(/^\/+/, ''); | |
| this.snapshotTempPath = path.join(path.dirname(this.dbPath), '.sqlite-backup.tmp'); | |
| this.managedFiles = this.normalizeManagedFiles(this.config.managedFiles); | |
| this.enabled = Boolean(this.config.enabled); | |
| this.requiredConfigured = Boolean(this.repoUrl && this.config.token); | |
| this.db = null; | |
| this.intervalHandle = null; | |
| this.idleHandle = null; | |
| this.flushPromise = null; | |
| this.dirty = false; | |
| this.activityCounter = 0; | |
| this.lastActivityAt = 0; | |
| this.lastBackupAt = 0; | |
| this.lastRestoreAt = 0; | |
| this.lastError = ''; | |
| this.stoppedReason = 'idle'; | |
| } | |
| normalizeManagedFiles(files = []) { | |
| if (!Array.isArray(files)) return []; | |
| return files | |
| .map((file) => { | |
| const localPath = file?.localPath ? path.resolve(file.localPath) : ''; | |
| const repoPath = String(file?.repoPath || '').replace(/^\/+/, ''); | |
| if (!localPath || !repoPath) return null; | |
| return { localPath, repoPath }; | |
| }) | |
| .filter(Boolean); | |
| } | |
| validateConfig() { | |
| if (!this.enabled) return; | |
| if (!this.requiredConfigured) { | |
| throw new Error('SQLite GitHub backup is enabled but SQLITE_BACKUP_GITHUB_REPO/SQLITE_BACKUP_GITHUB_TOKEN is incomplete.'); | |
| } | |
| } | |
| attachDatabase(db) { | |
| this.db = db; | |
| } | |
| getStatus() { | |
| if (!this.enabled) { | |
| return { | |
| enabled: false, | |
| running: false, | |
| dirty: false, | |
| message: 'SQLite GitHub backup disabled', | |
| }; | |
| } | |
| return { | |
| enabled: true, | |
| running: Boolean(this.intervalHandle), | |
| dirty: this.dirty, | |
| branch: this.config.branch, | |
| snapshotPath: this.snapshotPath, | |
| managedFiles: this.managedFiles.map((file) => file.repoPath), | |
| lastActivityAt: this.lastActivityAt || null, | |
| lastBackupAt: this.lastBackupAt || null, | |
| lastRestoreAt: this.lastRestoreAt || null, | |
| message: this.lastError || (this.intervalHandle ? 'SQLite GitHub backup active' : `SQLite GitHub backup idle (${this.stoppedReason})`), | |
| }; | |
| } | |
| buildGitEnv() { | |
| const env = { | |
| ...process.env, | |
| GIT_TERMINAL_PROMPT: '0', | |
| }; | |
| if (!this.config.token) { | |
| return env; | |
| } | |
| const remoteUrl = new URL(this.repoUrl); | |
| env.GIT_CONFIG_COUNT = '1'; | |
| env.GIT_CONFIG_KEY_0 = `http.${remoteUrl.protocol}//${remoteUrl.host}/.extraheader`; | |
| env.GIT_CONFIG_VALUE_0 = `AUTHORIZATION: basic ${Buffer.from(`x-access-token:${this.config.token}`).toString('base64')}`; | |
| return env; | |
| } | |
| runGit(args, options = {}) { | |
| const { cwd, allowFailure = false } = options; | |
| const env = this.buildGitEnv(); | |
| return new Promise((resolve, reject) => { | |
| const child = spawn('git', args, { | |
| cwd, | |
| env, | |
| stdio: ['ignore', 'pipe', 'pipe'], | |
| }); | |
| let stdout = ''; | |
| let stderr = ''; | |
| child.stdout.on('data', (chunk) => { | |
| stdout += chunk.toString(); | |
| }); | |
| child.stderr.on('data', (chunk) => { | |
| stderr += chunk.toString(); | |
| }); | |
| child.on('error', (error) => { | |
| if (allowFailure) { | |
| resolve({ ok: false, code: -1, stdout, stderr: error.message }); | |
| return; | |
| } | |
| reject(error); | |
| }); | |
| child.on('close', (code) => { | |
| const result = { | |
| ok: code === 0, | |
| code, | |
| stdout: stdout.trim(), | |
| stderr: stderr.trim(), | |
| }; | |
| if (!result.ok && !allowFailure) { | |
| const message = result.stderr || result.stdout || `git ${args.join(' ')} failed with exit code ${code}`; | |
| reject(new Error(message)); | |
| return; | |
| } | |
| resolve(result); | |
| }); | |
| }); | |
| } | |
| async remoteBranchExists() { | |
| const result = await this.runGit( | |
| ['ls-remote', '--heads', this.repoUrl, this.config.branch], | |
| { allowFailure: true } | |
| ); | |
| return result.ok && Boolean(result.stdout); | |
| } | |
| async ensureGitLfs(repoDir) { | |
| if (!this.config.lfsEnabled) return; | |
| const version = await this.runGit(['lfs', 'version'], { allowFailure: true }); | |
| if (!version.ok) { | |
| throw new Error('git-lfs is required for SQLite GitHub backup but is not available.'); | |
| } | |
| await this.runGit(['-C', repoDir, 'lfs', 'install', '--local']); | |
| await this.runGit(['-C', repoDir, 'lfs', 'track', this.snapshotPath]); | |
| } | |
| async ensureRepoReady({ syncRemote = false } = {}) { | |
| await fsp.mkdir(path.dirname(this.repoDir), { recursive: true }); | |
| if (!fs.existsSync(path.join(this.repoDir, '.git'))) { | |
| await this.runGit(['clone', this.repoUrl, this.repoDir]); | |
| } | |
| await this.runGit(['-C', this.repoDir, 'config', 'user.name', this.config.gitUserName]); | |
| await this.runGit(['-C', this.repoDir, 'config', 'user.email', this.config.gitUserEmail]); | |
| await this.ensureGitLfs(this.repoDir); | |
| const remoteBranchExists = await this.remoteBranchExists(); | |
| const currentBranch = await this.runGit(['-C', this.repoDir, 'branch', '--show-current'], { allowFailure: true }); | |
| const currentName = currentBranch.stdout.trim(); | |
| if (remoteBranchExists) { | |
| const localBranch = await this.runGit( | |
| ['-C', this.repoDir, 'rev-parse', '--verify', `refs/heads/${this.config.branch}`], | |
| { allowFailure: true } | |
| ); | |
| if (currentName !== this.config.branch) { | |
| if (localBranch.ok) { | |
| await this.runGit(['-C', this.repoDir, 'switch', this.config.branch]); | |
| } else { | |
| await this.runGit(['-C', this.repoDir, 'switch', '--track', `origin/${this.config.branch}`]); | |
| } | |
| } | |
| if (syncRemote) { | |
| await this.runGit(['-C', this.repoDir, 'pull', '--ff-only', 'origin', this.config.branch]); | |
| if (this.config.lfsEnabled) { | |
| await this.runGit(['-C', this.repoDir, 'lfs', 'pull', 'origin', this.config.branch]); | |
| } | |
| } | |
| } else if (currentName !== this.config.branch) { | |
| const localBranch = await this.runGit( | |
| ['-C', this.repoDir, 'rev-parse', '--verify', `refs/heads/${this.config.branch}`], | |
| { allowFailure: true } | |
| ); | |
| if (localBranch.ok) { | |
| await this.runGit(['-C', this.repoDir, 'switch', this.config.branch]); | |
| } else { | |
| await this.runGit(['-C', this.repoDir, 'switch', '-c', this.config.branch]); | |
| } | |
| } | |
| } | |
| async restoreIfAvailable() { | |
| if (!this.enabled) { | |
| return { restored: false, skipped: true, reason: 'disabled' }; | |
| } | |
| this.validateConfig(); | |
| await this.ensureRepoReady({ syncRemote: true }); | |
| const restoredManagedFiles = await this.restoreManagedFiles(); | |
| const sourcePath = path.join(this.repoDir, this.snapshotPath); | |
| if (!fs.existsSync(sourcePath)) { | |
| return { | |
| restored: false, | |
| skipped: true, | |
| reason: 'missing-backup', | |
| restoredManagedFiles, | |
| }; | |
| } | |
| if (!isLikelySqliteFile(sourcePath)) { | |
| throw new Error(`Backup file is not a valid SQLite snapshot: ${this.snapshotPath}`); | |
| } | |
| await fsp.mkdir(path.dirname(this.dbPath), { recursive: true }); | |
| await fsp.copyFile(sourcePath, this.dbPath); | |
| this.lastRestoreAt = Date.now(); | |
| this.lastError = ''; | |
| console.log(`[sqlite-backup] Restored ${this.snapshotPath} from ${this.config.branch}`); | |
| return { restored: true, sourcePath, restoredManagedFiles }; | |
| } | |
| async restoreManagedFiles() { | |
| const restored = []; | |
| for (const file of this.managedFiles) { | |
| const sourcePath = path.join(this.repoDir, file.repoPath); | |
| if (!fs.existsSync(sourcePath)) continue; | |
| await fsp.mkdir(path.dirname(file.localPath), { recursive: true }); | |
| await fsp.copyFile(sourcePath, file.localPath); | |
| restored.push(file.repoPath); | |
| } | |
| return restored; | |
| } | |
| recordActivity() { | |
| if (!this.enabled) return; | |
| this.lastActivityAt = Date.now(); | |
| this.activityCounter += 1; | |
| this.dirty = true; | |
| this.lastError = ''; | |
| if (!this.intervalHandle) { | |
| this.intervalHandle = setInterval(() => { | |
| void this.flushNow('interval'); | |
| }, this.config.intervalMs); | |
| } | |
| if (this.idleHandle) { | |
| clearTimeout(this.idleHandle); | |
| } | |
| this.idleHandle = setTimeout(() => { | |
| void this.handleIdleTimeout(); | |
| }, this.config.idleMs); | |
| } | |
| async handleIdleTimeout() { | |
| try { | |
| if (this.dirty) { | |
| await this.flushNow('idle-timeout'); | |
| } | |
| } catch (error) { | |
| this.lastError = error?.message || String(error); | |
| } finally { | |
| if (this.dirty) { | |
| this.idleHandle = setTimeout(() => { | |
| void this.handleIdleTimeout(); | |
| }, this.config.idleMs); | |
| return; | |
| } | |
| this.stop('idle-timeout'); | |
| } | |
| } | |
| stop(reason = 'manual') { | |
| this.stoppedReason = reason; | |
| if (this.intervalHandle) { | |
| clearInterval(this.intervalHandle); | |
| this.intervalHandle = null; | |
| } | |
| if (this.idleHandle) { | |
| clearTimeout(this.idleHandle); | |
| this.idleHandle = null; | |
| } | |
| } | |
| async exportSnapshot() { | |
| await fsp.mkdir(path.dirname(this.snapshotTempPath), { recursive: true }); | |
| await fsp.rm(this.snapshotTempPath, { force: true }); | |
| if (this.db) { | |
| this.db.exec(`VACUUM INTO '${sqliteLiteral(this.snapshotTempPath)}'`); | |
| return; | |
| } | |
| await fsp.copyFile(this.dbPath, this.snapshotTempPath); | |
| } | |
| async stageSnapshot() { | |
| await this.exportSnapshot(); | |
| const repoSnapshotPath = path.join(this.repoDir, this.snapshotPath); | |
| await fsp.mkdir(path.dirname(repoSnapshotPath), { recursive: true }); | |
| await fsp.copyFile(this.snapshotTempPath, repoSnapshotPath); | |
| const stageTargets = [this.snapshotPath]; | |
| for (const file of this.managedFiles) { | |
| if (!fs.existsSync(file.localPath)) continue; | |
| const repoManagedPath = path.join(this.repoDir, file.repoPath); | |
| await fsp.mkdir(path.dirname(repoManagedPath), { recursive: true }); | |
| await fsp.copyFile(file.localPath, repoManagedPath); | |
| stageTargets.push(file.repoPath); | |
| } | |
| const gitattributesPath = path.join(this.repoDir, '.gitattributes'); | |
| if (fs.existsSync(gitattributesPath)) { | |
| stageTargets.push('.gitattributes'); | |
| } | |
| await this.runGit(['-C', this.repoDir, 'add', '--', ...stageTargets]); | |
| const status = await this.runGit( | |
| ['-C', this.repoDir, 'status', '--porcelain', '--', ...stageTargets], | |
| { allowFailure: true } | |
| ); | |
| if (!status.ok) { | |
| throw new Error(status.stderr || 'git status failed'); | |
| } | |
| if (!status.stdout) { | |
| return false; | |
| } | |
| return true; | |
| } | |
| async pushSnapshot(reason) { | |
| const commitMessage = `chore: backup sqlite (${reason}) ${new Date().toISOString()}`; | |
| await this.runGit(['-C', this.repoDir, 'commit', '-m', commitMessage]); | |
| const firstPush = await this.runGit( | |
| ['-C', this.repoDir, 'push', '-u', 'origin', this.config.branch], | |
| { allowFailure: true } | |
| ); | |
| if (firstPush.ok) { | |
| return; | |
| } | |
| await this.runGit(['-C', this.repoDir, 'pull', '--rebase', 'origin', this.config.branch]); | |
| if (this.config.lfsEnabled) { | |
| await this.runGit(['-C', this.repoDir, 'lfs', 'pull', 'origin', this.config.branch], { allowFailure: true }); | |
| } | |
| await this.runGit(['-C', this.repoDir, 'push', '-u', 'origin', this.config.branch]); | |
| } | |
| async performBackup(reason) { | |
| if (!this.dirty) return false; | |
| this.validateConfig(); | |
| await this.ensureRepoReady(); | |
| const snapshotActivity = this.activityCounter; | |
| const changed = await this.stageSnapshot(); | |
| if (!changed) { | |
| this.dirty = this.activityCounter > snapshotActivity; | |
| return false; | |
| } | |
| await this.pushSnapshot(reason); | |
| this.dirty = this.activityCounter > snapshotActivity; | |
| this.lastBackupAt = Date.now(); | |
| this.lastError = ''; | |
| console.log(`[sqlite-backup] Pushed ${this.snapshotPath} to ${this.config.branch}`); | |
| return true; | |
| } | |
| async flushNow(reason = 'manual') { | |
| if (!this.enabled) return false; | |
| if (this.flushPromise) { | |
| return this.flushPromise; | |
| } | |
| this.flushPromise = (async () => { | |
| try { | |
| return await this.performBackup(reason); | |
| } catch (error) { | |
| this.lastError = error?.message || String(error); | |
| console.error('[sqlite-backup] Backup failed:', this.lastError); | |
| throw error; | |
| } finally { | |
| this.flushPromise = null; | |
| } | |
| })(); | |
| return this.flushPromise; | |
| } | |
| async shutdown() { | |
| if (!this.enabled) return; | |
| this.stop('shutdown'); | |
| if (this.flushPromise) { | |
| await this.flushPromise.catch(() => {}); | |
| } | |
| if (this.dirty) { | |
| for (let attempt = 0; attempt < 2; attempt += 1) { | |
| try { | |
| await this.flushNow('shutdown'); | |
| break; | |
| } catch (error) { | |
| if (attempt === 1) { | |
| throw error; | |
| } | |
| await delay(1000); | |
| } | |
| } | |
| } | |
| } | |
| } | |
| function createSqliteGitHubBackup(config, dbPath) { | |
| const backup = new SqliteGitHubBackup({ | |
| config, | |
| dbPath, | |
| }); | |
| return backup.enabled ? backup : null; | |
| } | |
| module.exports = { | |
| SqliteGitHubBackup, | |
| createSqliteGitHubBackup, | |
| }; | |