Skip to content

Commit

Permalink
Merge pull request #917 from pulsar-edit/sql-state-store
Browse files Browse the repository at this point in the history
Adding a SQL State Storage instead of IndexedDB
  • Loading branch information
DeeDeeG authored Oct 17, 2024
2 parents 9008caf + 58cdd5f commit 3dcfb5c
Show file tree
Hide file tree
Showing 8 changed files with 462 additions and 159 deletions.
4 changes: 2 additions & 2 deletions .github/workflows/editor-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,8 @@ jobs:

- name: Run Tests
if: runner.os != 'Linux'
run: node script/run-tests.js spec
run: yarn start --test spec

- name: Run Tests with xvfb-run (Linux)
if: runner.os == 'Linux'
run: xvfb-run --auto-servernum node script/run-tests.js spec
run: xvfb-run --auto-servernum yarn start --test spec
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
"background-tips": "file:packages/background-tips",
"base16-tomorrow-dark-theme": "file:packages/base16-tomorrow-dark-theme",
"base16-tomorrow-light-theme": "file:packages/base16-tomorrow-light-theme",
"better-sqlite3": "^11.1.2",
"bookmarks": "file:packages/bookmarks",
"bracket-matcher": "file:packages/bracket-matcher",
"chai": "4.3.4",
Expand Down
145 changes: 98 additions & 47 deletions spec/state-store-spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,66 +6,117 @@ describe('StateStore', () => {
let databaseName = `test-database-${Date.now()}`;
let version = 1;

it('can save, load, and delete states', () => {
const store = new StateStore(databaseName, version);
return store
.save('key', { foo: 'bar' })
.then(() => store.load('key'))
.then(state => {
expect(state).toEqual({ foo: 'bar' });
})
.then(() => store.delete('key'))
.then(() => store.load('key'))
.then(value => {
describe('with the default IndexedDB backend', () => {
beforeEach(() => {
atom.config.set('core.useLegacySessionStore', true)
})

it('can save, load, and delete states', () => {
const store = new StateStore(databaseName, version);
return store
.save('key', { foo: 'bar' })
.then(() => store.load('key'))
.then(state => {
expect(state).toEqual({ foo: 'bar' });
})
.then(() => store.delete('key'))
.then(() => store.load('key'))
.then(value => {
expect(value).toBeNull();
})
.then(() => store.count())
.then(count => {
expect(count).toBe(0);
});
});

it('resolves with null when a non-existent key is loaded', () => {
const store = new StateStore(databaseName, version);
return store.load('no-such-key').then(value => {
expect(value).toBeNull();
})
.then(() => store.count())
.then(count => {
expect(count).toBe(0);
});
});
});

it('resolves with null when a non-existent key is loaded', () => {
const store = new StateStore(databaseName, version);
return store.load('no-such-key').then(value => {
expect(value).toBeNull();
it('can clear the state object store', () => {
const store = new StateStore(databaseName, version);
return store
.save('key', { foo: 'bar' })
.then(() => store.count())
.then(count => expect(count).toBe(1))
.then(() => store.clear())
.then(() => store.count())
.then(count => {
expect(count).toBe(0);
});
});
});

it('can clear the state object store', () => {
const store = new StateStore(databaseName, version);
return store
.save('key', { foo: 'bar' })
.then(() => store.count())
.then(count => expect(count).toBe(1))
.then(() => store.clear())
.then(() => store.count())
.then(count => {
expect(count).toBe(0);
describe('when there is an error reading from the database', () => {
it('rejects the promise returned by load', () => {
const store = new StateStore(databaseName, version);

const fakeErrorEvent = {
target: { errorCode: 'Something bad happened' }
};

spyOn(IDBObjectStore.prototype, 'get').andCallFake(key => {
let request = {};
process.nextTick(() => request.onerror(fakeErrorEvent));
return request;
});

return store
.load('nonexistentKey')
.then(() => {
throw new Error('Promise should have been rejected');
})
.catch(event => {
expect(event).toBe(fakeErrorEvent);
});
});
});
});

describe('when there is an error reading from the database', () => {
it('rejects the promise returned by load', () => {
const store = new StateStore(databaseName, version);
describe('with the new SQLite3 backend', () => {
beforeEach(() => {
atom.config.set('core.useLegacySessionStore', false)
})

const fakeErrorEvent = {
target: { errorCode: 'Something bad happened' }
};
it('can save, load, and delete states', () => {
const store = new StateStore(databaseName, version);
return store
.save('key', { foo: 'bar' })
.then(() => store.load('key'))
.then(state => {
expect(state).toEqual({ foo: 'bar' });
})
.then(() => store.delete('key'))
.then(() => store.load('key'))
.then(value => {
expect(value).toBeNull();
})
.then(() => store.count())
.then(count => {
expect(count).toBe(0);
});
});

spyOn(IDBObjectStore.prototype, 'get').andCallFake(key => {
let request = {};
process.nextTick(() => request.onerror(fakeErrorEvent));
return request;
it('resolves with null when a non-existent key is loaded', () => {
const store = new StateStore(databaseName, version);
return store.load('no-such-key').then(value => {
expect(value).toBeNull();
});
});

it('can clear the state object store', () => {
const store = new StateStore(databaseName, version);
return store
.load('nonexistentKey')
.then(() => {
throw new Error('Promise should have been rejected');
})
.catch(event => {
expect(event).toBe(fakeErrorEvent);
.save('key', { foo: 'bar' })
.then(() => store.count())
.then(count => expect(count).toBe(1))
.then(() => store.clear())
.then(() => store.count())
.then(count => {
expect(count).toBe(0);
});
});
});
Expand Down
6 changes: 6 additions & 0 deletions src/config-schema.js
Original file line number Diff line number Diff line change
Expand Up @@ -369,6 +369,12 @@ const configSchema = {
title: 'Use Legacy Tree-sitter Implementation',
description: 'Opt into the legacy Atom Tree-sitter system instead of the modern system added by Pulsar. (We plan to remove this legacy system soon.) Has no effect unless “Use Tree-sitter Parsers” is also checked.'
},
useLegacySessionStore: {
type: 'boolean',
default: true,
title: 'Use Legacy Session Store',
description: 'Opt into the legacy Atom session store (IndexedDB) instead of the new SQLite backend (We plan to remove this legacy system soon).'
},
colorProfile: {
description:
"Specify whether Pulsar should use the operating system's color profile (recommended) or an alternative color profile.<br>Changing this setting will require a relaunch of Pulsar to take effect.",
Expand Down
148 changes: 38 additions & 110 deletions src/state-store.js
Original file line number Diff line number Diff line change
@@ -1,141 +1,69 @@
'use strict';
const IndexedDB = require('./state-store/indexed-db');
const SQL = require('./state-store/sql');

module.exports = class StateStore {
constructor(databaseName, version) {
this.connected = false;
this.databaseName = databaseName;
this.version = version;
}

get dbPromise() {
if (!this._dbPromise) {
this._dbPromise = new Promise(resolve => {
const dbOpenRequest = indexedDB.open(this.databaseName, this.version);
dbOpenRequest.onupgradeneeded = event => {
let db = event.target.result;
db.onerror = error => {
atom.notifications.addFatalError('Error loading database', {
stack: new Error('Error loading database').stack,
dismissable: true
});
console.error('Error loading database', error);
};
db.createObjectStore('states');
};
dbOpenRequest.onsuccess = () => {
this.connected = true;
resolve(dbOpenRequest.result);
};
dbOpenRequest.onerror = error => {
atom.notifications.addFatalError('Could not connect to indexedDB', {
stack: new Error('Could not connect to indexedDB').stack,
dismissable: true
});
console.error('Could not connect to indexedDB', error);
this.connected = false;
resolve(null);
};
});
}

return this._dbPromise;
}

isConnected() {
return this.connected;
// We don't need to wait for atom global here because this isConnected
// is only called on closing the editor
if(atom.config.get('core.useLegacySessionStore')) {
if(!this.indexed) return false;
return this.indexed.isConnected();
} else {
if(!this.sql) return false;
return this.sql.isConnected();
}
}

connect() {
return this.dbPromise.then(db => !!db);
return this._getCorrectImplementation().then(i => i.connect());
}

save(key, value) {
return new Promise((resolve, reject) => {
this.dbPromise.then(db => {
if (db == null) return resolve();

const request = db
.transaction(['states'], 'readwrite')
.objectStore('states')
.put({ value: value, storedAt: new Date().toString() }, key);

request.onsuccess = resolve;
request.onerror = reject;
});
});
return this._getCorrectImplementation().then(i => i.save(key, value));
}

load(key) {
return this.dbPromise.then(db => {
if (!db) return;

return new Promise((resolve, reject) => {
const request = db
.transaction(['states'])
.objectStore('states')
.get(key);

request.onsuccess = event => {
let result = event.target.result;
if (result && !result.isJSON) {
resolve(result.value);
} else {
resolve(null);
}
};

request.onerror = event => reject(event);
});
});
return this._getCorrectImplementation().then(i => i.load(key));
}

delete(key) {
return new Promise((resolve, reject) => {
this.dbPromise.then(db => {
if (db == null) return resolve();

const request = db
.transaction(['states'], 'readwrite')
.objectStore('states')
.delete(key);

request.onsuccess = resolve;
request.onerror = reject;
});
});
return this._getCorrectImplementation().then(i => i.delete(key));
}

clear() {
return this.dbPromise.then(db => {
if (!db) return;

return new Promise((resolve, reject) => {
const request = db
.transaction(['states'], 'readwrite')
.objectStore('states')
.clear();

request.onsuccess = resolve;
request.onerror = reject;
});
});
return this._getCorrectImplementation().then(i => i.clear());
}

count() {
return this.dbPromise.then(db => {
if (!db) return;

return new Promise((resolve, reject) => {
const request = db
.transaction(['states'])
.objectStore('states')
.count();
return this._getCorrectImplementation().then(i => i.count());
}

request.onsuccess = () => {
resolve(request.result);
};
request.onerror = reject;
});
_getCorrectImplementation() {
return awaitForAtomGlobal().then(() => {
if(atom.config.get('core.useLegacySessionStore')) {
this.indexed ||= new IndexedDB(this.databaseName, this.version);
return this.indexed;
} else {
this.sql ||= new SQL(this.databaseName, this.version);
return this.sql;
}
});
}
};

function awaitForAtomGlobal() {
return new Promise(resolve => {
const i = setInterval(() => {
if(atom) {
clearInterval(i)
resolve()
}
}, 50)
})
}
Loading

0 comments on commit 3dcfb5c

Please sign in to comment.