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:
admin
2026-02-24 11:33:20 -05:00
parent 94d1c0adae
commit 1e4bc99fd1
3 changed files with 568 additions and 0 deletions

View 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)}`);
}
}
}

View 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)}`);
}
}
}

View File

@@ -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';