o2o-infinith-demo/plugins/vite-plugin-supabase-sync.ts

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();
});
},
};
}