/** * vite-plugin-supabase-sync * * Dev-only Vite plugin that keeps src/data/db/ in sync with Supabase. * * Behaviour: * 1. On dev server start → fetches ALL marketing_reports → writes src/data/db/{id}.json * 2. Subscribes to Supabase Realtime → re-writes the changed file → sends HMR full-reload * 3. Generates src/data/db/_index.json (id → clinic_name map for quick lookup) * * The generated files are gitignored — they are local dev snapshots only. */ import { createClient } from '@supabase/supabase-js'; import { writeFileSync, mkdirSync, unlinkSync, existsSync } from 'fs'; import path from 'path'; import type { Plugin, ViteDevServer } from 'vite'; interface Options { supabaseUrl: string; serviceRoleKey: string; /** Output directory relative to project root. Default: src/data/db */ outDir?: string; } type ReportRow = { id: string; clinic_name: string | null; url: string | null; status: string | null; report: Record | null; channel_data: Record | null; scrape_data: Record | null; created_at: string; updated_at: string | null; }; function writeReport(outDir: string, row: ReportRow) { const filePath = path.join(outDir, `${row.id}.json`); writeFileSync(filePath, JSON.stringify(row, null, 2), 'utf-8'); } function writeIndex(outDir: string, rows: ReportRow[]) { const index = rows.map((r) => ({ id: r.id, clinic_name: r.clinic_name, url: r.url, status: r.status, created_at: r.created_at, gu_rating: (r.channel_data as Record> | null)?.gangnamUnni?.rating ?? null, lead_doctor: (r.report as Record>> | null)?.clinicInfo?.leadDoctor?.name ?? null, })); writeFileSync(path.join(outDir, '_index.json'), JSON.stringify(index, null, 2), 'utf-8'); } // eslint-disable-next-line @typescript-eslint/no-explicit-any async function syncAll(supabase: any, outDir: string, server?: ViteDevServer) { const { data, error } = await supabase .from('marketing_reports') .select('id, clinic_name, url, status, report, channel_data, scrape_data, created_at, updated_at') .order('created_at', { ascending: false }); if (error) { console.error('[supabase-sync] fetch error:', error.message); return; } const rows = (data ?? []) as ReportRow[]; for (const row of rows) writeReport(outDir, row); writeIndex(outDir, rows); console.log(`[supabase-sync] ✓ synced ${rows.length} report(s) → ${outDir}`); if (server) server.ws.send({ type: 'full-reload' }); } // eslint-disable-next-line @typescript-eslint/no-explicit-any async function syncOne(supabase: any, outDir: string, id: string, server: ViteDevServer) { const { data, error } = await supabase .from('marketing_reports') .select('id, clinic_name, url, status, report, channel_data, scrape_data, created_at, updated_at') .eq('id', id) .single(); if (error || !data) { console.error(`[supabase-sync] fetch error for ${id}:`, error?.message); return; } writeReport(outDir, data as ReportRow); // Rebuild index const { data: all } = await supabase .from('marketing_reports') .select('id, clinic_name, url, status, report, channel_data, scrape_data, created_at, updated_at') .order('created_at', { ascending: false }); if (all) writeIndex(outDir, all as ReportRow[]); console.log(`[supabase-sync] ✓ updated ${(data as ReportRow).clinic_name ?? id}`); server.ws.send({ type: 'full-reload' }); } export function supabaseSync(options: Options): Plugin { const { supabaseUrl, serviceRoleKey, outDir: outDirOption } = options; return { name: 'vite-plugin-supabase-sync', apply: 'serve', // dev only — excluded from production build async configureServer(server) { if (!supabaseUrl || !serviceRoleKey) { console.warn('[supabase-sync] Missing SUPABASE_URL or SERVICE_ROLE_KEY — sync disabled'); return; } const outDir = path.resolve(outDirOption ?? 'src/data/db'); if (!existsSync(outDir)) mkdirSync(outDir, { recursive: true }); // eslint-disable-next-line @typescript-eslint/no-explicit-any const supabase: any = createClient(supabaseUrl, serviceRoleKey, { auth: { persistSession: false }, }); // 1. Initial full sync await syncAll(supabase, outDir, undefined); // 2. Realtime subscription — fires on INSERT / UPDATE / DELETE supabase .channel('db-sync') .on( 'postgres_changes', { event: '*', schema: 'public', table: 'marketing_reports' }, async (payload) => { const newRow = payload.new as { id?: string } | undefined; const oldRow = payload.old as { id?: string } | undefined; const id = newRow?.id ?? oldRow?.id; if (!id) return; if (payload.eventType === 'DELETE') { const filePath = path.join(outDir, `${id}.json`); if (existsSync(filePath)) { unlinkSync(filePath); console.log(`[supabase-sync] ✓ removed ${id}.json`); } server.ws.send({ type: 'full-reload' }); } else { await syncOne(supabase, outDir, id, server); } }, ) .subscribe((status) => { if (status === 'SUBSCRIBED') { console.log('[supabase-sync] Realtime subscription active'); } }); // Clean up on server close server.httpServer?.on('close', () => { supabase.removeAllChannels(); }); }, }; }