@nozbe/watermelondb
Version:
Build powerful React Native and React web apps that scale from hundreds to tens of thousands of records and remain fast
416 lines (358 loc) • 14.8 kB
JavaScript
// @flow
import { type Observable, startWith, merge as merge$ } from '../utils/rx'
import { type Unsubscribe } from '../utils/subscriptions'
import { invariant, logger } from '../utils/common'
import {
noop,
fromArrayOrSpread,
// eslint-disable-next-line no-unused-vars
type ArrayOrSpreadFn,
} from '../utils/fp'
import type { DatabaseAdapter, BatchOperation } from '../adapters/type'
import DatabaseAdapterCompat from '../adapters/compat'
import type Model from '../Model'
import type Collection, { CollectionChangeSet } from '../Collection'
import type { TableName, AppSchema } from '../Schema'
import CollectionMap from './CollectionMap'
import type LocalStorage from './LocalStorage'
import WorkQueue, { type ReaderInterface, type WriterInterface } from './WorkQueue'
type DatabaseProps = $Exact<{
adapter: DatabaseAdapter,
modelClasses: Array<Class<Model>>,
}>
let experimentalAllowsFatalError = false
export function setExperimentalAllowsFatalError(): void {
experimentalAllowsFatalError = true
}
export default class Database {
/**
* Database's adapter - the low-level connection with the underlying database (e.g. SQLite)
*
* Unless you understand WatermelonDB's internals, you SHOULD NOT use adapter directly.
* Running queries, or updating/deleting records on the adapter will corrupt the in-memory cache
* if special care is not taken
*/
adapter: DatabaseAdapterCompat
schema: AppSchema
collections: CollectionMap
_workQueue: WorkQueue = new WorkQueue(this)
// (experimental) if true, Database is in a broken state and should not be used anymore
_isBroken: boolean = false
_localStorage: LocalStorage
constructor(options: DatabaseProps): void {
const { adapter, modelClasses } = options
if (process.env.NODE_ENV !== 'production') {
invariant(adapter, `Missing adapter parameter for new Database()`)
invariant(
modelClasses && Array.isArray(modelClasses),
`Missing modelClasses parameter for new Database()`,
)
}
this.adapter = new DatabaseAdapterCompat(adapter)
this.schema = adapter.schema
this.collections = new CollectionMap(this, modelClasses)
}
/**
* Returns a `Collection` for a given table name
*/
get<T: Model>(tableName: TableName<T>): Collection<T> {
return this.collections.get(tableName)
}
/**
* Returns a `LocalStorage` (WatermelonDB-based localStorage/AsyncStorage alternative)
*/
get localStorage(): LocalStorage {
if (!this._localStorage) {
const LocalStorageClass = require('./LocalStorage').default
this._localStorage = new LocalStorageClass(this)
}
return this._localStorage
}
/*:: batch: ArrayOrSpreadFn<?Model | false, Promise<void>> */
/**
* Executes multiple prepared operations
*
* Pass a list (or array) of operations like so:
* - `collection.prepareCreate(...)`
* - `record.prepareUpdate(...)`
* - `record.prepareMarkAsDeleted()` (or `record.prepareDestroyPermanently()`)
*
* Note that falsy values (null, undefined, false) passed to batch are simply ignored
* so you can use patterns like `.batch(condition && record.prepareUpdate(...))` for convenience.
*
* Note: This method must be called within a Writer {@link Database#write}.
*/
// $FlowFixMe
async batch(...records: Array<?Model | false>): Promise<void> {
const actualRecords: Array<?Model> = fromArrayOrSpread(records, 'Database.batch', 'Model')
this._ensureInWriter(`Database.batch()`)
// performance critical - using mutations
const batchOperations: BatchOperation[] = []
const changeNotifications: { [TableName<any>]: CollectionChangeSet<Model> } = {}
actualRecords.forEach((record) => {
if (!record) {
return
}
const preparedState = record._preparedState
if (!preparedState) {
invariant(record._raw._status !== 'disposable', `Cannot batch a disposable record`)
throw new Error(`Cannot batch a record that doesn't have a prepared create/update/delete`)
}
const raw = record._raw
const { id } = raw // faster than Model.id
const { table } = record.constructor // faster than Model.table
let changeType
if (preparedState === 'update') {
batchOperations.push(['update', table, raw])
changeType = 'updated'
} else if (preparedState === 'create') {
batchOperations.push(['create', table, raw])
changeType = 'created'
} else if (preparedState === 'markAsDeleted') {
batchOperations.push(['markAsDeleted', table, id])
changeType = 'destroyed'
} else if (preparedState === 'destroyPermanently') {
batchOperations.push(['destroyPermanently', table, id])
changeType = 'destroyed'
} else {
invariant(false, 'bad preparedState')
}
if (preparedState !== 'create') {
// We're (unsafely) assuming that batch will succeed and removing the "pending" state so that
// subsequent changes to the record don't trip up the invariant
// TODO: What if this fails?
record._preparedState = null
}
if (!changeNotifications[table]) {
changeNotifications[table] = []
}
changeNotifications[table].push({ record, type: changeType })
})
await this.adapter.batch(batchOperations)
// Debug info
if (this.experimentalIsVerbose) {
const debugInfo = batchOperations
.map(([type, table, rawOrId]) => {
switch (type) {
case 'create':
case 'update':
return `${type} ${table}#${(rawOrId: any).id}`
case 'markAsDeleted':
case 'destroyPermanently':
return `${type} ${table}#${(rawOrId: any)}`
default:
return `${type}???`
}
})
.join(', ')
logger.debug(`batch: ${debugInfo}`)
}
// NOTE: We must make two passes to ensure all changes to caches are applied before subscribers are called
const changes: [TableName<any>, CollectionChangeSet<any>][] = (Object.entries(
changeNotifications,
): any)
changes.forEach(([table, changeSet]) => {
this.collections.get(table)._applyChangesToCache(changeSet)
})
this._notify(changes)
return undefined // shuts up flow
}
_pendingNotificationBatches: number = 0
_pendingNotificationChanges: [TableName<any>, CollectionChangeSet<any>][][] = []
_notify(changes: [TableName<any>, CollectionChangeSet<any>][]): void {
if (this._pendingNotificationBatches > 0) {
this._pendingNotificationChanges.push(changes)
return
}
const affectedTables = new Set(changes.map(([table]) => table))
const databaseChangeNotifySubscribers = ([tables, subscriber]: [
Array<TableName<any>>,
() => void,
any,
]): void => {
if (tables.some((table) => affectedTables.has(table))) {
subscriber()
}
}
this._subscribers.forEach(databaseChangeNotifySubscribers)
changes.forEach(([table, changeSet]) => {
this.collections.get(table)._notify(changeSet)
})
}
async experimentalBatchNotifications<T>(work: () => Promise<T>): Promise<T> {
// TODO: Document & add tests if this proves useful
try {
this._pendingNotificationBatches += 1
const result = await work()
return result
} finally {
this._pendingNotificationBatches -= 1
if (this._pendingNotificationBatches === 0) {
const changes = this._pendingNotificationChanges
this._pendingNotificationChanges = []
changes.forEach((_changes) => this._notify(_changes))
}
}
}
/**
* Schedules a Writer
*
* Writer is a block of code, inside of which you can modify the database
* (call `Collection.create`, `Model.update`, `Database.batch` and so on).
*
* In a Writer, you're guaranteed that no other Writer is simultaneously executing. Therefore, you
* can rely on the results of queries and other asynchronous operations - they won't change for
* the duration of this Writer (except if changed by it).
*
* To call another Writer (or Reader) from this one without deadlocking, use `callWriter`
* (or `callReader`).
*
* See docs for more details and a practical guide.
*
* @param work - Block of code to execute
* @param [description] - Debug description of this Writer
*/
write<T>(work: (WriterInterface) => Promise<T>, description?: string): Promise<T> {
return this._workQueue.enqueue(work, description, true)
}
/**
* Schedules a Reader
*
* In a Reader, you're guaranteed that no Writer is running at the same time. Therefore, you can
* run many queries or other asynchronous operations, and you can rely on their results - they
* won't change for the duration of this Reader. However, other Readers might run concurrently.
*
* To call another Reader from this one, use `callReader`
*
* See docs for more details and a practical guide.
*
* @param work - Block of code to execute
* @param [description] - Debug description of this Reader
*/
read<T>(work: (ReaderInterface) => Promise<T>, description?: string): Promise<T> {
return this._workQueue.enqueue(work, description, false)
}
/**
* Returns an `Observable` that emits a signal (`null`) immediately, and on every change in
* any of the passed tables.
*
* A set of changes made is passed with the signal, with an array of changes per-table
* (Currently, if changes are made to multiple different tables, multiple signals will be emitted,
* even if they're made with a batch. However, this behavior might change. Use Rx to debounce,
* throttle, merge as appropriate for your use case.)
*
* Warning: You can easily introduce performance bugs in your application by using this method
* inappropriately.
*/
withChangesForTables(tables: TableName<any>[]): Observable<CollectionChangeSet<any> | null> {
const changesSignals = tables.map((table) => this.collections.get(table).changes)
return merge$(...changesSignals).pipe(startWith(null))
}
_subscribers: [TableName<any>[], () => void, any][] = []
/**
* Notifies `subscriber` on change in any of the passed tables.
*
* A single notification will be sent per `database.batch()` call.
* (Currently, no details about the changes made are provided, only a signal, but this behavior
* might change. Currently, subscribers are called before `withChangesForTables`).
*
* Warning: You can easily introduce performance bugs in your application by using this method
* inappropriately.
*/
experimentalSubscribe(
tables: TableName<any>[],
subscriber: () => void,
debugInfo?: any,
): Unsubscribe {
if (!tables.length) {
return noop
}
const entry = [tables, subscriber, debugInfo]
this._subscribers.push(entry)
return () => {
const idx = this._subscribers.indexOf(entry)
idx !== -1 && this._subscribers.splice(idx, 1)
}
}
_resetCount: number = 0
_isBeingReset: boolean = false
/**
* Resets the database
*
* This permanently deletes the database (all records, metadata, and `LocalStorage`) and sets
* up an empty database.
*
* Special care must be taken to safely reset the database. Ideally, you should reset your app
* to an empty / "logging out" state while doing this. Specifically:
*
* - You MUST NOT hold onto Watermelon records other than this `Database`. Do not keep references
* to records, collections, or any other objects from before database reset
* - You MUST NOT observe any Watermelon state. All Database, Collection, Query, and Model
* observers/subscribers should be disposed of before resetting
* - You SHOULD NOT have any pending (queued) Readers or Writers. Pending work will be aborted
* (rejected with an error)
*/
async unsafeResetDatabase(): Promise<void> {
this._ensureInWriter(`Database.unsafeResetDatabase()`)
try {
this._isBeingReset = true
// First kill actions, to ensure no more traffic to adapter happens
this._workQueue._abortPendingWork()
// Kill ability to call adapter methods during reset (to catch bugs if someone does this)
const { adapter } = this
const ErrorAdapter = require('../adapters/error').default
this.adapter = (new ErrorAdapter(): any)
// Check for illegal subscribers
if (this._subscribers.length) {
// TODO: This should be an error, not a console.log, but actually useful diagnostics are necessary for this to work, otherwise people will be confused
// eslint-disable-next-line no-console
console.log(
`Application error! Unexpected ${this._subscribers.length} Database subscribers were detected during database.unsafeResetDatabase() call. App should not hold onto subscriptions or Watermelon objects while resetting database.`,
)
// eslint-disable-next-line no-console
console.log(this._subscribers)
this._subscribers = []
}
// Clear the database
await adapter.unsafeResetDatabase()
// Only now clear caches, since there may have been queued fetches from DB still bringing in items to cache
Object.values(this.collections.map).forEach((collection) => {
// $FlowFixMe
collection._cache.unsafeClear()
})
// Restore working Database
this._resetCount += 1
this.adapter = adapter
} finally {
this._isBeingReset = false
}
}
// (experimental) if true, Models will print to console diagnostic information on every
// prepareCreate/Update/Delete call, as well as on commit (Database.batch() call). Note that this
// has a significant performance impact so should only be enabled when debugging.
experimentalIsVerbose: boolean = false
_ensureInWriter(debugName: string): void {
invariant(
this._workQueue.isWriterRunning,
`${debugName} can only be called from inside of a Writer. See docs for more details.`,
)
}
// (experimental) puts Database in a broken state
// TODO: Not used anywhere yet
_fatalError(error: Error): void {
if (!experimentalAllowsFatalError) {
logger.warn(
'Database is now broken, but experimentalAllowsFatalError has not been enabled to do anything about it...',
)
return
}
this._isBroken = true
logger.error('Database is broken. App must be reloaded before continuing.')
// TODO: Passing this to an adapter feels wrong, but it's tricky.
// $FlowFixMe
if (this.adapter.underlyingAdapter._fatalError) {
// $FlowFixMe
this.adapter.underlyingAdapter._fatalError(error)
}
}
}