Skip to content
Open

Large diffs are not rendered by default.

343 changes: 343 additions & 0 deletions docs/superpowers/specs/2026-06-16-backup-restore-overhaul-design.md

Large diffs are not rendered by default.

12 changes: 12 additions & 0 deletions src/main/db/clickhouse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -506,6 +506,18 @@ export class ClickHouseDriver extends BaseDriver {
return rows.length > 0 ? rows[0].statement : ''
}

/**
* Run a query and return the server's raw response body in the given ClickHouse output
* format (e.g. 'SQLInsert' to get INSERT statements). Used by the driver-based backup
* path so ClickHouse itself generates restorable SQL — avoiding hand-rolled value
* escaping — and works over HTTP, including through SSH tunnels.
*/
async queryRawText(sql: string, format: string): Promise<string> {
this.ensureConnected()
const resultSet = await this.client!.query({ query: sql, format: format as never })
return await resultSet.text()
}

async getTableData(table: string, options: DataOptions): Promise<DataResult> {
this.ensureConnected()

Expand Down
36 changes: 30 additions & 6 deletions src/main/ipc/backup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,10 @@ function validateBackupConfig(config: unknown): asserts config is BackupConfig {
const c = config as Record<string, unknown>
if (typeof c.connectionId !== 'string' || !c.connectionId) throw new Error('Invalid connectionId')
if (typeof c.outputPath !== 'string' || !c.outputPath) throw new Error('Invalid outputPath')
if (typeof c.binaryPath !== 'string' || !c.binaryPath) throw new Error('Invalid binaryPath')
if (!c.binaryPath.startsWith('/') && !/^[A-Za-z]:\\/.test(c.binaryPath as string)) {
// binaryPath may be empty for driver-based dialects (Redis/ClickHouse); when present it must
// be absolute. The execute/build handlers enforce its presence for binary-based dialects.
if (typeof c.binaryPath !== 'string') throw new Error('Invalid binaryPath')
if (c.binaryPath && !c.binaryPath.startsWith('/') && !/^[A-Za-z]:\\/.test(c.binaryPath)) {
throw new Error('binaryPath must be an absolute path')
}
}
Expand All @@ -35,8 +37,10 @@ function validateRestoreConfig(config: unknown): asserts config is RestoreConfig
if (!c.inputPath.startsWith('/') && !/^[A-Za-z]:\\/.test(c.inputPath as string)) {
throw new Error('inputPath must be an absolute path')
}
if (typeof c.binaryPath !== 'string' || !c.binaryPath) throw new Error('Invalid binaryPath')
if (!c.binaryPath.startsWith('/') && !/^[A-Za-z]:\\/.test(c.binaryPath as string)) {
// binaryPath may be empty for driver-based dialects (Redis/ClickHouse); when present it must
// be absolute. The execute/build handlers enforce its presence for binary-based dialects.
if (typeof c.binaryPath !== 'string') throw new Error('Invalid binaryPath')
if (c.binaryPath && !c.binaryPath.startsWith('/') && !/^[A-Za-z]:\\/.test(c.binaryPath)) {
throw new Error('binaryPath must be an absolute path')
}
}
Expand Down Expand Up @@ -173,6 +177,11 @@ export const registerBackupHandlers = (): void => {
}

const conn = resolveConnection(config.connectionId)
// Driver-based dialects (Redis/ClickHouse) run over the connection — show that in the
// preview instead of a misleading CLI command (which would also throw for ClickHouse+SSH).
if (backupService.usesDriverPath(conn.type)) {
return backupService.driverPreviewSpec(conn.type)
}
const password = await resolvePassword(config.connectionId)

return backupService.buildBackupCommand(config, conn, password)
Expand All @@ -191,8 +200,14 @@ export const registerBackupHandlers = (): void => {
}

const conn = resolveConnection(config.connectionId)
if (!backupService.usesDriverPath(conn.type) && !config.binaryPath) {
throw new Error('Binary path is required for this database type')
}
const password = await resolvePassword(config.connectionId)
return backupService.executeBackup(config, conn, password, event.sender.id)
// Pass the live driver so driver-based dialects (e.g. Redis) can back up over the
// connection instead of spawning a binary.
const driver = connectionManager.getConnection(config.connectionId)
return backupService.executeBackup(config, conn, password, event.sender.id, driver ?? undefined)
}
)

