fix(sync): ApiResponse envelope + atomic DB wipe on restore

This commit is contained in:
Hermes
2026-08-23 01:43:39 +00:00
parent c9010fed53
commit 402e2731ef
+14 -14
View File
@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from 'next/server';
import { spawnSync, execSync } from 'child_process';
import { spawnSync } from 'child_process';
import { ok, err } from '@/lib/api-response';
import path from 'path';
import fs from 'fs';
import os from 'os';
@@ -14,12 +15,12 @@ export async function POST(request: NextRequest) {
const body = await request.json().catch(() => ({} as Record<string, unknown>));
const expected = process.env.PULSE_SYNC_TOKEN;
if (!expected) {
return NextResponse.json({ error: 'Sync not configured (PULSE_SYNC_TOKEN missing)' }, { status: 500 });
return NextResponse.json(err('Sync not configured (PULSE_SYNC_TOKEN missing)'), { status: 500 });
}
const prodUrl = process.env.PULSE_PROD_URL ?? 'https://pulsy.hellobaka.com';
const confirm = (body as Record<string, unknown>).confirm;
if (confirm !== true && confirm !== 'true') {
return NextResponse.json({ error: 'Missing confirm:true — this wipes test DB' }, { status: 400 });
return NextResponse.json(err('Missing confirm:true — this wipes test DB'), { status: 400 });
}
// Fetch dump from prod
@@ -31,44 +32,43 @@ export async function POST(request: NextRequest) {
});
if (!res.ok) {
const text = await res.text().catch(() => '');
return NextResponse.json({ error: `prod dump failed: ${res.status} ${text.slice(0, 800)}` }, { status: 502 });
return NextResponse.json(err(`prod dump failed: ${res.status} ${text.slice(0, 800)}`), { status: 502 });
}
dump = await res.text();
if (!dump || dump.length < 100) {
return NextResponse.json({ error: 'prod dump empty or too small', len: dump.length }, { status: 502 });
return NextResponse.json(err('prod dump empty or too small'), { status: 502 });
}
} catch (e) {
return NextResponse.json({ error: `fetch prod failed: ${(e as Error).message}` }, { status: 502 });
return NextResponse.json(err(`fetch prod failed: ${(e as Error).message}`), { status: 502 });
}
const dbPath = resolveDbPath();
const dir = path.dirname(dbPath);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
// Write dump to temp file then restore
const tmp = path.join(os.tmpdir(), `pulse-restore-${Date.now()}.sql`);
try {
fs.writeFileSync(tmp, dump, 'utf-8');
// Count tables in dump for sanity
const tableCount = (dump.match(/CREATE TABLE/g) || []).length;
// Restore: sqlite3 db < dump.sql
// Wipe existing DB so PKs don't collide on re-sync (atomic replace)
for (const suffix of ['', '-wal', '-shm']) {
try { fs.unlinkSync(dbPath + suffix); } catch {}
}
const restore = spawnSync('sh', ['-c', `sqlite3 "${dbPath}" < "${tmp}"`], { encoding: 'utf-8', maxBuffer: 10 * 1024 * 1024 });
if (restore.status !== 0) {
return NextResponse.json({ error: 'restore failed', detail: (restore.stderr || restore.stdout || '').slice(0, 3000) }, { status: 500 });
return NextResponse.json(err('restore failed: ' + (restore.stderr || restore.stdout || '').slice(0, 1500)), { status: 500 });
}
// Quick sanity: count rows
const check = spawnSync('sqlite3', [dbPath, 'SELECT "companies:" || (SELECT count(*) FROM companies) || " employees:" || (SELECT count(*) FROM employees) || " entries:" || (SELECT count(*) FROM time_entries);'], { encoding: 'utf-8' });
const summary = (check.stdout || '').trim();
return NextResponse.json({ ok: true, bytes: dump.length, tables: tableCount, summary, dbPath });
return NextResponse.json(ok({ bytes: dump.length, tables: tableCount, summary, dbPath }));
} finally {
try { fs.unlinkSync(tmp); } catch {}
}
}
export async function GET() {
return NextResponse.json({ ok: true, hint: 'POST with { token, confirm: true } and x-sync-token header' });
return NextResponse.json(ok({ hint: 'POST with { confirm: true }' }));
}