feat(spaces): корзина, hard purge, transfer и полный backup
Docker / build (push) Failing after 20s

Soft-delete/restore/purge пространств, смена владельца, импорт JSON с spaces, proxy portal-users и polish Settings/ConfirmDialog.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-07-19 12:50:18 +07:00
co-authored by Cursor
parent 74af90270f
commit 670b5a4468
19 changed files with 1131 additions and 167 deletions
+130
View File
@@ -0,0 +1,130 @@
import { getDb, getSqlite, schema } from '@cfdm/db'
import {
spacesRepository,
vpsGrantsRepository,
} from '@cfdm/db/repositories/spaces'
const TABLE_ORDER_DELETE = [
'vps_grants',
'notification_log',
'notification_state',
'vps_health_checks',
'audit_log',
'sync_log',
'active_tariffs',
'tariff_sync_options',
'topology_diagrams',
'vps_domains',
'balance_ledger',
'payments',
'vps',
'provider_accounts',
'server_projects',
'providers',
'settings',
'space_members',
'spaces',
] as const
const TABLE_ORDER_INSERT = [...TABLE_ORDER_DELETE].reverse()
export const FULL_BACKUP_VERSION = 2
export type FullBackupPayload = {
backupVersion?: number
exportedAt?: string
tables?: Record<string, Record<string, unknown>[]>
/** Legacy v1 fields — ignored when tables present */
[key: string]: unknown
}
function selectAll(table: string): Record<string, unknown>[] {
const sqlite = getSqlite()
try {
return sqlite.prepare(`SELECT * FROM ${table}`).all() as Record<string, unknown>[]
} catch {
return []
}
}
/** Full multi-space dump for backup export. */
export function getFullBackupSnapshot(): FullBackupPayload {
const tables: Record<string, Record<string, unknown>[]> = {}
for (const table of TABLE_ORDER_INSERT) {
tables[table] = selectAll(table)
}
return {
backupVersion: FULL_BACKUP_VERSION,
exportedAt: new Date().toISOString(),
tables,
}
}
function quoteIdent(name: string): string {
return `"${name.replace(/"/g, '""')}"`
}
function insertRows(table: string, rows: Record<string, unknown>[]): void {
if (!rows.length) return
const sqlite = getSqlite()
const cols = Object.keys(rows[0]!)
const placeholders = cols.map(() => '?').join(', ')
const colList = cols.map(quoteIdent).join(', ')
const stmt = sqlite.prepare(
`INSERT INTO ${table} (${colList}) VALUES (${placeholders})`,
)
for (const row of rows) {
stmt.run(...cols.map((c) => row[c] ?? null))
}
}
/**
* Full overwrite import (v2 tables dump).
* Falls back to legacy importJsonSnapshot when `tables` missing.
*/
export function importFullBackup(data: FullBackupPayload): void {
const tables = data.tables
if (!tables || typeof tables !== 'object') {
throw new Error('FULL_BACKUP_REQUIRED')
}
const sqlite = getSqlite()
sqlite.exec('BEGIN')
try {
sqlite.pragma('foreign_keys = OFF')
for (const table of TABLE_ORDER_DELETE) {
try {
sqlite.exec(`DELETE FROM ${table}`)
} catch {
/* missing table */
}
}
for (const table of TABLE_ORDER_INSERT) {
const rows = Array.isArray(tables[table]) ? tables[table]! : []
insertRows(table, rows)
}
sqlite.pragma('foreign_keys = ON')
sqlite.exec('COMMIT')
} catch (err) {
sqlite.exec('ROLLBACK')
sqlite.pragma('foreign_keys = ON')
throw err
}
// Ensure main space exists after import
spacesRepository.getMain()
}
export function listAllSpaceMembers(): typeof schema.spaceMembers.$inferSelect[] {
return getDb().select().from(schema.spaceMembers).all()
}
export function listAllSpaces() {
return spacesRepository.listAll({ includeDeleted: true })
}
export function listAllGrants() {
return vpsGrantsRepository.listAll()
}