Skip to content

Commit

Permalink
MemoryCache: introduce cache-specific fork / merge / rebase
Browse files Browse the repository at this point in the history
  • Loading branch information
dgeb committed Jul 26, 2021
1 parent d356f51 commit f1a249d
Show file tree
Hide file tree
Showing 2 changed files with 296 additions and 5 deletions.
119 changes: 114 additions & 5 deletions packages/@orbit/memory/src/memory-cache.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { Orbit } from '@orbit/core';
import { FullResponse, RequestOptions } from '@orbit/data';
import {
DefaultRequestOptions,
FullRequestOptions,
FullResponse,
RequestOptions
} from '@orbit/data';
import { ImmutableMap } from '@orbit/immutable';
import {
RecordCacheQueryOptions,
Expand All @@ -12,6 +17,7 @@ import {
SyncRecordCacheSettings
} from '@orbit/record-cache';
import {
coalesceRecordOperations,
equalRecordIdentities,
InitializedRecord,
RecordIdentity,
Expand Down Expand Up @@ -78,7 +84,7 @@ export class MemoryCache<
protected _inverseRelationships!: Dict<
ImmutableMap<string, RecordRelationshipIdentity[]>
>;
protected _updateOperations!: RecordOperation[];
protected _updateOperations?: RecordOperation[];
protected _isTrackingUpdateOperations: boolean;

constructor(settings: MemoryCacheSettings<QO, TO, QB, TB, QRD, TRD>) {
Expand All @@ -100,6 +106,106 @@ export class MemoryCache<
return this._base;
}

/**
* Create a clone, or "fork", from a "base" cache.
*
* The forked cache will have the same `schema` and `keyMap` as its base
* source. The forked cache will start with the same immutable document as the
* base source. Its contents and log will evolve independently.
*
* @returns the forked cache
*/
fork(
settings: Partial<MemoryCacheSettings<QO, TO, QB, TB, QRD, TRD>> = {}
): MemoryCache<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.validatorFor ??= this._validatorFor;
settings.defaultQueryOptions ??= this._defaultQueryOptions;
settings.defaultTransformOptions ??= this._defaultTransformOptions;

return new MemoryCache(
settings as MemoryCacheSettings<QO, TO, QB, TB, QRD, TRD>
);
}

/**
* Merges the operations from a forked cache back to this cache.
*
* @returns the result of calling `update` with the operations
*/
merge<RequestData extends RecordTransformResult = RecordTransformResult>(
forkedCache: MemoryCache<QO, TO, QB, TB, QRD, TRD>,
options?: DefaultRequestOptions<TO> & MemoryCacheMergeOptions
): RequestData;
merge<RequestData extends RecordTransformResult = RecordTransformResult>(
forkedCache: MemoryCache<QO, TO, QB, TB, QRD, TRD>,
options: FullRequestOptions<TO> & MemoryCacheMergeOptions
): FullResponse<RequestData, TRD, RecordOperation>;
merge<RequestData extends RecordTransformResult = RecordTransformResult>(
forkedCache: MemoryCache<QO, TO, QB, TB, QRD, TRD>,
options?: TO & MemoryCacheMergeOptions
): RequestData | FullResponse<RequestData, TRD, RecordOperation> {
let { coalesce, ...remainingOptions } = options ?? {};

assert(
'MemoryCache#merge can only merge a forked cache that is configured with `trackUpdateOperations: true`.',
forkedCache.isTrackingUpdateOperations
);

let ops = forkedCache.getAllUpdateOperations();

if (coalesce !== false) {
ops = coalesceRecordOperations(ops);
}

if (options?.fullResponse) {
return this.update<RequestData>(
ops,
remainingOptions as FullRequestOptions<TO>
);
} else {
return this.update<RequestData>(
ops,
remainingOptions as DefaultRequestOptions<TO>
);
}
}

/**
* Rebase resets this cache's state to that of its base cache, then re-applies
* any tracked update operations.
*
* Rebasing requires both a `base` cache as well as tracking of update
* operations (which is enabled by default when a `base` cache is assigned).
*/
rebase(): void {
const base = this._base;

assert(
'A `base` cache must be defined for `rebase` to work',
base !== undefined
);

assert(
'MemoryCache#rebase requires that the cache is configured with `trackUpdateOperations: true`.',
this._isTrackingUpdateOperations
);

// get update ops prior to resetting state
let ops = this.getAllUpdateOperations();

// reset the state of the cache to match the base cache
this.reset(base);

// reapply update ops
this.update(ops);
}

getRecordSync(identity: RecordIdentity): InitializedRecord | undefined {
Expand Down Expand Up @@ -250,7 +356,10 @@ export class MemoryCache<
*/
reset(base?: MemoryCache<QO, TO, QB, TB, QRD, TRD>): void {
this._records = {};
this._updateOperations = [];

if (this._isTrackingUpdateOperations) {
this._updateOperations = [];
}

Object.keys(this._schema.models).forEach((type) => {
let baseRecords = base && base._records[type];
Expand Down Expand Up @@ -290,11 +399,11 @@ export class MemoryCache<

getAllUpdateOperations(): RecordOperation[] {
assert(
'MemoryCache#getAllUpdateOperations: requires that cache be configured with `trackUpdateOperations: true`.',
'MemoryCache#getAllUpdateOperations requires that cache be configured with `trackUpdateOperations: true`.',
this._isTrackingUpdateOperations
);

return this._updateOperations;
return this._updateOperations as RecordOperation[];
}

/////////////////////////////////////////////////////////////////////////////
Expand Down
182 changes: 182 additions & 0 deletions packages/@orbit/memory/test/memory-cache-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2550,4 +2550,186 @@ module('MemoryCache', function (hooks) {
jupiter
);
});

test('#fork - creates a new cache that starts with the same schema and keyMap as the base cache', function (assert) {
const cache = new MemoryCache({ schema, keyMap });

const jupiter: InitializedRecord = {
type: 'planet',
id: 'jupiter',
attributes: { name: 'Jupiter', classification: 'gas giant' }
};

cache.update((t) => t.addRecord(jupiter));

assert.deepEqual(
cache.getRecordSync({ type: 'planet', id: 'jupiter' }),
jupiter,
'verify base data'
);

const fork = cache.fork();

assert.deepEqual(
fork.getRecordSync({ type: 'planet', id: 'jupiter' }),
jupiter,
'data in fork matches data in source'
);
assert.strictEqual(fork.schema, cache.schema, 'schema matches');
assert.strictEqual(fork.keyMap, cache.keyMap, 'keyMap matches');
assert.strictEqual(
fork.transformBuilder,
cache.transformBuilder,
'transformBuilder is shared'
);
assert.strictEqual(
fork.queryBuilder,
cache.queryBuilder,
'queryBuilder is shared'
);
assert.strictEqual(
fork.validatorFor,
cache.validatorFor,
'validatorFor is shared'
);
assert.strictEqual(
fork.base,
cache,
'base cache is set on the forked cache'
);
});

test('#merge - merges changes from a forked cache back into a base cache', function (assert) {
const cache = new MemoryCache({ schema, keyMap });

const jupiter: InitializedRecord = {
type: 'planet',
id: 'jupiter',
attributes: { name: 'Jupiter', classification: 'gas giant' }
};

let fork = cache.fork();

fork.update((t) => t.addRecord(jupiter));

assert.deepEqual(
fork.getRecordSync({ type: 'planet', id: 'jupiter' }),
jupiter,
'verify fork data'
);

let response = cache.merge(fork);

assert.deepEqual(response, [jupiter], 'response is array');

assert.deepEqual(
cache.getRecordSync({ type: 'planet', id: 'jupiter' }),
jupiter,
'data in cache matches data in fork'
);
});

test('#rebase - change in base ends up in fork', function (assert) {
assert.expect(3);

const jupiter: InitializedRecord = {
type: 'planet',
id: 'jupiter',
attributes: { name: 'Jupiter', classification: 'gas giant' }
};

const cache = new MemoryCache({ schema, keyMap });

let fork = cache.fork();

cache.update((t) => t.addRecord(jupiter));

assert.deepEqual(
cache.getRecordSync({ type: 'planet', id: 'jupiter' }),
jupiter,
'verify base cache data'
);
assert.equal(
fork.getRecordsSync('planet').length,
0,
'forked cache is still empty'
);

fork.rebase();

assert.deepEqual(
fork.getRecordSync({ type: 'planet', id: 'jupiter' }),
jupiter,
'verify data in forked cache'
);
});

test('#rebase - changes in fork are replayed after reset', function (assert) {
assert.expect(8);

const jupiter: InitializedRecord = {
type: 'planet',
id: 'jupiter',
attributes: { name: 'Jupiter', classification: 'gas giant' }
};

const earth: InitializedRecord = {
type: 'planet',
id: 'earth',
attributes: { name: 'Earth', classification: 'terrestrial' }
};

const cache = new MemoryCache({ schema, keyMap });

let fork = cache.fork();

cache.update((t) => t.addRecord(jupiter));
fork.update((t) => t.addRecord(earth));

assert.deepEqual(
cache.getRecordSync({ type: 'planet', id: 'jupiter' }),
jupiter,
'jupiter is in base'
);
assert.deepEqual(
cache.getRecordSync({ type: 'planet', id: 'earth' }),
undefined,
'earth is not in base'
);

assert.deepEqual(
fork.getRecordSync({ type: 'planet', id: 'jupiter' }),
undefined,
'jupiter is not in fork'
);
assert.deepEqual(
fork.getRecordSync({ type: 'planet', id: 'earth' }),
earth,
'earth is in fork'
);

fork.rebase();

assert.deepEqual(
fork.getRecordSync({ type: 'planet', id: 'jupiter' }),
jupiter,
'after rebase, jupiter is now in fork'
);
assert.deepEqual(
fork.getRecordSync({ type: 'planet', id: 'earth' }),
earth,
'after rebase, earth is still in fork'
);

assert.deepEqual(
cache.getRecordSync({ type: 'planet', id: 'jupiter' }),
jupiter,
'after rebase, jupiter is still in base'
);
assert.deepEqual(
cache.getRecordSync({ type: 'planet', id: 'earth' }),
undefined,
'after rebase, earth is still not in base'
);
});
});

0 comments on commit f1a249d

Please sign in to comment.