@nozbe/watermelondb
Version:
Build powerful React Native and React web apps that scale from hundreds to tens of thousands of records and remain fast
312 lines (271 loc) • 9.06 kB
JavaScript
// @flow
/* eslint-disable no-continue */
import forEachAsync from '../../utils/fp/forEachAsync'
import type { Database } from '../..'
import { columnName } from '../../Schema'
import * as Q from '../../QueryDescription'
import censorRaw from '../censorRaw'
import type { DiagnoseDatabaseStructureOptions, DatabaseStructureDiagnosis } from './index'
const pad = (text: string, len: number) => {
const padding = Array(Math.max(0, len - text.length))
.fill(' ')
.join('')
return `${text}${padding}`
}
const yieldLog = () =>
new Promise((resolve) => {
setTimeout(resolve, 0)
})
const getCollections = (db: Database) =>
Object.entries(db.collections.map).map(([table, collection]) => {
return {
name: table,
// $FlowFixMe
parents: Object.entries(collection.modelClass.associations)
// $FlowFixMe
.filter(([, association]) => association.type === 'belongs_to')
// $FlowFixMe
.map(([parentTable, association]) => [parentTable, association.key]),
}
})
const logCollections = (
log: (text?: string) => void,
collections: Array<$Exact<{ name: string, parents: Array<Array<any | string>> }>>,
) => {
collections.forEach(({ name, parents }) => {
const parentsText = parents.length
? parents.map(([table, key]) => pad(`${table}(${key})`, 27)).join(', ')
: '(root)'
log(`- ${pad(name, 20)}: ${parentsText}`)
})
log()
}
const isUniqueIndexValid = (collection: any, key: string) => {
const index = collection.constraints.unique[key]
if (!index) {
return { skip: true }
}
const lokiMap = Object.entries(index.lokiMap)
// >= and undefined checks are needed because items are not removed from unique index, just made undefined
const lokiMapValid =
lokiMap.length >= collection.data.length &&
lokiMap.every(([lokiId, value]) => value === undefined || collection.get(lokiId)[key] === value)
const keyMap = Object.entries(index.keyMap)
const keyMapValid =
keyMap.length >= collection.data.length &&
keyMap.every(
([value, record]) =>
record === undefined ||
// $FlowFixMe
(record[key] === value && collection.get(record.$loki) === record),
)
return { skip: false, lokiMapValid, keyMapValid }
}
async function verifyLokiIndices(db: Database, log: (text?: string) => void): Promise<number> {
log('## Verify LokiJS indices')
let issueCount = 0
// $FlowFixMe
const { loki } = db.adapter.underlyingAdapter._driver
loki.collections.forEach((collection) => {
const { name, idIndex, data, binaryIndices, uniqueNames } = collection
log(`**Indices of \`${name}\`**`)
log()
// check idIndex
if (idIndex) {
if (
idIndex.length === data.length &&
idIndex.every((lokiId, i) => data[i].$loki === lokiId)
) {
log('idIndex: ok')
} else {
log('❌ idIndex: corrupted!')
issueCount += 1
}
} else {
log('idIndex: (skipping)')
}
// check binary indices
const binKeys = Object.keys(binaryIndices)
binKeys.forEach((binKey) => {
if (collection.checkIndex(binKey, { repair: true })) {
log(`${binKey} binary index: ok`)
} else {
log(`❌ ${binKey} binary index: corrupted! checking if repaired...`)
issueCount += 1
if (collection.checkIndex(binKey)) {
log('repaired ok')
} else {
log('❌❌ still broken after repair!')
}
}
})
// check unique indices
if (name !== 'local_storage' && !(uniqueNames.length === 1 && uniqueNames[0] === 'id')) {
log(`❌ expected to only have a single unique index for 'id', has: ${uniqueNames.join(', ')}`)
issueCount += 1
}
uniqueNames.forEach((key) => {
const results = isUniqueIndexValid(collection, key)
if (!results.skip) {
if (results.lokiMapValid) {
log(`${key} index loki map: ok`)
} else {
log(`❌ ${key} index loki map: corrupted!`)
issueCount += 1
}
if (results.keyMapValid) {
log(`${key} index key map: ok`)
} else {
log(`❌ ${key} index key map: corrupted!`)
issueCount += 1
}
} else {
log(`${key} index: (skipping)`)
}
})
log()
})
return issueCount
}
export default function diagnoseDatabaseStructure({
db,
log: _log = () => {},
shouldSkipParent = () => false,
isOrphanAllowed = async () => false,
}: DiagnoseDatabaseStructureOptions): Promise<DatabaseStructureDiagnosis> {
return db.read(async () => {
const startTime = Date.now()
let logText = ''
const log = (text: string = '') => {
logText = `${logText}\n${text}`
_log(text)
}
let totalIssueCount = 0
log('# Database structure diagnostics')
log()
if ((db.adapter.underlyingAdapter.constructor: any).adapterType === 'loki') {
// eslint-disable-next-line require-atomic-updates
totalIssueCount += await verifyLokiIndices(db, log)
}
log('## Collection parent-child relations')
log()
const collections = getCollections(db)
// log(JSON.stringify(collections, null, 2))
log('```')
logCollections(log, collections)
log('```')
await yieldLog()
await forEachAsync(collections, async ({ name, parents }) => {
log(`## Structure of ${name}`)
log()
if (!parents.length) {
log(`(skipping - no parents)`)
log()
return
}
await yieldLog()
const records = await db.collections
// $FlowFixMe
.get(name)
.query()
.fetch()
log(`Found ${records.length} \`${name}\``)
await yieldLog()
let collectionOrphanCount = 0
await forEachAsync(parents, async ([parentName, key]) => {
const expectedParentSet = new Set([])
records.forEach((record) => {
const id = record._getRaw(key)
if (
id !== null &&
!shouldSkipParent({
tableName: (name: any),
parentTableName: (parentName: any),
relationKey: (key: any),
record: record._raw,
})
) {
expectedParentSet.add(id)
}
})
const expectedParents = [...expectedParentSet]
const parentsFound = await db.collections
// $FlowFixMe
.get(parentName)
// $FlowFixMe
.query(Q.where(columnName('id'), Q.oneOf(expectedParents)))
.fetch()
log()
log(`Found ${parentsFound.length} parent \`${parentName}\` (via \`${name}.${key}\`)`)
const allowedOprhans = []
if (parentsFound.length !== expectedParents.length) {
const foundParentSet = new Set(parentsFound.map((record) => record.id))
const orphans = []
await forEachAsync(records, async (record) => {
const parentId = record._getRaw(key)
if (
parentId === null ||
foundParentSet.has(parentId) ||
shouldSkipParent({
tableName: (name: any),
parentTableName: (parentName: any),
relationKey: (key: any),
record: record._raw,
})
) {
// ok
} else if (
await isOrphanAllowed({
tableName: (name: any),
parentTableName: (parentName: any),
relationKey: (key: any),
record: record._raw,
})
) {
allowedOprhans.push(record)
} else {
orphans.push(record)
}
})
if (orphans.length) {
collectionOrphanCount += orphans.length
log(
`❌ Error! ${
expectedParents.length - parentsFound.length
} missing parent \`${parentName}\` across ${orphans.length} orphans:`,
)
orphans.forEach((orphan) => {
log()
log(`MISSING PARENT \`${parentName}.${orphan._getRaw(key)} (via ${key})\`:`)
log()
log('```')
log(`${JSON.stringify(censorRaw(orphan._raw), null, ' ')}`)
log('```')
})
}
await yieldLog()
if (allowedOprhans.length) {
log(`❓ Config allowed ${allowedOprhans.length} orphans for this field`)
// log(allowedOprhans.join(','))
}
}
await yieldLog()
})
if (!collectionOrphanCount) {
// log(`No orphans found in ${name}`)
}
totalIssueCount += collectionOrphanCount
log()
})
log('## Conclusion')
log()
if (totalIssueCount) {
log(`❌ ${totalIssueCount} issues found`)
} else {
log(`✅ No issues found in this database!`)
}
log()
log(`Done in ${(Date.now() - startTime) / 1000} s.`)
return { issueCount: totalIssueCount, log: logText }
})
}