@rikishi/watermelondb
Version:
Build powerful React Native and React web apps that scale from hundreds to tens of thousands of records and remain fast
262 lines (232 loc) • 8.97 kB
JavaScript
// @flow
import { mapObj, filterObj, pipe, toPairs } from '../../utils/fp'
import splitEvery from '../../utils/fp/splitEvery'
import allPromisesObj from '../../utils/fp/allPromisesObj'
import { logError, invariant, logger } from '../../utils/common'
import type { Database, RecordId, Collection, Model, TableName, DirtyRaw } from '../..'
import * as Q from '../../QueryDescription'
import { columnName } from '../../Schema'
import type {
SyncTableChangeSet,
SyncDatabaseChangeSet,
SyncLog,
SyncConflictResolver,
} from '../index'
import { prepareCreateFromRaw, prepareUpdateFromRaw } from './helpers'
const idsForChanges = ({ created, updated, deleted }: SyncTableChangeSet): RecordId[] => {
const ids = []
created.forEach((record) => {
ids.push(record.id)
})
updated.forEach((record) => {
ids.push(record.id)
})
return ids.concat(deleted)
}
const fetchRecordsForChanges = <T: Model>(
collection: Collection<T>,
changes: SyncTableChangeSet,
): Promise<T[]> => {
const ids = idsForChanges(changes)
if (ids.length) {
return collection.query(Q.where(columnName('id'), Q.oneOf(ids))).fetch()
}
return Promise.resolve([])
}
const findRecord = <T: Model>(id: RecordId, list: T[]): T | null => {
// perf-critical
for (let i = 0, len = list.length; i < len; i += 1) {
if (list[i]._raw.id === id) {
return list[i]
}
}
return null
}
type RecordsToApplyRemoteChangesTo<T: Model> = {
...SyncTableChangeSet,
records: T[],
recordsToDestroy: T[],
locallyDeletedIds: RecordId[],
deletedRecordsToDestroy: RecordId[],
}
async function recordsToApplyRemoteChangesTo<T: Model>(
collection: Collection<T>,
changes: SyncTableChangeSet,
): Promise<RecordsToApplyRemoteChangesTo<T>> {
const { database, table } = collection
const { deleted: deletedIds } = changes
const [records, locallyDeletedIds] = await Promise.all([
fetchRecordsForChanges(collection, changes),
database.adapter.getDeletedRecords(table),
])
return {
...changes,
records,
locallyDeletedIds,
recordsToDestroy: records.filter((record) => deletedIds.includes(record.id)),
deletedRecordsToDestroy: locallyDeletedIds.filter((id) => deletedIds.includes(id)),
}
}
function validateRemoteRaw(raw: DirtyRaw): void {
// TODO: I think other code is actually resilient enough to handle illegal _status and _changed
// would be best to change that part to a warning - but tests are needed
invariant(
raw && typeof raw === 'object' && 'id' in raw && !('_status' in raw || '_changed' in raw),
`[Sync] Invalid raw record supplied to Sync. Records must be objects, must have an 'id' field, and must NOT have a '_status' or '_changed' fields`,
)
}
function prepareApplyRemoteChangesToCollection<T: Model>(
collection: Collection<T>,
recordsToApply: RecordsToApplyRemoteChangesTo<T>,
sendCreatedAsUpdated: boolean,
log?: SyncLog,
conflictResolver?: SyncConflictResolver,
): T[] {
const { database, table } = collection
const { created, updated, recordsToDestroy: deleted, records, locallyDeletedIds } = recordsToApply
// if `sendCreatedAsUpdated`, server should send all non-deleted records as `updated`
// log error if it doesn't — but disable standard created vs updated errors
if (sendCreatedAsUpdated && created.length) {
logError(
`[Sync] 'sendCreatedAsUpdated' option is enabled, and yet server sends some records as 'created'`,
)
}
const recordsToBatch: T[] = [] // mutating - perf critical
// Insert and update records
created.forEach((raw) => {
validateRemoteRaw(raw)
const currentRecord = findRecord(raw.id, records)
if (currentRecord) {
logError(
`[Sync] Server wants client to create record ${table}#${raw.id}, but it already exists locally. This may suggest last sync partially executed, and then failed; or it could be a serious bug. Will update existing record instead.`,
)
recordsToBatch.push(prepareUpdateFromRaw(currentRecord, raw, log, conflictResolver))
} else if (locallyDeletedIds.includes(raw.id)) {
logError(
`[Sync] Server wants client to create record ${table}#${raw.id}, but it already exists locally and is marked as deleted. This may suggest last sync partially executed, and then failed; or it could be a serious bug. Will delete local record and recreate it instead.`,
)
// Note: we're not awaiting the async operation (but it will always complete before the batch)
database.adapter.destroyDeletedRecords(table, [raw.id])
recordsToBatch.push(prepareCreateFromRaw(collection, raw))
} else {
recordsToBatch.push(prepareCreateFromRaw(collection, raw))
}
})
updated.forEach((raw) => {
validateRemoteRaw(raw)
const currentRecord = findRecord(raw.id, records)
if (currentRecord) {
recordsToBatch.push(prepareUpdateFromRaw(currentRecord, raw, log, conflictResolver))
} else if (locallyDeletedIds.includes(raw.id)) {
// Nothing to do, record was locally deleted, deletion will be pushed later
} else {
// Record doesn't exist (but should) — just create it
!sendCreatedAsUpdated &&
logError(
`[Sync] Server wants client to update record ${table}#${raw.id}, but it doesn't exist locally. This could be a serious bug. Will create record instead. If this was intentional, please check the flag sendCreatedAsUpdated in https://nozbe.github.io/WatermelonDB/Advanced/Sync.html#additional-synchronize-flags`,
)
recordsToBatch.push(prepareCreateFromRaw(collection, raw))
}
})
deleted.forEach((record) => {
// $FlowFixMe
recordsToBatch.push(record.prepareDestroyPermanently())
})
return recordsToBatch
}
type AllRecordsToApply = { [TableName<any>]: RecordsToApplyRemoteChangesTo<Model> }
const getAllRecordsToApply = (
db: Database,
remoteChanges: SyncDatabaseChangeSet,
): AllRecordsToApply =>
allPromisesObj(
pipe(
filterObj((_changes, tableName: TableName<any>) => {
const collection = db.get((tableName: any))
if (!collection) {
logger.warn(
`You are trying to sync a collection named ${tableName}, but it does not exist. Will skip it (for forward-compatibility). If this is unexpected, perhaps you forgot to add it to your Database constructor's modelClasses property?`,
)
}
return !!collection
}),
mapObj((changes, tableName: TableName<any>) => {
return recordsToApplyRemoteChangesTo(db.get((tableName: any)), changes)
}),
)(remoteChanges),
)
const destroyAllDeletedRecords = (db: Database, recordsToApply: AllRecordsToApply): Promise<*> => {
const promises = toPairs(recordsToApply).map(([tableName, { deletedRecordsToDestroy }]) => {
return deletedRecordsToDestroy.length
? db.adapter.destroyDeletedRecords((tableName: any), deletedRecordsToDestroy)
: null
})
return Promise.all(promises)
}
const applyAllRemoteChanges = (
db: Database,
recordsToApply: AllRecordsToApply,
sendCreatedAsUpdated: boolean,
log?: SyncLog,
conflictResolver?: SyncConflictResolver,
): Promise<void> => {
const allRecords = []
toPairs(recordsToApply).forEach(([tableName, records]) => {
allRecords.push(
...prepareApplyRemoteChangesToCollection(
db.get((tableName: any)),
records,
sendCreatedAsUpdated,
log,
conflictResolver,
),
)
})
return db.batch(allRecords)
}
// See _unsafeBatchPerCollection - temporary fix
const unsafeApplyAllRemoteChangesByBatches = (
db: Database,
recordsToApply: AllRecordsToApply,
sendCreatedAsUpdated: boolean,
log?: SyncLog,
conflictResolver?: SyncConflictResolver,
): Promise<*> => {
const promises = []
toPairs(recordsToApply).forEach(([tableName, records]) => {
const preparedModels: Model[] = prepareApplyRemoteChangesToCollection(
db.collections.get((tableName: any)),
records,
sendCreatedAsUpdated,
log,
conflictResolver,
)
const batches = splitEvery(5000, preparedModels).map((recordBatch) => db.batch(recordBatch))
promises.push(...batches)
})
return Promise.all(promises)
}
export default async function applyRemoteChanges(
db: Database,
remoteChanges: SyncDatabaseChangeSet,
sendCreatedAsUpdated: boolean,
log?: SyncLog,
conflictResolver?: SyncConflictResolver,
_unsafeBatchPerCollection?: boolean,
): Promise<void> {
// $FlowFixMe
const recordsToApply = await getAllRecordsToApply(db, remoteChanges)
// Perform steps concurrently
await Promise.all([
destroyAllDeletedRecords(db, recordsToApply),
_unsafeBatchPerCollection
? unsafeApplyAllRemoteChangesByBatches(
db,
recordsToApply,
sendCreatedAsUpdated,
log,
conflictResolver,
)
: applyAllRemoteChanges(db, recordsToApply, sendCreatedAsUpdated, log, conflictResolver),
])
}