mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-24 13:31:13 -05:00
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:
@@ -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,
|
||||
|
||||
@@ -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
192
src/lib/submissionQueue.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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: [] };
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user