@orbit/memory
Version:
Orbit source for managing in-memory records.
560 lines (486 loc) • 17.3 kB
text/typescript
import { Assertion, Orbit } from '@orbit/core';
import {
DefaultRequestOptions,
FullRequestOptions,
FullResponse,
queryable,
RequestOptions,
Resettable,
ResponseHints,
syncable,
updatable
} from '@orbit/data';
import { RecordCacheUpdateDetails } from '@orbit/record-cache';
import {
coalesceRecordOperations,
RecordOperation,
RecordOperationResult,
RecordQuery,
RecordQueryable,
RecordQueryBuilder,
RecordQueryExpressionResult,
RecordQueryResult,
RecordSource,
RecordSourceQueryOptions,
RecordSourceSettings,
RecordSyncable,
RecordTransform,
RecordTransformBuilder,
RecordTransformResult,
RecordUpdatable
} from '@orbit/records';
import { Dict, toArray } from '@orbit/utils';
import {
MemoryCache,
MemoryCacheClass,
MemoryCacheSettings
} from './memory-cache';
const { assert, deprecate } = Orbit;
export interface MemorySourceSettings<
QO extends RequestOptions = RecordSourceQueryOptions,
TO extends RequestOptions = RequestOptions,
QB = RecordQueryBuilder,
TB = RecordTransformBuilder,
QRD = unknown,
TRD extends RecordCacheUpdateDetails = RecordCacheUpdateDetails
> extends RecordSourceSettings<QO, TO, QB, TB> {
base?: MemorySource<QO, TO, QB, TB, QRD, TRD>;
cacheClass?: MemoryCacheClass<QO, TO, QB, TB, QRD, TRD>;
cacheSettings?: Partial<MemoryCacheSettings<QO, TO, QB, TB, QRD, TRD>>;
}
export interface MemorySourceMergeOptions {
coalesce?: boolean;
/**
* @deprecated since v0.17
*/
sinceTransformId?: string;
/**
* @deprecated since v0.17, include transform options alongside merge options instead
*/
transformOptions?: RequestOptions;
}
export interface MemorySource<
QO extends RequestOptions = RecordSourceQueryOptions,
TO extends RequestOptions = RequestOptions,
QB = RecordQueryBuilder,
TB = RecordTransformBuilder,
QRD = unknown,
TRD extends RecordCacheUpdateDetails = RecordCacheUpdateDetails
> extends RecordSource<QO, TO, QB, TB>,
RecordSyncable,
RecordQueryable<QRD, QB, QO>,
RecordUpdatable<TRD, TB, TO> {}
@syncable
@queryable
@updatable
export class MemorySource<
QO extends RequestOptions = RecordSourceQueryOptions,
TO extends RequestOptions = RequestOptions,
QB = RecordQueryBuilder,
TB = RecordTransformBuilder,
QRD = unknown,
TRD extends RecordCacheUpdateDetails = RecordCacheUpdateDetails
>
extends RecordSource<QO, TO, QB, TB>
implements
RecordSyncable,
RecordQueryable<QRD, QB, QO>,
RecordUpdatable<TRD, TB, TO>,
Resettable {
protected _cache: MemoryCache<QO, TO, QB, TB, QRD, TRD>;
protected _base?: MemorySource<QO, TO, QB, TB, QRD, TRD>;
protected _forkPoint?: string;
protected _transforms: Dict<RecordTransform>;
protected _transformInverses: Dict<RecordOperation[]>;
constructor(settings: MemorySourceSettings<QO, TO, QB, TB, QRD, TRD>) {
const { keyMap, schema, base } = settings;
settings.name = settings.name ?? 'memory';
super(settings);
this._transforms = {};
this._transformInverses = {};
this.transformLog.on('clear', this._logCleared.bind(this));
this.transformLog.on('truncate', this._logTruncated.bind(this));
this.transformLog.on('rollback', this._logRolledback.bind(this));
let cacheSettings: Partial<MemoryCacheSettings<QO, TO, QB, TB, QRD, TRD>> =
settings.cacheSettings ?? {};
cacheSettings.schema = schema;
cacheSettings.keyMap = keyMap;
cacheSettings.queryBuilder ??= this.queryBuilder;
cacheSettings.transformBuilder ??= this.transformBuilder;
cacheSettings.defaultQueryOptions ??= this.defaultQueryOptions;
cacheSettings.defaultTransformOptions ??= this.defaultTransformOptions;
cacheSettings.autoValidate ??= settings.autoValidate;
if (
cacheSettings.autoValidate !== false &&
cacheSettings.validatorFor === undefined &&
cacheSettings.validators === undefined
) {
cacheSettings.validatorFor = this._validatorFor;
}
if (base) {
this._base = base;
this._forkPoint = base.transformLog.head;
cacheSettings.base = base.cache;
}
const cacheClass = settings.cacheClass ?? MemoryCache;
this._cache = new cacheClass(
cacheSettings as MemoryCacheSettings<QO, TO, QB, TB, QRD, TRD>
);
}
get cache(): MemoryCache<QO, TO, QB, TB, QRD, TRD> {
return this._cache;
}
get base(): MemorySource<QO, TO, QB, TB, QRD, TRD> | undefined {
return this._base;
}
get forkPoint(): string | undefined {
return this._forkPoint;
}
async upgrade(): Promise<void> {
this._cache.upgrade();
}
/////////////////////////////////////////////////////////////////////////////
// Syncable interface implementation
/////////////////////////////////////////////////////////////////////////////
async _sync(transform: RecordTransform): Promise<void> {
if (!this.transformLog.contains(transform.id)) {
this._applyTransform(transform);
await this.transformed([transform]);
}
}
/////////////////////////////////////////////////////////////////////////////
// Updatable interface implementation
/////////////////////////////////////////////////////////////////////////////
async _update(
transform: RecordTransform,
hints?: ResponseHints<RecordTransformResult, TRD>
): Promise<FullResponse<RecordTransformResult, TRD, RecordOperation>> {
let results: RecordTransformResult;
const response: FullResponse<
RecordTransformResult,
TRD,
RecordOperation
> = {};
if (!this.transformLog.contains(transform.id)) {
results = this._applyTransform(transform);
response.transforms = [transform];
}
if (hints?.data) {
if (Array.isArray(transform.operations)) {
assert(
'MemorySource#update: `hints.data` must be an array if `transform.operations` is an array',
Array.isArray(hints.data)
);
response.data = (hints.data as RecordOperationResult[]).map((h) =>
this._retrieveOperationResult(h)
);
} else {
response.data = this._retrieveOperationResult(
hints.data as RecordOperationResult
);
}
} else if (results) {
response.data = results;
}
if (hints?.details) {
response.details = hints.details;
}
return response;
}
/////////////////////////////////////////////////////////////////////////////
// Queryable interface implementation
/////////////////////////////////////////////////////////////////////////////
async _query(
query: RecordQuery,
hints?: ResponseHints<RecordQueryResult, QRD>
): Promise<FullResponse<RecordQueryResult, QRD, RecordOperation>> {
let response: FullResponse<RecordQueryResult, QRD, RecordOperation>;
if (hints?.data) {
response = {};
if (Array.isArray(query.expressions)) {
assert(
'MemorySource#query: `hints.data` must be an array if `query.expressions` is an array',
Array.isArray(hints.data)
);
response.data = (hints.data as RecordQueryExpressionResult[]).map((h) =>
this._retrieveQueryExpressionResult(h)
);
} else {
response.data = this._retrieveQueryExpressionResult(
hints.data as RecordQueryExpressionResult
);
}
} else {
response = this._cache.query(query, {
fullResponse: true
} as FullRequestOptions<QO>);
}
if (hints?.details) {
response.details = hints.details;
}
return response;
}
/////////////////////////////////////////////////////////////////////////////
// Public methods
/////////////////////////////////////////////////////////////////////////////
/**
* Create a clone, or "fork", from a "base" source.
*
* The forked source will have the same `schema` and `keyMap` as its base source.
* The forked source's cache will start with the same immutable document as
* the base source. Its contents and log will evolve independently.
*
* @returns The forked source.
*/
fork(
settings: Partial<MemorySourceSettings<QO, TO, QB, TB, QRD, TRD>> = {}
): MemorySource<QO, TO, QB, TB, QRD, TRD> {
// required settings
settings.base = this;
settings.schema = this.schema;
settings.keyMap = this.keyMap;
// customizable settings
settings.queryBuilder ??= this._queryBuilder;
settings.transformBuilder ??= this._transformBuilder;
settings.defaultQueryOptions ??= this._defaultQueryOptions;
settings.defaultTransformOptions ??= this._defaultTransformOptions;
if (settings.autoValidate !== false) {
settings.validatorFor ??= this._validatorFor;
if (
settings.autoValidate === undefined &&
settings.validatorFor === undefined
) {
settings.autoValidate = false;
}
}
return new MemorySource<QO, TO, QB, TB, QRD, TRD>(
settings as MemorySourceSettings<QO, TO, QB, TB, QRD, TRD>
);
}
/**
* Merge transforms from a forked source back into a base source.
*
* By default, all of the operations from all of the transforms in the forked
* source's history will be reduced into a single transform. A subset of
* operations can be selected by specifying the `sinceTransformId` option.
*
* The `coalesce` option controls whether operations are coalesced into a
* minimal equivalent set before being reduced into a transform.
*
* @param forkedSource - The source to merge.
* @param options - Merge options
* @returns The result of calling `update()` with the forked transforms.
*/
merge<RequestData extends RecordTransformResult = RecordTransformResult>(
forkedSource: MemorySource<QO, TO, QB, TB, QRD, TRD>,
options?: DefaultRequestOptions<TO> & MemorySourceMergeOptions
): Promise<RequestData>;
merge<RequestData extends RecordTransformResult = RecordTransformResult>(
forkedSource: MemorySource<QO, TO, QB, TB, QRD, TRD>,
options: FullRequestOptions<TO> & MemorySourceMergeOptions
): Promise<FullResponse<RequestData, TRD, RecordOperation>>;
async merge<
RequestData extends RecordTransformResult = RecordTransformResult
>(
forkedSource: MemorySource<QO, TO, QB, TB, QRD, TRD>,
options?: TO & MemorySourceMergeOptions
): Promise<
RecordTransformResult | FullResponse<RequestData, TRD, RecordOperation>
> {
let { coalesce, sinceTransformId, transformOptions, ...remainingOptions } =
options ?? {};
let requestOptions: TO;
if (transformOptions) {
deprecate(
'In MemorySource#merge, passing `transformOptions` nested within `options` is deprecated. Instead, include them directly alongside other options.'
);
requestOptions = transformOptions as TO;
} else {
requestOptions = (remainingOptions ?? {}) as TO;
}
let ops: RecordOperation[] = [];
if (forkedSource.cache.isTrackingUpdateOperations) {
ops = forkedSource.cache.getAllUpdateOperations();
} else {
let transforms: RecordTransform[];
if (sinceTransformId) {
deprecate(
'In MemorySource#merge, passing `sinceTransformId` is deprecated. Instead, call `update` with a custom transform/operations.'
);
transforms = forkedSource.getTransformsSince(sinceTransformId);
} else {
transforms = forkedSource.getAllTransforms();
}
transforms.forEach((t) => {
Array.prototype.push.apply(ops, toArray(t.operations));
});
}
if (coalesce !== false) {
ops = coalesceRecordOperations(ops);
}
if (requestOptions.fullResponse) {
return this.update<RequestData>(
ops,
requestOptions as FullRequestOptions<TO>
);
} else {
return this.update<RequestData>(
ops,
requestOptions as DefaultRequestOptions<TO>
);
}
}
/**
* Rebase works similarly to a git rebase:
*
* After a source is forked, there is a parent- and a child-source. Both may
* be updated with transforms. When `childSource.rebase()` is called, the
* child source's state will be reset to match the current state of its
* parent, and then any locally made transforms will be replayed on the child
* source.
*/
rebase(): void {
const base = this._base;
if (!base) {
throw new Assertion(
'A `base` source must be defined for `rebase` to work'
);
}
// reset the state of the cache to match the base cache
this.cache.reset();
// replay all locally made transforms
this.getAllTransforms().forEach((t) => this._applyTransform(t));
// reset the fork point
this._forkPoint = base.transformLog.head;
}
/**
* Reset the source's cache and transform log to its initial state, which will
* be either empty or a matching its `base`, if it has one.
*/
async reset(): Promise<void> {
// reset the state of the cache (which will match a base cache, if present)
this.cache.reset();
// reset the fork point
this._forkPoint = this._base ? this._base.transformLog.head : undefined;
// clear the transform log, which in turn will clear any tracked transforms
await this.transformLog.clear();
}
/**
* Rolls back the source to a particular `transformId`.
*
* `relativePosition` can be a positive or negative integer used to specify a
* position relative to `transformId`.
*/
rollback(transformId: string, relativePosition = 0): Promise<void> {
return this.transformLog.rollback(transformId, relativePosition);
}
/**
* Returns all logged transforms since a particular `transformId`.
*/
getTransformsSince(transformId: string): RecordTransform[] {
return this.transformLog
.after(transformId)
.map((id) => this._transforms[id]);
}
/**
* @deprecated since v0.17, call `getTransformsSince` instead
*/
transformsSince(transformId: string): RecordTransform[] {
deprecate(
'MemorySource#transformsSince has been deprecated. Please call `source.getTransformsSince(tranformId)` instead.'
);
return this.getTransformsSince(transformId);
}
/**
* Returns all logged transforms.
*/
getAllTransforms(): RecordTransform[] {
return this.transformLog.entries.map((id) => this._transforms[id]);
}
/**
* @deprecated since v0.17, call `getAllTransforms` instead
*/
allTransforms(): RecordTransform[] {
deprecate(
'MemorySource#allTransforms has been deprecated. Please call `source.getAllTransforms()` instead.'
);
return this.getAllTransforms();
}
getTransform(transformId: string): RecordTransform {
return this._transforms[transformId];
}
getInverseOperations(transformId: string): RecordOperation[] {
return this._transformInverses[transformId];
}
get defaultQueryOptions(): DefaultRequestOptions<QO> | undefined {
return super.defaultQueryOptions;
}
set defaultQueryOptions(options: DefaultRequestOptions<QO> | undefined) {
super.defaultQueryOptions = this._cache.defaultQueryOptions = options;
}
get defaultTransformOptions(): DefaultRequestOptions<TO> | undefined {
return super.defaultTransformOptions;
}
set defaultTransformOptions(options: DefaultRequestOptions<TO> | undefined) {
this._defaultTransformOptions = this._cache.defaultTransformOptions = options;
}
/////////////////////////////////////////////////////////////////////////////
// Protected methods
/////////////////////////////////////////////////////////////////////////////
protected _retrieveQueryExpressionResult(
result: RecordQueryExpressionResult
): RecordQueryExpressionResult {
if (Array.isArray(result)) {
return this._cache.getRecordsSync(result);
} else if (result) {
return this._cache.getRecordSync(result);
} else {
return result;
}
}
protected _retrieveOperationResult(
result: RecordOperationResult
): RecordOperationResult {
if (result) {
return this._cache.getRecordSync(result);
} else {
return result;
}
}
protected _applyTransform(transform: RecordTransform): RecordTransformResult {
const { data, details } = this.cache.update(transform, {
fullResponse: true
} as FullRequestOptions<TO>);
this._transforms[transform.id] = transform;
this._transformInverses[transform.id] = details?.inverseOperations ?? [];
return data;
}
protected _clearTransformFromHistory(transformId: string): void {
delete this._transforms[transformId];
delete this._transformInverses[transformId];
}
protected _logCleared(): void {
this._transforms = {};
this._transformInverses = {};
}
protected _logTruncated(
transformId: string,
relativePosition: number,
removed: string[]
): void {
removed.forEach((id) => this._clearTransformFromHistory(id));
}
protected _logRolledback(
transformId: string,
relativePosition: number,
removed: string[]
): void {
removed.reverse().forEach((id) => {
const inverseOperations = this._transformInverses[id];
if (inverseOperations) {
this.cache.update(inverseOperations);
}
this._clearTransformFromHistory(id);
});
}
}