From 402e2731ef2fe4ff08af8fe514f0426811527502 Mon Sep 17 00:00:00 2001 From: Hermes Date: Sun, 23 Aug 2026 01:43:39 +0000 Subject: [PATCH] fix(sync): ApiResponse envelope + atomic DB wipe on restore --- src/app/api/admin/sync-from-prod/route.ts | 28 +++++++++++------------ 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/src/app/api/admin/sync-from-prod/route.ts b/src/app/api/admin/sync-from-prod/route.ts index 77e66f9..0ff57e0 100644 --- a/src/app/api/admin/sync-from-prod/route.ts +++ b/src/app/api/admin/sync-from-prod/route.ts @@ -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)); 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).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 }' })); }