Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement DatabaseSystem for Edit History Storage #1678

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
210 changes: 210 additions & 0 deletions modules/core/DatabaseSystem.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
import { AbstractSystem } from './AbstractSystem.js';


/**
* `DatabaseSystem` is a wrapper around `IndexedDB`
* It is used to store larger amounts of data asynchronously,
* such as the user's edit history.
*/
export class DatabaseSystem extends AbstractSystem {
/**
* @constructor
* @param context Global shared application context
* @param dbName Name of the database
* @param version Version of the database
*/
constructor(context, dbName = 'myDatabase', version = 1) {
super(context);
this.id = 'database';
this.dependencies = new Set();
this.dbName = dbName;
this.version = version;
this.db = null;
}
/**
* initAsync
* Called after all core objects have been constructed.
* @return {Promise} Promise resolved when this component has completed initialization
*/
initAsync() {
for (const id of this.dependencies) {
if (!this.context.systems[id]) {
return Promise.reject(`Cannot init: ${this.id} requires ${id}`);
}
}

return this.open()
.then(() => {
// Initialization successful
})
.catch((error) => {
console.error('Failed to initialize:', error);
});
}


/**
* open
* Opens a connection to the IndexedDB database
*/
open() {
return new Promise((resolve, reject) => {
const request = indexedDB.open(this.dbName, 2);
request.onupgradeneeded = (event) => {
this.db = event.target.result;
try {
if (!this.db.objectStoreNames.contains('editHistory')) {
this.db.createObjectStore('editHistory', { keyPath: 'id', autoIncrement: true });
}
if (!this.db.objectStoreNames.contains('backups')) {
this.db.createObjectStore('backups', { keyPath: 'id' });
}
} catch (error) {
// eslint-disable-next-line no-console
console.error('Error during onupgradeneeded:', error);
}
};
request.onsuccess = (event) => {
this.db = event.target.result;
resolve(this.db);
};
request.onerror = (event) => {
// eslint-disable-next-line no-console
console.error('Database error:', event.target.error);
reject(`Database error: ${event.target.errorCode}`);
};
});
}


/**
* add
* Adds data to the specified object store
* @param storeName Name of the object store
* @param data Data to add
*/
add(storeName, data) {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction(storeName, 'readwrite');
const store = transaction.objectStore(storeName);
const request = store.add(data);

request.onsuccess = () => resolve();
request.onerror = (event) => {
// eslint-disable-next-line no-console
console.error('Add error:', event.target.error);
reject(`Add error: ${event.target.errorCode}`);
};
});
}


/**
* get
* Retrieves data by key from the specified object store
* @param storeName Name of the object store
* @param key Key of the data to retrieve
*/
get(storeName, key) {
return new Promise((resolve, reject) => {
if (!this.db) {
reject(new Error('Database not initialized'));
return;
}

const transaction = this.db.transaction(storeName, 'readonly');
const store = transaction.objectStore(storeName);
const request = store.get(key);

request.onsuccess = (event) => resolve(event.target.result);
request.onerror = (event) => reject(`Get error: ${event.target.errorCode}`);
});
}


/**
* update
* Updates existing data in the specified object store
* @param storeName Name of the object store
* @param data Data to update
*/
update(storeName, data) {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction(storeName, 'readwrite');
const store = transaction.objectStore(storeName);
const request = store.put(data);

request.onsuccess = () => resolve();
request.onerror = (event) => reject(`Update error: ${event.target.errorCode}`);
});
}


/**
* put
* Inserts or updates data in the specified object store
* @param storeName Name of the object store
* @param data Data to insert or update
*/
put(storeName, data) {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction(storeName, 'readwrite');
const store = transaction.objectStore(storeName);
const request = store.put(data);

request.onsuccess = () => resolve();
request.onerror = (event) => {
// eslint-disable-next-line no-console
console.error('Put error:', event.target.error);
reject(`Put error: ${event.target.errorCode}`);
};
});
}


/**
* delete
* Deletes data by key from the specified object store
* @param storeName Name of the object store
* @param key Key of the data to delete
*/
delete(storeName, key) {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction(storeName, 'readwrite');
const store = transaction.objectStore(storeName);
const request = store.delete(key);

request.onsuccess = () => resolve();
request.onerror = (event) => reject(`Delete error: ${event.target.errorCode}`);
});
}


/**
* clear
* Clears all data from the specified object store
* @param storeName Name of the object store
*/
clear(storeName) {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction(storeName, 'readwrite');
const store = transaction.objectStore(storeName);
const request = store.clear();

request.onsuccess = () => resolve();
request.onerror = (event) => reject(`Clear error: ${event.target.errorCode}`);
});
}


