fix: start on refactoring database

This commit is contained in:
Nolan Lawson 2024-03-10 12:56:51 -07:00
parent 55872ba996
commit 08307187db
4 changed files with 111 additions and 66 deletions

View File

@ -9,73 +9,60 @@ import {
import { uniqEmoji } from './utils/uniqEmoji'
import {
closeDatabase,
deleteDatabase,
addOnCloseListener,
openDatabase
deleteDatabase
} from './databaseLifecycle'
import {
isEmpty, getEmojiByGroup,
getEmojiByGroup,
getEmojiBySearchQuery, getEmojiByShortcode, getEmojiByUnicode,
get, set, getTopFavoriteEmoji, incrementFavoriteEmojiCount
} from './idbInterface'
import { customEmojiIndex } from './customEmojiIndex'
import { cleanEmoji } from './utils/cleanEmoji'
import { loadDataForFirstTime, checkForUpdates } from './dataLoading'
import { initDatabase } from './initDatabase.js'
export default class Database {
constructor ({ dataSource = DEFAULT_DATA_SOURCE, locale = DEFAULT_LOCALE, customEmoji = [] } = {}) {
this.dataSource = dataSource
this.locale = locale
this._dbName = `emoji-picker-element-${this.locale}`
this._db = undefined
this._lazyUpdate = undefined
this._custom = customEmojiIndex(customEmoji)
this._clear = this._clear.bind(this)
this._ready = this._init()
/* no await */ this._init()
}
async _init () {
const db = this._db = await openDatabase(this._dbName)
addOnCloseListener(this._dbName, this._clear)
const dataSource = this.dataSource
const empty = await isEmpty(db)
if (empty) {
await loadDataForFirstTime(db, dataSource)
} else { // offline-first - do an update asynchronously
this._lazyUpdate = checkForUpdates(db, dataSource)
if (this._controller) {
this._controller.abort()
}
this._controller = new AbortController()
this._dbPromise = initDatabase(this._dbName, this.dataSource, this._clear, this._controller.signal)
}
async ready () {
const checkReady = async () => {
if (!this._ready) {
this._ready = this._init()
}
return this._ready
}
await checkReady()
// There's a possibility of a race condition where the element gets added, removed, and then added again
// with a particular timing, which would set the _db to undefined.
// We *could* do a while loop here, but that seems excessive and could lead to an infinite loop.
if (!this._db) {
await checkReady()
await this._recreateIfNecessary()
await this._dbPromise
}
async _recreateIfNecessary () {
if (!this._controller) {
await this._init()
}
const db = await this._dbPromise
return db
}
async getEmojiByGroup (group) {
assertNumber(group)
await this.ready()
return uniqEmoji(await getEmojiByGroup(this._db, group)).map(cleanEmoji)
const db = await this._recreateIfNecessary()
return uniqEmoji(await getEmojiByGroup(db, group)).map(cleanEmoji)
}
async getEmojiBySearchQuery (query) {
assertNonEmptyString(query)
await this.ready()
const db = await this._recreateIfNecessary()
const customs = this._custom.search(query)
const natives = uniqEmoji(await getEmojiBySearchQuery(this._db, query)).map(cleanEmoji)
const natives = uniqEmoji(await getEmojiBySearchQuery(db, query)).map(cleanEmoji)
return [
...customs,
...natives
@ -84,45 +71,45 @@ export default class Database {
async getEmojiByShortcode (shortcode) {
assertNonEmptyString(shortcode)
await this.ready()
const db = await this._recreateIfNecessary()
const custom = this._custom.byShortcode(shortcode)
if (custom) {
return custom
}
return cleanEmoji(await getEmojiByShortcode(this._db, shortcode))
return cleanEmoji(await getEmojiByShortcode(db, shortcode))
}
async getEmojiByUnicodeOrName (unicodeOrName) {
assertNonEmptyString(unicodeOrName)
await this.ready()
const db = await this._recreateIfNecessary()
const custom = this._custom.byName(unicodeOrName)
if (custom) {
return custom
}
return cleanEmoji(await getEmojiByUnicode(this._db, unicodeOrName))
return cleanEmoji(await getEmojiByUnicode(db, unicodeOrName))
}
async getPreferredSkinTone () {
await this.ready()
return (await get(this._db, STORE_KEYVALUE, KEY_PREFERRED_SKINTONE)) || 0
const db = await this._recreateIfNecessary()
return (await get(db, STORE_KEYVALUE, KEY_PREFERRED_SKINTONE)) || 0
}
async setPreferredSkinTone (skinTone) {
assertNumber(skinTone)
await this.ready()
return set(this._db, STORE_KEYVALUE, KEY_PREFERRED_SKINTONE, skinTone)
const db = await this._recreateIfNecessary()
return set(db, STORE_KEYVALUE, KEY_PREFERRED_SKINTONE, skinTone)
}
async incrementFavoriteEmojiCount (unicodeOrName) {
assertNonEmptyString(unicodeOrName)
await this.ready()
return incrementFavoriteEmojiCount(this._db, unicodeOrName)
const db = await this._recreateIfNecessary()
return incrementFavoriteEmojiCount(db, unicodeOrName)
}
async getTopFavoriteEmoji (limit) {
assertNumber(limit)
await this.ready()
return (await getTopFavoriteEmoji(this._db, this._custom, limit)).map(cleanEmoji)
const db = await this._recreateIfNecessary()
return (await getTopFavoriteEmoji(db, this._custom, limit)).map(cleanEmoji)
}
set customEmoji (customEmojis) {
@ -133,11 +120,12 @@ export default class Database {
return this._custom.all
}
async _shutdown () {
await this.ready() // reopen if we've already been closed/deleted
try {
await this._lazyUpdate // allow any lazy updates to process before closing/deleting
} catch (err) { /* ignore network errors (offline-first) */ }
_shutdown () {
if (this._controller) {
this._controller.abort()
this._controller = undefined
this._dbPromise = undefined
}
}
// clear references to IDB, e.g. during a close event
@ -147,16 +135,16 @@ export default class Database {
// The memory leak tests prove this is unnecessary. It's because:
// 1) IDBDatabases that can no longer fire "close" automatically have listeners GCed
// 2) we clear the manual close listeners in databaseLifecycle.js.
this._db = this._ready = this._lazyUpdate = undefined
this._shutdown()
}
async close () {
await this._shutdown()
this._shutdown()
await closeDatabase(this._dbName)
}
async delete () {
await this._shutdown()
this._shutdown()
await deleteDatabase(this._dbName)
}
}

View File

@ -2,37 +2,71 @@ import { getETag, getETagAndData } from './utils/ajax'
import { jsonChecksum } from './utils/jsonChecksum'
import { hasData, loadData } from './idbInterface'
export async function checkForUpdates (db, dataSource) {
export async function checkForUpdates (db, dataSource, signal) {
// just do a simple HEAD request first to see if the eTags match
let emojiData
let eTag = await getETag(dataSource)
if (signal.aborted) {
return
}
if (!eTag) { // work around lack of ETag/Access-Control-Expose-Headers
const eTagAndData = await getETagAndData(dataSource)
eTag = eTagAndData[0]
emojiData = eTagAndData[1]
if (signal.aborted) {
return
}
if (!eTag) {
eTag = await jsonChecksum(emojiData)
}
}
if (signal.aborted) {
return
}
if (await hasData(db, dataSource, eTag)) {
console.log('Database already populated')
} else {
console.log('Database update available')
if (signal.aborted) {
return
}
if (!emojiData) {
const eTagAndData = await getETagAndData(dataSource)
emojiData = eTagAndData[1]
}
if (signal.aborted) {
return
}
await loadData(db, emojiData, dataSource, eTag)
}
}
export async function loadDataForFirstTime (db, dataSource) {
export async function loadDataForFirstTime (db, dataSource, signal) {
let [eTag, emojiData] = await getETagAndData(dataSource)
if (signal.aborted) {
return
}
if (!eTag) {
// Handle lack of support for ETag or Access-Control-Expose-Headers
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Expose-Headers#Browser_compatibility
eTag = await jsonChecksum(emojiData)
}
if (signal.aborted) {
return
}
await loadData(db, emojiData, dataSource, eTag)
}

View File

@ -0,0 +1,28 @@
import { addOnCloseListener, openDatabase } from './databaseLifecycle.js'
import { isEmpty } from './idbInterface.js'
import { checkForUpdates, loadDataForFirstTime } from './dataLoading.js'
export async function initDatabase (dbName, dataSource, onClear, signal) {
// signal.addEventListener('abort', () => {
// closeDatabase(dbName)
// })
const db = await openDatabase(dbName)
addOnCloseListener(dbName, onClear)
if (signal.aborted) {
return
}
const empty = await isEmpty(db)
if (signal.aborted) {
return
}
if (empty) {
await loadDataForFirstTime(db, dataSource, signal)
} else { // offline-first - do an update asynchronously
await checkForUpdates(db, dataSource, signal)
}
return db
}

View File

@ -46,7 +46,7 @@ export default class PickerElement extends HTMLElement {
delete this[prop]
}
}
this._dbFlush() // wait for a flush before creating the db, in case the user calls e.g. a setter or setAttribute
this._dbCreate()
}
connectedCallback () {
@ -94,7 +94,7 @@ export default class PickerElement extends HTMLElement {
this._cmp.$set({ [prop]: newValue })
}
if (['locale', 'dataSource'].includes(prop)) {
this._dbFlush()
this._dbCreate()
}
}
@ -102,17 +102,12 @@ export default class PickerElement extends HTMLElement {
const { locale, dataSource, database } = this._ctx
// only create a new database if we really need to
if (!database || database.locale !== locale || database.dataSource !== dataSource) {
if (database) {
database.close()
}
this._set('database', new Database({ locale, dataSource }))
}
}
// Update the Database in one microtask if the locale/dataSource change. We do one microtask
// so we don't create two Databases if e.g. both the locale and the dataSource change
_dbFlush () {
queueMicrotask(() => (
this._dbCreate()
))
}
}
const definitions = {}