feat(01-01): add HealthCheckModel and AlertEventModel with barrel exports
- HealthCheckModel: typed interfaces (ServiceHealthCheck, CreateHealthCheckData), static methods create/findLatestByService/findAll/deleteOlderThan, input validation, getSupabaseServiceClient() per-method, Winston logging - AlertEventModel: typed interfaces (AlertEvent, CreateAlertEventData), static methods create/findActive/acknowledge/resolve/findRecentByService/deleteOlderThan, input validation, PGRST116 handled as null, Winston logging - Update models/index.ts to re-export both models and their types - Strict TypeScript: Record<string, unknown> for JSONB fields, no any types
This commit is contained in:
343
backend/src/models/AlertEventModel.ts
Normal file
343
backend/src/models/AlertEventModel.ts
Normal file
@@ -0,0 +1,343 @@
|
|||||||
|
import { getSupabaseServiceClient } from '../config/supabase';
|
||||||
|
import { logger } from '../utils/logger';
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Types
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export interface AlertEvent {
|
||||||
|
id: string;
|
||||||
|
service_name: string;
|
||||||
|
alert_type: 'service_down' | 'service_degraded' | 'recovery';
|
||||||
|
status: 'active' | 'acknowledged' | 'resolved';
|
||||||
|
message: string | null;
|
||||||
|
details: Record<string, unknown> | null;
|
||||||
|
created_at: string;
|
||||||
|
acknowledged_at: string | null;
|
||||||
|
resolved_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateAlertEventData {
|
||||||
|
service_name: string;
|
||||||
|
alert_type: 'service_down' | 'service_degraded' | 'recovery';
|
||||||
|
status?: 'active' | 'acknowledged' | 'resolved';
|
||||||
|
message?: string;
|
||||||
|
details?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Model
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export class AlertEventModel {
|
||||||
|
/**
|
||||||
|
* Create a new alert event.
|
||||||
|
* Defaults status to 'active' if not provided.
|
||||||
|
* Validates input before writing to the database.
|
||||||
|
*/
|
||||||
|
static async create(data: CreateAlertEventData): Promise<AlertEvent> {
|
||||||
|
const {
|
||||||
|
service_name,
|
||||||
|
alert_type,
|
||||||
|
status = 'active',
|
||||||
|
message,
|
||||||
|
details,
|
||||||
|
} = data;
|
||||||
|
|
||||||
|
if (!service_name || service_name.trim() === '') {
|
||||||
|
throw new Error('AlertEventModel.create: service_name must be a non-empty string');
|
||||||
|
}
|
||||||
|
|
||||||
|
const validAlertTypes: Array<'service_down' | 'service_degraded' | 'recovery'> = [
|
||||||
|
'service_down',
|
||||||
|
'service_degraded',
|
||||||
|
'recovery',
|
||||||
|
];
|
||||||
|
if (!validAlertTypes.includes(alert_type)) {
|
||||||
|
throw new Error(`AlertEventModel.create: alert_type must be one of ${validAlertTypes.join(', ')}, got "${alert_type}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const validStatuses: Array<'active' | 'acknowledged' | 'resolved'> = [
|
||||||
|
'active',
|
||||||
|
'acknowledged',
|
||||||
|
'resolved',
|
||||||
|
];
|
||||||
|
if (!validStatuses.includes(status)) {
|
||||||
|
throw new Error(`AlertEventModel.create: status must be one of ${validStatuses.join(', ')}, got "${status}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const supabase = getSupabaseServiceClient();
|
||||||
|
|
||||||
|
const { data: record, error } = await supabase
|
||||||
|
.from('alert_events')
|
||||||
|
.insert({
|
||||||
|
service_name: service_name.trim(),
|
||||||
|
alert_type,
|
||||||
|
status,
|
||||||
|
message: message ?? null,
|
||||||
|
details: details ?? null,
|
||||||
|
})
|
||||||
|
.select()
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
logger.error('AlertEventModel.create: Supabase insert failed', {
|
||||||
|
error: error.message,
|
||||||
|
code: error.code,
|
||||||
|
details: error.details,
|
||||||
|
service_name,
|
||||||
|
alert_type,
|
||||||
|
status,
|
||||||
|
});
|
||||||
|
throw new Error(`AlertEventModel.create: failed to insert alert event — ${error.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!record) {
|
||||||
|
throw new Error('AlertEventModel.create: insert succeeded but no data returned');
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('AlertEventModel.create: alert event recorded', {
|
||||||
|
id: record.id,
|
||||||
|
service_name: record.service_name,
|
||||||
|
alert_type: record.alert_type,
|
||||||
|
status: record.status,
|
||||||
|
});
|
||||||
|
|
||||||
|
return record as AlertEvent;
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error && error.message.startsWith('AlertEventModel.create:')) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
throw new Error(`AlertEventModel.create: unexpected error — ${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get active (unresolved, unacknowledged) alerts.
|
||||||
|
* Optional service_name filter. Ordered by created_at DESC.
|
||||||
|
*/
|
||||||
|
static async findActive(serviceName?: string): Promise<AlertEvent[]> {
|
||||||
|
try {
|
||||||
|
const supabase = getSupabaseServiceClient();
|
||||||
|
|
||||||
|
let query = supabase
|
||||||
|
.from('alert_events')
|
||||||
|
.select('*')
|
||||||
|
.eq('status', 'active')
|
||||||
|
.order('created_at', { ascending: false });
|
||||||
|
|
||||||
|
if (serviceName) {
|
||||||
|
query = query.eq('service_name', serviceName);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data, error } = await query;
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
logger.error('AlertEventModel.findActive: query failed', {
|
||||||
|
error: error.message,
|
||||||
|
code: error.code,
|
||||||
|
serviceName,
|
||||||
|
});
|
||||||
|
throw new Error(`AlertEventModel.findActive: query failed — ${error.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (data ?? []) as AlertEvent[];
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error && error.message.startsWith('AlertEventModel.findActive:')) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
throw new Error(`AlertEventModel.findActive: unexpected error — ${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Acknowledge an alert event.
|
||||||
|
* Sets status to 'acknowledged' and records acknowledged_at timestamp.
|
||||||
|
* Returns the updated row.
|
||||||
|
*/
|
||||||
|
static async acknowledge(id: string): Promise<AlertEvent> {
|
||||||
|
try {
|
||||||
|
const supabase = getSupabaseServiceClient();
|
||||||
|
|
||||||
|
const { data: record, error } = await supabase
|
||||||
|
.from('alert_events')
|
||||||
|
.update({
|
||||||
|
status: 'acknowledged',
|
||||||
|
acknowledged_at: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
.eq('id', id)
|
||||||
|
.select()
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
if (error.code === 'PGRST116') {
|
||||||
|
throw new Error(`AlertEventModel.acknowledge: alert event not found — id="${id}"`);
|
||||||
|
}
|
||||||
|
logger.error('AlertEventModel.acknowledge: update failed', {
|
||||||
|
error: error.message,
|
||||||
|
code: error.code,
|
||||||
|
id,
|
||||||
|
});
|
||||||
|
throw new Error(`AlertEventModel.acknowledge: update failed — ${error.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!record) {
|
||||||
|
throw new Error(`AlertEventModel.acknowledge: no data returned for id="${id}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('AlertEventModel.acknowledge: alert acknowledged', { id, service_name: record.service_name });
|
||||||
|
|
||||||
|
return record as AlertEvent;
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error && error.message.startsWith('AlertEventModel.acknowledge:')) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
throw new Error(`AlertEventModel.acknowledge: unexpected error — ${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve an alert event.
|
||||||
|
* Sets status to 'resolved' and records resolved_at timestamp.
|
||||||
|
* Returns the updated row.
|
||||||
|
*/
|
||||||
|
static async resolve(id: string): Promise<AlertEvent> {
|
||||||
|
try {
|
||||||
|
const supabase = getSupabaseServiceClient();
|
||||||
|
|
||||||
|
const { data: record, error } = await supabase
|
||||||
|
.from('alert_events')
|
||||||
|
.update({
|
||||||
|
status: 'resolved',
|
||||||
|
resolved_at: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
.eq('id', id)
|
||||||
|
.select()
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
if (error.code === 'PGRST116') {
|
||||||
|
throw new Error(`AlertEventModel.resolve: alert event not found — id="${id}"`);
|
||||||
|
}
|
||||||
|
logger.error('AlertEventModel.resolve: update failed', {
|
||||||
|
error: error.message,
|
||||||
|
code: error.code,
|
||||||
|
id,
|
||||||
|
});
|
||||||
|
throw new Error(`AlertEventModel.resolve: update failed — ${error.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!record) {
|
||||||
|
throw new Error(`AlertEventModel.resolve: no data returned for id="${id}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('AlertEventModel.resolve: alert resolved', { id, service_name: record.service_name });
|
||||||
|
|
||||||
|
return record as AlertEvent;
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error && error.message.startsWith('AlertEventModel.resolve:')) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
throw new Error(`AlertEventModel.resolve: unexpected error — ${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the most recent alert of a given type for a service within a time window.
|
||||||
|
* Used by the Phase 2 alert service for deduplication — prevents repeat alerts
|
||||||
|
* for the same condition within a cooldown period.
|
||||||
|
* Returns null if no matching alert found within the window.
|
||||||
|
*/
|
||||||
|
static async findRecentByService(
|
||||||
|
serviceName: string,
|
||||||
|
alertType: string,
|
||||||
|
withinMinutes: number
|
||||||
|
): Promise<AlertEvent | null> {
|
||||||
|
try {
|
||||||
|
const supabase = getSupabaseServiceClient();
|
||||||
|
|
||||||
|
const cutoff = new Date();
|
||||||
|
cutoff.setMinutes(cutoff.getMinutes() - withinMinutes);
|
||||||
|
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('alert_events')
|
||||||
|
.select('*')
|
||||||
|
.eq('service_name', serviceName)
|
||||||
|
.eq('alert_type', alertType)
|
||||||
|
.gte('created_at', cutoff.toISOString())
|
||||||
|
.order('created_at', { ascending: false })
|
||||||
|
.limit(1)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
if (error.code === 'PGRST116') {
|
||||||
|
return null; // No matching alert within the window
|
||||||
|
}
|
||||||
|
logger.error('AlertEventModel.findRecentByService: query failed', {
|
||||||
|
error: error.message,
|
||||||
|
code: error.code,
|
||||||
|
serviceName,
|
||||||
|
alertType,
|
||||||
|
withinMinutes,
|
||||||
|
});
|
||||||
|
throw new Error(`AlertEventModel.findRecentByService: query failed — ${error.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return data as AlertEvent;
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error && error.message.startsWith('AlertEventModel.findRecentByService:')) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
throw new Error(`AlertEventModel.findRecentByService: unexpected error — ${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete alert event records older than the specified number of days.
|
||||||
|
* Used by the Phase 2 scheduler for 30-day retention enforcement.
|
||||||
|
* Returns the count of deleted rows.
|
||||||
|
*/
|
||||||
|
static async deleteOlderThan(days: number): Promise<number> {
|
||||||
|
if (days <= 0) {
|
||||||
|
throw new Error('AlertEventModel.deleteOlderThan: days must be a positive integer');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const supabase = getSupabaseServiceClient();
|
||||||
|
|
||||||
|
const cutoff = new Date();
|
||||||
|
cutoff.setDate(cutoff.getDate() - days);
|
||||||
|
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('alert_events')
|
||||||
|
.delete()
|
||||||
|
.lt('created_at', cutoff.toISOString())
|
||||||
|
.select('id');
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
logger.error('AlertEventModel.deleteOlderThan: delete failed', {
|
||||||
|
error: error.message,
|
||||||
|
code: error.code,
|
||||||
|
days,
|
||||||
|
});
|
||||||
|
throw new Error(`AlertEventModel.deleteOlderThan: delete failed — ${error.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const deletedCount = data?.length ?? 0;
|
||||||
|
|
||||||
|
logger.info('AlertEventModel.deleteOlderThan: retention cleanup complete', {
|
||||||
|
days,
|
||||||
|
deletedCount,
|
||||||
|
cutoff: cutoff.toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return deletedCount;
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error && error.message.startsWith('AlertEventModel.deleteOlderThan:')) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
throw new Error(`AlertEventModel.deleteOlderThan: unexpected error — ${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
219
backend/src/models/HealthCheckModel.ts
Normal file
219
backend/src/models/HealthCheckModel.ts
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
import { getSupabaseServiceClient } from '../config/supabase';
|
||||||
|
import { logger } from '../utils/logger';
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Types
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export interface ServiceHealthCheck {
|
||||||
|
id: string;
|
||||||
|
service_name: string;
|
||||||
|
status: 'healthy' | 'degraded' | 'down';
|
||||||
|
latency_ms: number | null;
|
||||||
|
checked_at: string;
|
||||||
|
error_message: string | null;
|
||||||
|
probe_details: Record<string, unknown> | null;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateHealthCheckData {
|
||||||
|
service_name: string;
|
||||||
|
status: 'healthy' | 'degraded' | 'down';
|
||||||
|
latency_ms?: number;
|
||||||
|
error_message?: string;
|
||||||
|
probe_details?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Model
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export class HealthCheckModel {
|
||||||
|
/**
|
||||||
|
* Create a new health check record.
|
||||||
|
* Validates input before writing to the database.
|
||||||
|
*/
|
||||||
|
static async create(data: CreateHealthCheckData): Promise<ServiceHealthCheck> {
|
||||||
|
const { service_name, status, latency_ms, error_message, probe_details } = data;
|
||||||
|
|
||||||
|
if (!service_name || service_name.trim() === '') {
|
||||||
|
throw new Error('HealthCheckModel.create: service_name must be a non-empty string');
|
||||||
|
}
|
||||||
|
|
||||||
|
const validStatuses: Array<'healthy' | 'degraded' | 'down'> = ['healthy', 'degraded', 'down'];
|
||||||
|
if (!validStatuses.includes(status)) {
|
||||||
|
throw new Error(`HealthCheckModel.create: status must be one of ${validStatuses.join(', ')}, got "${status}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const supabase = getSupabaseServiceClient();
|
||||||
|
|
||||||
|
const { data: record, error } = await supabase
|
||||||
|
.from('service_health_checks')
|
||||||
|
.insert({
|
||||||
|
service_name: service_name.trim(),
|
||||||
|
status,
|
||||||
|
latency_ms: latency_ms ?? null,
|
||||||
|
error_message: error_message ?? null,
|
||||||
|
probe_details: probe_details ?? null,
|
||||||
|
})
|
||||||
|
.select()
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
logger.error('HealthCheckModel.create: Supabase insert failed', {
|
||||||
|
error: error.message,
|
||||||
|
code: error.code,
|
||||||
|
details: error.details,
|
||||||
|
service_name,
|
||||||
|
status,
|
||||||
|
});
|
||||||
|
throw new Error(`HealthCheckModel.create: failed to insert health check — ${error.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!record) {
|
||||||
|
throw new Error('HealthCheckModel.create: insert succeeded but no data returned');
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('HealthCheckModel.create: health check recorded', {
|
||||||
|
id: record.id,
|
||||||
|
service_name: record.service_name,
|
||||||
|
status: record.status,
|
||||||
|
});
|
||||||
|
|
||||||
|
return record as ServiceHealthCheck;
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error && error.message.startsWith('HealthCheckModel.create:')) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
throw new Error(`HealthCheckModel.create: unexpected error — ${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the most recent health check for a given service.
|
||||||
|
* Ordered by checked_at DESC (probe time, not row creation time).
|
||||||
|
* Returns null if no record found.
|
||||||
|
*/
|
||||||
|
static async findLatestByService(serviceName: string): Promise<ServiceHealthCheck | null> {
|
||||||
|
try {
|
||||||
|
const supabase = getSupabaseServiceClient();
|
||||||
|
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('service_health_checks')
|
||||||
|
.select('*')
|
||||||
|
.eq('service_name', serviceName)
|
||||||
|
.order('checked_at', { ascending: false })
|
||||||
|
.limit(1)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
if (error.code === 'PGRST116') {
|
||||||
|
return null; // No rows returned — not an error
|
||||||
|
}
|
||||||
|
logger.error('HealthCheckModel.findLatestByService: query failed', {
|
||||||
|
error: error.message,
|
||||||
|
code: error.code,
|
||||||
|
serviceName,
|
||||||
|
});
|
||||||
|
throw new Error(`HealthCheckModel.findLatestByService: query failed — ${error.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return data as ServiceHealthCheck;
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error && error.message.startsWith('HealthCheckModel.findLatestByService:')) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
throw new Error(`HealthCheckModel.findLatestByService: unexpected error — ${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List health checks with optional filtering by service name.
|
||||||
|
* Ordered by created_at DESC. Defaults to limit 100.
|
||||||
|
*/
|
||||||
|
static async findAll(options?: { limit?: number; serviceName?: string }): Promise<ServiceHealthCheck[]> {
|
||||||
|
const limit = options?.limit ?? 100;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const supabase = getSupabaseServiceClient();
|
||||||
|
|
||||||
|
let query = supabase
|
||||||
|
.from('service_health_checks')
|
||||||
|
.select('*')
|
||||||
|
.order('created_at', { ascending: false })
|
||||||
|
.limit(limit);
|
||||||
|
|
||||||
|
if (options?.serviceName) {
|
||||||
|
query = query.eq('service_name', options.serviceName);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data, error } = await query;
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
logger.error('HealthCheckModel.findAll: query failed', {
|
||||||
|
error: error.message,
|
||||||
|
code: error.code,
|
||||||
|
options,
|
||||||
|
});
|
||||||
|
throw new Error(`HealthCheckModel.findAll: query failed — ${error.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (data ?? []) as ServiceHealthCheck[];
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error && error.message.startsWith('HealthCheckModel.findAll:')) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
throw new Error(`HealthCheckModel.findAll: unexpected error — ${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete health check records older than the specified number of days.
|
||||||
|
* Used by the Phase 2 scheduler for 30-day retention enforcement.
|
||||||
|
* Returns the count of deleted rows.
|
||||||
|
*/
|
||||||
|
static async deleteOlderThan(days: number): Promise<number> {
|
||||||
|
if (days <= 0) {
|
||||||
|
throw new Error('HealthCheckModel.deleteOlderThan: days must be a positive integer');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const supabase = getSupabaseServiceClient();
|
||||||
|
|
||||||
|
// Supabase does not support arithmetic in filters directly — compute cutoff in JS
|
||||||
|
const cutoff = new Date();
|
||||||
|
cutoff.setDate(cutoff.getDate() - days);
|
||||||
|
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('service_health_checks')
|
||||||
|
.delete()
|
||||||
|
.lt('created_at', cutoff.toISOString())
|
||||||
|
.select('id');
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
logger.error('HealthCheckModel.deleteOlderThan: delete failed', {
|
||||||
|
error: error.message,
|
||||||
|
code: error.code,
|
||||||
|
days,
|
||||||
|
});
|
||||||
|
throw new Error(`HealthCheckModel.deleteOlderThan: delete failed — ${error.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const deletedCount = data?.length ?? 0;
|
||||||
|
|
||||||
|
logger.info('HealthCheckModel.deleteOlderThan: retention cleanup complete', {
|
||||||
|
days,
|
||||||
|
deletedCount,
|
||||||
|
cutoff: cutoff.toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return deletedCount;
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error && error.message.startsWith('HealthCheckModel.deleteOlderThan:')) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
throw new Error(`HealthCheckModel.deleteOlderThan: unexpected error — ${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,12 @@ export { DocumentModel } from './DocumentModel';
|
|||||||
export { DocumentFeedbackModel } from './DocumentFeedbackModel';
|
export { DocumentFeedbackModel } from './DocumentFeedbackModel';
|
||||||
export { DocumentVersionModel } from './DocumentVersionModel';
|
export { DocumentVersionModel } from './DocumentVersionModel';
|
||||||
export { ProcessingJobModel } from './ProcessingJobModel';
|
export { ProcessingJobModel } from './ProcessingJobModel';
|
||||||
|
export { HealthCheckModel } from './HealthCheckModel';
|
||||||
|
export { AlertEventModel } from './AlertEventModel';
|
||||||
|
|
||||||
|
// Export monitoring model types
|
||||||
|
export type { ServiceHealthCheck, CreateHealthCheckData } from './HealthCheckModel';
|
||||||
|
export type { AlertEvent, CreateAlertEventData } from './AlertEventModel';
|
||||||
|
|
||||||
// Export types
|
// Export types
|
||||||
export * from './types';
|
export * from './types';
|
||||||
|
|||||||
Reference in New Issue
Block a user