fix(sync): ApiResponse envelope + atomic DB wipe on restore
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
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 path from 'path';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import os from 'os';
|
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 body = await request.json().catch(() => ({} as Record<string, unknown>));
|
||||||
const expected = process.env.PULSE_SYNC_TOKEN;
|
const expected = process.env.PULSE_SYNC_TOKEN;
|
||||||
if (!expected) {
|
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 prodUrl = process.env.PULSE_PROD_URL ?? 'https://pulsy.hellobaka.com';
|
||||||
const confirm = (body as Record<string, unknown>).confirm;
|
const confirm = (body as Record<string, unknown>).confirm;
|
||||||
if (confirm !== true && confirm !== 'true') {
|
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
|
// Fetch dump from prod
|
||||||
@@ -31,44 +32,43 @@ export async function POST(request: NextRequest) {
|
|||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const text = await res.text().catch(() => '');
|
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();
|
dump = await res.text();
|
||||||
if (!dump || dump.length < 100) {
|
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) {
|
} 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 dbPath = resolveDbPath();
|
||||||
const dir = path.dirname(dbPath);
|
const dir = path.dirname(dbPath);
|
||||||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
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`);
|
const tmp = path.join(os.tmpdir(), `pulse-restore-${Date.now()}.sql`);
|
||||||
try {
|
try {
|
||||||
fs.writeFileSync(tmp, dump, 'utf-8');
|
fs.writeFileSync(tmp, dump, 'utf-8');
|
||||||
|
|
||||||
// Count tables in dump for sanity
|
|
||||||
const tableCount = (dump.match(/CREATE TABLE/g) || []).length;
|
const tableCount = (dump.match(/CREATE TABLE/g) || []).length;
|
||||||
|
// Wipe existing DB so PKs don't collide on re-sync (atomic replace)
|
||||||
// Restore: sqlite3 db < dump.sql
|
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 });
|
const restore = spawnSync('sh', ['-c', `sqlite3 "${dbPath}" < "${tmp}"`], { encoding: 'utf-8', maxBuffer: 10 * 1024 * 1024 });
|
||||||
if (restore.status !== 0) {
|
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
|
// 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 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();
|
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 {
|
} finally {
|
||||||
try { fs.unlinkSync(tmp); } catch {}
|
try { fs.unlinkSync(tmp); } catch {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function GET() {
|
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 }' }));
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user