UNPKG

@nozbe/watermelondb

Version:

Build powerful React Native and React web apps that scale from hundreds to tens of thousands of records and remain fast

482 lines (399 loc) 14 kB
// @flow // don't import the whole utils/ here! import logger from '../../../utils/common/logger' import invariant from '../../../utils/common/invariant' import type { CachedQueryResult, CachedFindResult, BatchOperation, UnsafeExecuteOperations, } from '../../type' import type { TableName, AppSchema, SchemaVersion, TableSchema } from '../../../Schema' import type { SchemaMigrations, CreateTableMigrationStep, AddColumnsMigrationStep, MigrationStep, } from '../../../Schema/migrations' import type { SerializedQuery } from '../../../Query' import type { RecordId } from '../../../Model' import { type RawRecord, sanitizedRaw, setRawSanitized, type DirtyRaw } from '../../../RawRecord' import type { Loki, LokiCollection } from '../type' import { newLoki, deleteDatabase, lokiFatalError } from './lokiExtensions' import { executeQuery, executeCount } from './executeQuery' import type { LokiAdapterOptions } from '../index' const SCHEMA_VERSION_KEY = '_loki_schema_version' let experimentalAllowsFatalError = false export function setExperimentalAllowsFatalError(): void { experimentalAllowsFatalError = true } export default class DatabaseDriver { options: LokiAdapterOptions schema: AppSchema migrations: ?SchemaMigrations loki: Loki cachedRecords: Map<TableName<any>, Set<RecordId>> = new Map() // (experimental) if true, DatabaseDriver is in a broken state and should not be used anymore _isBroken: boolean = false constructor(options: LokiAdapterOptions): void { const { schema, migrations } = options this.options = options this.schema = schema this.migrations = migrations } async setUp(): Promise<void> { await this._openDatabase() await this._migrateIfNeeded() } isCached(table: TableName<any>, id: RecordId): boolean { const cachedSet = this.cachedRecords.get(table) return cachedSet ? cachedSet.has(id) : false } markAsCached(table: TableName<any>, id: RecordId): void { const cachedSet = this.cachedRecords.get(table) if (cachedSet) { cachedSet.add(id) } else { this.cachedRecords.set(table, new Set([id])) } } removeFromCache(table: TableName<any>, id: RecordId): void { const cachedSet = this.cachedRecords.get(table) if (cachedSet) { cachedSet.delete(id) } } clearCachedRecords(): void { this.cachedRecords = new Map() } getCache(table: TableName<any>): Set<RecordId> { const cache = this.cachedRecords.get(table) if (cache) { return cache } const newCache = new Set([]) this.cachedRecords.set(table, newCache) return newCache } find(table: TableName<any>, id: RecordId): CachedFindResult { if (this.isCached(table, id)) { return id } const raw = this.loki.getCollection(table).by('id', id) if (!raw) { return null } this.markAsCached(table, id) return sanitizedRaw(raw, this.schema.tables[table]) } query(query: SerializedQuery): CachedQueryResult { const records = executeQuery(query, this.loki) return this._compactQueryResults(records, query.table) } queryIds(query: SerializedQuery): RecordId[] { return executeQuery(query, this.loki).map((record) => record.id) } unsafeQueryRaw(query: SerializedQuery): any[] { return executeQuery(query, this.loki) } count(query: SerializedQuery): number { return executeCount(query, this.loki) } batch(operations: BatchOperation[]): void { // NOTE: Mutations to LokiJS db are *not* transactional! // This is terrible and lame for a database, but there's just no simple and good solution to this // Loki transactions rely on making a full copy of the data, and reverting to it if something breaks. // This is just unbearable for production-sized databases (too much memory required) // It could be done with some sort of advanced journaling/CoW structure scheme, but that would // be very complicated (in itself a source of bugs), and possibly quite expensive cpu-wise // // So instead, we assume that writes MUST succeed. If they don't, we put DatabaseDriver in a "broken" // state, refuse to persist or further mutate the DB, and notify the app (and user) about it. // // It can be assumed that Loki-level mutations that fail are WatermelonDB bugs that must be fixed this._assertNotBroken() try { const recordsToCreate: { [TableName<any>]: RawRecord[] } = {} operations.forEach((operation) => { const [type, table, raw] = operation switch (type) { case 'create': if (!recordsToCreate[table]) { recordsToCreate[table] = [] } recordsToCreate[table].push((raw: $FlowFixMe<RawRecord>)) break default: break } }) // We're doing a second pass, because batch insert is much faster in Loki Object.entries(recordsToCreate).forEach((args: any) => { const [table, raws]: [TableName<any>, RawRecord[]] = args const shouldRebuildIndexAfterInsert = raws.length >= 1000 // only profitable for large inserts this.loki.getCollection(table).insert(raws, shouldRebuildIndexAfterInsert) const cache = this.getCache(table) raws.forEach((raw) => { cache.add(raw.id) }) }) operations.forEach((operation) => { const [type, table, rawOrId] = operation const collection = this.loki.getCollection(table) switch (type) { case 'update': { // Loki identifies records using internal $loki ID so we must find the saved record first const lokiId = collection.by('id', (rawOrId: any).id).$loki const raw: DirtyRaw = rawOrId raw.$loki = lokiId collection.update(raw) break } case 'markAsDeleted': { const id: RecordId = (rawOrId: any) const record = collection.by('id', id) if (record) { record._status = 'deleted' collection.update(record) this.removeFromCache(table, id) } break } case 'destroyPermanently': { const id: RecordId = (rawOrId: any) const record = collection.by('id', id) record && collection.remove(record) this.removeFromCache(table, id) break } default: break } }) } catch (error) { this._fatalError(error) } } getDeletedRecords(table: TableName<any>): RecordId[] { return this.loki .getCollection(table) .find({ _status: { $eq: 'deleted' } }) .map((record) => record.id) } unsafeExecute(operations: UnsafeExecuteOperations): void { if (process.env.NODE_ENV !== 'production') { invariant( operations && typeof operations === 'object' && Object.keys(operations).length === 1 && typeof operations.loki === 'function', 'unsafeExecute expects an { loki: loki => { ... } } object', ) } const lokiBlock: (Loki) => void = (operations: any).loki lokiBlock(this.loki) } async unsafeResetDatabase(): Promise<void> { await deleteDatabase(this.loki) this.cachedRecords.clear() logger.log('[Loki] Database is now reset') await this._openDatabase() this._setUpSchema() } // *** LocalStorage *** getLocal(key: string): ?string { const record = this._findLocal(key) return record ? record.value : null } setLocal(key: string, value: string): void { this._assertNotBroken() try { const record = this._findLocal(key) if (record) { record.value = value this._localStorage.update(record) } else { const newRecord = { key, value } this._localStorage.insert(newRecord) } } catch (error) { this._fatalError(error) } } removeLocal(key: string): void { this._assertNotBroken() try { const record = this._findLocal(key) if (record) { this._localStorage.remove(record) } } catch (error) { this._fatalError(error) } } // *** Internals *** async _openDatabase(): Promise<void> { logger.log('[Loki] Initializing IndexedDB') this.loki = await newLoki(this.options) logger.log('[Loki] Database loaded') } _setUpSchema(): void { logger.log('[Loki] Setting up schema') // Add collections const tables: TableSchema[] = (Object.values(this.schema.tables): any) tables.forEach((tableSchema) => { this._addCollection(tableSchema) }) this.loki.addCollection('local_storage', { unique: ['key'], indices: [], disableMeta: true, }) // Set database version this._databaseVersion = this.schema.version logger.log('[Loki] Database collections set up') } _addCollection(tableSchema: TableSchema): void { const { name, columnArray } = tableSchema const indexedColumns: string[] = columnArray.reduce( (indexes: string[], column) => column.isIndexed ? indexes.concat([(column.name: string)]) : indexes, [], ) this.loki.addCollection(name, { unique: ['id'], indices: ['_status', ...indexedColumns], disableMeta: true, }) } get _databaseVersion(): SchemaVersion { const databaseVersionRaw = this.getLocal(SCHEMA_VERSION_KEY) || '' return parseInt(databaseVersionRaw, 10) || 0 } set _databaseVersion(version: SchemaVersion): void { this.setLocal(SCHEMA_VERSION_KEY, `${version}`) } async _migrateIfNeeded(): Promise<void> { const dbVersion = this._databaseVersion const schemaVersion = this.schema.version if (dbVersion === schemaVersion) { // All good! } else if (dbVersion === 0) { logger.log('[Loki] Empty database, setting up') await this.unsafeResetDatabase() } else if (dbVersion > 0 && dbVersion < schemaVersion) { logger.log('[Loki] Database has old schema version. Migration is required.') const migrationSteps = this._getMigrationSteps(dbVersion) if (migrationSteps) { logger.log(`[Loki] Migrating from version ${dbVersion} to ${this.schema.version}...`) try { await this._migrate(migrationSteps) } catch (error) { logger.error('[Loki] Migration failed', error) throw error } } else { logger.warn( '[Loki] Migrations not available for this version range, resetting database instead', ) await this.unsafeResetDatabase() } } else { logger.warn( `[Loki] Database has newer version ${dbVersion} than app schema ${schemaVersion}. Resetting database.`, ) await this.unsafeResetDatabase() } } _getMigrationSteps(fromVersion: SchemaVersion): ?(MigrationStep[]) { // TODO: Remove this after migrations are shipped const { migrations } = this if (!migrations) { return null } const { stepsForMigration } = require('../../../Schema/migrations/stepsForMigration') return stepsForMigration({ migrations, fromVersion, toVersion: this.schema.version, }) } async _migrate(steps: MigrationStep[]): Promise<void> { steps.forEach((step) => { if (step.type === 'create_table') { this._executeCreateTableMigration(step) } else if (step.type === 'add_columns') { this._executeAddColumnsMigration(step) } else if (step.type === 'sql') { // ignore } else { throw new Error(`Unsupported migration step ${step.type}`) } }) // Set database version this._databaseVersion = this.schema.version logger.log(`[Loki] Migration successful`) } _executeCreateTableMigration({ schema }: CreateTableMigrationStep): void { this._addCollection(schema) } _executeAddColumnsMigration({ table, columns }: AddColumnsMigrationStep): void { const collection = this.loki.getCollection(table) // update ALL records in the collection, adding new fields collection.findAndUpdate({}, (record) => { columns.forEach((column) => { setRawSanitized(record, column.name, null, column) }) }) // add indexes, if needed columns.forEach((column) => { if (column.isIndexed) { collection.ensureIndex(column.name) } }) } // Maps records to their IDs if the record is already cached on JS side _compactQueryResults(records: DirtyRaw[], table: TableName<any>): CachedQueryResult { const cache = this.getCache(table) return records.map((raw) => { const { id } = raw if (cache.has(id)) { return id } cache.add(id) return sanitizedRaw(raw, this.schema.tables[table]) }) } get _localStorage(): LokiCollection { return this.loki.getCollection('local_storage') } _findLocal(key: string): ?{ value: string } { const localStorage = this._localStorage return localStorage && localStorage.by('key', key) } _assertNotBroken(): void { if (this._isBroken) { throw new Error('DatabaseDriver is in a broken state, bailing...') } } // (experimental) // TODO: Setup, migrations, delete database should also break driver _fatalError(error: Error): void { if (!experimentalAllowsFatalError) { logger.warn( 'DatabaseDriver is broken, but experimentalAllowsFatalError has not been enabled to do anything about it...', ) throw error } // Stop further mutations this._isBroken = true // Disable Loki autosave lokiFatalError(this.loki) // Notify handler logger.error('DatabaseDriver is broken. App must be reloaded before continuing.') const handler = this.options._onFatalError handler && handler(error) // Rethrow error throw error } }