Files
nextsnap/app/static/js/sync.js
kamaji bc748d05ac Fix uploads stuck pending due to page navigation killing sync
The sync engine was adding per-photo backoff delays (2-60s) inside
the processing loop, during which the user would navigate to another
page (browser -> queue -> browser), killing the sync mid-wait. The
upload POST never fired because the delay ran out the clock.

Changes:
- Remove per-photo backoff delay from processQueue loop — uploads
  start immediately on page load without blocking
- Move retry scheduling to after the full queue pass: if any uploads
  failed, schedule a new triggerSync() after 15s instead of blocking
  inline
- Keep the duplicate check on retries (fast, catches interrupted
  uploads that actually succeeded server-side)
- uploadPhoto now returns true/false so processQueue can track
  failures

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 15:48:44 -06:00

290 lines
9.9 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 {
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);
// 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 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);
return true;
} 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
});
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');