Expand Down Expand Up @@ -250,6 +265,11 @@ export const registerBackupHandlers = (): void => {
}

const conn = resolveConnection(config.connectionId)
// Driver-based dialects (Redis/ClickHouse) run over the connection — show that in the
// preview instead of a misleading CLI command (which would also throw for ClickHouse+SSH).
if (backupService.usesDriverPath(conn.type)) {
return backupService.driverPreviewSpec(conn.type)
}
const password = await resolvePassword(config.connectionId)

return backupService.buildRestoreCommand(config, conn, password)
Expand All @@ -268,8 +288,12 @@ export const registerBackupHandlers = (): void => {
}

const conn = resolveConnection(config.connectionId)
if (!backupService.usesDriverPath(conn.type) && !config.binaryPath) {
throw new Error('Binary path is required for this database type')
}
const password = await resolvePassword(config.connectionId)
return backupService.executeRestore(config, conn, password, event.sender.id)
const driver = connectionManager.getConnection(config.connectionId)
return backupService.executeRestore(config, conn, password, event.sender.id, driver ?? undefined)
}
)

Expand Down
228 changes: 4 additions & 224 deletions src/main/ipc/export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ import { connectionManager } from '@main/db/manager'
import { windowManager } from '@main/services/windowManager'
import { splitSqlStatements } from '@main/ipc/query'
import type { RedisDriver } from '@main/db/redis'
import {
serializeRedis as backupRedis,
deserializeRedis as importRedis,
} from '@main/services/backup/native/redis-serializer'
import type { MongoDBDriver } from '@main/db/mongodb'
import type { DatabaseDriver } from '@main/db/base'
import type { PostgreSQLDriver } from '@main/db/postgres'
Expand Down Expand Up @@ -649,230 +653,6 @@ const importSQL = async (
return { successCount, errors }
}

// ─── Redis backup helpers ──────────────────────────────────────────────────

interface RedisBackupEntry {
type: string
value: unknown
ttl: number
}

const backupRedis = async (driver: RedisDriver): Promise<string> => {
const client = driver.getClient()
const keys = await driver.getAllKeys()

logger.info(`Redis backup: found ${keys.length} keys to export`)

const backup: Record<string, RedisBackupEntry> = {}
let exportedCount = 0
let errorCount = 0

for (const key of keys) {
try {
const keyType = await client.type(key)
const ttl = await client.ttl(key)

let value: unknown

switch (keyType) {
case 'string':
value = await client.get(key)
break

case 'list':
value = await client.lrange(key, 0, -1)
break

case 'set':
value = await client.smembers(key)
break

case 'hash':
value = await client.hgetall(key)
break

case 'zset': {
// Retrieve members with scores as alternating array [member, score, ...]
const raw = await client.zrange(key, 0, -1, 'WITHSCORES')
const pairs: { member: string; score: string }[] = []
for (let i = 0; i < raw.length; i += 2) {
pairs.push({ member: raw[i], score: raw[i + 1] })
}
value = pairs
break
}

case 'stream': {
try {
const entries = await client.xrange(key, '-', '+', 'COUNT', 10000)
value = entries.map(([id, fields]) => {
const obj: Record<string, string> = { _id: id }
for (let i = 0; i < fields.length; i += 2) {
obj[fields[i]] = fields[i + 1]
}
return obj
})
} catch {
value = null
logger.warn(`Redis backup: could not read stream key "${key}", skipping value`)
}
break
}

default:
// Unknown type; store null
value = null
logger.warn(`Redis backup: unknown type "${keyType}" for key "${key}", skipping value`)
}

backup[key] = { type: keyType, value, ttl }
exportedCount++

if (exportedCount % 500 === 0) {
logger.info(`Redis backup: exported ${exportedCount}/${keys.length} keys`)
}
} catch (err) {
errorCount++
logger.warn(`Redis backup: failed to export key "${key}": ${err instanceof Error ? err.message : String(err)}`)
}
}

logger.info(`Redis backup: completed. Exported ${exportedCount} keys, ${errorCount} errors`)

const backupWrapper = {
_meta: {
type: 'redis',
version: 1,
exportedAt: new Date().toISOString(),
keyCount: exportedCount
},
data: backup
}

return JSON.stringify(backupWrapper, null, 2)
}

