rxdb
Version:
A local-first realtime NoSQL Database for JavaScript applications - https://rxdb.info/
543 lines (499 loc) • 17.4 kB
text/typescript
import {
Observable
} from 'rxjs';
import {
distinctUntilChanged,
filter,
map,
shareReplay,
startWith
} from 'rxjs/operators';
import {
clone,
trimDots,
pluginMissing,
flatClone,
PROMISE_RESOLVE_NULL,
RXJS_SHARE_REPLAY_DEFAULTS,
getProperty,
getFromMapOrCreate,
ensureNotFalsy
} from './plugins/utils/index.ts';
import {
newRxError
} from './rx-error.ts';
import {
runPluginHooks
} from './hooks.ts';
import type {
RxDocument,
RxCollection,
RxDocumentData,
RxDocumentWriteData,
UpdateQuery,
CRDTEntry,
ModifyFunction
} from './types/index.d.ts';
import { getDocumentDataOfRxChangeEvent } from './rx-change-event.ts';
import { overwritable } from './overwritable.ts';
import { getSchemaByObjectPath } from './rx-schema-helper.ts';
import { getWrittenDocumentsFromBulkWriteResponse, throwIfIsStorageWriteError } from './rx-storage-helper.ts';
import { modifierFromPublicToInternal } from './incremental-write.ts';
export const basePrototype = {
get primaryPath() {
const _this: RxDocument = this as any;
if (!_this.isInstanceOfRxDocument) {
return undefined;
}
return _this.collection.schema.primaryPath;
},
get primary() {
const _this: RxDocument = this as any;
if (!_this.isInstanceOfRxDocument) {
return undefined;
}
return (_this._data as any)[_this.primaryPath];
},
get revision() {
const _this: RxDocument = this as any;
if (!_this.isInstanceOfRxDocument) {
return undefined;
}
return _this._data._rev;
},
get deleted$() {
const _this: RxDocument<any> = this as any;
if (!_this.isInstanceOfRxDocument) {
return undefined;
}
return _this.$.pipe(
map((d: any) => d._data._deleted)
);
},
get deleted$$() {
const _this: RxDocument = this as any;
const reactivity = _this.collection.database.getReactivityFactory();
return reactivity.fromObservable(
_this.deleted$,
_this.getLatest().deleted,
_this.collection.database
);
},
get deleted() {
const _this: RxDocument = this as any;
if (!_this.isInstanceOfRxDocument) {
return undefined;
}
return _this._data._deleted;
},
getLatest(this: RxDocument): RxDocument {
const latestDocData = this.collection._docCache.getLatestDocumentData(this.primary);
return this.collection._docCache.getCachedRxDocument(latestDocData);
},
/**
* returns the observable which emits the plain-data of this document
*/
get $(): Observable<RxDocumentData<any>> {
const _this: RxDocument<{}, {}, {}> = this as any;
const id = this.primary;
return _this.collection.eventBulks$.pipe(
filter(bulk => !bulk.isLocal),
map(bulk => bulk.events.find(ev => ev.documentId === id)),
filter(event => !!event),
map(changeEvent => getDocumentDataOfRxChangeEvent(ensureNotFalsy(changeEvent))),
startWith(_this.collection._docCache.getLatestDocumentData(id)),
distinctUntilChanged((prev, curr) => prev._rev === curr._rev),
map(docData => (this as RxDocument<any>).collection._docCache.getCachedRxDocument(docData)),
shareReplay(RXJS_SHARE_REPLAY_DEFAULTS)
);
},
get $$(): any {
const _this: RxDocument = this as any;
const reactivity = _this.collection.database.getReactivityFactory();
return reactivity.fromObservable(
_this.$,
_this.getLatest()._data,
_this.collection.database
);
},
/**
* returns observable of the value of the given path
*/
get$(this: RxDocument, path: string): Observable<any> {
if (overwritable.isDevMode()) {
if (path.includes('.item.')) {
throw newRxError('DOC1', {
path
});
}
if (path === this.primaryPath) {
throw newRxError('DOC2');
}
// final fields cannot be modified and so also not observed
if (this.collection.schema.finalFields.includes(path)) {
throw newRxError('DOC3', {
path
});
}
const schemaObj = getSchemaByObjectPath(
this.collection.schema.jsonSchema,
path
);
if (!schemaObj) {
throw newRxError('DOC4', {
path
});
}
}
return this.$
.pipe(
map(data => getProperty(data, path)),
distinctUntilChanged()
);
},
get$$(this: RxDocument, path: string) {
const obs = this.get$(path);
const reactivity = this.collection.database.getReactivityFactory();
return reactivity.fromObservable(
obs,
this.getLatest().get(path),
this.collection.database
);
},
/**
* populate the given path
*/
populate(this: RxDocument, path: string): Promise<RxDocument | null> {
const schemaObj = getSchemaByObjectPath(
this.collection.schema.jsonSchema,
path
);
const value = this.get(path);
if (!value) {
return PROMISE_RESOLVE_NULL;
}
if (!schemaObj) {
throw newRxError('DOC5', {
path
});
}
if (!schemaObj.ref) {
throw newRxError('DOC6', {
path,
schemaObj
});
}
const refCollection: RxCollection = this.collection.database.collections[schemaObj.ref];
if (!refCollection) {
throw newRxError('DOC7', {
ref: schemaObj.ref,
path,
schemaObj
});
}
if (schemaObj.type === 'array') {
return refCollection.findByIds(value).exec().then(res => {
const valuesIterator = res.values();
return Array.from(valuesIterator) as any;
});
} else {
return refCollection.findOne(value).exec();
}
},
/**
* get data by objectPath
* @hotPath Performance here is really important,
* run some tests before changing anything.
*/
get(this: RxDocument, objPath: string): any | null {
return getDocumentProperty(this, objPath);
},
toJSON(this: RxDocument, withMetaFields = false) {
if (!withMetaFields) {
const data = flatClone(this._data);
delete (data as any)._rev;
delete (data as any)._attachments;
delete (data as any)._deleted;
delete (data as any)._meta;
return overwritable.deepFreezeWhenDevMode(data);
} else {
return overwritable.deepFreezeWhenDevMode(this._data);
}
},
toMutableJSON(this: RxDocument, withMetaFields = false) {
return clone(this.toJSON(withMetaFields as any));
},
/**
* updates document
* @overwritten by plugin (optional)
* @param updateObj mongodb-like syntax
*/
update(_updateObj: UpdateQuery<any>) {
throw pluginMissing('update');
},
incrementalUpdate(_updateObj: UpdateQuery<any>) {
throw pluginMissing('update');
},
updateCRDT(_updateObj: CRDTEntry<any> | CRDTEntry<any>[]) {
throw pluginMissing('crdt');
},
putAttachment() {
throw pluginMissing('attachments');
},
putAttachmentBase64() {
throw pluginMissing('attachments');
},
getAttachment() {
throw pluginMissing('attachments');
},
allAttachments() {
throw pluginMissing('attachments');
},
get allAttachments$() {
throw pluginMissing('attachments');
},
async modify<RxDocType>(
this: RxDocument<RxDocType>,
mutationFunction: ModifyFunction<RxDocType>,
// used by some plugins that wrap the method
_context?: string
): Promise<RxDocument> {
const oldData = this._data;
const newData: RxDocumentData<RxDocType> = await modifierFromPublicToInternal<RxDocType>(mutationFunction)(oldData) as any;
return this._saveData(newData, oldData) as any;
},
/**
* runs an incremental update over the document
* @param function that takes the document-data and returns a new data-object
*/
incrementalModify(
this: RxDocument,
mutationFunction: ModifyFunction<any>,
// used by some plugins that wrap the method
_context?: string
): Promise<RxDocument> {
return this.collection.incrementalWriteQueue.addWrite(
this._data,
modifierFromPublicToInternal(mutationFunction)
).then(result => this.collection._docCache.getCachedRxDocument(result));
},
patch<RxDocType>(
this: RxDocument<RxDocType>,
patch: Partial<RxDocType>
) {
const oldData = this._data;
const newData = clone(oldData);
Object
.entries(patch)
.forEach(([k, v]) => {
(newData as any)[k] = v;
});
return this._saveData(newData, oldData);
},
/**
* patches the given properties
*/
incrementalPatch<RxDocumentType = any>(
this: RxDocument<RxDocumentType>,
patch: Partial<RxDocumentType>
): Promise<RxDocument<RxDocumentType>> {
return this.incrementalModify((docData) => {
Object
.entries(patch)
.forEach(([k, v]) => {
(docData as any)[k] = v;
});
return docData;
});
},
/**
* saves the new document-data
* and handles the events
*/
async _saveData<RxDocType>(
this: RxDocument<RxDocType>,
newData: RxDocumentWriteData<RxDocType>,
oldData: RxDocumentData<RxDocType>
): Promise<RxDocument<RxDocType>> {
newData = flatClone(newData);
// deleted documents cannot be changed
if (this._data._deleted) {
throw newRxError('DOC11', {
id: this.primary,
document: this
});
}
await beforeDocumentUpdateWrite(this.collection, newData, oldData);
const writeRows = [{
previous: oldData,
document: newData
}];
const writeResult = await this.collection.storageInstance.bulkWrite(writeRows, 'rx-document-save-data');
const isError = writeResult.error[0];
throwIfIsStorageWriteError(this.collection, this.primary, newData, isError);
await this.collection._runHooks('post', 'save', newData, this);
return this.collection._docCache.getCachedRxDocument(
getWrittenDocumentsFromBulkWriteResponse(
this.collection.schema.primaryPath,
writeRows,
writeResult
)[0]
);
},
/**
* Remove the document.
* Notice that there is no hard delete,
* instead deleted documents get flagged with _deleted=true.
*/
async remove(this: RxDocument): Promise<RxDocument> {
if (this.deleted) {
return Promise.reject(newRxError('DOC13', {
document: this,
id: this.primary
}));
}
const removeResult = await this.collection.bulkRemove([this]);
if (removeResult.error.length > 0) {
const error = removeResult.error[0];
throwIfIsStorageWriteError(
this.collection,
this.primary,
this._data,
error
);
}
return removeResult.success[0];
},
incrementalRemove(this: RxDocument): Promise<RxDocument> {
return this.incrementalModify(async (docData) => {
await this.collection._runHooks('pre', 'remove', docData, this);
docData._deleted = true;
return docData;
}).then(async (newDoc) => {
await this.collection._runHooks('post', 'remove', newDoc._data, newDoc);
return newDoc;
});
},
close() {
throw newRxError('DOC14');
}
};
export function createRxDocumentConstructor(proto = basePrototype) {
const constructor = function RxDocumentConstructor(
this: RxDocument,
collection: RxCollection,
docData: RxDocumentData<any>
) {
this.collection = collection;
// assume that this is always equal to the doc-data in the database
this._data = docData;
this._propertyCache = new Map<string, any>();
/**
* because of the prototype-merge,
* we can not use the native instanceof operator
*/
this.isInstanceOfRxDocument = true;
};
constructor.prototype = proto;
return constructor;
}
export function createWithConstructor<RxDocType>(
constructor: any,
collection: RxCollection<RxDocType>,
jsonData: RxDocumentData<RxDocType>
): RxDocument<RxDocType> | null {
const doc = new constructor(collection, jsonData);
runPluginHooks('createRxDocument', doc);
return doc;
}
export function isRxDocument(obj: any): boolean {
return typeof obj === 'object' && obj !== null && 'isInstanceOfRxDocument' in obj;
}
export function beforeDocumentUpdateWrite<RxDocType>(
collection: RxCollection<RxDocType>,
newData: RxDocumentWriteData<RxDocType>,
oldData: RxDocumentData<RxDocType>
): Promise<any> {
/**
* Meta values must always be merged
* instead of overwritten.
* This ensures that different plugins do not overwrite
* each others meta properties.
*/
newData._meta = Object.assign(
{},
oldData._meta,
newData._meta
);
// ensure modifications are ok
if (overwritable.isDevMode()) {
collection.schema.validateChange(oldData, newData);
}
return collection._runHooks('pre', 'save', newData, oldData);
}
function getDocumentProperty(doc: RxDocument, objPath: string): any | null {
return getFromMapOrCreate(
doc._propertyCache,
objPath,
() => {
const valueObj = getProperty(doc._data, objPath);
// direct return if array or non-object
if (
typeof valueObj !== 'object' ||
valueObj === null ||
Array.isArray(valueObj)
) {
return overwritable.deepFreezeWhenDevMode(valueObj);
}
const proxy = new Proxy(
/**
* In dev-mode, the _data is deep-frozen
* so we have to flat clone here so that
* the proxy can work.
*/
flatClone(valueObj),
{
/**
* @performance is really important here
* because people access nested properties very often
* and might not be aware that this is internally using a Proxy
*/
get(target, property: any) {
if (typeof property !== 'string') {
return target[property];
}
const lastChar = property.charAt(property.length - 1);
if (lastChar === '$') {
if (property.endsWith('$$')) {
const key = property.slice(0, -2);
return doc.get$$(trimDots(objPath + '.' + key));
} else {
const key = property.slice(0, -1);
return doc.get$(trimDots(objPath + '.' + key));
}
} else if (lastChar === '_') {
const key = property.slice(0, -1);
return doc.populate(trimDots(objPath + '.' + key));
} else {
/**
* Performance shortcut
* In most cases access to nested properties
* will only access simple values which can be directly returned
* without creating a new Proxy or utilizing the cache.
*/
const plainValue = target[property];
if (
typeof plainValue === 'number' ||
typeof plainValue === 'string' ||
typeof plainValue === 'boolean'
) {
return plainValue;
}
return getDocumentProperty(doc, trimDots(objPath + '.' + property));
}
}
});
return proxy;
}
);
};