UNPKG

rxdb

Version:

A local-first realtime NoSQL Database for JavaScript applications - https://rxdb.info/

506 lines (461 loc) 17.4 kB
import { newRxError } from '../../rx-error.ts'; import type { CRDTDocumentField, CRDTEntry, CRDTOperation, FilledMangoQuery, HashFunction, JsonSchema, RxConflictHandler, RxConflictHandlerInput, RxDocument, RxDocumentData, RxJsonSchema, RxPlugin, WithDeleted } from '../../types/index.d.ts'; import { clone, deepEqual, ensureNotFalsy, getProperty, now, objectPathMonad, setProperty, toArray } from '../../plugins/utils/index.ts'; import { getQueryMatcher, META_LWT_UNIX_TIME_MAX, overwritable, RxCollection, RxDocumentWriteData, RxError } from '../../index.ts'; import { mingoUpdater } from '../update/mingo-updater.ts'; export async function updateCRDT<RxDocType>( this: RxDocument<RxDocType>, entry: CRDTEntry<RxDocType> | CRDTEntry<RxDocType>[] ) { entry = overwritable.deepFreezeWhenDevMode(entry) as any; const jsonSchema = this.collection.schema.jsonSchema; if (!jsonSchema.crdt) { throw newRxError('CRDT1', { schema: jsonSchema, queryObj: entry }); } const crdtOptions = ensureNotFalsy(jsonSchema.crdt); const storageToken = await this.collection.database.storageToken; return this.incrementalModify(async (docData) => { const crdtDocField: CRDTDocumentField<RxDocType> = clone(getProperty(docData as any, crdtOptions.field)); const operation: CRDTOperation<RxDocType> = { body: toArray(entry), creator: storageToken, time: now() }; /** * A new write will ALWAYS be an operation in the last * array which was non existing before. */ const lastAr: CRDTOperation<RxDocType>[] = [operation]; crdtDocField.operations.push(lastAr); crdtDocField.hash = await hashCRDTOperations(this.collection.database.hashFunction, crdtDocField); docData = runOperationOnDocument( this.collection.schema.jsonSchema, docData, operation ); setProperty(docData, crdtOptions.field, crdtDocField); return docData; }, RX_CRDT_CONTEXT); } export async function insertCRDT<RxDocType>( this: RxCollection<RxDocType>, entry: CRDTEntry<RxDocType> | CRDTEntry<RxDocType>[] ) { entry = overwritable.deepFreezeWhenDevMode(entry) as any; const jsonSchema = this.schema.jsonSchema; if (!jsonSchema.crdt) { throw newRxError('CRDT1', { schema: jsonSchema, queryObj: entry }); } const crdtOptions = ensureNotFalsy(jsonSchema.crdt); const storageToken = await this.database.storageToken; const operation: CRDTOperation<RxDocType> = { body: Array.isArray(entry) ? entry : [entry], creator: storageToken, time: now() }; let insertData: RxDocumentWriteData<RxDocType> = {} as any; insertData = runOperationOnDocument( this.schema.jsonSchema, insertData as any, operation ) as any; const crdtDocField: CRDTDocumentField<RxDocType> = { operations: [], hash: '' }; setProperty(insertData as any, crdtOptions.field, crdtDocField); const lastAr: CRDTOperation<RxDocType>[] = [operation]; crdtDocField.operations.push(lastAr); crdtDocField.hash = await hashCRDTOperations(this.database.hashFunction, crdtDocField); const result = await this.insert(insertData).catch(async (err: RxError) => { if (err.code === 'CONFLICT') { // was a conflict, update document instead of inserting const doc = await this.findOne(err.parameters.id).exec(true); return doc.updateCRDT(entry); } else { throw err; } }); return result; } export function sortOperationComparator<RxDocType>(a: CRDTOperation<RxDocType>, b: CRDTOperation<RxDocType>) { return a.creator > b.creator ? 1 : -1; } function runOperationOnDocument<RxDocType>( schema: RxJsonSchema<RxDocumentData<RxDocType>>, docData: WithDeleted<RxDocType>, operation: CRDTOperation<RxDocType> ): WithDeleted<RxDocType> { const entryParts = operation.body; entryParts.forEach(entryPart => { let isMatching: boolean; if (entryPart.selector) { const query: FilledMangoQuery<RxDocType> = { selector: ensureNotFalsy(entryPart.selector as any), sort: [], skip: 0 }; const matcher = getQueryMatcher(schema, query); isMatching = matcher(docData as any); } else { isMatching = true; } if (isMatching) { if (entryPart.ifMatch) { docData = mingoUpdater<WithDeleted<RxDocType>>(docData, entryPart.ifMatch); } } else { if (entryPart.ifNotMatch) { docData = mingoUpdater<WithDeleted<RxDocType>>(docData, entryPart.ifNotMatch); } } }); return docData; } export async function hashCRDTOperations( hashFunction: HashFunction, crdts: CRDTDocumentField<any> ): Promise<string> { const hashObj = crdts.operations.map((operations) => { return operations.map(op => op.creator); }); const hash = await hashFunction(JSON.stringify(hashObj)); return hash; } export function getCRDTSchemaPart<RxDocType>(): JsonSchema<CRDTDocumentField<RxDocType>> { const operationSchema: JsonSchema<CRDTOperation<RxDocType>> = { type: 'object', properties: { body: { type: 'array', items: { type: 'object', properties: { selector: { type: 'object' }, ifMatch: { type: 'object' }, ifNotMatch: { type: 'object' } }, additionalProperties: false }, minItems: 1 }, creator: { type: 'string' }, time: { type: 'number', minimum: 1, maximum: META_LWT_UNIX_TIME_MAX, multipleOf: 0.01 } }, additionalProperties: false, required: [ 'body', 'creator', 'time' ] }; return { type: 'object', properties: { operations: { type: 'array', items: { type: 'array', items: operationSchema } }, hash: { type: 'string', // set a minLength to not accidentally store an empty string minLength: 2 } }, additionalProperties: false, required: ['operations', 'hash'] }; } export async function mergeCRDTFields<RxDocType>( hashFunction: HashFunction, crdtsA: CRDTDocumentField<RxDocType>, crdtsB: CRDTDocumentField<RxDocType> ): Promise<CRDTDocumentField<RxDocType>> { // the value with most operations must be A to // ensure we not miss out rows when iterating over both fields. if (crdtsA.operations.length < crdtsB.operations.length) { [crdtsA, crdtsB] = [crdtsB, crdtsA]; } const ret: CRDTDocumentField<RxDocType> = { operations: [], hash: '' }; crdtsA.operations.forEach((row, index) => { let mergedOps: CRDTOperation<RxDocType>[] = []; const ids = new Set<string>(); // used to deduplicate row.forEach(op => { ids.add(op.creator); mergedOps.push(op); }); if (crdtsB.operations[index]) { crdtsB.operations[index].forEach(op => { if (!ids.has(op.creator)) { mergedOps.push(op); } }); } mergedOps = mergedOps.sort(sortOperationComparator); ret.operations[index] = mergedOps; }); ret.hash = await hashCRDTOperations(hashFunction, ret); return ret; } export function rebuildFromCRDT<RxDocType>( schema: RxJsonSchema<RxDocumentData<RxDocType>>, docData: WithDeleted<RxDocType> | RxDocType, crdts: CRDTDocumentField<RxDocType> ): WithDeleted<RxDocType> { let base: WithDeleted<RxDocType> = { _deleted: false } as any; setProperty(base, ensureNotFalsy(schema.crdt).field, crdts); crdts.operations.forEach(operations => { operations.forEach(op => { base = runOperationOnDocument( schema, base, op ); }); }); return base; } export function getCRDTConflictHandler<RxDocType>( hashFunction: HashFunction, schema: RxJsonSchema<RxDocumentData<RxDocType>> ): RxConflictHandler<RxDocType> { const crdtOptions = ensureNotFalsy(schema.crdt); const crdtField = crdtOptions.field; const getCRDTValue = objectPathMonad<WithDeleted<RxDocType> | RxDocType, CRDTDocumentField<RxDocType>>(crdtField); const conflictHandler: RxConflictHandler<RxDocType> = { isEqual(a, b, ctx) { return getCRDTValue(a).hash === getCRDTValue(b).hash; }, async resolve(i) { const newDocCrdt = getCRDTValue(i.newDocumentState); const masterDocCrdt = getCRDTValue(i.realMasterState); const mergedCrdt = await mergeCRDTFields(hashFunction, newDocCrdt, masterDocCrdt); const mergedDoc = rebuildFromCRDT( schema, i.newDocumentState, mergedCrdt ); return mergedDoc; } } return conflictHandler; } export const RX_CRDT_CONTEXT = 'rx-crdt'; export const RxDBcrdtPlugin: RxPlugin = { name: 'crdt', rxdb: true, prototypes: { RxDocument: (proto: any) => { proto.updateCRDT = updateCRDT; const oldRemove = proto.remove; proto.remove = function (this: RxDocument) { if (!this.collection.schema.jsonSchema.crdt) { return oldRemove.bind(this)(); } return this.updateCRDT({ ifMatch: { $set: { _deleted: true } } }); }; const oldincrementalPatch = proto.incrementalPatch; proto.incrementalPatch = function (this: RxDocument, patch: any) { if (!this.collection.schema.jsonSchema.crdt) { return oldincrementalPatch.bind(this)(patch); } return this.updateCRDT({ ifMatch: { $set: patch } }); }; const oldincrementalModify = proto.incrementalModify; proto.incrementalModify = function (fn: any, context: string) { if (!this.collection.schema.jsonSchema.crdt) { return oldincrementalModify.bind(this)(fn); } if (context === RX_CRDT_CONTEXT) { return oldincrementalModify.bind(this)(fn); } else { throw newRxError('CRDT2', { id: this.primary, args: { context } }); } }; }, RxCollection: (proto: any) => { proto.insertCRDT = insertCRDT; } }, overwritable: {}, hooks: { preCreateRxCollection: { after: (data) => { if (!data.schema.crdt) { return; } if (data.conflictHandler) { throw newRxError('CRDT3', { collection: data.name, schema: data.schema }); } data.conflictHandler = getCRDTConflictHandler( data.database.hashFunction, data.schema ); } }, createRxCollection: { after: ({ collection }) => { if (!collection.schema.jsonSchema.crdt) { return; } const crdtOptions = ensureNotFalsy(collection.schema.jsonSchema.crdt); const crdtField = crdtOptions.field; const getCrdt = objectPathMonad<any, CRDTDocumentField<any>>(crdtOptions.field); /** * In dev-mode we have to ensure that all document writes * have the correct crdt state so that nothing is missed out * or could accidentally do non-crdt writes to the document. */ if (overwritable.isDevMode()) { const bulkWriteBefore = collection.storageInstance.bulkWrite.bind(collection.storageInstance); collection.storageInstance.bulkWrite = async function (writes, context) { await Promise.all( writes.map(async (write) => { const newDocState: typeof write.document = clone(write.document); const crdts = getCrdt(newDocState); const rebuild = rebuildFromCRDT( collection.schema.jsonSchema, newDocState, crdts ); function docWithoutMeta(doc: any) { const ret: any = {}; Object.entries(doc).forEach(([k, v]) => { if ( !k.startsWith('_') && typeof v !== 'undefined' ) { ret[k] = v; } }); return ret; } if (!deepEqual(docWithoutMeta(newDocState), docWithoutMeta(rebuild))) { throw newRxError('SNH', { document: newDocState }); } const recalculatedHash = await hashCRDTOperations(collection.database.hashFunction, crdts); if (crdts.hash !== recalculatedHash) { throw newRxError('SNH', { document: newDocState, args: { hash: crdts.hash, recalculatedHash } }); } }) ); return bulkWriteBefore(writes, context); }; } const bulkInsertBefore = collection.bulkInsert.bind(collection); collection.bulkInsert = async function (docsData: any[]) { const storageToken = await collection.database.storageToken; const useDocsData = await Promise.all( docsData.map(async (docData) => { const setMe: Partial<RxDocumentData<any>> = {}; Object.entries(docData).forEach(([key, value]) => { if ( !key.startsWith('_') && key !== crdtField ) { setMe[key] = value; } }); const crdtOperations: CRDTDocumentField<any> = { operations: [ [{ creator: storageToken, body: [{ ifMatch: { $set: setMe } }], time: now() }] ], hash: '' }; crdtOperations.hash = await hashCRDTOperations(collection.database.hashFunction, crdtOperations); setProperty(docData, crdtOptions.field, crdtOperations); return docData; }) ); return bulkInsertBefore(useDocsData); }; } } } };