/**
* close
* Closes the database connection
*/
close() {
if (this.db) {
this.db.close();
this.db = null;
}
}
}
96 changes: 60 additions & 36 deletions modules/core/EditSystem.js
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ export class EditSystem extends AbstractSystem {
constructor(context) {
super(context);
this.id = 'editor'; // was: 'history'
this.dependencies = new Set(['gfx', 'imagery', 'map', 'photos', 'storage']);
this.dependencies = new Set(['gfx', 'imagery', 'map', 'photos', 'database']);

this._mutex = utilSessionMutex('lock');
this._canRestoreBackup = false;
Expand Down Expand Up @@ -118,6 +118,10 @@ export class EditSystem extends AbstractSystem {
initAsync() {
if (this._initPromise) return this._initPromise;

if (!this._mutex.locked()) {
this._mutex.lock();
}

for (const id of this.dependencies) {
if (!this.context.systems[id]) {
return Promise.reject(`Cannot init: ${this.id} requires ${id}`);
Expand All @@ -127,12 +131,28 @@ export class EditSystem extends AbstractSystem {
this._reset();

const storage = this.context.systems.storage;
const prerequisites = storage.initAsync();
const database = this.context.systems.database;

// Ensure both storage and database are initialized
const prerequisites = Promise.all([
storage.initAsync(),
database.initAsync()
]);

return this._initPromise = prerequisites
.then(() => {
.then(async () => {
if (window.mocha) return;

// Check if a backup exists in IndexedDB
try {
const backup = await database.get('backups', this._backupKey());
this._canRestoreBackup = !!backup;
} catch (error) {
// eslint-disable-next-line no-console
console.error('Failed to check for backup:', error);
this._canRestoreBackup = false;
}

// Setup event handlers..
window.addEventListener('beforeunload', e => {
if (this._history.length > 1) { // user did something
Expand All @@ -143,9 +163,6 @@ export class EditSystem extends AbstractSystem {
});

window.addEventListener('unload', () => this._mutex.unlock());

// changes are restorable if Rapid is not open in another window/tab and a backup exists in localStorage
this._canRestoreBackup = this._mutex.lock() && storage.hasItem(this._backupKey());
});
}

Expand Down Expand Up @@ -1178,15 +1195,16 @@ export class EditSystem extends AbstractSystem {
if (this._inTransaction) return; // Don't backup edits mid-transaction
if (!this._mutex.locked()) return; // Another browser tab owns the history

const storage = context.systems.storage;
const json = this.toJSON();
if (json) {
// status will be `true` if the backup succeeded
const status = storage.setItem(this._backupKey(), json);
if (status !== this._backupStatus) {
this._backupStatus = status;
this.emit('backupstatuschange', this._backupStatus);
}
this.context.systems.database.put('backups', { id: this._backupKey(), data: json })
.then(() => {
// Backup saved successfully
})
.catch((error) => {
// eslint-disable-next-line no-console
console.error('Failed to save backup:', error);
});
}
}

Expand All @@ -1210,20 +1228,30 @@ export class EditSystem extends AbstractSystem {
* - The user chooses to "Restore my changes" from the restore screen
*/
restoreBackup() {
this._canRestoreBackup = false;

if (!this._mutex.locked()) return; // another browser tab owns the history

const context = this.context;
const storage = context.systems.storage;
const json = storage.getItem(this._backupKey());
if (json) {
context.resetAsync()
.then(() => this.fromJSONAsync(json));
}
this.context.systems.database.get('backups', this._backupKey())
.then((backup) => {
if (backup) {
return this.fromJSONAsync(backup.data)
.then(() => {
// Reset flags and emit events
this._canRestoreBackup = false;
this._inTransition = false;
this._inTransaction = false;

// Emit events to ensure the system is aware of the restored state
this.emit('stagingchange', this._fullDifference);
this.emit('stablechange', this._fullDifference);
});
}
})
.catch((error) => {
// eslint-disable-next-line no-console
console.error('Failed to restore backup:', error);
});
}



/**
* clearBackup
* Remove any backup stored in localStorage.
Expand All @@ -1233,18 +1261,14 @@ export class EditSystem extends AbstractSystem {
* - A changeset is inflight, we remove it to prevent the user from restoring duplicate edits
*/
clearBackup() {
this._canRestoreBackup = false;
this.deferredBackup.cancel();

if (!this._mutex.locked()) return; // another browser tab owns the history

const storage = this.context.systems.storage;
storage.removeItem(this._backupKey());

// clear the changeset metadata associated with the saved history
storage.removeItem('comment');
storage.removeItem('hashtags');
storage.removeItem('source');
this.context.systems.database.delete('backups', this._backupKey())
.then(() => {
// Backup cleared successfully
})
.catch((error) => {
// eslint-disable-next-line no-console
console.error('Failed to clear backup:', error);
});
}


Expand Down
3 changes: 1 addition & 2 deletions modules/core/StorageSystem.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,7 @@ import { AbstractSystem } from './AbstractSystem.js';

/**
* `StorageSystem` is a wrapper around `window.localStorage`
* It is used to store user preferences (good)
* and the user's edit history (not good)
* It is used to store user preferences
*
* n.b.: `localStorage` is a _synchronous_ API.
* We should add another system for wrapping `indexedDB`,
Expand Down
3 changes: 3 additions & 0 deletions modules/core/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { PhotoSystem } from './PhotoSystem.js';
import { PresetSystem } from './PresetSystem.js';
import { RapidSystem } from './RapidSystem.js';
import { StorageSystem } from './StorageSystem.js';
import { DatabaseSystem } from './DatabaseSystem.js';
import { StyleSystem } from './StyleSystem.js';
import { UiSystem } from './UiSystem.js';
import { UploaderSystem } from './UploaderSystem.js';
Expand All @@ -35,6 +36,7 @@ export {
PresetSystem,
RapidSystem,
StorageSystem,
DatabaseSystem,
StyleSystem,
UiSystem,
UploaderSystem,
Expand All @@ -60,6 +62,7 @@ systems.available.set('photos', PhotoSystem);
systems.available.set('presets', PresetSystem);
systems.available.set('rapid', RapidSystem);
systems.available.set('storage', StorageSystem);
systems.available.set('database', DatabaseSystem);
systems.available.set('styles', StyleSystem);
systems.available.set('ui', UiSystem);
systems.available.set('uploader', UploaderSystem);
Expand Down
Loading
Loading