diff --git a/.github/workflows/editor-tests.yml b/.github/workflows/editor-tests.yml index c40264941f..53af44d66d 100644 --- a/.github/workflows/editor-tests.yml +++ b/.github/workflows/editor-tests.yml @@ -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 diff --git a/package.json b/package.json index 43ad58ac71..85c0b25e13 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/spec/state-store-spec.js b/spec/state-store-spec.js index 6bb3f11170..89679a7b8d 100644 --- a/spec/state-store-spec.js +++ b/spec/state-store-spec.js @@ -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); }); }); }); diff --git a/src/config-schema.js b/src/config-schema.js index d1a2ed2fe4..4fec1ce11e 100644 --- a/src/config-schema.js +++ b/src/config-schema.js @@ -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.
Changing this setting will require a relaunch of Pulsar to take effect.", diff --git a/src/state-store.js b/src/state-store.js index 840ec18319..1b3482aedc 100644 --- a/src/state-store.js +++ b/src/state-store.js @@ -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) + }) +} diff --git a/src/state-store/indexed-db.js b/src/state-store/indexed-db.js new file mode 100644 index 0000000000..4e64d6edae --- /dev/null +++ b/src/state-store/indexed-db.js @@ -0,0 +1,141 @@ +'use strict'; + +module.exports = class IndexedDBStateStore { + 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; + } + + connect() { + return this.dbPromise.then(db => !!db); + } + + 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; + }); + }); + } + + 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); + }); + }); + } + + 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; + }); + }); + } + + 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; + }); + }); + } + + count() { + return this.dbPromise.then(db => { + if (!db) return; + + return new Promise((resolve, reject) => { + const request = db + .transaction(['states']) + .objectStore('states') + .count(); + + request.onsuccess = () => { + resolve(request.result); + }; + request.onerror = reject; + }); + }); + } +}; diff --git a/src/state-store/sql.js b/src/state-store/sql.js new file mode 100644 index 0000000000..85b92bd4a7 --- /dev/null +++ b/src/state-store/sql.js @@ -0,0 +1,117 @@ +'use strict'; + +const sqlite3 = require('better-sqlite3'); +const path = require('path'); + +module.exports = class SQLStateStore { + constructor(databaseName, version) { + const table = databaseName + version; + this.tableName = '"' + table + '"'; + this.dbPromise = (async () => { + await awaitForAtomGlobal(); + const dbPath = path.join(atom.getConfigDirPath(), 'session-store.db'); + let db; + try { + db = sqlite3(dbPath); + } catch(error) { + atom.notifications.addFatalError('Error loading database', { + stack: new Error('Error loading SQLite database for state storage').stack, + dismissable: true + }); + console.error('Error loading SQLite database', error); + return null; + } + db.pragma('journal_mode = WAL'); + db.exec( + `CREATE TABLE IF NOT EXISTS ${this.tableName} (key VARCHAR, value JSON)` + ); + db.exec( + `CREATE UNIQUE INDEX IF NOT EXISTS "${table}_index" ON ${this.tableName}(key)` + ); + return db; + })(); + this.connected = false; + this.dbPromise.then(db => this.connected = !!db); + } + + isConnected() { + return this.connected; + } + + connect() { + return this.dbPromise.then(db => !!db); + } + + save(key, value) { + return this.dbPromise.then(db => { + if(!db) return null; + return exec(db, + `REPLACE INTO ${this.tableName} VALUES (?, ?)`, + key, + JSON.stringify({ value: value, storedAt: new Date().toString() }) + ); + }); + } + + load(key) { + return this.dbPromise.then(db => { + if(!db) return null; + return getOne(db, `SELECT value FROM ${this.tableName} WHERE key = ?`, key); + }).then(result => { + if(result) { + const parsed = JSON.parse(result.value, reviver); + return parsed?.value; + } + return null; + }); + } + + delete(key) { + return this.dbPromise.then(db => + exec(db, `DELETE FROM ${this.tableName} WHERE key = ?`, key) + ); + } + + clear() { + return this.dbPromise.then(db => + exec(db, `DELETE FROM ${this.tableName}`) + ); + } + + count() { + return this.dbPromise.then(db => { + if(!db) return null; + const r = getOne(db, `SELECT COUNT(key) c FROM ${this.tableName}`); + return r.c; + }); + } +}; + +function getOne(db, sql, ...params) { + const stmt = db.prepare(sql); + return stmt.get(params) +} + +function exec(db, sql, ...params) { + const stmt = db.prepare(sql); + stmt.run(params) +} + +function awaitForAtomGlobal() { + return new Promise(resolve => { + const i = setInterval(() => { + if(atom) { + clearInterval(i); + resolve(); + } + }, 50); + }) +} + +function reviver(_, value) { + if(value?.type === 'Buffer') { + return Buffer.from(value.data); + } else { + return value; + } +} diff --git a/yarn.lock b/yarn.lock index 4505c010e9..9f0ed94d7b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2517,6 +2517,14 @@ bcrypt-pbkdf@^1.0.0: dependencies: tweetnacl "^0.14.3" +better-sqlite3@^11.1.2: + version "11.1.2" + resolved "https://registry.yarnpkg.com/better-sqlite3/-/better-sqlite3-11.1.2.tgz#6c9d064c9f1ff2a7f507477648ca0ba67bf564a3" + integrity sha512-gujtFwavWU4MSPT+h9B+4pkvZdyOUkH54zgLdIrMmmmd4ZqiBIrRNBzNzYVFO417xo882uP5HBu4GjOfaSrIQw== + dependencies: + bindings "^1.5.0" + prebuild-install "^7.1.1" + binary-extensions@^2.0.0: version "2.2.0" resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.2.0.tgz#75f502eeaf9ffde42fc98829645be4ea76bd9e2d" @@ -2527,6 +2535,13 @@ binary-search@^1.3.3: resolved "https://registry.yarnpkg.com/binary-search/-/binary-search-1.3.6.tgz#e32426016a0c5092f0f3598836a1c7da3560565c" integrity sha512-nbE1WxOTTrUWIfsfZ4aHGYu5DOuNkbxGokjV6Z2kxfJK3uaAb8zNK1muzOeipoLHZjInT4Br88BHpzevc681xA== +bindings@^1.5.0: + version "1.5.0" + resolved "https://registry.yarnpkg.com/bindings/-/bindings-1.5.0.tgz#10353c9e945334bc0511a6d90b38fbc7c9c504df" + integrity sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ== + dependencies: + file-uri-to-path "1.0.0" + bintrees@1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/bintrees/-/bintrees-1.0.2.tgz#49f896d6e858a4a499df85c38fb399b9aff840f8" @@ -3632,6 +3647,11 @@ detect-libc@^1.0.3: resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-1.0.3.tgz#fa137c4bd698edf55cd5cd02ac559f91a4c4ba9b" integrity sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg== +detect-libc@^2.0.0: + version "2.0.2" + resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-2.0.2.tgz#8ccf2ba9315350e1241b88d0ac3b0e1fbd99605d" + integrity sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw== + detect-node@^2.0.4: version "2.1.0" resolved "https://registry.yarnpkg.com/detect-node/-/detect-node-2.1.0.tgz#c9c70775a49c3d03bc2c06d9a73be550f978f8b1" @@ -4501,6 +4521,11 @@ file-set@^4.0.2: array-back "^5.0.0" glob "^7.1.6" +file-uri-to-path@1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz#553a7b8446ff6f684359c445f1e37a05dacc33dd" + integrity sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw== + filelist@^1.0.1: version "1.0.4" resolved "https://registry.yarnpkg.com/filelist/-/filelist-1.0.4.tgz#f78978a1e944775ff9e62e744424f215e58352b5" @@ -7103,6 +7128,13 @@ node-abi@^3.0.0: dependencies: semver "^7.3.5" +node-abi@^3.3.0: + version "3.54.0" + resolved "https://registry.yarnpkg.com/node-abi/-/node-abi-3.54.0.tgz#f6386f7548817acac6434c6cba02999c9aebcc69" + integrity sha512-p7eGEiQil0YUV3ItH4/tBb781L5impVmmx2E9FRKF7d18XXzp4PGT2tdYMFY6wQqgxD0IwNZOiSJ0/K0fSi/OA== + dependencies: + semver "^7.3.5" + node-addon-api@*: version "5.0.0" resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-5.0.0.tgz#7d7e6f9ef89043befdb20c1989c905ebde18c501" @@ -7749,6 +7781,24 @@ prebuild-install@^6.0.0, prebuild-install@^6.0.1: tar-fs "^2.0.0" tunnel-agent "^0.6.0" +prebuild-install@^7.1.1: + version "7.1.1" + resolved "https://registry.yarnpkg.com/prebuild-install/-/prebuild-install-7.1.1.tgz#de97d5b34a70a0c81334fd24641f2a1702352e45" + integrity sha512-jAXscXWMcCK8GgCoHOfIr0ODh5ai8mj63L2nWrjuAgXE6tDyYGnx4/8o/rCgU+B4JSyZBKbeZqzhtwtC3ovxjw== + dependencies: + detect-libc "^2.0.0" + expand-template "^2.0.3" + github-from-package "0.0.0" + minimist "^1.2.3" + mkdirp-classic "^0.5.3" + napi-build-utils "^1.0.1" + node-abi "^3.3.0" + pump "^3.0.0" + rc "^1.2.7" + simple-get "^4.0.0" + tar-fs "^2.0.0" + tunnel-agent "^0.6.0" + prelude-ls@^1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396" @@ -8617,6 +8667,15 @@ simple-get@^3.0.3: once "^1.3.1" simple-concat "^1.0.0" +simple-get@^4.0.0: + version "4.0.1" + resolved "https://registry.yarnpkg.com/simple-get/-/simple-get-4.0.1.tgz#4a39db549287c979d352112fa03fd99fd6bc3543" + integrity sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA== + dependencies: + decompress-response "^6.0.0" + once "^1.3.1" + simple-concat "^1.0.0" + simple-swizzle@^0.2.2: version "0.2.2" resolved "https://registry.yarnpkg.com/simple-swizzle/-/simple-swizzle-0.2.2.tgz#a4da6b635ffcccca33f70d17cb92592de95e557a"