mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-11-01 18:19:43 +00:00
feat(backend): add standalone data plane
Replace the Supabase application data plane with the Node/PostgreSQL API, migrate mobile, admin, web, and MCP callers, add controlled cutover tooling, and define hardened Dokploy infrastructure.
This commit is contained in:
1 parent
01bb8225ae
commit
006761d3b0
294 files changed
+16259
-6138
No files matched your search
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { supabase } from '@/config';
|
||||
import { adminData } from '@/lib/adminApi';
|
||||
|
||||
export interface CouncilGenerator {
|
||||
model: string;
|
||||
@@ -74,7 +74,7 @@ export function useAiConfig(): UseAiConfigReturn {
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const { data, error: fetchError } = await supabase
|
||||
const { data, error: fetchError } = await adminData
|
||||
.from('ai_config')
|
||||
.select('*')
|
||||
.limit(1)
|
||||
@@ -82,7 +82,7 @@ export function useAiConfig(): UseAiConfigReturn {
|
||||
|
||||
if (fetchError) {
|
||||
// If user doesn't have access (not super admin), that's expected
|
||||
if (fetchError.code === 'PGRST116' || fetchError.message.includes('permission')) {
|
||||
if (fetchError.message.includes('permission')) {
|
||||
setError('Access denied. Super admin privileges required.');
|
||||
} else {
|
||||
setError(fetchError.message);
|
||||
@@ -110,7 +110,7 @@ export function useAiConfig(): UseAiConfigReturn {
|
||||
}
|
||||
|
||||
try {
|
||||
const { error: updateError } = await supabase
|
||||
const { error: updateError } = await adminData
|
||||
.from('ai_config')
|
||||
.update(updates)
|
||||
.eq('id', config.id);
|
||||
@@ -160,7 +160,7 @@ export async function preloadAiConfig(): Promise<AiConfig | null> {
|
||||
}
|
||||
|
||||
try {
|
||||
const { data } = await supabase
|
||||
const { data } = await adminData
|
||||
.from('ai_config')
|
||||
.select('*')
|
||||
.limit(1)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { supabase } from '@/config';
|
||||
import { adminData } from '@/lib/adminApi';
|
||||
|
||||
export interface AppConfig {
|
||||
id: string;
|
||||
@@ -40,7 +40,7 @@ export function useAppConfig(): UseAppConfigReturn {
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const { data, error: fetchError } = await supabase
|
||||
const { data, error: fetchError } = await adminData
|
||||
.from('app_config')
|
||||
.select('*')
|
||||
.limit(1)
|
||||
@@ -71,7 +71,7 @@ export function useAppConfig(): UseAppConfigReturn {
|
||||
}
|
||||
|
||||
try {
|
||||
const { error: updateError } = await supabase
|
||||
const { error: updateError } = await adminData
|
||||
.from('app_config')
|
||||
.update(updates)
|
||||
.eq('id', config.id);
|
||||
|
||||
+18
-31
@@ -1,4 +1,4 @@
|
||||
import { supabase } from '@/config';
|
||||
import { adminData } from '@/lib/adminApi';
|
||||
|
||||
// Tables to audit
|
||||
const AUDITED_TABLES = [
|
||||
@@ -26,23 +26,10 @@ async function logAction(
|
||||
oldValues?: Record<string, unknown> | null,
|
||||
newValues?: Record<string, unknown> | null
|
||||
): Promise<void> {
|
||||
if (!AUDITED_TABLES.includes(tableName)) return;
|
||||
|
||||
try {
|
||||
const { error } = await supabase.rpc('log_admin_action', {
|
||||
p_table_name: tableName,
|
||||
p_action: action,
|
||||
p_record_id: recordId,
|
||||
p_old_values: oldValues || null,
|
||||
p_new_values: newValues || null,
|
||||
});
|
||||
if (error) {
|
||||
console.error('Failed to log audit action:', error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to log audit action:', error);
|
||||
// Don't throw - logging failure shouldn't block the operation
|
||||
}
|
||||
// The standalone admin API writes the mutation and audit record in one
|
||||
// transaction. Keeping this no-op preserves the existing call sites while
|
||||
// avoiding duplicate or client-forgeable audit entries.
|
||||
void tableName; void action; void recordId; void oldValues; void newValues;
|
||||
}
|
||||
|
||||
interface InsertResult<T> {
|
||||
@@ -56,9 +43,9 @@ interface SingleResult<T> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Audited Supabase operations - automatically logs INSERT/UPDATE/DELETE to audit_logs
|
||||
* Admin API mutations. The server writes each mutation and audit record atomically.
|
||||
*/
|
||||
export const auditedSupabase = {
|
||||
export const auditedAdminData = {
|
||||
/**
|
||||
* Insert records with audit logging
|
||||
*/
|
||||
@@ -68,7 +55,7 @@ export const auditedSupabase = {
|
||||
): Promise<InsertResult<T>> {
|
||||
const recordArray = Array.isArray(records) ? records : [records];
|
||||
|
||||
const { data, error } = await supabase
|
||||
const { data, error } = await adminData
|
||||
.from(table)
|
||||
.insert(recordArray)
|
||||
.select();
|
||||
@@ -94,7 +81,7 @@ export const auditedSupabase = {
|
||||
// Fetch old values before update
|
||||
let oldValues: Record<string, unknown> | null = null;
|
||||
if (AUDITED_TABLES.includes(table)) {
|
||||
const { data: oldData } = await supabase
|
||||
const { data: oldData } = await adminData
|
||||
.from(table)
|
||||
.select('*')
|
||||
.eq('id', id)
|
||||
@@ -102,7 +89,7 @@ export const auditedSupabase = {
|
||||
oldValues = oldData;
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
const { data, error } = await adminData
|
||||
.from(table)
|
||||
.update(updates)
|
||||
.eq('id', id)
|
||||
@@ -127,14 +114,14 @@ export const auditedSupabase = {
|
||||
// Fetch old values before update
|
||||
let oldRecords: Record<string, unknown>[] = [];
|
||||
if (AUDITED_TABLES.includes(table)) {
|
||||
const { data: oldData } = await supabase
|
||||
const { data: oldData } = await adminData
|
||||
.from(table)
|
||||
.select('*')
|
||||
.eq(filter.column, filter.value);
|
||||
oldRecords = oldData || [];
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
const { data, error } = await adminData
|
||||
.from(table)
|
||||
.update(updates)
|
||||
.eq(filter.column, filter.value)
|
||||
@@ -161,7 +148,7 @@ export const auditedSupabase = {
|
||||
// Fetch old values before delete
|
||||
let oldValues: Record<string, unknown> | null = null;
|
||||
if (AUDITED_TABLES.includes(table)) {
|
||||
const { data: oldData } = await supabase
|
||||
const { data: oldData } = await adminData
|
||||
.from(table)
|
||||
.select('*')
|
||||
.eq('id', id)
|
||||
@@ -169,7 +156,7 @@ export const auditedSupabase = {
|
||||
oldValues = oldData;
|
||||
}
|
||||
|
||||
const { error } = await supabase
|
||||
const { error } = await adminData
|
||||
.from(table)
|
||||
.delete()
|
||||
.eq('id', id);
|
||||
@@ -191,14 +178,14 @@ export const auditedSupabase = {
|
||||
// Fetch old values before delete
|
||||
let oldRecords: Record<string, unknown>[] = [];
|
||||
if (AUDITED_TABLES.includes(table)) {
|
||||
const { data: oldData } = await supabase
|
||||
const { data: oldData } = await adminData
|
||||
.from(table)
|
||||
.select('*')
|
||||
.in('id', ids);
|
||||
oldRecords = oldData || [];
|
||||
}
|
||||
|
||||
const { error } = await supabase
|
||||
const { error } = await adminData
|
||||
.from(table)
|
||||
.delete()
|
||||
.in('id', ids);
|
||||
@@ -224,7 +211,7 @@ export const auditedSupabase = {
|
||||
// Fetch old values before delete
|
||||
let oldValues: Record<string, unknown> | null = null;
|
||||
if (AUDITED_TABLES.includes(table)) {
|
||||
const { data: oldData } = await supabase
|
||||
const { data: oldData } = await adminData
|
||||
.from(table)
|
||||
.select('*')
|
||||
.eq(field, value)
|
||||
@@ -232,7 +219,7 @@ export const auditedSupabase = {
|
||||
oldValues = oldData;
|
||||
}
|
||||
|
||||
const { error } = await supabase
|
||||
const { error } = await adminData
|
||||
.from(table)
|
||||
.delete()
|
||||
.eq(field, value);
|
||||
@@ -1,14 +1,14 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { supabase } from '@/config';
|
||||
import { auditedSupabase } from '@/hooks/useAuditedSupabase';
|
||||
import { adminData } from '@/lib/adminApi';
|
||||
import { auditedAdminData } from '@/hooks/useAuditedAdminData';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type SupabaseFilterFn = (query: any) => any;
|
||||
type AdminFilterFn = (query: any) => any;
|
||||
|
||||
interface UseCrudOperationsOptions<T> {
|
||||
/**
|
||||
* The Supabase table name
|
||||
* The standalone admin API resource name
|
||||
*/
|
||||
table: string;
|
||||
|
||||
@@ -41,7 +41,7 @@ interface UseCrudOperationsOptions<T> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic CRUD operations hook for Supabase tables
|
||||
* Generic CRUD operations hook for standalone admin API resources
|
||||
* Reduces boilerplate for data fetching and mutations across pages.
|
||||
*
|
||||
* @example
|
||||
@@ -90,13 +90,13 @@ export function useCrudOperations<T extends { id: string }>({
|
||||
* @param filter Optional filter function to modify the query
|
||||
*/
|
||||
const fetchAll = useCallback(async (
|
||||
filter?: SupabaseFilterFn
|
||||
filter?: AdminFilterFn
|
||||
): Promise<T[]> => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let query: any = supabase.from(table).select(select);
|
||||
let query: any = adminData.from(table).select(select);
|
||||
|
||||
if (filter) {
|
||||
query = filter(query);
|
||||
@@ -131,7 +131,7 @@ export function useCrudOperations<T extends { id: string }>({
|
||||
*/
|
||||
const fetchById = useCallback(async (id: string): Promise<T | null> => {
|
||||
try {
|
||||
const { data, error: fetchError } = await supabase
|
||||
const { data, error: fetchError } = await adminData
|
||||
.from(table)
|
||||
.select(select)
|
||||
.eq('id', id)
|
||||
@@ -158,7 +158,7 @@ export function useCrudOperations<T extends { id: string }>({
|
||||
const { refetch = true, silent = false } = options;
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const { data: created, error: createError } = await auditedSupabase.insert(table, data as any);
|
||||
const { data: created, error: createError } = await auditedAdminData.insert(table, data as any);
|
||||
|
||||
if (createError) throw createError;
|
||||
|
||||
@@ -170,7 +170,7 @@ export function useCrudOperations<T extends { id: string }>({
|
||||
await fetchAll();
|
||||
}
|
||||
|
||||
// auditedSupabase.insert returns T[] | null, get first element
|
||||
// auditedAdminData.insert returns T[] | null, get first element
|
||||
return created && created.length > 0 ? (created[0] as T) : null;
|
||||
} catch (err) {
|
||||
if (!silent) {
|
||||
@@ -194,7 +194,7 @@ export function useCrudOperations<T extends { id: string }>({
|
||||
): Promise<boolean> => {
|
||||
const { refetch = true, silent = false } = options;
|
||||
try {
|
||||
const { error: updateError } = await auditedSupabase.update(table, id, data);
|
||||
const { error: updateError } = await auditedAdminData.update(table, id, data);
|
||||
|
||||
if (updateError) throw updateError;
|
||||
|
||||
@@ -233,7 +233,7 @@ export function useCrudOperations<T extends { id: string }>({
|
||||
}
|
||||
|
||||
try {
|
||||
const { error: deleteError } = await auditedSupabase.delete(table, id);
|
||||
const { error: deleteError } = await auditedAdminData.delete(table, id);
|
||||
|
||||
if (deleteError) throw deleteError;
|
||||
|
||||
|
||||
@@ -1,243 +1,92 @@
|
||||
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import { supabase } from '@/config';
|
||||
import { toast } from 'sonner';
|
||||
import type { RealtimeChannel } from '@supabase/supabase-js';
|
||||
|
||||
export type SubscriptionStatus = 'SUBSCRIBED' | 'SUBSCRIBING' | 'CHANNEL_ERROR' | 'TIMED_OUT' | 'CLOSED';
|
||||
|
||||
export interface UseRealtimeSubscriptionOptions<T> {
|
||||
table: string;
|
||||
schema?: string;
|
||||
event?: 'INSERT' | 'UPDATE' | 'DELETE' | '*';
|
||||
filter?: string;
|
||||
onInsert?: (payload: T) => void;
|
||||
onUpdate?: (payload: { old: T; new: T }) => void;
|
||||
onDelete?: (payload: T) => void;
|
||||
insertToast?: {
|
||||
enabled: boolean;
|
||||
message: string | ((payload: T) => string);
|
||||
type?: 'success' | 'info' | 'warning';
|
||||
};
|
||||
updateToast?: {
|
||||
enabled: boolean;
|
||||
message: string | ((payload: { old: T; new: T }) => string);
|
||||
type?: 'success' | 'info' | 'warning';
|
||||
};
|
||||
deleteToast?: {
|
||||
enabled: boolean;
|
||||
message: string | ((payload: T) => string);
|
||||
type?: 'success' | 'info' | 'warning';
|
||||
};
|
||||
debounceMs?: number;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface UseRealtimeSubscriptionReturn {
|
||||
status: SubscriptionStatus;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
const MAX_RETRIES = 3;
|
||||
|
||||
export function useRealtimeSubscription<T>(
|
||||
options: UseRealtimeSubscriptionOptions<T>
|
||||
): UseRealtimeSubscriptionReturn {
|
||||
const {
|
||||
table,
|
||||
schema = 'public',
|
||||
event = '*',
|
||||
filter,
|
||||
onInsert,
|
||||
onUpdate,
|
||||
onDelete,
|
||||
insertToast,
|
||||
updateToast,
|
||||
deleteToast,
|
||||
debounceMs = 0,
|
||||
enabled = true,
|
||||
} = options;
|
||||
|
||||
const [status, setStatus] = useState<SubscriptionStatus>('CLOSED');
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const channelRef = useRef<RealtimeChannel | null>(null);
|
||||
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const retryCountRef = useRef(0);
|
||||
const isMountedRef = useRef(true);
|
||||
|
||||
// Use refs for callbacks and toast options to avoid re-subscription on every render
|
||||
const onInsertRef = useRef(onInsert);
|
||||
const onUpdateRef = useRef(onUpdate);
|
||||
const onDeleteRef = useRef(onDelete);
|
||||
const insertToastRef = useRef(insertToast);
|
||||
const updateToastRef = useRef(updateToast);
|
||||
const deleteToastRef = useRef(deleteToast);
|
||||
|
||||
// Keep refs up to date
|
||||
useEffect(() => {
|
||||
onInsertRef.current = onInsert;
|
||||
onUpdateRef.current = onUpdate;
|
||||
onDeleteRef.current = onDelete;
|
||||
insertToastRef.current = insertToast;
|
||||
updateToastRef.current = updateToast;
|
||||
deleteToastRef.current = deleteToast;
|
||||
});
|
||||
|
||||
// Debounced callback wrapper
|
||||
const debouncedCallback = useCallback(
|
||||
(callback: () => void) => {
|
||||
if (debounceTimerRef.current) {
|
||||
clearTimeout(debounceTimerRef.current);
|
||||
}
|
||||
if (debounceMs > 0) {
|
||||
debounceTimerRef.current = setTimeout(() => {
|
||||
if (isMountedRef.current) {
|
||||
callback();
|
||||
}
|
||||
}, debounceMs);
|
||||
} else {
|
||||
callback();
|
||||
}
|
||||
},
|
||||
[debounceMs]
|
||||
);
|
||||
|
||||
// Handle incoming changes - use refs to avoid dependency changes
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const handleChange = useCallback(
|
||||
(payload: { eventType: string; new: any; old: any }) => {
|
||||
if (!isMountedRef.current) return;
|
||||
|
||||
const eventType = payload.eventType;
|
||||
|
||||
if (eventType === 'INSERT') {
|
||||
const newRecord = payload.new as T;
|
||||
|
||||
if (insertToastRef.current?.enabled) {
|
||||
const message =
|
||||
typeof insertToastRef.current.message === 'function'
|
||||
? insertToastRef.current.message(newRecord)
|
||||
: insertToastRef.current.message;
|
||||
toast[insertToastRef.current.type || 'info'](message);
|
||||
}
|
||||
|
||||
if (onInsertRef.current) {
|
||||
debouncedCallback(() => onInsertRef.current!(newRecord));
|
||||
}
|
||||
}
|
||||
|
||||
if (eventType === 'UPDATE') {
|
||||
const updatePayload = { old: payload.old as T, new: payload.new as T };
|
||||
|
||||
if (updateToastRef.current?.enabled) {
|
||||
const message =
|
||||
typeof updateToastRef.current.message === 'function'
|
||||
? updateToastRef.current.message(updatePayload)
|
||||
: updateToastRef.current.message;
|
||||
toast[updateToastRef.current.type || 'info'](message);
|
||||
}
|
||||
|
||||
if (onUpdateRef.current) {
|
||||
debouncedCallback(() => onUpdateRef.current!(updatePayload));
|
||||
}
|
||||
}
|
||||
|
||||
if (eventType === 'DELETE') {
|
||||
const deletedRecord = payload.old as T;
|
||||
|
||||
if (deleteToastRef.current?.enabled) {
|
||||
const message =
|
||||
typeof deleteToastRef.current.message === 'function'
|
||||
? deleteToastRef.current.message(deletedRecord)
|
||||
: deleteToastRef.current.message;
|
||||
toast[deleteToastRef.current.type || 'info'](message);
|
||||
}
|
||||
|
||||
if (onDeleteRef.current) {
|
||||
debouncedCallback(() => onDeleteRef.current!(deletedRecord));
|
||||
}
|
||||
}
|
||||
},
|
||||
[debouncedCallback]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
isMountedRef.current = true;
|
||||
|
||||
if (!enabled) {
|
||||
setStatus('CLOSED');
|
||||
return;
|
||||
}
|
||||
|
||||
const subscribe = () => {
|
||||
// Create unique channel name
|
||||
const channelName = `admin-${table}-${filter || 'all'}-${Date.now()}`;
|
||||
|
||||
// Build subscription config
|
||||
const subscriptionConfig: {
|
||||
event: 'INSERT' | 'UPDATE' | 'DELETE' | '*';
|
||||
schema: string;
|
||||
table: string;
|
||||
filter?: string;
|
||||
} = {
|
||||
event,
|
||||
schema,
|
||||
table,
|
||||
};
|
||||
|
||||
if (filter) {
|
||||
subscriptionConfig.filter = filter;
|
||||
}
|
||||
|
||||
setStatus('SUBSCRIBING');
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const channel = (supabase.channel(channelName) as any)
|
||||
.on('postgres_changes', subscriptionConfig, handleChange)
|
||||
.subscribe((subscriptionStatus: string) => {
|
||||
if (!isMountedRef.current) return;
|
||||
|
||||
if (subscriptionStatus === 'SUBSCRIBED') {
|
||||
setStatus('SUBSCRIBED');
|
||||
setError(null);
|
||||
retryCountRef.current = 0;
|
||||
} else if (subscriptionStatus === 'CHANNEL_ERROR') {
|
||||
setStatus('CHANNEL_ERROR');
|
||||
setError(new Error(`Failed to subscribe to ${table}`));
|
||||
|
||||
// Auto-retry with exponential backoff
|
||||
if (retryCountRef.current < MAX_RETRIES) {
|
||||
const timeout = Math.pow(2, retryCountRef.current) * 1000;
|
||||
retryCountRef.current++;
|
||||
setTimeout(() => {
|
||||
if (isMountedRef.current && channelRef.current) {
|
||||
supabase.removeChannel(channelRef.current);
|
||||
subscribe();
|
||||
}
|
||||
}, timeout);
|
||||
}
|
||||
} else if (subscriptionStatus === 'TIMED_OUT') {
|
||||
setStatus('TIMED_OUT');
|
||||
setError(new Error(`Subscription to ${table} timed out`));
|
||||
} else if (subscriptionStatus === 'CLOSED') {
|
||||
setStatus('CLOSED');
|
||||
}
|
||||
});
|
||||
|
||||
channelRef.current = channel;
|
||||
};
|
||||
|
||||
subscribe();
|
||||
|
||||
return () => {
|
||||
isMountedRef.current = false;
|
||||
if (debounceTimerRef.current) {
|
||||
clearTimeout(debounceTimerRef.current);
|
||||
}
|
||||
if (channelRef.current) {
|
||||
supabase.removeChannel(channelRef.current);
|
||||
}
|
||||
};
|
||||
}, [enabled, table, schema, event, filter, handleChange]);
|
||||
|
||||
return { status, error };
|
||||
}
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { queryAdminRows, type AdminFilter } from '@/lib/adminApi';
|
||||
|
||||
export type SubscriptionStatus = 'SUBSCRIBED' | 'SUBSCRIBING' | 'CHANNEL_ERROR' | 'TIMED_OUT' | 'CLOSED';
|
||||
export interface UseRealtimeSubscriptionOptions<T> {
|
||||
table: string; schema?: string; event?: 'INSERT' | 'UPDATE' | 'DELETE' | '*'; filter?: string;
|
||||
onInsert?: (payload: T) => void; onUpdate?: (payload: { old: T; new: T }) => void; onDelete?: (payload: T) => void;
|
||||
insertToast?: { enabled: boolean; message: string | ((payload: T) => string); type?: 'success' | 'info' | 'warning' };
|
||||
updateToast?: { enabled: boolean; message: string | ((payload: { old: T; new: T }) => string); type?: 'success' | 'info' | 'warning' };
|
||||
deleteToast?: { enabled: boolean; message: string | ((payload: T) => string); type?: 'success' | 'info' | 'warning' };
|
||||
debounceMs?: number; enabled?: boolean; pollIntervalMs?: number;
|
||||
}
|
||||
export interface UseRealtimeSubscriptionReturn { status: SubscriptionStatus; error: Error | null }
|
||||
|
||||
const DEFAULT_POLL_INTERVAL_MS = 10_000;
|
||||
const MIN_POLL_INTERVAL_MS = 5_000;
|
||||
|
||||
function parseFilter(filter?: string): AdminFilter[] | undefined {
|
||||
if (!filter) return undefined;
|
||||
const match = /^([a-z_][a-z0-9_]*)=(eq|neq|gte|lte)\.(.+)$/.exec(filter);
|
||||
return match ? [{ column: match[1], op: match[2] as AdminFilter['op'], value: match[3] }] : undefined;
|
||||
}
|
||||
|
||||
function rowKey(row: unknown, index: number): string {
|
||||
if (row && typeof row === 'object') {
|
||||
const record = row as Record<string, unknown>;
|
||||
const key = record.id ?? `${record.pack_id ?? ''}:${record.topic_id ?? ''}`;
|
||||
if (key) return String(key);
|
||||
}
|
||||
return String(index);
|
||||
}
|
||||
|
||||
export function useRealtimeSubscription<T>(options: UseRealtimeSubscriptionOptions<T>): UseRealtimeSubscriptionReturn {
|
||||
const { table, event = '*', filter, onInsert, onUpdate, onDelete, insertToast, updateToast, deleteToast,
|
||||
debounceMs = 0, enabled = true, pollIntervalMs = DEFAULT_POLL_INTERVAL_MS } = options;
|
||||
const [status, setStatus] = useState<SubscriptionStatus>('CLOSED');
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const snapshotRef = useRef<Map<string, { value: T; json: string }> | null>(null);
|
||||
const callbacksRef = useRef({ onInsert, onUpdate, onDelete, insertToast, updateToast, deleteToast });
|
||||
callbacksRef.current = { onInsert, onUpdate, onDelete, insertToast, updateToast, deleteToast };
|
||||
const emit = useCallback((callback: () => void) => {
|
||||
if (debounceMs <= 0) callback(); else window.setTimeout(callback, debounceMs);
|
||||
}, [debounceMs]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) { setStatus('CLOSED'); return; }
|
||||
let cancelled = false;
|
||||
let timer: number | undefined;
|
||||
const interval = Math.max(MIN_POLL_INTERVAL_MS, pollIntervalMs);
|
||||
const poll = async () => {
|
||||
if (document.visibilityState === 'hidden') { timer = window.setTimeout(poll, interval); return; }
|
||||
try {
|
||||
setStatus((current) => current === 'SUBSCRIBED' ? current : 'SUBSCRIBING');
|
||||
const { rows } = await queryAdminRows<T>(table, { filters: parseFilter(filter), limit: 500 });
|
||||
if (cancelled) return;
|
||||
const next = new Map(rows.map((row, index) => [rowKey(row, index), { value: row, json: JSON.stringify(row) }]));
|
||||
const previous = snapshotRef.current;
|
||||
if (previous) {
|
||||
if (event === '*' || event === 'INSERT') for (const [key, entry] of next) if (!previous.has(key)) {
|
||||
const config = callbacksRef.current;
|
||||
if (config.insertToast?.enabled) toast[config.insertToast.type || 'info'](typeof config.insertToast.message === 'function' ? config.insertToast.message(entry.value) : config.insertToast.message);
|
||||
if (config.onInsert) emit(() => config.onInsert!(entry.value));
|
||||
}
|
||||
if (event === '*' || event === 'UPDATE') for (const [key, entry] of next) {
|
||||
const old = previous.get(key);
|
||||
if (old && old.json !== entry.json) {
|
||||
const payload = { old: old.value, new: entry.value };
|
||||
const config = callbacksRef.current;
|
||||
if (config.updateToast?.enabled) toast[config.updateToast.type || 'info'](typeof config.updateToast.message === 'function' ? config.updateToast.message(payload) : config.updateToast.message);
|
||||
if (config.onUpdate) emit(() => config.onUpdate!(payload));
|
||||
}
|
||||
}
|
||||
if (event === '*' || event === 'DELETE') for (const [key, entry] of previous) if (!next.has(key)) {
|
||||
const config = callbacksRef.current;
|
||||
if (config.deleteToast?.enabled) toast[config.deleteToast.type || 'info'](typeof config.deleteToast.message === 'function' ? config.deleteToast.message(entry.value) : config.deleteToast.message);
|
||||
if (config.onDelete) emit(() => config.onDelete!(entry.value));
|
||||
}
|
||||
}
|
||||
snapshotRef.current = next;
|
||||
setStatus('SUBSCRIBED'); setError(null);
|
||||
} catch (cause) {
|
||||
if (!cancelled) { setStatus('CHANNEL_ERROR'); setError(cause instanceof Error ? cause : new Error(String(cause))); }
|
||||
} finally {
|
||||
if (!cancelled) timer = window.setTimeout(poll, interval);
|
||||
}
|
||||
};
|
||||
void poll();
|
||||
return () => { cancelled = true; if (timer !== undefined) window.clearTimeout(timer); snapshotRef.current = null; };
|
||||
}, [emit, enabled, event, filter, pollIntervalMs, table]);
|
||||
return { status, error };
|
||||
}
|
||||
Reference in new issue
Block a user