@nozbe/watermelondb
Version:
Build powerful React Native and React web apps that scale from hundreds to tens of thousands of records and remain fast
489 lines (426 loc) • 15.1 kB
JavaScript
// @flow
import { type Observable, BehaviorSubject } from '../utils/rx'
import { type Unsubscribe } from '../utils/subscriptions'
import logger from '../utils/common/logger'
import invariant from '../utils/common/invariant'
import ensureSync from '../utils/common/ensureSync'
import fromPairs from '../utils/fp/fromPairs'
import noop from '../utils/fp/noop'
import type { $RE } from '../types'
import type Database from '../Database'
import type Collection from '../Collection'
import type CollectionMap from '../Database/CollectionMap'
import { type TableName, type ColumnName, columnName } from '../Schema'
import type { Value } from '../QueryDescription'
import { type RawRecord, type DirtyRaw, sanitizedRaw, setRawSanitized } from '../RawRecord'
import { setRawColumnChange } from '../sync/helpers'
import { createTimestampsFor, fetchDescendants } from './helpers'
export type RecordId = string
/**
* Sync status of this record:
*
* - `synced` - up to date as of last sync
* - `created` - locally created, not yet pushed
* - `updated` - locally updated, not yet pushed
* - `deleted` - locally marked as deleted, not yet pushed
* - `disposable` - read-only, memory-only, not part of sync, MUST NOT appear in a persisted record
*/
export type SyncStatus = 'synced' | 'created' | 'updated' | 'deleted' | 'disposable'
export type BelongsToAssociation = $RE<{ type: 'belongs_to', key: ColumnName }>
export type HasManyAssociation = $RE<{ type: 'has_many', foreignKey: ColumnName }>
export type AssociationInfo = BelongsToAssociation | HasManyAssociation
export type Associations = { +[TableName<any>]: AssociationInfo }
// TODO: Refactor associations API and ideally get rid of this in favor of plain arrays/objects
export function associations(
...associationList: [TableName<any>, AssociationInfo][]
): Associations {
return (fromPairs(associationList): any)
}
export default class Model {
/**
* This must be set in Model subclasses to the name of associated database table
*/
static +table: TableName<this>
/**
* This can be set in Model subclasses to define (parent/child) relationships between different
* Models.
*
* See docs for more details.
*/
static associations: Associations = {}
// Used by withObservables to differentiate between object types
static _wmelonTag: string = 'model'
_raw: RawRecord
_isEditing: boolean = false
_preparedState: null | 'create' | 'update' | 'markAsDeleted' | 'destroyPermanently' = null
__changes: ?BehaviorSubject<$FlowFixMe<this>> = null
_getChanges(): BehaviorSubject<$FlowFixMe<this>> {
if (!this.__changes) {
// initializing lazily - it has non-trivial perf impact on very large collections
this.__changes = new BehaviorSubject(this)
}
return this.__changes
}
/**
* Record's ID
*/
get id(): RecordId {
return this._raw.id
}
/**
* Record's sync status
*
* @see SyncStatus
*/
get syncStatus(): SyncStatus {
return this._raw._status
}
/**
* Modifies the record.
* Pass a function to set attributes of the new record.
*
* Updates `updateAt` field (if available)
*
* Note: This method must be called within a Writer {@link Database#write}.
*
* * @example
* ```js
* someTask.create(task => {
* task.name = 'New name'
* })
*/
async update(recordUpdater: (this) => void = noop): Promise<this> {
this.__ensureInWriter(`Model.update()`)
const record = this.prepareUpdate(recordUpdater)
await this.db.batch(this)
return record
}
/**
* Prepares record to be updated
*
* Use this to batch-execute multiple changes at once.
* Note: Prepared changes must be executed by **synchronously** passing them to `database.batch()`
* @see {Model#update}
* @see {Database#batch}
*/
prepareUpdate(recordUpdater: (this) => void = noop): this {
invariant(
!this._preparedState,
`Cannot update a record with pending changes (${this.__debugName})`,
)
this.__ensureNotDisposable(`Model.prepareUpdate()`)
this._isEditing = true
// Touch updatedAt (if available)
if ('updatedAt' in this) {
this._setRaw(columnName('updated_at'), Date.now())
}
// Perform updates
ensureSync(recordUpdater(this))
this._isEditing = false
this._preparedState = 'update'
// TODO: `process.nextTick` doesn't work on React Native
// We could polyfill with setImmediate, but it doesn't have the same effect — test and enseure
// it would actually work for this purpose
// TODO: Also add to other prepared changes
if (
process.env.NODE_ENV !== 'production' &&
typeof process !== 'undefined' &&
process &&
process.nextTick
) {
process.nextTick(() => {
invariant(
this._preparedState !== 'update',
`record.prepareUpdate was called on ${this.__debugName} but wasn't sent to batch() synchronously -- this is bad!`,
)
})
}
this.__logVerbose('prepareUpdate')
return this
}
/**
* Marks this record as deleted (it will be deleted permanently after sync)
*
* Note: This method must be called within a Writer {@link Database#write}.
*/
async markAsDeleted(): Promise<void> {
this.__ensureInWriter(`Model.markAsDeleted()`)
this.__ensureNotDisposable(`Model.markAsDeleted()`)
await this.db.batch(this.prepareMarkAsDeleted())
}
/**
* Prepares record to be marked as deleted
*
* Use this to batch-execute multiple changes at once.
* Note: Prepared changes must be executed by **synchronously** passing them to `database.batch()`
* @see {Model#markAsDeleted}
* @see {Database#batch}
*/
prepareMarkAsDeleted(): this {
invariant(
!this._preparedState,
`Cannot mark a record with pending changes as deleted (${this.__debugName})`,
)
this.__ensureNotDisposable(`Model.prepareMarkAsDeleted()`)
this._raw._status = 'deleted'
this._preparedState = 'markAsDeleted'
this.__logVerbose('prepareMarkAsDeleted')
return this
}
/**
* Permanently deletes this record from the database
*
* Note: Do not use this when using Sync, as deletion will not be synced.
*
* Note: This method must be called within a Writer {@link Database#write}.
*/
async destroyPermanently(): Promise<void> {
this.__ensureInWriter(`Model.destroyPermanently()`)
this.__ensureNotDisposable(`Model.destroyPermanently()`)
await this.db.batch(this.prepareDestroyPermanently())
}
/**
* Prepares record to be permanently destroyed
*
* Note: Do not use this when using Sync, as deletion will not be synced.
*
* Use this to batch-execute multiple changes at once.
* Note: Prepared changes must be executed by **synchronously** passing them to `database.batch()`
* @see {Model#destroyPermanently}
* @see {Database#batch}
*/
prepareDestroyPermanently(): this {
invariant(
!this._preparedState,
`Cannot destroy permanently record with pending changes (${this.__debugName})`,
)
this.__ensureNotDisposable(`Model.prepareDestroyPermanently()`)
this._raw._status = 'deleted'
this._preparedState = 'destroyPermanently'
this.__logVerbose('prepareDestroyPermanently')
return this
}
/**
* Marks this records and its descendants as deleted (they will be deleted permenently after sync)
*
* Descendants are determined by taking Model's `has_many` (children) associations, and then their
* children associations recursively.
*
* Note: This method must be called within a Writer {@link Database#write}.
*/
async experimentalMarkAsDeleted(): Promise<void> {
this.__ensureInWriter(`Model.experimentalMarkAsDeleted()`)
this.__ensureNotDisposable(`Model.experimentalMarkAsDeleted()`)
const records = await fetchDescendants(this)
records.forEach((model) => model.prepareMarkAsDeleted())
records.push(this.prepareMarkAsDeleted())
await this.db.batch(records)
}
/**
* Permanently deletes this record and its descendants from the database
*
* Descendants are determined by taking Model's `has_many` (children) associations, and then their
* children associations recursively.
*
* Note: Do not use this when using Sync, as deletion will not be synced.
*
* Note: This method must be called within a Writer {@link Database#write}.
*/
async experimentalDestroyPermanently(): Promise<void> {
this.__ensureInWriter(`Model.experimentalDestroyPermanently()`)
this.__ensureNotDisposable(`Model.experimentalDestroyPermanently()`)
const records = await fetchDescendants(this)
records.forEach((model) => model.prepareDestroyPermanently())
records.push(this.prepareDestroyPermanently())
await this.db.batch(records)
}
// *** Observing changes ***
/**
* Returns an `Rx.Observable` that emits a signal immediately upon subscription and then every time
* this record changes.
*
* Signals contain this record as its value for convenience.
*
* Emits `complete` signal if this record is deleted (marked as deleted or permanently destroyed)
*/
observe(): Observable<this> {
invariant(
this._preparedState !== 'create',
`Cannot observe uncommitted record (${this.__debugName})`,
)
return this._getChanges()
}
/**
* Collection associated with this Model
*/
+collection: Collection<$FlowFixMe<this>>
// TODO: Deprecate
/**
* Collections of other Models in the same database as this record.
*
* @deprecated
*/
get collections(): CollectionMap {
return this.database.collections
}
// TODO: Deprecate
get database(): Database {
return this.collection.database
}
/**
* `Database` this record is associated with
*/
get db(): Database {
return this.collection.database
}
get asModel(): this {
return this
}
/**
* Table name of this record
*/
get table(): TableName<this> {
return this.constructor.table
}
// TODO: protect batch,callWriter,... from being used outside a @reader/@writer
/**
* Convenience method that should ONLY be used by Model's `@writer`-decorated methods
*
* @see {Database#batch}
*/
batch(...records: $ReadOnlyArray<Model | null | void | false>): Promise<void> {
return this.db.batch((records: any))
}
/**
* Convenience method that should ONLY be used by Model's `@writer`-decorated methods
*
* @see {WriterInterface#callWriter}
*/
callWriter<T>(action: () => Promise<T>): Promise<T> {
return this.db._workQueue.subAction(action)
}
/**
* Convenience method that should ONLY be used by Model's `@writer`/`@reader`-decorated methods
*
* @see {ReaderInterface#callReader}
*/
callReader<T>(action: () => Promise<T>): Promise<T> {
return this.db._workQueue.subAction(action)
}
// *** Implementation details ***
// Don't use this directly! Use `collection.create()`
constructor(collection: Collection<this>, raw: RawRecord): void {
this.collection = collection
this._raw = raw
}
static _prepareCreate(
collection: Collection<$FlowFixMe<this>>,
recordBuilder: (this) => void,
): this {
const record = new this(
collection,
// sanitizedRaw sets id
sanitizedRaw(createTimestampsFor(this.prototype), collection.schema),
)
record._preparedState = 'create'
record._isEditing = true
ensureSync(recordBuilder(record))
record._isEditing = false
record.__logVerbose('prepareCreate')
return record
}
static _prepareCreateFromDirtyRaw(
collection: Collection<$FlowFixMe<this>>,
dirtyRaw: DirtyRaw,
): this {
const record = new this(collection, sanitizedRaw(dirtyRaw, collection.schema))
record._preparedState = 'create'
record.__logVerbose('prepareCreateFromDirtyRaw')
return record
}
static _disposableFromDirtyRaw(
collection: Collection<$FlowFixMe<this>>,
dirtyRaw: DirtyRaw,
): this {
const record = new this(collection, sanitizedRaw(dirtyRaw, collection.schema))
record._raw._status = 'disposable'
record.__logVerbose('disposableFromDirtyRaw')
return record
}
_subscribers: [(isDeleted: boolean) => void, any][] = []
/**
* Notifies `subscriber` on every change (update/delete) of this record
*
* Notification contains a flag that indicates whether the change is due to deletion
* (Currently, subscribers are called after `changes` emissions, but this behavior might change)
*/
experimentalSubscribe(subscriber: (isDeleted: boolean) => void, debugInfo?: any): Unsubscribe {
const entry = [subscriber, debugInfo]
this._subscribers.push(entry)
return () => {
const idx = this._subscribers.indexOf(entry)
idx !== -1 && this._subscribers.splice(idx, 1)
}
}
_notifyChanged(): void {
this._getChanges().next(this)
this._subscribers.forEach(([subscriber]) => {
subscriber(false)
})
}
_notifyDestroyed(): void {
this._getChanges().complete()
this._subscribers.forEach(([subscriber]) => {
subscriber(true)
})
}
// TODO: Make this official API
_getRaw(rawFieldName: ColumnName): Value {
return this._raw[(rawFieldName: string)]
}
// TODO: Make this official API
_setRaw(rawFieldName: ColumnName, rawValue: Value): void {
this.__ensureCanSetRaw()
const valueBefore = this._raw[(rawFieldName: string)]
setRawSanitized(this._raw, rawFieldName, rawValue, this.collection.schema.columns[rawFieldName])
if (valueBefore !== this._raw[(rawFieldName: string)] && this._preparedState !== 'create') {
setRawColumnChange(this._raw, rawFieldName)
}
}
// Please don't use this unless you really understand how Watermelon Sync works, and thought long and
// hard about risks of inconsistency after sync
// TODO: Make this official API
_dangerouslySetRawWithoutMarkingColumnChange(rawFieldName: ColumnName, rawValue: Value): void {
this.__ensureCanSetRaw()
setRawSanitized(this._raw, rawFieldName, rawValue, this.collection.schema.columns[rawFieldName])
}
get __debugName(): string {
return `${this.table}#${this.id}`
}
__ensureCanSetRaw(): void {
this.__ensureNotDisposable(`Model._setRaw()`)
invariant(
this._isEditing,
`Not allowed to change record ${this.__debugName} outside of create/update()`,
)
invariant(
!(this._getChanges(): $FlowFixMe<BehaviorSubject<any>>).isStopped &&
this._raw._status !== 'deleted',
`Not allowed to change deleted record ${this.__debugName}`,
)
}
__ensureNotDisposable(debugName: string): void {
invariant(
this._raw._status !== 'disposable',
`${debugName} cannot be called on a disposable record ${this.__debugName}`,
)
}
__ensureInWriter(debugName: string): void {
this.db._ensureInWriter(`${debugName} (${this.__debugName})`)
}
__logVerbose(debugName: string): void {
if (this.db.experimentalIsVerbose) {
logger.debug(`${debugName}: ${this.__debugName}`)
}
}
}