iOS Safari evicts Blob file-backed data from IndexedDB under memory pressure, causing upload POSTs to throw 'Load failed' without ever reaching the server. Two-pronged fix: 1. Store photos as ArrayBuffer (inline bytes) instead of Blob (file reference) in IndexedDB — ArrayBuffers are not subject to eviction 2. Request navigator.storage.persist() to signal the browser not to evict our storage under pressure Also adds Storage.getBlob() helper for converting stored ArrayBuffer back to Blob at upload/display time, with backward compat for any existing Blob-stored photos. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
303 lines
11 KiB
JavaScript
303 lines
11 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;
|
|
}
|
|
|
|
// Convert stored data (ArrayBuffer or legacy Blob) to a Blob for upload
|
|
const blob = Storage.getBlob(freshPhoto);
|
|
|
|
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 exists and has content
|
|
if (!blob || 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);
|
|
|
|
// 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 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');
|