diff --git a/packages/@orbit/memory/src/memory-cache.ts b/packages/@orbit/memory/src/memory-cache.ts index f1f9bf5f..d62437c3 100644 --- a/packages/@orbit/memory/src/memory-cache.ts +++ b/packages/@orbit/memory/src/memory-cache.ts @@ -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, @@ -12,6 +17,7 @@ import { SyncRecordCacheSettings } from '@orbit/record-cache'; import { + coalesceRecordOperations, equalRecordIdentities, InitializedRecord, RecordIdentity, @@ -78,7 +84,7 @@ export class MemoryCache< protected _inverseRelationships!: Dict< ImmutableMap >; - protected _updateOperations!: RecordOperation[]; + protected _updateOperations?: RecordOperation[]; protected _isTrackingUpdateOperations: boolean; constructor(settings: MemoryCacheSettings) { @@ -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> = {} + ): MemoryCache { + // 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 + ); + } + + /** + * Merges the operations from a forked cache back to this cache. + * + * @returns the result of calling `update` with the operations + */ + merge( + forkedCache: MemoryCache, + options?: DefaultRequestOptions & MemoryCacheMergeOptions + ): RequestData; + merge( + forkedCache: MemoryCache, + options: FullRequestOptions & MemoryCacheMergeOptions + ): FullResponse; + merge( + forkedCache: MemoryCache, + options?: TO & MemoryCacheMergeOptions + ): RequestData | FullResponse { + 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( + ops, + remainingOptions as FullRequestOptions + ); + } else { + return this.update( + ops, + remainingOptions as DefaultRequestOptions + ); + } + } + + /** + * 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 { @@ -250,7 +356,10 @@ export class MemoryCache< */ reset(base?: MemoryCache): void { this._records = {}; - this._updateOperations = []; + + if (this._isTrackingUpdateOperations) { + this._updateOperations = []; + } Object.keys(this._schema.models).forEach((type) => { let baseRecords = base && base._records[type]; @@ -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[]; } ///////////////////////////////////////////////////////////////////////////// diff --git a/packages/@orbit/memory/test/memory-cache-test.ts b/packages/@orbit/memory/test/memory-cache-test.ts index 3703eea7..19242efd 100644 --- a/packages/@orbit/memory/test/memory-cache-test.ts +++ b/packages/@orbit/memory/test/memory-cache-test.ts @@ -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' + ); + }); });