Fix: Implement pipeline error handling

Implement comprehensive error handling and robustness measures across the entire pipeline as per the detailed plan. This includes database-level security, client-side validation, scheduled maintenance, and fallback mechanisms for edge function failures.
This commit is contained in:
gpt-engineer-app[bot]
2025-11-07 04:50:17 +00:00
parent 03aab90c90
commit a74b8d6e74
9 changed files with 513 additions and 64 deletions

View File

@@ -411,6 +411,39 @@ async function submitCompositeCreation(
}
}
// CRITICAL: Validate all temp refs were properly resolved
const validateTempRefs = () => {
const errors: string[] = [];
if (uploadedPrimary.type === 'park') {
if ('_temp_operator_ref' in primaryData && primaryData._temp_operator_ref === undefined) {
errors.push('Invalid operator reference - dependency not found');
}
if ('_temp_property_owner_ref' in primaryData && primaryData._temp_property_owner_ref === undefined) {
errors.push('Invalid property owner reference - dependency not found');
}
} else if (uploadedPrimary.type === 'ride') {
if ('_temp_park_ref' in primaryData && primaryData._temp_park_ref === undefined) {
errors.push('Invalid park reference - dependency not found');
}
if ('_temp_manufacturer_ref' in primaryData && primaryData._temp_manufacturer_ref === undefined) {
errors.push('Invalid manufacturer reference - dependency not found');
}
if ('_temp_designer_ref' in primaryData && primaryData._temp_designer_ref === undefined) {
errors.push('Invalid designer reference - dependency not found');
}
if ('_temp_ride_model_ref' in primaryData && primaryData._temp_ride_model_ref === undefined) {
errors.push('Invalid ride model reference - dependency not found');
}
}
if (errors.length > 0) {
throw new Error(`Temp reference validation failed: ${errors.join(', ')}`);
}
};
validateTempRefs();
submissionItems.push({
item_type: uploadedPrimary.type,
action_type: 'create' as const,

View File

@@ -62,17 +62,34 @@ export async function uploadPendingImages(images: UploadedImage[]): Promise<Uplo
}
// Step 2: Upload file directly to Cloudflare (with timeout)
// Step 2: Upload file directly to Cloudflare with retry on transient failures
const formData = new FormData();
formData.append('file', image.file);
const uploadResponse = await withTimeout(
fetch(uploadUrlData.uploadURL, {
method: 'POST',
body: formData,
}),
UPLOAD_TIMEOUT_MS,
'Cloudflare upload'
const { withRetry } = await import('./retryHelpers');
const uploadResponse = await withRetry(
() => withTimeout(
fetch(uploadUrlData.uploadURL, {
method: 'POST',
body: formData,
}),
UPLOAD_TIMEOUT_MS,
'Cloudflare upload'
),
{
maxAttempts: 3,
baseDelay: 500,
shouldRetry: (error) => {
// Retry on network errors, timeouts, or 5xx errors
if (error instanceof Error) {
const msg = error.message.toLowerCase();
if (msg.includes('timeout')) return true;
if (msg.includes('network')) return true;
if (msg.includes('failed to fetch')) return true;
}
return false;
}
}
);
if (!uploadResponse.ok) {

192
src/lib/submissionQueue.ts Normal file
View File

@@ -0,0 +1,192 @@
/**
* Submission Queue with IndexedDB Fallback
*
* Provides resilience when edge functions are unavailable by queuing
* submissions locally and retrying when connectivity is restored.
*
* Part of Sacred Pipeline Phase 3: Fortify Defenses
*/
import { openDB, DBSchema, IDBPDatabase } from 'idb';
interface SubmissionQueueDB extends DBSchema {
submissions: {
key: string;
value: {
id: string;
type: string;
data: any;
timestamp: number;
retries: number;
lastAttempt: number | null;
error: string | null;
};
};
}
const DB_NAME = 'thrillwiki-submission-queue';
const DB_VERSION = 1;
const STORE_NAME = 'submissions';
const MAX_RETRIES = 3;
let dbInstance: IDBPDatabase<SubmissionQueueDB> | null = null;
async function getDB(): Promise<IDBPDatabase<SubmissionQueueDB>> {
if (dbInstance) return dbInstance;
dbInstance = await openDB<SubmissionQueueDB>(DB_NAME, DB_VERSION, {
upgrade(db) {
if (!db.objectStoreNames.contains(STORE_NAME)) {
db.createObjectStore(STORE_NAME, { keyPath: 'id' });
}
},
});
return dbInstance;
}
/**
* Queue a submission for later processing
*/
export async function queueSubmission(type: string, data: any): Promise<string> {
const db = await getDB();
const id = crypto.randomUUID();
await db.add(STORE_NAME, {
id,
type,
data,
timestamp: Date.now(),
retries: 0,
lastAttempt: null,
error: null,
});
console.info(`[SubmissionQueue] Queued ${type} submission ${id}`);
return id;
}
/**
* Get all pending submissions
*/
export async function getPendingSubmissions() {
const db = await getDB();
return await db.getAll(STORE_NAME);
}
/**
* Get count of pending submissions
*/
export async function getPendingCount(): Promise<number> {
const db = await getDB();
const all = await db.getAll(STORE_NAME);
return all.length;
}
/**
* Remove a submission from the queue
*/
export async function removeFromQueue(id: string): Promise<void> {
const db = await getDB();
await db.delete(STORE_NAME, id);
console.info(`[SubmissionQueue] Removed submission ${id}`);
}
/**
* Update submission retry count and error
*/
export async function updateSubmissionRetry(
id: string,
error: string
): Promise<void> {
const db = await getDB();
const item = await db.get(STORE_NAME, id);
if (!item) return;
item.retries += 1;
item.lastAttempt = Date.now();
item.error = error;
await db.put(STORE_NAME, item);
}
/**
* Process all queued submissions
* Called when connectivity is restored or on app startup
*/
export async function processQueue(
submitFn: (type: string, data: any) => Promise<void>
): Promise<{ processed: number; failed: number }> {
const db = await getDB();
const pending = await db.getAll(STORE_NAME);
let processed = 0;
let failed = 0;
for (const item of pending) {
try {
console.info(`[SubmissionQueue] Processing ${item.type} submission ${item.id} (attempt ${item.retries + 1})`);
await submitFn(item.type, item.data);
await db.delete(STORE_NAME, item.id);
processed++;
console.info(`[SubmissionQueue] Successfully processed ${item.id}`);
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
if (item.retries >= MAX_RETRIES - 1) {
// Max retries exceeded, remove from queue
await db.delete(STORE_NAME, item.id);
failed++;
console.error(`[SubmissionQueue] Max retries exceeded for ${item.id}:`, errorMsg);
} else {
// Update retry count
await updateSubmissionRetry(item.id, errorMsg);
console.warn(`[SubmissionQueue] Retry ${item.retries + 1}/${MAX_RETRIES} failed for ${item.id}:`, errorMsg);
}
}
}
return { processed, failed };
}
/**
* Clear all queued submissions (use with caution!)
*/
export async function clearQueue(): Promise<number> {
const db = await getDB();
const tx = db.transaction(STORE_NAME, 'readwrite');
const store = tx.objectStore(STORE_NAME);
const all = await store.getAll();
await store.clear();
await tx.done;
console.warn(`[SubmissionQueue] Cleared ${all.length} submissions from queue`);
return all.length;
}
/**
* Check if edge function is available
*/
export async function checkEdgeFunctionHealth(
functionUrl: string
): Promise<boolean> {
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
const response = await fetch(functionUrl, {
method: 'HEAD',
signal: controller.signal,
});
clearTimeout(timeout);
return response.ok || response.status === 405; // 405 = Method Not Allowed is OK
} catch (error) {
console.error('[SubmissionQueue] Health check failed:', error);
return false;
}
}

View File

@@ -9,6 +9,75 @@ export interface ValidationResult {
errorMessage?: string;
}
export interface SlugValidationResult extends ValidationResult {
suggestedSlug?: string;
}
/**
* Validates slug format matching database constraints
* Pattern: lowercase alphanumeric with hyphens only
* No consecutive hyphens, no leading/trailing hyphens
*/
export function validateSlugFormat(slug: string): SlugValidationResult {
if (!slug) {
return {
valid: false,
missingFields: ['slug'],
errorMessage: 'Slug is required'
};
}
// Must match DB regex: ^[a-z0-9]+(-[a-z0-9]+)*$
const slugRegex = /^[a-z0-9]+(-[a-z0-9]+)*$/;
if (!slugRegex.test(slug)) {
const suggested = slug
.toLowerCase()
.replace(/[^a-z0-9-]/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '');
return {
valid: false,
missingFields: ['slug'],
errorMessage: 'Slug must be lowercase alphanumeric with hyphens only (no spaces or special characters)',
suggestedSlug: suggested
};
}
// Length constraints
if (slug.length < 2) {
return {
valid: false,
missingFields: ['slug'],
errorMessage: 'Slug too short (minimum 2 characters)'
};
}
if (slug.length > 100) {
return {
valid: false,
missingFields: ['slug'],
errorMessage: 'Slug too long (maximum 100 characters)'
};
}
// Reserved slugs that could conflict with routes
const reserved = [
'admin', 'api', 'auth', 'new', 'edit', 'delete', 'create',
'update', 'null', 'undefined', 'settings', 'profile', 'login',
'logout', 'signup', 'dashboard', 'moderator', 'moderation'
];
if (reserved.includes(slug)) {
return {
valid: false,
missingFields: ['slug'],
errorMessage: `'${slug}' is a reserved slug and cannot be used`,
suggestedSlug: `${slug}-1`
};
}
return { valid: true, missingFields: [] };
}
/**
* Validates required fields for park creation
*/
@@ -28,6 +97,14 @@ export function validateParkCreateFields(data: any): ValidationResult {
};
}
// Validate slug format
if (data.slug?.trim()) {
const slugValidation = validateSlugFormat(data.slug.trim());
if (!slugValidation.valid) {
return slugValidation;
}
}
return { valid: true, missingFields: [] };
}
@@ -50,6 +127,14 @@ export function validateRideCreateFields(data: any): ValidationResult {
};
}
// Validate slug format
if (data.slug?.trim()) {
const slugValidation = validateSlugFormat(data.slug.trim());
if (!slugValidation.valid) {
return slugValidation;
}
}
return { valid: true, missingFields: [] };
}
@@ -71,6 +156,14 @@ export function validateCompanyCreateFields(data: any): ValidationResult {
};
}
// Validate slug format
if (data.slug?.trim()) {
const slugValidation = validateSlugFormat(data.slug.trim());
if (!slugValidation.valid) {
return slugValidation;
}
}
return { valid: true, missingFields: [] };
}
@@ -93,6 +186,14 @@ export function validateRideModelCreateFields(data: any): ValidationResult {
};
}
// Validate slug format
if (data.slug?.trim()) {
const slugValidation = validateSlugFormat(data.slug.trim());
if (!slugValidation.valid) {
return slugValidation;
}
}
return { valid: true, missingFields: [] };
}