import { DatabaseSync } from 'node:sqlite'; import { mkdirSync, chmodSync } from 'node:fs'; import path from 'node:path'; import { randomUUID, createHash } from 'node:crypto'; const hash = value => createHash('sha256').update(value).digest('hex'); export function briefWeekKey(now = new Date()) { const date = new Date(now.getTime() + 3 * 3600000); date.setUTCDate(date.getUTCDate() - (date.getUTCDay()+6)%7); return date.toISOString().slice(0,10); } /** A claimed message is never blindly replayed after an interrupted provider call. */ export function createBriefOutbox(directory) { function transaction(work) { mkdirSync(directory,{recursive:true,mode:0o700}); const file=path.join(directory,'brief.sqlite'); const db = new DatabaseSync(file); try { chmodSync(file,0o600); db.exec(`PRAGMA busy_timeout=5000; PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL; CREATE TABLE IF NOT EXISTS brief_batches(id TEXT PRIMARY KEY,week TEXT NOT NULL,email TEXT NOT NULL,manifest TEXT NOT NULL); CREATE TABLE IF NOT EXISTS brief_messages(batch TEXT NOT NULL,position INTEGER NOT NULL,state TEXT NOT NULL,lease TEXT,lease_until INTEGER,receipt TEXT,error TEXT,PRIMARY KEY(batch,position));`); db.exec('BEGIN IMMEDIATE'); const result=work(db); db.exec('COMMIT'); return result; } catch(error) { try { db.exec('ROLLBACK'); } catch {} throw error; } finally { db.close(); } } return { prepare(week,email,messages) { const id=hash(JSON.stringify([week,email])),manifest=JSON.stringify(messages); return transaction(db=>{ const row=db.prepare('SELECT manifest FROM brief_batches WHERE id=?').get(id); if(row) { const changed=row.manifest!==manifest; if(changed) db.prepare("UPDATE brief_messages SET state='uncertain',error=? WHERE batch=? AND state='pending'") .run('Haftalık özet içeriği veya alıcı kanalı değişti; yeniden gönderim öncesi inceleme gerekli.',id); return {id,changed}; } db.prepare('INSERT INTO brief_batches VALUES (?,?,?,?)').run(id,week,email,manifest); const insert=db.prepare("INSERT INTO brief_messages(batch,position,state) VALUES (?,?,'pending')"); messages.forEach((_,i)=>insert.run(id,i)); return {id,changed:false}; }); }, claim(batch,position,clock=Date.now()) { return transaction(db=>{ const row=db.prepare('SELECT * FROM brief_messages WHERE batch=? AND position=?').get(batch,position); if(!row)throw new Error('Özet teslimat kaydı bulunamadı.'); if(row.state==='accepted')return {state:'accepted'}; if(row.state==='uncertain')return {state:'uncertain'}; if(row.state==='sending') { if(row.lease_until>clock)return {state:'busy'}; db.prepare("UPDATE brief_messages SET state='uncertain',error=? WHERE batch=? AND position=?").run('Kesintiye uğrayan gönderim; sağlayıcı kaydı incelenmeli.',batch,position); return {state:'uncertain'}; } const lease=randomUUID(); db.prepare("UPDATE brief_messages SET state='sending',lease=?,lease_until=? WHERE batch=? AND position=?").run(lease,clock+120000,batch,position); return {state:'sending',batch,position,lease}; }); }, accept(claim,receipt) { if(typeof receipt!=='string'||!receipt.trim())throw new Error('Gönderim kabul kaydı gerekli.'); return transaction(db=>{ const result=db.prepare("UPDATE brief_messages SET state='accepted',receipt=?,error=NULL WHERE batch=? AND position=? AND lease=? AND state='sending'") .run(receipt,claim.batch,claim.position,claim.lease); if(result.changes!==1)throw new Error('Özet gönderim kaydı değişti.'); }); }, hold(claim,error) { return transaction(db=>db.prepare("UPDATE brief_messages SET state='uncertain',error=? WHERE batch=? AND position=? AND lease=? AND state='sending'") .run(String(error?.message || error),claim.batch,claim.position,claim.lease).changes===1); }, inspect() { return transaction(db=>db.prepare('SELECT b.week,b.email,m.* FROM brief_messages m JOIN brief_batches b ON b.id=m.batch ORDER BY b.week DESC,b.email,m.position').all()); }, }; }