const importRedis = async (
driver: RedisDriver,
content: string
): Promise<{ successCount: number; errors: string[] }> => {
const parsed = JSON.parse(content)

// Support both wrapped format (with _meta) and plain format
const backup: Record<string, RedisBackupEntry> =
parsed._meta && parsed.data ? parsed.data : parsed

const client = driver.getClient()
const keys = Object.keys(backup)
let successCount = 0
const errors: string[] = []

logger.info(`Redis import: restoring ${keys.length} keys`)

for (const key of keys) {
try {
const entry = backup[key]
const { type, value, ttl } = entry

switch (type) {
case 'string': {
if (value !== null && value !== undefined) {
await client.set(key, String(value))
}
break
}

case 'list': {
if (Array.isArray(value) && value.length > 0) {
// Delete existing key first to avoid appending to existing data
await client.del(key)
// RPUSH to maintain order
await client.rpush(key, ...value.map(String))
}
break
}

case 'set': {
if (Array.isArray(value) && value.length > 0) {
await client.del(key)
await client.sadd(key, ...value.map(String))
}
break
}

case 'hash': {
if (value && typeof value === 'object' && !Array.isArray(value)) {
await client.del(key)
const hashEntries = Object.entries(value as Record<string, unknown>)
if (hashEntries.length > 0) {
const flatArgs: string[] = []
for (const [field, val] of hashEntries) {
flatArgs.push(field, String(val))
}
await client.hset(key, ...flatArgs)
}
}
break
}

case 'zset': {
if (Array.isArray(value) && value.length > 0) {
await client.del(key)
// Each entry is { member, score }
const zaddArgs: (string | number)[] = []
for (const item of value) {
const entry = item as { member: string; score: string | number }
zaddArgs.push(Number(entry.score), String(entry.member))
}
await (client as any).zadd(key, ...zaddArgs)
}
break
}

case 'stream': {
if (Array.isArray(value) && value.length > 0) {
await client.del(key)
for (const entry of value) {
const obj = entry as Record<string, string>
const fields: string[] = []
for (const [field, val] of Object.entries(obj)) {
if (field !== '_id') {
fields.push(field, String(val))
}
}
if (fields.length > 0) {
await client.xadd(key, '*', ...fields)
}
}
}
break
}

default:
logger.warn(`Redis import: unknown type "${type}" for key "${key}", skipping`)
continue
}

// Restore TTL if it was set (positive value means expiry was set)
if (ttl > 0) {
await client.expire(key, ttl)
}

successCount++

if (successCount % 500 === 0) {
logger.info(`Redis import: restored ${successCount}/${keys.length} keys`)
}
} catch (err) {
const errorMsg = err instanceof Error ? err.message : String(err)
errors.push(`Failed to restore key "${key}": ${errorMsg}`)
logger.warn(`Redis import: failed to restore key "${key}": ${errorMsg}`)
}
}

logger.info(`Redis import: completed. Restored ${successCount} keys, ${errors.length} errors`)
return { successCount, errors }
}

// ─── MongoDB backup helpers ────────────────────────────────────────────────

Expand Down
Loading
Loading