163 lines
5.5 KiB
TypeScript
163 lines
5.5 KiB
TypeScript
/**
|
|
* 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<string, unknown> | null;
|
|
channel_data: Record<string, unknown> | null;
|
|
scrape_data: Record<string, unknown> | 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<string, Record<string, unknown>> | null)?.gangnamUnni?.rating ?? null,
|
|
lead_doctor: (r.report as Record<string, Record<string, Record<string, unknown>>> | 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();
|
|
});
|
|
},
|
|
};
|
|
}
|