Files
nextsnap/app/static/js/storage.js
kamaji 5105b42c46 Prevent blob eviction: store as ArrayBuffer + request persistence
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>
2026-02-07 16:01:07 -06:00

166 lines
5.0 KiB
JavaScript

// NextSnap - IndexedDB Storage using Dexie.js
'use strict';
const Storage = {
db: null,
init() {
// Initialize Dexie database
this.db = new Dexie('nextsnap');
// Define schema with compound indexes
this.db.version(1).stores({
photos: '++id, username, timestamp, filename, targetPath, status, [username+status], [username+timestamp]',
settings: '++id, username, [username+key]'
});
// Request persistent storage so the browser won't evict our data
this.requestPersistence();
return this.db.open();
},
async requestPersistence() {
if (navigator.storage && navigator.storage.persist) {
const granted = await navigator.storage.persist();
console.log('[STORAGE] Persistence:', granted ? 'granted' : 'denied');
}
},
async savePhoto(photoData) {
// Convert Blob to ArrayBuffer for storage resilience.
// iOS Safari can evict Blob file-backed data from IndexedDB,
// but ArrayBuffer is stored inline and won't be evicted.
let imageData = photoData.blob;
if (imageData instanceof Blob) {
imageData = await imageData.arrayBuffer();
}
const id = await this.db.photos.add({
username: photoData.username,
timestamp: photoData.timestamp,
filename: photoData.filename,
targetPath: photoData.targetPath,
blob: imageData,
status: photoData.status || 'pending',
retryCount: photoData.retryCount || 0,
lastError: photoData.lastError || null
});
return id;
},
// Convert stored photo data back to a Blob for upload or display.
// Handles both legacy Blob storage and new ArrayBuffer storage.
getBlob(photo) {
if (!photo || !photo.blob) return null;
if (photo.blob instanceof Blob) return photo.blob;
return new Blob([photo.blob], { type: 'image/jpeg' });
},
async getPhoto(id) {
return await this.db.photos.get(id);
},
async getAllPhotos(username = null) {
if (username) {
return await this.db.photos
.where('username').equals(username)
.reverse()
.sortBy('timestamp');
}
return await this.db.photos.reverse().sortBy('timestamp');
},
async getPendingPhotos(username = null) {
if (username) {
return await this.db.photos
.where('[username+status]')
.equals([username, 'pending'])
.sortBy('timestamp');
}
return await this.db.photos
.where('status').equals('pending')
.sortBy('timestamp');
},
async getRecentPhotos(username, limit = 5) {
return await this.db.photos
.where('username').equals(username)
.reverse()
.limit(limit)
.sortBy('timestamp');
},
async updatePhoto(id, updates) {
return await this.db.photos.update(id, updates);
},
async deletePhoto(id) {
return await this.db.photos.delete(id);
},
async getPhotoCount(username = null, status = null) {
let collection = this.db.photos;
if (username && status) {
return await collection
.where('[username+status]')
.equals([username, status])
.count();
} else if (username) {
return await collection
.where('username').equals(username)
.count();
} else if (status) {
return await collection
.where('status').equals(status)
.count();
}
return await collection.count();
},
async saveSetting(username, key, value) {
// Check if setting exists
const existing = await this.db.settings
.where('[username+key]')
.equals([username, key])
.first();
if (existing) {
await this.db.settings.update(existing.id, { value: value });
} else {
await this.db.settings.add({
username: username,
key: key,
value: value
});
}
},
async getSetting(username, key, defaultValue = null) {
const setting = await this.db.settings
.where('[username+key]')
.equals([username, key])
.first();
return setting ? setting.value : defaultValue;
},
async clearVerifiedPhotos(username = null) {
if (username) {
return await this.db.photos
.where('[username+status]')
.equals([username, 'verified'])
.delete();
}
return await this.db.photos
.where('status').equals('verified')
.delete();
}
};
// Make Storage globally available
window.Storage = Storage;