Compare commits
No commits in common. '551c9e3d30adbf0957bfc1927affb46877f6fe38' and 'b1e1420b4ccc7251144c6f561aaa342a571ebb33' have entirely different histories.
551c9e3d30
...
b1e1420b4c
@ -1,179 +0,0 @@
|
|||||||
{
|
|
||||||
"running": true,
|
|
||||||
"startedAt": "2026-07-20T16:44:54.772Z",
|
|
||||||
"workers": {
|
|
||||||
"map": {
|
|
||||||
"runCount": 11,
|
|
||||||
"successCount": 11,
|
|
||||||
"failureCount": 0,
|
|
||||||
"averageDurationMs": 8.090909090909092,
|
|
||||||
"isRunning": false,
|
|
||||||
"nextRun": "2026-07-20T19:29:55.184Z",
|
|
||||||
"lastStartedAt": "2026-07-20T19:14:55.176Z",
|
|
||||||
"lastRun": "2026-07-20T19:14:55.183Z"
|
|
||||||
},
|
|
||||||
"audit": {
|
|
||||||
"runCount": 17,
|
|
||||||
"successCount": 17,
|
|
||||||
"failureCount": 0,
|
|
||||||
"averageDurationMs": 8.882352941176471,
|
|
||||||
"isRunning": false,
|
|
||||||
"nextRun": "2026-07-20T19:26:56.025Z",
|
|
||||||
"lastStartedAt": "2026-07-20T19:26:56.097Z",
|
|
||||||
"lastRun": "2026-07-20T19:26:56.134Z"
|
|
||||||
},
|
|
||||||
"optimize": {
|
|
||||||
"runCount": 11,
|
|
||||||
"successCount": 11,
|
|
||||||
"failureCount": 0,
|
|
||||||
"averageDurationMs": 6.454545454545454,
|
|
||||||
"isRunning": false,
|
|
||||||
"nextRun": "2026-07-20T19:33:55.170Z",
|
|
||||||
"lastStartedAt": "2026-07-20T19:18:55.161Z",
|
|
||||||
"lastRun": "2026-07-20T19:18:55.166Z"
|
|
||||||
},
|
|
||||||
"consolidate": {
|
|
||||||
"runCount": 6,
|
|
||||||
"successCount": 6,
|
|
||||||
"failureCount": 0,
|
|
||||||
"averageDurationMs": 7.5,
|
|
||||||
"isRunning": false,
|
|
||||||
"nextRun": "2026-07-20T19:50:55.096Z",
|
|
||||||
"lastStartedAt": "2026-07-20T19:20:55.092Z",
|
|
||||||
"lastRun": "2026-07-20T19:20:55.094Z"
|
|
||||||
},
|
|
||||||
"testgaps": {
|
|
||||||
"runCount": 8,
|
|
||||||
"successCount": 8,
|
|
||||||
"failureCount": 0,
|
|
||||||
"averageDurationMs": 8.375,
|
|
||||||
"isRunning": false,
|
|
||||||
"nextRun": "2026-07-20T19:32:55.117Z",
|
|
||||||
"lastStartedAt": "2026-07-20T19:12:55.104Z",
|
|
||||||
"lastRun": "2026-07-20T19:12:55.114Z"
|
|
||||||
},
|
|
||||||
"backup": {
|
|
||||||
"runCount": 1,
|
|
||||||
"successCount": 1,
|
|
||||||
"failureCount": 0,
|
|
||||||
"averageDurationMs": 9,
|
|
||||||
"isRunning": false,
|
|
||||||
"nextRun": "2026-07-21T16:54:54.808Z",
|
|
||||||
"lastStartedAt": "2026-07-20T16:54:54.797Z",
|
|
||||||
"lastRun": "2026-07-20T16:54:54.806Z"
|
|
||||||
},
|
|
||||||
"harness": {
|
|
||||||
"runCount": 1,
|
|
||||||
"successCount": 1,
|
|
||||||
"failureCount": 0,
|
|
||||||
"averageDurationMs": 729,
|
|
||||||
"isRunning": false,
|
|
||||||
"nextRun": "2026-07-20T22:56:55.533Z",
|
|
||||||
"lastStartedAt": "2026-07-20T16:56:54.797Z",
|
|
||||||
"lastRun": "2026-07-20T16:56:55.526Z"
|
|
||||||
},
|
|
||||||
"predict": {
|
|
||||||
"runCount": 0,
|
|
||||||
"successCount": 0,
|
|
||||||
"failureCount": 0,
|
|
||||||
"averageDurationMs": 0,
|
|
||||||
"isRunning": false
|
|
||||||
},
|
|
||||||
"document": {
|
|
||||||
"runCount": 0,
|
|
||||||
"successCount": 0,
|
|
||||||
"failureCount": 0,
|
|
||||||
"averageDurationMs": 0,
|
|
||||||
"isRunning": false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"config": {
|
|
||||||
"autoStart": false,
|
|
||||||
"logDir": "C:\\SiMS\\hq\\.claude-flow\\logs",
|
|
||||||
"stateFile": "C:\\SiMS\\hq\\.claude-flow\\daemon-state.json",
|
|
||||||
"maxConcurrent": 2,
|
|
||||||
"workerTimeoutMs": 960000,
|
|
||||||
"resourceThresholds": {
|
|
||||||
"maxCpuLoad": 9.600000000000001,
|
|
||||||
"minFreeMemoryPercent": 10
|
|
||||||
},
|
|
||||||
"ttlMs": 43200000,
|
|
||||||
"idleShutdownMs": 0,
|
|
||||||
"aiWorkersEnabled": false,
|
|
||||||
"workers": [
|
|
||||||
{
|
|
||||||
"type": "map",
|
|
||||||
"intervalMs": 900000,
|
|
||||||
"offsetMs": 0,
|
|
||||||
"priority": "normal",
|
|
||||||
"description": "Codebase mapping",
|
|
||||||
"enabled": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "audit",
|
|
||||||
"intervalMs": 600000,
|
|
||||||
"offsetMs": 120000,
|
|
||||||
"priority": "critical",
|
|
||||||
"description": "Security analysis",
|
|
||||||
"enabled": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "optimize",
|
|
||||||
"intervalMs": 900000,
|
|
||||||
"offsetMs": 240000,
|
|
||||||
"priority": "high",
|
|
||||||
"description": "Performance optimization",
|
|
||||||
"enabled": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "consolidate",
|
|
||||||
"intervalMs": 1800000,
|
|
||||||
"offsetMs": 360000,
|
|
||||||
"priority": "low",
|
|
||||||
"description": "Memory distillation (ADR-174)",
|
|
||||||
"enabled": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "testgaps",
|
|
||||||
"intervalMs": 1200000,
|
|
||||||
"offsetMs": 480000,
|
|
||||||
"priority": "normal",
|
|
||||||
"description": "Test coverage analysis",
|
|
||||||
"enabled": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "backup",
|
|
||||||
"intervalMs": 86400000,
|
|
||||||
"offsetMs": 600000,
|
|
||||||
"priority": "low",
|
|
||||||
"description": "Nightly memory DB backup (WAL-safe, rotated)",
|
|
||||||
"enabled": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "harness",
|
|
||||||
"intervalMs": 21600000,
|
|
||||||
"offsetMs": 720000,
|
|
||||||
"priority": "low",
|
|
||||||
"description": "Self-optimizing harness loop (opt-in RUFLO_HARNESS_LOOP, $0-default)",
|
|
||||||
"enabled": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "predict",
|
|
||||||
"intervalMs": 600000,
|
|
||||||
"offsetMs": 0,
|
|
||||||
"priority": "low",
|
|
||||||
"description": "Predictive preloading",
|
|
||||||
"enabled": false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "document",
|
|
||||||
"intervalMs": 3600000,
|
|
||||||
"offsetMs": 0,
|
|
||||||
"priority": "low",
|
|
||||||
"description": "Auto-documentation",
|
|
||||||
"enabled": false
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"savedAt": "2026-07-20T19:26:56.135Z"
|
|
||||||
}
|
|
||||||
@ -1,11 +0,0 @@
|
|||||||
{
|
|
||||||
"timestamp": "2026-07-20T19:14:55.182Z",
|
|
||||||
"projectRoot": "C:\\SiMS\\hq",
|
|
||||||
"structure": {
|
|
||||||
"hasPackageJson": true,
|
|
||||||
"hasTsConfig": true,
|
|
||||||
"hasClaudeConfig": true,
|
|
||||||
"hasClaudeFlow": true
|
|
||||||
},
|
|
||||||
"scannedAt": 1784574895183
|
|
||||||
}
|
|
||||||
@ -1,16 +0,0 @@
|
|||||||
{
|
|
||||||
"timestamp": "2026-07-20T19:20:55.094Z",
|
|
||||||
"distillationEnabled": true,
|
|
||||||
"patternsConsolidated": 0,
|
|
||||||
"memoryCleaned": 0,
|
|
||||||
"duplicatesRemoved": 0,
|
|
||||||
"episodes": 0,
|
|
||||||
"patternEmbeddings": 0,
|
|
||||||
"causalEdges": 0,
|
|
||||||
"promoted": 0,
|
|
||||||
"byProvenance": {},
|
|
||||||
"namespaces": [],
|
|
||||||
"dryRun": false,
|
|
||||||
"corrupt": false,
|
|
||||||
"skipped": "no-db"
|
|
||||||
}
|
|
||||||
@ -1,17 +0,0 @@
|
|||||||
{
|
|
||||||
"timestamp": "2026-07-20T19:18:55.166Z",
|
|
||||||
"mode": "local",
|
|
||||||
"memoryUsage": {
|
|
||||||
"rss": 46977024,
|
|
||||||
"heapTotal": 21909504,
|
|
||||||
"heapUsed": 20452192,
|
|
||||||
"external": 3903735,
|
|
||||||
"arrayBuffers": 100670
|
|
||||||
},
|
|
||||||
"uptime": 9240.595012,
|
|
||||||
"optimizations": {
|
|
||||||
"cacheHitRate": 0.78,
|
|
||||||
"avgResponseTime": 45
|
|
||||||
},
|
|
||||||
"note": "Install Claude Code CLI for AI-powered optimization suggestions"
|
|
||||||
}
|
|
||||||
@ -1,12 +0,0 @@
|
|||||||
{
|
|
||||||
"timestamp": "2026-07-20T19:26:56.127Z",
|
|
||||||
"mode": "local",
|
|
||||||
"checks": {
|
|
||||||
"envFilesProtected": true,
|
|
||||||
"gitIgnoreExists": true,
|
|
||||||
"noHardcodedSecrets": true
|
|
||||||
},
|
|
||||||
"riskLevel": "low",
|
|
||||||
"recommendations": [],
|
|
||||||
"note": "Install Claude Code CLI for AI-powered security analysis"
|
|
||||||
}
|
|
||||||
@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"timestamp": "2026-07-20T19:12:55.112Z",
|
|
||||||
"mode": "local",
|
|
||||||
"hasTestDir": false,
|
|
||||||
"estimatedCoverage": "unknown",
|
|
||||||
"gaps": [],
|
|
||||||
"note": "Install Claude Code CLI for AI-powered test gap analysis"
|
|
||||||
}
|
|
||||||
@ -1 +0,0 @@
|
|||||||
sha256:6141a8ea990c5063b77e090ae8f37f9c539d8aa8f58dcceb30f3a82f97e57319
|
|
||||||
@ -1,42 +0,0 @@
|
|||||||
{
|
|
||||||
"adoptedAt": 1784565886864,
|
|
||||||
"championId": "sha256:6141a8ea990c5063b77e090ae8f37f9c539d8aa8f58dcceb30f3a82f97e57319",
|
|
||||||
"manifest": {
|
|
||||||
"schema": "ruflo.proven-config/v1",
|
|
||||||
"policy": {
|
|
||||||
"ref": "sha256:6141a8ea990c5063b77e090ae8f37f9c539d8aa8f58dcceb30f3a82f97e57319",
|
|
||||||
"value": {
|
|
||||||
"alpha": 0.3,
|
|
||||||
"subjectWeight": 1,
|
|
||||||
"mmrLambda": 0.5,
|
|
||||||
"bodyWeight": 1.5,
|
|
||||||
"typePenaltyFactor": 0.5
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"layer": "framework/node-cli",
|
|
||||||
"compatibility": {
|
|
||||||
"ruflo": ">=3.24.0"
|
|
||||||
},
|
|
||||||
"benchmark": {
|
|
||||||
"corpus": "ADR-081-labelled-v1",
|
|
||||||
"corpusHash": "sha256:2f700b5c363e20a3bd88ce2bc9b87bbbbaa61732c6177894c6ec37890f888982"
|
|
||||||
},
|
|
||||||
"receipt": {
|
|
||||||
"heldOutDelta": 0.07381404928570845,
|
|
||||||
"redblue": "PASS",
|
|
||||||
"drift": 0,
|
|
||||||
"canary": {
|
|
||||||
"rollbackRate": 0,
|
|
||||||
"latencyP95": 244.612458000076,
|
|
||||||
"costPerTask": 0
|
|
||||||
},
|
|
||||||
"receiptCoverage": 1
|
|
||||||
},
|
|
||||||
"platform": [
|
|
||||||
"linux",
|
|
||||||
"macOS",
|
|
||||||
"windows"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"previous": ""
|
|
||||||
}
|
|
||||||
@ -1,24 +0,0 @@
|
|||||||
// apps/hq/scripts/backup.ts — `npm run backup` (D33). Dumps the production Postgres to a
|
|
||||||
// timestamped, compressed, restorable file under ./backups (or $HQ_BACKUP_DIR) and rotates to
|
|
||||||
// the newest $HQ_BACKUP_KEEP (default 14). Run from the repo root:
|
|
||||||
// npm run backup
|
|
||||||
// DATABASE_URL=postgres://… npm run backup (explicit target, overrides resolution)
|
|
||||||
// The target is resolved through the SAME shared resolver the server and every other
|
|
||||||
// maintenance script uses (`requireDbUrlForEnv`, D19) — the old scripts/backup.mjs read
|
|
||||||
// DATABASE_URL directly and so could never find the production database, which is resolved
|
|
||||||
// rather than exported on that box. The resolved host/db is logged (never the password)
|
|
||||||
// before pg_dump runs.
|
|
||||||
//
|
|
||||||
// Restore a dump with:
|
|
||||||
// pg_restore --clean --if-exists --no-owner -d "$DATABASE_URL" backups/hq-<ts>.dump
|
|
||||||
//
|
|
||||||
// SECURITY (D32): credentials are stored in the CLEAR, so these dumps contain plaintext
|
|
||||||
// logins. Keep backups/ access-controlled and off any shared drive (it is gitignored).
|
|
||||||
import { runBackup } from '../src/backup'
|
|
||||||
|
|
||||||
try {
|
|
||||||
runBackup()
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e instanceof Error ? e.message : String(e))
|
|
||||||
process.exit(1)
|
|
||||||
}
|
|
||||||
@ -1,75 +0,0 @@
|
|||||||
// Go-live data correction: the earlier migrate-sqlite-to-pg.ts run pulled from local SQLite,
|
|
||||||
// which turned out to be stale/near-empty — the real working data was in a local Postgres
|
|
||||||
// instance instead. This wipes the TARGET (cloud) hq database's public schema and reloads it
|
|
||||||
// from the real SOURCE (local Postgres). Both engines are Postgres here, so every column type
|
|
||||||
// matches exactly — no conversion needed.
|
|
||||||
// SOURCE_DATABASE_URL and TARGET_DATABASE_URL must both be set explicitly, no defaults, so
|
|
||||||
// this can never accidentally point at (or wipe) the wrong database.
|
|
||||||
//
|
|
||||||
// Run from repo root:
|
|
||||||
// SOURCE_DATABASE_URL=postgres://hq:<local-pw>@127.0.0.1:5432/hq ^
|
|
||||||
// TARGET_DATABASE_URL=postgres://postgres:<cloud-pw>@13.232.53.115:5432/hq ^
|
|
||||||
// npx tsx apps/hq/scripts/migrate-pg-to-pg.ts
|
|
||||||
import { Pool } from 'pg'
|
|
||||||
import { openPgDb, type PgDb } from '../src/db-pg'
|
|
||||||
|
|
||||||
const SKIP_TABLES = new Set(['stg_client', 'stg_invoice', 'schema_migrations'])
|
|
||||||
|
|
||||||
async function main(): Promise<void> {
|
|
||||||
const sourceUrl = process.env['SOURCE_DATABASE_URL']
|
|
||||||
const targetUrl = process.env['TARGET_DATABASE_URL']
|
|
||||||
if (sourceUrl === undefined || sourceUrl === '') throw new Error('Set SOURCE_DATABASE_URL (local Postgres).')
|
|
||||||
if (targetUrl === undefined || targetUrl === '') throw new Error('Set TARGET_DATABASE_URL (cloud Postgres).')
|
|
||||||
if (sourceUrl === targetUrl) throw new Error('SOURCE and TARGET are identical — refusing.')
|
|
||||||
|
|
||||||
// Wipe the target's public schema completely, then let the app's own migrations rebuild
|
|
||||||
// it clean — guarantees the target schema matches this codebase exactly, not a stale copy.
|
|
||||||
const wipePool = new Pool({ connectionString: targetUrl, max: 1 })
|
|
||||||
await wipePool.query('DROP SCHEMA public CASCADE')
|
|
||||||
await wipePool.query('CREATE SCHEMA public')
|
|
||||||
await wipePool.end()
|
|
||||||
console.log('Target schema wiped.')
|
|
||||||
|
|
||||||
const target = (await openPgDb(targetUrl)) as PgDb // recreates schema via migrations, empty
|
|
||||||
const source = (await openPgDb(sourceUrl)) as PgDb // idempotent no-op if already migrated
|
|
||||||
|
|
||||||
const tables = await source.all<{ tablename: string }>(`SELECT tablename FROM pg_tables WHERE schemaname='public'`)
|
|
||||||
|
|
||||||
const client = await target.pool.connect()
|
|
||||||
try {
|
|
||||||
await client.query('BEGIN')
|
|
||||||
// Load in discovery order and ignore FK/trigger checks — safe: one transaction into a
|
|
||||||
// target we just wiped and confirmed empty.
|
|
||||||
await client.query('SET LOCAL session_replication_role = replica')
|
|
||||||
for (const { tablename: name } of tables) {
|
|
||||||
if (SKIP_TABLES.has(name)) {
|
|
||||||
console.log(`skip ${name}`)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
const rows = await source.all<Record<string, unknown>>(`SELECT * FROM ${name}`)
|
|
||||||
if (rows.length === 0) {
|
|
||||||
console.log(`${name}: 0 rows`)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
const cols = Object.keys(rows[0]!)
|
|
||||||
const placeholders = cols.map((_, i) => `$${i + 1}`).join(',')
|
|
||||||
const insertSql = `INSERT INTO ${name} (${cols.join(',')}) VALUES (${placeholders})`
|
|
||||||
for (const row of rows) {
|
|
||||||
await client.query(insertSql, cols.map((c) => row[c]))
|
|
||||||
}
|
|
||||||
console.log(`${name}: ${rows.length} row(s) migrated`)
|
|
||||||
}
|
|
||||||
await client.query('COMMIT')
|
|
||||||
} catch (err) {
|
|
||||||
await client.query('ROLLBACK')
|
|
||||||
throw err
|
|
||||||
} finally {
|
|
||||||
client.release()
|
|
||||||
}
|
|
||||||
console.log('Migration complete.')
|
|
||||||
}
|
|
||||||
|
|
||||||
main().catch((e: unknown) => {
|
|
||||||
console.error(e)
|
|
||||||
process.exit(1)
|
|
||||||
})
|
|
||||||
@ -1,76 +0,0 @@
|
|||||||
// Go-live data carry-over: copies every row from the local SQLite dev database into the
|
|
||||||
// target (cloud) Postgres database, preserving values and ids as-is — this is a one-time
|
|
||||||
// full copy, not the "born in Postgres" empty-start path in docs/DEPLOY-HQ.md.
|
|
||||||
// Column types are a straight 1:1 map on this schema (money is bigint paise, flags stay
|
|
||||||
// integer, JSON payloads stay text — see migrations-pg.ts header), so no value conversion
|
|
||||||
// is needed; every column is copied through unchanged.
|
|
||||||
// Refuses to run if the target already has staff_user rows, so a re-run (or pointing at
|
|
||||||
// the wrong DATABASE_URL) can't silently duplicate or clobber data.
|
|
||||||
// stg_client/stg_invoice are APEX-import scratch tables, not real data — skipped.
|
|
||||||
//
|
|
||||||
// Run from repo root, source = local ./data/hq.db (or HQ_DATA_DIR), target = DATABASE_URL:
|
|
||||||
// DATABASE_URL=postgres://postgres:<pw>@<host>:5432/hq npx tsx apps/hq/scripts/migrate-sqlite-to-pg.ts
|
|
||||||
import { openDb, type SqliteDb } from '../src/db'
|
|
||||||
import { openPgDb, type PgDb } from '../src/db-pg'
|
|
||||||
|
|
||||||
const SKIP_TABLES = new Set(['stg_client', 'stg_invoice', 'schema_migrations'])
|
|
||||||
|
|
||||||
async function main(): Promise<void> {
|
|
||||||
const pgUrl = process.env['DATABASE_URL']
|
|
||||||
if (pgUrl === undefined || pgUrl === '') {
|
|
||||||
throw new Error('Set DATABASE_URL to the target (cloud) Postgres connection string.')
|
|
||||||
}
|
|
||||||
|
|
||||||
const sqlite = openDb(process.env['HQ_DATA_DIR']) as SqliteDb
|
|
||||||
const pg = (await openPgDb(pgUrl)) as PgDb // schema is created by migrations here; seedIfEmpty is NOT called
|
|
||||||
|
|
||||||
const existingStaff = (await pg.get<{ n: number }>(`SELECT COUNT(*) AS n FROM staff_user`))!.n
|
|
||||||
if (existingStaff > 0) {
|
|
||||||
throw new Error(
|
|
||||||
`Target already has ${existingStaff} staff_user row(s) — refusing to import over existing data. ` +
|
|
||||||
`Wrong DATABASE_URL, or this has already been migrated.`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const tables = sqlite.raw
|
|
||||||
.prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'`)
|
|
||||||
.all() as { name: string }[]
|
|
||||||
|
|
||||||
const client = await pg.pool.connect()
|
|
||||||
try {
|
|
||||||
await client.query('BEGIN')
|
|
||||||
// Load in table-discovery order and ignore FK/trigger checks — safe here because the
|
|
||||||
// whole load is one transaction into a database we just confirmed is empty.
|
|
||||||
await client.query('SET LOCAL session_replication_role = replica')
|
|
||||||
for (const { name } of tables) {
|
|
||||||
if (SKIP_TABLES.has(name)) {
|
|
||||||
console.log(`skip ${name}`)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
const rows = sqlite.raw.prepare(`SELECT * FROM ${name}`).all() as Record<string, unknown>[]
|
|
||||||
if (rows.length === 0) {
|
|
||||||
console.log(`${name}: 0 rows`)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
const cols = Object.keys(rows[0]!)
|
|
||||||
const placeholders = cols.map((_, i) => `$${i + 1}`).join(',')
|
|
||||||
const insertSql = `INSERT INTO ${name} (${cols.join(',')}) VALUES (${placeholders})`
|
|
||||||
for (const row of rows) {
|
|
||||||
await client.query(insertSql, cols.map((c) => row[c]))
|
|
||||||
}
|
|
||||||
console.log(`${name}: ${rows.length} row(s) migrated`)
|
|
||||||
}
|
|
||||||
await client.query('COMMIT')
|
|
||||||
} catch (err) {
|
|
||||||
await client.query('ROLLBACK')
|
|
||||||
throw err
|
|
||||||
} finally {
|
|
||||||
client.release()
|
|
||||||
}
|
|
||||||
console.log('Migration complete.')
|
|
||||||
}
|
|
||||||
|
|
||||||
main().catch((e: unknown) => {
|
|
||||||
console.error(e)
|
|
||||||
process.exit(1)
|
|
||||||
})
|
|
||||||
@ -1,128 +0,0 @@
|
|||||||
import { execFileSync } from 'node:child_process'
|
|
||||||
import { existsSync, mkdirSync, readdirSync, statSync, unlinkSync } from 'node:fs'
|
|
||||||
import { join } from 'node:path'
|
|
||||||
import { sqliteFilePath } from './db'
|
|
||||||
import { pgTargetDescription, resolveDbUrl } from './db-pg'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Database backup (D33): `pg_dump` of the production Postgres to a timestamped, compressed,
|
|
||||||
* restorable file under ./backups (or $HQ_BACKUP_DIR), rotated to the newest N.
|
|
||||||
*
|
|
||||||
* Lives in src/ (not scripts/) so it is typechecked and unit-testable; `scripts/backup.ts` is
|
|
||||||
* the thin CLI wrapper `npm run backup` invokes.
|
|
||||||
*
|
|
||||||
* The fix this file exists for: the previous `scripts/backup.mjs` read `process.env.DATABASE_URL`
|
|
||||||
* directly and bailed out when it was unset. On the production box DATABASE_URL is NOT set — the
|
|
||||||
* connection is resolved by `resolveDbUrl` (D19) — so the backup command could never reach the
|
|
||||||
* real database. It now resolves its target through the SAME shared resolver every other
|
|
||||||
* maintenance script uses, and logs the resolved target (never the password) before it runs.
|
|
||||||
*
|
|
||||||
* SECURITY (D32): credentials are stored in the CLEAR, so these dumps contain plaintext logins.
|
|
||||||
* Keep the backups/ directory access-controlled and off any shared drive; it is gitignored so it
|
|
||||||
* can never be committed.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** Env knobs, defaulted here so the CLI wrapper stays a one-liner. */
|
|
||||||
export const DEFAULT_KEEP = 14
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The Postgres URL this backup run must dump — via `resolveDbUrl`, the one shared resolver
|
|
||||||
* (D19: DATABASE_URL, else the production connection). This is the fix: the target is
|
|
||||||
* RESOLVED exactly as the server resolves it, instead of being read straight off
|
|
||||||
* `process.env.DATABASE_URL`, which is not set on the production box.
|
|
||||||
*
|
|
||||||
* A SQLite outcome is fatal here rather than an opt-in (unlike the migration scripts, which
|
|
||||||
* can legitimately run against SQLite): `pg_dump` cannot read a SQLite file at all, so the
|
|
||||||
* only useful answer is a loud error naming the SQLite snapshot script instead.
|
|
||||||
*/
|
|
||||||
export function resolveBackupTarget(env: NodeJS.ProcessEnv = process.env): string {
|
|
||||||
const url = resolveDbUrl(env)
|
|
||||||
if (url === '') {
|
|
||||||
throw new Error(
|
|
||||||
'[backup] refusing to run: no Postgres target resolved, so this would back up nothing. ' +
|
|
||||||
`The database resolved to SQLite at ${sqliteFilePath(env['HQ_DATA_DIR'])}, and this ` +
|
|
||||||
'backup dumps Postgres (pg_dump). Set DATABASE_URL=postgres://… to back up the real ' +
|
|
||||||
'database, or use apps/hq/scripts/backup-hq.sh for a SQLite snapshot.',
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return url
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Locate pg_dump: PATH first, then a standard Windows PostgreSQL install. */
|
|
||||||
export function findPgDump(): string | null {
|
|
||||||
try {
|
|
||||||
execFileSync(process.platform === 'win32' ? 'where' : 'which', ['pg_dump'], { stdio: 'ignore' })
|
|
||||||
return 'pg_dump'
|
|
||||||
} catch { /* not on PATH */ }
|
|
||||||
if (process.platform === 'win32') {
|
|
||||||
const base = 'C:/Program Files/PostgreSQL'
|
|
||||||
try {
|
|
||||||
for (const v of readdirSync(base).sort().reverse()) {
|
|
||||||
const cand = join(base, v, 'bin', 'pg_dump.exe')
|
|
||||||
if (existsSync(cand)) return cand
|
|
||||||
}
|
|
||||||
} catch { /* no install dir */ }
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
/** `hq-YYYYMMDD-HHMMSS.dump` for the given local time. Pure — the rotation regex must match it. */
|
|
||||||
export function backupFileName(now: Date): string {
|
|
||||||
const p2 = (n: number): string => String(n).padStart(2, '0')
|
|
||||||
const ts = `${now.getFullYear()}${p2(now.getMonth() + 1)}${p2(now.getDate())}-` +
|
|
||||||
`${p2(now.getHours())}${p2(now.getMinutes())}${p2(now.getSeconds())}`
|
|
||||||
return `hq-${ts}.dump`
|
|
||||||
}
|
|
||||||
|
|
||||||
const DUMP_RE = /^hq-\d{8}-\d{6}\.dump$/
|
|
||||||
|
|
||||||
/** Which of `files` fall outside the newest `keep` dumps. Pure, so rotation is testable. */
|
|
||||||
export function dumpsToRotate(files: readonly string[], keep: number): string[] {
|
|
||||||
const dumps = files.filter((f) => DUMP_RE.test(f)).sort() // name sorts chronologically
|
|
||||||
return dumps.slice(0, Math.max(0, dumps.length - Math.max(1, keep)))
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BackupResult { file: string; bytes: number; rotated: string[]; retained: number }
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Run the backup end to end. `log` is injected so the CLI prints and tests stay quiet.
|
|
||||||
* Env: HQ_BACKUP_DIR (default ./backups), HQ_BACKUP_KEEP (default 14).
|
|
||||||
*/
|
|
||||||
export function runBackup(
|
|
||||||
env: NodeJS.ProcessEnv = process.env,
|
|
||||||
log: (msg: string) => void = console.log,
|
|
||||||
): BackupResult {
|
|
||||||
const url = resolveBackupTarget(env)
|
|
||||||
// Log the resolved target BEFORE doing anything: a backup that quietly dumped the wrong
|
|
||||||
// database is indistinguishable from a correct one until the day you need the restore.
|
|
||||||
log(`[db] engine: postgres ${pgTargetDescription(url)}`)
|
|
||||||
|
|
||||||
const pgDump = findPgDump()
|
|
||||||
if (pgDump === null) {
|
|
||||||
throw new Error('[backup] pg_dump not found. Install the PostgreSQL client tools, or add pg_dump to PATH.')
|
|
||||||
}
|
|
||||||
|
|
||||||
const out = env['HQ_BACKUP_DIR'] || join(process.cwd(), 'backups')
|
|
||||||
const keep = Math.max(1, Number(env['HQ_BACKUP_KEEP'] || DEFAULT_KEEP))
|
|
||||||
mkdirSync(out, { recursive: true })
|
|
||||||
const file = join(out, backupFileName(new Date()))
|
|
||||||
|
|
||||||
// -Fc = custom, compressed, restorable with pg_restore. --no-owner keeps it portable.
|
|
||||||
try {
|
|
||||||
execFileSync(pgDump, ['-Fc', '--no-owner', '-f', file, url], { stdio: ['ignore', 'inherit', 'inherit'] })
|
|
||||||
} catch (e) {
|
|
||||||
throw new Error(`[backup] pg_dump failed: ${e instanceof Error ? e.message : String(e)}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const bytes = statSync(file).size
|
|
||||||
log(`✓ backup written: ${file} (${(bytes / 1024).toFixed(1)} KB)`)
|
|
||||||
|
|
||||||
const rotated = dumpsToRotate(readdirSync(out), keep)
|
|
||||||
for (const f of rotated) {
|
|
||||||
unlinkSync(join(out, f))
|
|
||||||
log(` rotated out: ${f}`)
|
|
||||||
}
|
|
||||||
const retained = readdirSync(out).filter((f) => DUMP_RE.test(f)).length
|
|
||||||
log(`retained ${retained} backup(s) in ${out} (HQ_BACKUP_KEEP=${keep})`)
|
|
||||||
return { file, bytes, rotated, retained }
|
|
||||||
}
|
|
||||||
@ -1,51 +0,0 @@
|
|||||||
// apps/hq/test/backup.test.ts — `npm run backup` (D33) must find the PRODUCTION database.
|
|
||||||
// It used to read process.env.DATABASE_URL directly and exit when it was unset — but on the
|
|
||||||
// prod box DATABASE_URL is not exported; the connection is RESOLVED (D19). So the backup
|
|
||||||
// command could never reach the real data. It now goes through the one shared resolver, and
|
|
||||||
// a SQLite outcome is a loud error rather than a pg_dump against a file it cannot read.
|
|
||||||
import { describe, it, expect } from 'vitest'
|
|
||||||
import { backupFileName, dumpsToRotate, resolveBackupTarget } from '../src/backup'
|
|
||||||
|
|
||||||
describe('resolveBackupTarget (same resolver as the server + every maintenance script)', () => {
|
|
||||||
it('uses DATABASE_URL when it is set', () => {
|
|
||||||
expect(resolveBackupTarget({ DATABASE_URL: 'postgres://x/y' })).toBe('postgres://x/y')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('resolves the production target with no DATABASE_URL exported — the whole point of the fix', () => {
|
|
||||||
// NODE_ENV=production with no DATABASE_URL used to be an instant "DATABASE_URL is not set"
|
|
||||||
// exit; it must now resolve to the same Postgres the server picks.
|
|
||||||
const url = resolveBackupTarget({ NODE_ENV: 'production' })
|
|
||||||
expect(url).toMatch(/^postgres:\/\//)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('refuses (never silently no-ops) when nothing resolves a target', () => {
|
|
||||||
expect(() => resolveBackupTarget({})).toThrow(/refusing to run/i)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('says WHY, naming pg_dump, the SQLite path it landed on, and the SQLite snapshot script', () => {
|
|
||||||
const err = (() => { try { resolveBackupTarget({ HQ_DATA_DIR: 'D:/hq-data' }); return '' } catch (e) { return String(e) } })()
|
|
||||||
expect(err).toMatch(/pg_dump/)
|
|
||||||
expect(err).toMatch(/backup-hq\.sh/)
|
|
||||||
expect(err).toMatch(/hq\.db/)
|
|
||||||
expect(err).toMatch(/DATABASE_URL=postgres/)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('backup file naming + rotation', () => {
|
|
||||||
it('names dumps hq-YYYYMMDD-HHMMSS.dump', () => {
|
|
||||||
expect(backupFileName(new Date(2026, 6, 19, 2, 5, 9))).toBe('hq-20260719-020509.dump')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('rotates out everything past the newest KEEP, ignoring unrelated files', () => {
|
|
||||||
const files = [
|
|
||||||
'hq-20260101-000000.dump', 'hq-20260102-000000.dump', 'hq-20260103-000000.dump',
|
|
||||||
'notes.txt', 'hq-old.dump',
|
|
||||||
]
|
|
||||||
expect(dumpsToRotate(files, 2)).toEqual(['hq-20260101-000000.dump'])
|
|
||||||
expect(dumpsToRotate(files, 14)).toEqual([])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('keeps at least one dump however low KEEP is set', () => {
|
|
||||||
expect(dumpsToRotate(['hq-20260101-000000.dump'], 0)).toEqual([])
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@ -1,113 +0,0 @@
|
|||||||
// apps/hq/test/milestone-link-preservation.test.ts — re-ticking an already-done step must not
|
|
||||||
// wipe the payment / document link it already carries.
|
|
||||||
//
|
|
||||||
// The bug: setMilestone wrote `payment_id`/`document_id` as `done ? linked : null`, so any tick
|
|
||||||
// that didn't resend the ids cleared them. bulkSetMilestone routes through setMilestone and
|
|
||||||
// never passes ids at all (there is one key for many projects), so a single bulk re-tick — the
|
|
||||||
// onboarding-cleanup tool — silently cut every project's step loose from the payment or
|
|
||||||
// quotation it had been linked to one at a time. Unticking must still clear both.
|
|
||||||
import { describe, it, expect } from 'vitest'
|
|
||||||
import { openDb } from '../src/db'
|
|
||||||
import { seedIfEmpty } from '../src/seed'
|
|
||||||
import { createClient } from '../src/repos-clients'
|
|
||||||
import { createModule, setPrice, assignModule } from '../src/repos-modules'
|
|
||||||
import { createDraft } from '../src/repos-documents'
|
|
||||||
import { recordPayment } from '../src/repos-payments'
|
|
||||||
import { bulkSetMilestone, listProjectMilestones, setMilestone } from '../src/repos-milestones'
|
|
||||||
|
|
||||||
async function world() {
|
|
||||||
const db = openDb(':memory:')
|
|
||||||
await seedIfEmpty(db)
|
|
||||||
const c = await createClient(db, 'u1', { name: 'Kothavara SCB', stateCode: '32' })
|
|
||||||
const m = await createModule(db, 'u1', { code: 'SMS', name: 'Bulk SMS' })
|
|
||||||
await setPrice(db, 'u1', { moduleId: m.id, kind: 'yearly', pricePaise: 10_000_00, effectiveFrom: '2020-01-01' })
|
|
||||||
const cm = await assignModule(db, 'u1', { clientId: c.id, moduleId: m.id, kind: 'yearly' })
|
|
||||||
const step = async (key: string) => (await listProjectMilestones(db, cm.id)).find((s) => s.key === key)!
|
|
||||||
return { db, clientId: c.id, moduleId: m.id, cmId: cm.id, step }
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('bulk re-tick preserves the payment link (5a)', () => {
|
|
||||||
it('tick with a payment link → bulk re-tick keeps it → bulk untick clears it', async () => {
|
|
||||||
const { db, clientId, cmId, step } = await world()
|
|
||||||
const pay = await recordPayment(db, 'u1', {
|
|
||||||
clientId, receivedOn: '2026-05-01', mode: 'bank', amountPaise: 1000_00,
|
|
||||||
})
|
|
||||||
await setMilestone(db, 'u1', cmId, 'payment_received', true, undefined, pay.payment.id)
|
|
||||||
expect((await step('payment_received')).paymentId).toBe(pay.payment.id)
|
|
||||||
|
|
||||||
// The bulk tool carries no ids — the stored link must survive.
|
|
||||||
const res = await bulkSetMilestone(db, 'u1', [cmId], 'payment_received', true, '2026-06-01')
|
|
||||||
expect(res.updated).toBe(1)
|
|
||||||
const after = await step('payment_received')
|
|
||||||
expect(after.done).toBe(true)
|
|
||||||
expect(after.paymentId).toBe(pay.payment.id)
|
|
||||||
|
|
||||||
// Unticking still cuts the link loose.
|
|
||||||
await bulkSetMilestone(db, 'u1', [cmId], 'payment_received', false)
|
|
||||||
const cleared = await step('payment_received')
|
|
||||||
expect(cleared.done).toBe(false)
|
|
||||||
expect(cleared.doneOn).toBeNull()
|
|
||||||
expect(cleared.paymentId).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('a single re-tick that sends no ids keeps the link too', async () => {
|
|
||||||
const { db, clientId, cmId, step } = await world()
|
|
||||||
const pay = await recordPayment(db, 'u1', {
|
|
||||||
clientId, receivedOn: '2026-05-01', mode: 'bank', amountPaise: 1000_00,
|
|
||||||
})
|
|
||||||
await setMilestone(db, 'u1', cmId, 'payment_received', true, undefined, pay.payment.id)
|
|
||||||
await setMilestone(db, 'u1', cmId, 'payment_received', true, '2026-06-01')
|
|
||||||
const s = await step('payment_received')
|
|
||||||
expect(s.doneOn).toBe('2026-06-01') // the date the caller did send still moves
|
|
||||||
expect(s.paymentId).toBe(pay.payment.id)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('an explicit empty id is still an unlink, not a preserve', async () => {
|
|
||||||
const { db, clientId, cmId, step } = await world()
|
|
||||||
const pay = await recordPayment(db, 'u1', {
|
|
||||||
clientId, receivedOn: '2026-05-01', mode: 'bank', amountPaise: 1000_00,
|
|
||||||
})
|
|
||||||
await setMilestone(db, 'u1', cmId, 'payment_received', true, undefined, pay.payment.id)
|
|
||||||
await setMilestone(db, 'u1', cmId, 'payment_received', true, undefined, '')
|
|
||||||
expect((await step('payment_received')).paymentId).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('the preserved link is what the audit row reports', async () => {
|
|
||||||
const { db, clientId, cmId } = await world()
|
|
||||||
const pay = await recordPayment(db, 'u1', {
|
|
||||||
clientId, receivedOn: '2026-05-01', mode: 'bank', amountPaise: 1000_00,
|
|
||||||
})
|
|
||||||
await setMilestone(db, 'u1', cmId, 'payment_received', true, undefined, pay.payment.id)
|
|
||||||
await bulkSetMilestone(db, 'u1', [cmId], 'payment_received', true, '2026-06-01')
|
|
||||||
const audits = await db.all<{ after_json: string | null }>(
|
|
||||||
`SELECT after_json FROM audit_log WHERE action='set_milestone' ORDER BY id`,
|
|
||||||
)
|
|
||||||
expect(JSON.parse(audits.at(-1)!.after_json!)).toMatchObject({ paymentId: pay.payment.id })
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('bulk re-tick preserves the document link (5a)', () => {
|
|
||||||
it('tick with a quotation link → bulk re-tick keeps it → untick clears it', async () => {
|
|
||||||
const { db, clientId, moduleId, cmId, step } = await world()
|
|
||||||
const doc = await createDraft(db, 'u1', {
|
|
||||||
docType: 'QUOTATION', clientId, lines: [{ moduleId, qty: 1, kind: 'yearly' }],
|
|
||||||
})
|
|
||||||
await setMilestone(db, 'u1', cmId, 'quotation_sent', true, '2026-05-01', undefined, doc.id)
|
|
||||||
expect((await step('quotation_sent')).documentId).toBe(doc.id)
|
|
||||||
|
|
||||||
await bulkSetMilestone(db, 'u1', [cmId], 'quotation_sent', true, '2026-06-01')
|
|
||||||
expect((await step('quotation_sent')).documentId).toBe(doc.id)
|
|
||||||
|
|
||||||
await bulkSetMilestone(db, 'u1', [cmId], 'quotation_sent', false)
|
|
||||||
expect((await step('quotation_sent')).documentId).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('a step that was never done still starts with no link (nothing to preserve)', async () => {
|
|
||||||
const { db, cmId, step } = await world()
|
|
||||||
await bulkSetMilestone(db, 'u1', [cmId], 'quotation_sent', true, '2026-06-01')
|
|
||||||
const s = await step('quotation_sent')
|
|
||||||
expect(s.done).toBe(true)
|
|
||||||
expect(s.documentId).toBeNull()
|
|
||||||
expect(s.paymentId).toBeNull()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@ -0,0 +1,78 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* SiMS HQ database backup (ops slice). Dumps the Postgres database to a timestamped,
|
||||||
|
* compressed file under ./backups (or $HQ_BACKUP_DIR), then rotates to the newest N.
|
||||||
|
*
|
||||||
|
* DATABASE_URL=postgres://user:pass@host:5432/db node scripts/backup.mjs
|
||||||
|
* npm run backup (from apps/hq — passes DATABASE_URL through)
|
||||||
|
*
|
||||||
|
* Restore a dump with:
|
||||||
|
* pg_restore --clean --if-exists --no-owner -d "$DATABASE_URL" backups/hq-<ts>.dump
|
||||||
|
*
|
||||||
|
* SECURITY (D32): credentials are stored in the CLEAR, so these dumps contain plaintext
|
||||||
|
* logins. Keep the backups/ directory access-controlled and off any shared drive; it is
|
||||||
|
* gitignored so it can never be committed.
|
||||||
|
*
|
||||||
|
* Env: DATABASE_URL (required), HQ_BACKUP_DIR (default ./backups), HQ_BACKUP_KEEP (default 14).
|
||||||
|
*/
|
||||||
|
import { execFileSync } from 'node:child_process'
|
||||||
|
import { mkdirSync, readdirSync, statSync, unlinkSync, existsSync } from 'node:fs'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
|
||||||
|
const url = process.env.DATABASE_URL
|
||||||
|
if (!url || url.trim() === '') {
|
||||||
|
console.error('DATABASE_URL is not set. Run: DATABASE_URL=postgres://… node scripts/backup.mjs')
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Locate pg_dump: PATH first, then a standard Windows PostgreSQL install. */
|
||||||
|
function findPgDump() {
|
||||||
|
try {
|
||||||
|
execFileSync(process.platform === 'win32' ? 'where' : 'which', ['pg_dump'], { stdio: 'ignore' })
|
||||||
|
return 'pg_dump'
|
||||||
|
} catch { /* not on PATH */ }
|
||||||
|
if (process.platform === 'win32') {
|
||||||
|
const base = 'C:/Program Files/PostgreSQL'
|
||||||
|
try {
|
||||||
|
for (const v of readdirSync(base).sort().reverse()) {
|
||||||
|
const cand = join(base, v, 'bin', 'pg_dump.exe')
|
||||||
|
if (existsSync(cand)) return cand
|
||||||
|
}
|
||||||
|
} catch { /* no install dir */ }
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const pgDump = findPgDump()
|
||||||
|
if (pgDump === null) {
|
||||||
|
console.error('pg_dump not found. Install the PostgreSQL client tools, or add pg_dump to PATH.')
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const OUT = process.env.HQ_BACKUP_DIR || join(process.cwd(), 'backups')
|
||||||
|
const KEEP = Math.max(1, Number(process.env.HQ_BACKUP_KEEP || 14))
|
||||||
|
mkdirSync(OUT, { recursive: true })
|
||||||
|
|
||||||
|
const d = new Date()
|
||||||
|
const p2 = (n) => String(n).padStart(2, '0')
|
||||||
|
const ts = `${d.getFullYear()}${p2(d.getMonth() + 1)}${p2(d.getDate())}-${p2(d.getHours())}${p2(d.getMinutes())}${p2(d.getSeconds())}`
|
||||||
|
const file = join(OUT, `hq-${ts}.dump`)
|
||||||
|
|
||||||
|
// -Fc = custom, compressed, restorable with pg_restore. --no-owner keeps it portable.
|
||||||
|
try {
|
||||||
|
execFileSync(pgDump, ['-Fc', '--no-owner', '-f', file, url], { stdio: ['ignore', 'inherit', 'inherit'] })
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`pg_dump failed: ${e instanceof Error ? e.message : String(e)}`)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const kb = (statSync(file).size / 1024).toFixed(1)
|
||||||
|
console.log(`✓ backup written: ${file} (${kb} KB)`)
|
||||||
|
|
||||||
|
// Rotation: keep the newest KEEP hq-YYYYMMDD-HHMMSS.dump files.
|
||||||
|
const dumps = readdirSync(OUT).filter((f) => /^hq-\d{8}-\d{6}\.dump$/.test(f)).sort()
|
||||||
|
for (const f of dumps.slice(0, Math.max(0, dumps.length - KEEP))) {
|
||||||
|
unlinkSync(join(OUT, f))
|
||||||
|
console.log(` rotated out: ${f}`)
|
||||||
|
}
|
||||||
|
console.log(`retained ${Math.min(dumps.length, KEEP)} backup(s) in ${OUT} (HQ_BACKUP_KEEP=${KEEP})`)
|
||||||
Loading…
Reference in New Issue