iOS Safari can evict blob data from IndexedDB while keeping the Blob reference intact (instanceof/size checks pass but actual data read fails). This caused upload POST to throw 'Load failed' client-side, never reaching the server, burning retries endlessly. Changes: - Add validateBlobReadable() that reads first byte to verify data access - If blob is unreadable, mark as 'failed' immediately with clear message - Re-read photo from IndexedDB before upload for fresh blob reference - Capture detailed error name/type in catch for better diagnostics - Bump SW cache versions to v14/v10 to force new code delivery Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
329 lines
12 KiB
JavaScript
329 lines
12 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,
|
|
_retryTimerId: null,
|
|
|
|
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;
|
|
if (this._retryTimerId) {
|
|
clearTimeout(this._retryTimerId);
|
|
this._retryTimerId = null;
|
|
}
|
|
});
|
|
},
|
|
|
|
// 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;
|
|
|
|
let hadFailures = false;
|
|
try {
|
|
hadFailures = await this.processQueue();
|
|
} catch (error) {
|
|
console.error('[SYNC] Error:', error);
|
|
hadFailures = true;
|
|
} finally {
|
|
this.isSyncing = false;
|
|
this._setUploading(false);
|
|
}
|
|
|
|
// If there were failures, schedule a retry cycle after a delay
|
|
// so it doesn't block the current page load
|
|
if (hadFailures && this.isOnline) {
|
|
console.log('[SYNC] Scheduling retry in 15s...');
|
|
this._retryTimerId = setTimeout(() => {
|
|
this._retryTimerId = null;
|
|
this.triggerSync();
|
|
}, 15000);
|
|
}
|
|
},
|
|
|
|
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 false;
|
|
}
|
|
|
|
let hadFailures = false;
|
|
|
|
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;
|
|
}
|
|
|
|
// No delay — upload immediately. Retries are handled by
|
|
// scheduling a new triggerSync() after the full queue pass.
|
|
const success = await this.uploadPhoto(photo);
|
|
if (!success) hadFailures = true;
|
|
}
|
|
|
|
return hadFailures;
|
|
},
|
|
|
|
async uploadPhoto(photo) {
|
|
const retryCount = photo.retryCount || 0;
|
|
|
|
try {
|
|
// Re-read photo from IndexedDB to get a fresh blob reference
|
|
// (iOS Safari can invalidate blob references between reads)
|
|
const freshPhoto = await Storage.db.photos.get(photo.id);
|
|
if (!freshPhoto || (freshPhoto.status !== 'pending' && freshPhoto.status !== 'uploading')) {
|
|
console.log('[SYNC] Photo no longer pending, skipping:', photo.id);
|
|
return true;
|
|
}
|
|
|
|
const blob = freshPhoto.blob;
|
|
|
|
console.log('[SYNC] Uploading:', photo.filename,
|
|
'(' + (blob ? (blob.size / 1024 / 1024).toFixed(1) + 'MB' : 'no blob') + ')',
|
|
'attempt', retryCount + 1, 'of', this.MAX_RETRIES);
|
|
|
|
// Validate blob reference exists
|
|
if (!blob || !(blob instanceof Blob) || blob.size === 0) {
|
|
throw new Error('Photo data is missing or corrupted - please delete and re-capture');
|
|
}
|
|
|
|
// Actually try to read the blob to verify data is accessible
|
|
// (iOS Safari can evict blob data from IndexedDB while keeping the reference)
|
|
const readable = await this.validateBlobReadable(blob);
|
|
if (!readable) {
|
|
// Blob data is gone — no amount of retrying will fix this
|
|
console.error('[SYNC] Blob data is no longer readable:', photo.filename);
|
|
await Storage.updatePhoto(photo.id, {
|
|
status: 'failed',
|
|
lastError: 'Photo data was lost by the browser - please re-capture',
|
|
error: 'Photo data was lost by the browser - please re-capture'
|
|
});
|
|
return false;
|
|
}
|
|
|
|
await Storage.updatePhoto(photo.id, { status: 'uploading' });
|
|
|
|
// Prevent page navigation during upload
|
|
this._setUploading(true);
|
|
|
|
// On retries, first check if a previous attempt actually succeeded
|
|
// (the upload may have completed but the verify/status update was interrupted)
|
|
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 true;
|
|
}
|
|
}
|
|
|
|
const formData = new FormData();
|
|
formData.append('file', blob, 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)');
|
|
}
|
|
// Capture detailed error info for debugging
|
|
const errDetail = fetchErr.name + ': ' + fetchErr.message;
|
|
console.error('[SYNC] Fetch threw:', errDetail);
|
|
throw new Error('Network error: ' + errDetail);
|
|
}
|
|
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);
|
|
return true;
|
|
|
|
} catch (error) {
|
|
this._setUploading(false);
|
|
const errMsg = (error.name || 'Error') + ': ' + error.message;
|
|
console.error('[SYNC] Upload failed:', errMsg, '(attempt', retryCount + 1 + ')');
|
|
await Storage.updatePhoto(photo.id, {
|
|
status: 'pending',
|
|
retryCount: retryCount + 1,
|
|
lastError: errMsg,
|
|
error: errMsg
|
|
});
|
|
return false;
|
|
}
|
|
},
|
|
|
|
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 validateBlobReadable(blob) {
|
|
try {
|
|
// Try to read the first byte — if iOS has evicted the data,
|
|
// this will throw even though the Blob reference looks valid
|
|
const slice = blob.slice(0, 1);
|
|
const buf = await slice.arrayBuffer();
|
|
return buf.byteLength > 0;
|
|
} catch (e) {
|
|
console.error('[SYNC] Blob readability check failed:', e.message);
|
|
return false;
|
|
}
|
|
},
|
|
|
|
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) {
|
|
return false;
|
|
}
|
|
},
|
|
|
|
delay(ms) {
|
|
return new Promise(resolve => setTimeout(resolve, ms));
|
|
}
|
|
};
|
|
|
|
window.SyncEngine = Sync;
|
|
console.log('[SYNC] SyncEngine exported successfully');
|