iOS Safari kills fetch requests when the app goes to background or the connection drops during large uploads, producing 'load failed'. The sync engine was burning through all 5 retries instantly with no delay, so a transient failure became permanent. Changes: - Add AbortController timeout (120s) on upload fetch - Add exponential backoff between retries (2s, 5s, 10s...60s) - Increase max retries from 5 to 15 for flaky mobile networks - Remove 10MB resize step that was re-compressing photos already sized at capture time, avoiding extra memory pressure on iOS - Log photo size with each upload attempt for easier debugging Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
279 lines
9.6 KiB
JavaScript
279 lines
9.6 KiB
JavaScript
// NextSnap - Sync Engine
|
|
'use strict';
|
|
|
|
console.log('[SYNC] Loading sync.js...');
|
|
|
|
const Sync = {
|
|
currentUsername: null,
|
|
isOnline: navigator.onLine,
|
|
isSyncing: false,
|
|
MAX_RETRIES: 15,
|
|
VERIFY_DELAY_MS: 800,
|
|
UPLOAD_TIMEOUT_MS: 120000,
|
|
// Backoff delays in ms per retry attempt
|
|
BACKOFF_DELAYS: [0, 2000, 5000, 10000, 15000, 30000, 30000, 60000, 60000, 60000, 60000, 60000, 60000, 60000, 60000],
|
|
|
|
init(username) {
|
|
this.currentUsername = username;
|
|
this.setupEventListeners();
|
|
console.log('[SYNC] Initialized for:', username);
|
|
|
|
if (this.isOnline) {
|
|
this.triggerSync();
|
|
}
|
|
},
|
|
|
|
setupEventListeners() {
|
|
window.addEventListener('online', () => {
|
|
console.log('[SYNC] Network online');
|
|
this.isOnline = true;
|
|
this.triggerSync();
|
|
});
|
|
|
|
window.addEventListener('offline', () => {
|
|
console.log('[SYNC] Network offline');
|
|
this.isOnline = false;
|
|
this.isSyncing = false;
|
|
});
|
|
},
|
|
|
|
// Prevent page navigation during active upload
|
|
_setUploading(active) {
|
|
if (active) {
|
|
this._beforeUnloadHandler = (e) => {
|
|
e.preventDefault();
|
|
e.returnValue = 'Upload in progress - leaving will cancel it.';
|
|
return e.returnValue;
|
|
};
|
|
window.addEventListener('beforeunload', this._beforeUnloadHandler);
|
|
} else {
|
|
if (this._beforeUnloadHandler) {
|
|
window.removeEventListener('beforeunload', this._beforeUnloadHandler);
|
|
this._beforeUnloadHandler = null;
|
|
}
|
|
}
|
|
},
|
|
|
|
async triggerSync() {
|
|
if (!this.isOnline || this.isSyncing) {
|
|
console.log('[SYNC] Skip sync - online:', this.isOnline, 'syncing:', this.isSyncing);
|
|
return;
|
|
}
|
|
|
|
console.log('[SYNC] Starting sync...');
|
|
this.isSyncing = true;
|
|
|
|
try {
|
|
await this.processQueue();
|
|
} catch (error) {
|
|
console.error('[SYNC] Error:', error);
|
|
} finally {
|
|
this.isSyncing = false;
|
|
this._setUploading(false);
|
|
}
|
|
},
|
|
|
|
async processQueue() {
|
|
const pendingPhotos = await Storage.db.photos
|
|
.where('username').equals(this.currentUsername)
|
|
.and(photo => photo.status === 'pending' || photo.status === 'uploading')
|
|
.sortBy('timestamp');
|
|
|
|
console.log('[SYNC] Found', pendingPhotos.length, 'photos to process');
|
|
|
|
if (pendingPhotos.length === 0) {
|
|
return;
|
|
}
|
|
|
|
for (const photo of pendingPhotos) {
|
|
if (!this.isOnline) {
|
|
console.log('[SYNC] Lost connection, stopping');
|
|
break;
|
|
}
|
|
|
|
// Skip photos that have exceeded max retries
|
|
const retryCount = photo.retryCount || 0;
|
|
if (retryCount >= this.MAX_RETRIES) {
|
|
console.warn('[SYNC] Skipping photo (max retries reached):', photo.filename, 'retries:', retryCount);
|
|
await Storage.updatePhoto(photo.id, { status: 'failed' });
|
|
continue;
|
|
}
|
|
|
|
// Backoff delay before retry attempts
|
|
if (retryCount > 0) {
|
|
const backoff = this.BACKOFF_DELAYS[Math.min(retryCount, this.BACKOFF_DELAYS.length - 1)];
|
|
console.log('[SYNC] Waiting', backoff + 'ms before retry', retryCount + 1);
|
|
await this.delay(backoff);
|
|
|
|
// Re-check connectivity after waiting
|
|
if (!this.isOnline) {
|
|
console.log('[SYNC] Lost connection during backoff, stopping');
|
|
break;
|
|
}
|
|
}
|
|
|
|
await this.uploadPhoto(photo);
|
|
}
|
|
},
|
|
|
|
async uploadPhoto(photo) {
|
|
const retryCount = photo.retryCount || 0;
|
|
|
|
try {
|
|
console.log('[SYNC] Uploading:', photo.filename,
|
|
'(' + (photo.blob ? (photo.blob.size / 1024 / 1024).toFixed(1) + 'MB' : 'no blob') + ')',
|
|
'attempt', retryCount + 1, 'of', this.MAX_RETRIES);
|
|
|
|
// Validate blob before attempting upload
|
|
if (!photo.blob || !(photo.blob instanceof Blob) || photo.blob.size === 0) {
|
|
throw new Error('Photo data is missing or corrupted - please delete and re-capture');
|
|
}
|
|
|
|
await Storage.updatePhoto(photo.id, { status: 'uploading' });
|
|
|
|
// Prevent page navigation during upload
|
|
this._setUploading(true);
|
|
|
|
// Check for duplicates on retries (skip on first attempt to reduce latency)
|
|
if (retryCount > 0) {
|
|
const fullPath = photo.targetPath.replace(/\/$/, '') + '/' + photo.filename;
|
|
const alreadyExists = await this.checkFileExists(fullPath);
|
|
|
|
if (alreadyExists) {
|
|
console.log('[SYNC] File already exists on server, skipping upload:', fullPath);
|
|
await Storage.updatePhoto(photo.id, {
|
|
status: 'verified',
|
|
blob: null,
|
|
completedAt: Date.now()
|
|
});
|
|
await this.pruneHistory();
|
|
this._setUploading(false);
|
|
return;
|
|
}
|
|
}
|
|
|
|
const uploadBlob = photo.blob;
|
|
|
|
const formData = new FormData();
|
|
formData.append('file', uploadBlob, photo.filename);
|
|
|
|
const uploadUrl = '/api/files/upload?path=' + encodeURIComponent(photo.targetPath);
|
|
|
|
// Upload with timeout via AbortController
|
|
const controller = new AbortController();
|
|
const timeoutId = setTimeout(() => controller.abort(), this.UPLOAD_TIMEOUT_MS);
|
|
|
|
let uploadResponse;
|
|
try {
|
|
uploadResponse = await fetch(uploadUrl, {
|
|
method: 'POST',
|
|
body: formData,
|
|
signal: controller.signal
|
|
});
|
|
} catch (fetchErr) {
|
|
clearTimeout(timeoutId);
|
|
if (fetchErr.name === 'AbortError') {
|
|
throw new Error('Upload timed out (' + (this.UPLOAD_TIMEOUT_MS / 1000) + 's)');
|
|
}
|
|
throw fetchErr;
|
|
}
|
|
clearTimeout(timeoutId);
|
|
|
|
if (!uploadResponse.ok) {
|
|
let errorMsg = 'Upload failed';
|
|
try {
|
|
const errData = await uploadResponse.json();
|
|
errorMsg = errData.error || errorMsg;
|
|
} catch (e) {
|
|
errorMsg = 'Upload failed (HTTP ' + uploadResponse.status + ')';
|
|
}
|
|
throw new Error(errorMsg);
|
|
}
|
|
|
|
const uploadResult = await uploadResponse.json();
|
|
console.log('[SYNC] Upload successful:', uploadResult.path);
|
|
|
|
await Storage.updatePhoto(photo.id, { status: 'uploaded' });
|
|
|
|
// Wait before verifying to allow server-side processing
|
|
await this.delay(this.VERIFY_DELAY_MS);
|
|
|
|
const verifyUrl = '/api/files/verify?path=' + encodeURIComponent(uploadResult.path);
|
|
const verifyResponse = await fetch(verifyUrl);
|
|
|
|
if (!verifyResponse.ok) {
|
|
throw new Error('Verification failed');
|
|
}
|
|
|
|
const verifyResult = await verifyResponse.json();
|
|
|
|
if (!verifyResult.exists) {
|
|
throw new Error('File not found on server');
|
|
}
|
|
|
|
console.log('[SYNC] Verified:', uploadResult.path);
|
|
|
|
// Keep record but strip blob to save storage
|
|
await Storage.updatePhoto(photo.id, {
|
|
status: 'verified',
|
|
blob: null,
|
|
completedAt: Date.now()
|
|
});
|
|
|
|
console.log('[SYNC] Upload complete:', photo.id);
|
|
|
|
// Prune old completed entries beyond 20
|
|
await this.pruneHistory();
|
|
|
|
// Clear navigation guard after successful upload
|
|
this._setUploading(false);
|
|
|
|
} catch (error) {
|
|
this._setUploading(false);
|
|
console.error('[SYNC] Upload failed:', error.message, '(attempt', retryCount + 1 + ')');
|
|
await Storage.updatePhoto(photo.id, {
|
|
status: 'pending',
|
|
retryCount: retryCount + 1,
|
|
lastError: error.message,
|
|
error: error.message
|
|
});
|
|
}
|
|
},
|
|
|
|
async pruneHistory() {
|
|
// Keep only the last 20 completed (verified/failed) entries per user
|
|
const completed = await Storage.db.photos
|
|
.where('username').equals(this.currentUsername)
|
|
.and(p => p.status === 'verified' || p.status === 'failed')
|
|
.sortBy('timestamp');
|
|
|
|
if (completed.length > 20) {
|
|
const toDelete = completed.slice(0, completed.length - 20);
|
|
for (const photo of toDelete) {
|
|
await Storage.deletePhoto(photo.id);
|
|
}
|
|
console.log('[SYNC] Pruned', toDelete.length, 'old history entries');
|
|
}
|
|
},
|
|
|
|
async checkFileExists(path) {
|
|
try {
|
|
const verifyUrl = '/api/files/verify?path=' + encodeURIComponent(path);
|
|
const response = await fetch(verifyUrl);
|
|
if (!response.ok) return false;
|
|
const result = await response.json();
|
|
return result.exists === true;
|
|
} catch (e) {
|
|
// If we can't check, assume it doesn't exist and proceed with upload
|
|
return false;
|
|
}
|
|
},
|
|
|
|
delay(ms) {
|
|
return new Promise(resolve => setTimeout(resolve, ms));
|
|
}
|
|
};
|
|
|
|
window.SyncEngine = Sync;
|
|
console.log('[SYNC] SyncEngine exported successfully');
|