From a7acbda1bb42f1fefeabeecfca0880909871a6f0 Mon Sep 17 00:00:00 2001 From: Jon Johnson Date: Fri, 28 Jul 2023 01:05:47 -0700 Subject: [PATCH] Forward fixes from 3.12.x into main (#8751) * fix: @ember-data/debug should declare its peer-dependency on @ember-data/store (#8703) * fix: de-dupe coalescing when includes or adapterOptions is present but still use findRecord (#8704) * fix: make implicit relationship teardown following delete of related record safe (#8705) * fix: catch errors during didCommit in DEBUG (#8708) --------- Co-authored-by: Chris Thoburn --- packages/debug/package.json | 3 +- packages/graph/src/-private/graph/graph.ts | 17 + .../legacy-network-handler/fetch-manager.ts | 143 ++- .../legacy-network-handler.ts | 3 +- packages/model/src/-private/many-array.ts | 4 + .../-private/managers/record-array-manager.ts | 41 +- .../record-arrays/identifier-array.ts | 10 +- packages/store/src/-private/store-service.ts | 37 +- .../tests/integration/coalescing-test.js | 390 +++++- .../tests/integration/graph/unload-test.ts | 1094 +++++++++++++++++ .../integration/record-array-manager-test.js | 10 +- .../records/new-record-unload-test.js | 358 ++++++ .../tests/integration/records/save-test.js | 24 + .../tests/integration/records/unload-test.js | 30 + 14 files changed, 2049 insertions(+), 115 deletions(-) create mode 100644 tests/graph/tests/integration/graph/unload-test.ts create mode 100644 tests/main/tests/integration/records/new-record-unload-test.js diff --git a/packages/debug/package.json b/packages/debug/package.json index 4aca9155f75..7924526c205 100644 --- a/packages/debug/package.json +++ b/packages/debug/package.json @@ -15,7 +15,8 @@ "directories": {}, "scripts": {}, "peerDependencies": { - "@ember/string": "^3.1.1" + "@ember/string": "^3.1.1", + "@ember-data/store": "workspace:4.12.2" }, "dependenciesMeta": { "@ember-data/private-build-infra": { diff --git a/packages/graph/src/-private/graph/graph.ts b/packages/graph/src/-private/graph/graph.ts index 56ef92c55f8..bf7986f8c56 100644 --- a/packages/graph/src/-private/graph/graph.ts +++ b/packages/graph/src/-private/graph/graph.ts @@ -201,16 +201,33 @@ export class Graph { isReleasable(identifier: StableRecordIdentifier): boolean { const relationships = this.identifiers.get(identifier); if (!relationships) { + if (LOG_GRAPH) { + // eslint-disable-next-line no-console + console.log(`graph: RELEASABLE ${String(identifier)}`); + } return true; } const keys = Object.keys(relationships); for (let i = 0; i < keys.length; i++) { const relationship: RelationshipEdge = relationships[keys[i]]; + // account for previously unloaded relationships + // typically from a prior deletion of a record that pointed to this one implicitly + if (relationship === undefined) { + continue; + } assert(`Expected a relationship`, relationship); if (relationship.definition.inverseIsAsync) { + if (LOG_GRAPH) { + // eslint-disable-next-line no-console + console.log(`graph: <> RELEASABLE ${String(identifier)}`); + } return false; } } + if (LOG_GRAPH) { + // eslint-disable-next-line no-console + console.log(`graph: RELEASABLE ${String(identifier)}`); + } return true; } diff --git a/packages/legacy-compat/src/legacy-network-handler/fetch-manager.ts b/packages/legacy-compat/src/legacy-network-handler/fetch-manager.ts index 5c722fb0925..4b8dd1578ba 100644 --- a/packages/legacy-compat/src/legacy-network-handler/fetch-manager.ts +++ b/packages/legacy-compat/src/legacy-network-handler/fetch-manager.ts @@ -44,7 +44,7 @@ interface PendingFetchItem { resolver: Deferred; options: FindOptions; trace?: unknown; - promise: Promise; + promise: Promise; } interface PendingSaveItem { @@ -60,7 +60,7 @@ export default class FetchManager { declare isDestroyed: boolean; declare requestCache: RequestStateService; // fetches pending in the runloop, waiting to be coalesced - declare _pendingFetch: Map; + declare _pendingFetch: Map>; declare _store: Store; constructor(store: Store) { @@ -114,7 +114,7 @@ export default class FetchManager { identifier: StableExistingRecordIdentifier, options: FindOptions, request: StoreRequestInfo - ): Promise { + ): Promise { let query: FindRecordQuery = { op: 'findRecord', recordIdentifier: identifier, @@ -193,13 +193,21 @@ export default class FetchManager { }); } - let fetches = this._pendingFetch; + let fetchesByType = this._pendingFetch; + let fetchesById = fetchesByType.get(modelName); - if (!fetches.has(modelName)) { - fetches.set(modelName, []); + if (!fetchesById) { + fetchesById = new Map(); + fetchesByType.set(modelName, fetchesById); } - (fetches.get(modelName) as PendingFetchItem[]).push(pendingFetchItem); + let requestsForIdentifier = fetchesById.get(identifier); + if (!requestsForIdentifier) { + requestsForIdentifier = []; + fetchesById.set(identifier, requestsForIdentifier); + } + + requestsForIdentifier.push(pendingFetchItem); if (TESTING) { if (!request.disableTestWaiter) { @@ -214,14 +222,12 @@ export default class FetchManager { return promise; } - getPendingFetch(identifier: StableRecordIdentifier, options: FindOptions) { - let pendingFetches = this._pendingFetch.get(identifier.type); + getPendingFetch(identifier: StableExistingRecordIdentifier, options: FindOptions) { + let pendingFetches = this._pendingFetch.get(identifier.type)?.get(identifier); // We already have a pending fetch for this if (pendingFetches) { - let matchingPendingFetch = pendingFetches.find( - (fetch) => fetch.identifier === identifier && isSameRequest(options, fetch.options) - ); + let matchingPendingFetch = pendingFetches.find((fetch) => isSameRequest(options, fetch.options)); if (matchingPendingFetch) { return matchingPendingFetch.promise; } @@ -239,15 +245,15 @@ export default class FetchManager { } fetchDataIfNeededForIdentifier( - identifier: StableRecordIdentifier, + identifier: StableExistingRecordIdentifier, options: FindOptions = {}, request: StoreRequestInfo - ): Promise { + ): Promise { // pre-loading will change the isEmpty value const isEmpty = _isEmpty(this._store._instanceCache, identifier); const isLoading = _isLoading(this._store._instanceCache, identifier); - let promise: Promise; + let promise: Promise; if (isEmpty) { assertIdentifierHasId(identifier); @@ -296,12 +302,47 @@ function _isLoading(cache: InstanceCache, identifier: StableRecordIdentifier): b ); } +function includesSatisfies(current: undefined | string | string[], existing: undefined | string | string[]): boolean { + // if we have no includes we are good + if (!current?.length) { + return true; + } + + // if we are here we have includes, + // and if existing has no includes then we will need a new request + if (!existing?.length) { + return false; + } + + const arrCurrent = (Array.isArray(current) ? current : current.split(',')).sort(); + const arrExisting = (Array.isArray(existing) ? existing : existing.split(',')).sort(); + + // includes are identical + if (arrCurrent.join(',') === arrExisting.join(',')) { + return true; + } + + // if all of current includes are in existing includes then we are good + // so if we find one that is not in existing then we need a new request + for (let i = 0; i < arrCurrent.length; i++) { + if (!arrExisting.includes(arrCurrent[i])) { + return false; + } + } + + return true; +} + +function optionsSatisfies(current: object | undefined, existing: object | undefined): boolean { + return !current || current === existing || Object.keys(current).length === 0; +} + // this function helps resolve whether we have a pending request that we should use instead function isSameRequest(options: FindOptions = {}, existingOptions: FindOptions = {}) { - let includedMatches = !options.include || options.include === existingOptions.include; - let adapterOptionsMatches = options.adapterOptions === existingOptions.adapterOptions; - - return includedMatches && adapterOptionsMatches; + return ( + optionsSatisfies(options.adapterOptions, existingOptions.adapterOptions) && + includesSatisfies(options.include, existingOptions.include) + ); } function _findMany( @@ -499,35 +540,57 @@ function _processCoalescedGroup( } } -function _flushPendingFetchForType(store: Store, pendingFetchItems: PendingFetchItem[], modelName: string) { +function _flushPendingFetchForType( + store: Store, + pendingFetchMap: Map, + modelName: string +) { let adapter = store.adapterFor(modelName); let shouldCoalesce = !!adapter.findMany && adapter.coalesceFindRequests; - let totalItems = pendingFetchItems.length; if (shouldCoalesce) { - let snapshots = new Array(totalItems); - let fetchMap = new Map(); - for (let i = 0; i < totalItems; i++) { - let fetchItem = pendingFetchItems[i]; - snapshots[i] = store._fetchManager.createSnapshot(fetchItem.identifier, fetchItem.options); - fetchMap.set(snapshots[i], fetchItem); - } + const pendingFetchItems: PendingFetchItem[] = []; + pendingFetchMap.forEach((requestsForIdentifier, identifier) => { + if (requestsForIdentifier.length > 1) { + return; + } - let groups: Snapshot[][]; - if (adapter.groupRecordsForFindMany) { - groups = adapter.groupRecordsForFindMany(store, snapshots); - } else { - groups = [snapshots]; - } + // remove this entry from the map so it's not processed again + pendingFetchMap.delete(identifier); + pendingFetchItems.push(requestsForIdentifier[0]); + }); - for (let i = 0, l = groups.length; i < l; i++) { - _processCoalescedGroup(store, fetchMap, groups[i], adapter, modelName); - } - } else { - for (let i = 0; i < totalItems; i++) { - void _fetchRecord(store, adapter, pendingFetchItems[i]); + let totalItems = pendingFetchItems.length; + + if (totalItems > 1) { + let snapshots = new Array(totalItems); + let fetchMap = new Map(); + for (let i = 0; i < totalItems; i++) { + let fetchItem = pendingFetchItems[i]; + snapshots[i] = store._fetchManager.createSnapshot(fetchItem.identifier, fetchItem.options); + fetchMap.set(snapshots[i], fetchItem); + } + + let groups: Snapshot[][]; + if (adapter.groupRecordsForFindMany) { + groups = adapter.groupRecordsForFindMany(store, snapshots); + } else { + groups = [snapshots]; + } + + for (let i = 0, l = groups.length; i < l; i++) { + _processCoalescedGroup(store, fetchMap, groups[i], adapter, modelName); + } + } else if (totalItems === 1) { + void _fetchRecord(store, adapter, pendingFetchItems[0]); } } + + pendingFetchMap.forEach((pendingFetchItems) => { + pendingFetchItems.forEach((pendingFetchItem) => { + void _fetchRecord(store, adapter, pendingFetchItem); + }); + }); } function _flushPendingSave(store: Store, pending: PendingSaveItem) { diff --git a/packages/legacy-compat/src/legacy-network-handler/legacy-network-handler.ts b/packages/legacy-compat/src/legacy-network-handler/legacy-network-handler.ts index 91c386c0576..20b44ed3bab 100644 --- a/packages/legacy-compat/src/legacy-network-handler/legacy-network-handler.ts +++ b/packages/legacy-compat/src/legacy-network-handler/legacy-network-handler.ts @@ -97,7 +97,8 @@ function findBelongsTo(context: StoreRequestContext): Promise { const identifier = identifiers?.[0]; // short circuit if we are already loading - let pendingRequest = identifier && store._fetchManager.getPendingFetch(identifier, options); + let pendingRequest = + identifier && store._fetchManager.getPendingFetch(identifier as StableExistingRecordIdentifier, options); if (pendingRequest) { return pendingRequest as Promise; } diff --git a/packages/model/src/-private/many-array.ts b/packages/model/src/-private/many-array.ts index 7f9716e445d..ee46938ce64 100644 --- a/packages/model/src/-private/many-array.ts +++ b/packages/model/src/-private/many-array.ts @@ -343,6 +343,10 @@ export default class RelatedCollection extends RecordArray { return record; } + + destroy() { + super.destroy(false); + } } RelatedCollection.prototype.isAsync = false; RelatedCollection.prototype.isPolymorphic = false; diff --git a/packages/store/src/-private/managers/record-array-manager.ts b/packages/store/src/-private/managers/record-array-manager.ts index ed9e5bacc1c..70cca01b5d0 100644 --- a/packages/store/src/-private/managers/record-array-manager.ts +++ b/packages/store/src/-private/managers/record-array-manager.ts @@ -79,6 +79,7 @@ class RecordArrayManager { declare store: Store; declare isDestroying: boolean; declare isDestroyed: boolean; + declare _set: Map>; declare _live: Map; declare _managed: Set; declare _pending: Map; @@ -86,6 +87,7 @@ class RecordArrayManager { declare _staged: Map; declare _subscription: UnsubscribeToken; declare _keyedArrays: Map; + declare _visibilitySet: Map; constructor(options: { store: Store }) { this.store = options.store; @@ -97,13 +99,17 @@ class RecordArrayManager { this._staged = new Map(); this._keyedArrays = new Map(); this._identifiers = new Map(); + this._set = new Map(); + this._visibilitySet = new Map(); this._subscription = this.store.notifications.subscribe( 'resource', (identifier: StableRecordIdentifier, type: CacheOperation) => { if (type === 'added') { + this._visibilitySet.set(identifier, true); this.identifierAdded(identifier); } else if (type === 'removed') { + this._visibilitySet.set(identifier, false); this.identifierRemoved(identifier); } else if (type === 'state') { this.identifierChanged(identifier); @@ -119,7 +125,7 @@ class RecordArrayManager { return; } - sync(array, pending); + sync(array, pending, this._set.get(array)!); this._pending.delete(array); } @@ -154,6 +160,7 @@ class RecordArrayManager { manager: this, }); this._live.set(type, array); + this._set.set(array, new Set(identifiers)); } return array; @@ -178,6 +185,7 @@ class RecordArrayManager { }; let array = new Collection(options); this._managed.add(array); + this._set.set(array, new Set(options.identifiers || [])); if (config.identifiers) { associate(this._identifiers, array, config.identifiers); } @@ -261,6 +269,7 @@ class RecordArrayManager { const old = source.slice(); source.length = 0; fastPush(source, identifiers); + this._set.set(array, new Set(identifiers)); notifyArray(array); array.meta = payload.meta || null; @@ -306,6 +315,12 @@ class RecordArrayManager { identifierChanged(identifier: StableRecordIdentifier): void { let newState = this.store._instanceCache.recordIsLoaded(identifier, true); + // if the change matches the most recent direct added/removed + // state, then we can ignore it + if (this._visibilitySet.get(identifier) === newState) { + return; + } + if (newState) { this.identifierAdded(identifier); } else { @@ -313,16 +328,19 @@ class RecordArrayManager { } } - clear() { - this._live.forEach((array) => array.destroy()); - this._managed.forEach((array) => array.destroy()); + clear(isClear = true) { + this._live.forEach((array) => array.destroy(isClear)); + this._managed.forEach((array) => array.destroy(isClear)); this._managed.clear(); this._identifiers.clear(); + this._pending.clear(); + this._set.forEach((set) => set.clear()); + this._visibilitySet.clear(); } destroy() { this.isDestroying = true; - this.clear(); + this.clear(false); this._live.clear(); this.isDestroyed = true; // eslint-disable-next-line @typescript-eslint/no-unsafe-argument @@ -367,19 +385,24 @@ export function disassociateIdentifier( } } -function sync(array: IdentifierArray, changes: Map) { +function sync( + array: IdentifierArray, + changes: Map, + arraySet: Set +) { let state = array[SOURCE]; const adds: StableRecordIdentifier[] = []; const removes: StableRecordIdentifier[] = []; changes.forEach((value, key) => { if (value === 'add') { // likely we want to keep a Set along-side - if (state.includes(key)) { + if (arraySet.has(key)) { return; } adds.push(key); + arraySet.add(key); } else { - if (state.includes(key)) { + if (arraySet.has(key)) { removes.push(key); } } @@ -387,6 +410,7 @@ function sync(array: IdentifierArray, changes: Map void) { assert(`EmberData should never encounter a nested run`, !this._cbs); const _cbs: { coalesce?: () => void; sync?: () => void; notify?: () => void } = (this._cbs = {}); - cb(); - if (_cbs.coalesce) { - _cbs.coalesce(); - } - if (_cbs.sync) { - _cbs.sync(); - } - if (_cbs.notify) { - _cbs.notify(); + if (DEBUG) { + try { + cb(); + if (_cbs.coalesce) { + _cbs.coalesce(); + } + if (_cbs.sync) { + _cbs.sync(); + } + if (_cbs.notify) { + _cbs.notify(); + } + } finally { + this._cbs = null; + } + } else { + cb(); + if (_cbs.coalesce) { + _cbs.coalesce(); + } + if (_cbs.sync) { + _cbs.sync(); + } + if (_cbs.notify) { + _cbs.notify(); + } + this._cbs = null; } - this._cbs = null; } _join(cb: () => void): void { if (this._cbs) { diff --git a/tests/adapter-encapsulation/tests/integration/coalescing-test.js b/tests/adapter-encapsulation/tests/integration/coalescing-test.js index 36271fe8846..c39030f832c 100644 --- a/tests/adapter-encapsulation/tests/integration/coalescing-test.js +++ b/tests/adapter-encapsulation/tests/integration/coalescing-test.js @@ -183,7 +183,100 @@ module('integration/coalescing - Coalescing Tests', function (hooks) { assert.deepEqual(serializedRecords, expectedResults, 'each findRecord returns expected result'); }); - test('coalescing works with multiple includes options specified', async function (assert) { + test('Coalescing works with multiple includes options specified (bypass findMany)', async function (assert) { + let findRecordCalled = 0; + + let { owner } = this; + let store = owner.lookup('service:store'); + + class TestFindRecordAdapter extends EmberObject { + coalesceFindRequests = true; + + findRecord(_store, _schema, id, snapshot) { + findRecordCalled++; + + return { + data: + id === '1' + ? { + id: '1', + type: 'person', + attributes: { + firstName: 'Gaurav', + lastName: 'Munjal', + }, + } + : { + id: '2', + type: 'person', + attributes: { + firstName: 'Chris', + lastName: 'Thoburn', + }, + }, + }; + } + + findMany() { + throw new Error(`We should not call findMany`); + } + + groupRecordsForFindMany() { + throw new Error(`We should not call groupRecordForFindMany`); + } + } + + owner.register('adapter:application', TestFindRecordAdapter); + + let person1 = store.identifierCache.getOrCreateRecordIdentifier({ type: 'person', id: '1' }); + let person2 = store.identifierCache.getOrCreateRecordIdentifier({ type: 'person', id: '2' }); + let promises = [ + store.findRecord('person', '1'), // creates request (1) + store.findRecord('person', '1', { include: '' }), // de-duped + store.findRecord('person', '1', { include: 'users' }), // creates request (2) + store.findRecord('person', '1', { include: 'users', adapterOptions: { opt: '1' } }), // creates request (3) + store.findRecord('person', '1', { include: 'users', adapterOptions: { opt: '2' } }), // creates request (4) + store.findRecord('person', '1', { include: 'users' }), // de-duped + store.findRecord('person', '1', { adapterOptions: { opt: '2' } }), // creates request (5) + store.findRecord('person', '1', { include: 'users.foo' }), // creates request (6) + store.findRecord('person', '2', { include: 'users.foo' }), // creates request (7) + store.findRecord('person', '2', { include: 'users' }), // creates request (8) + store.findRecord('person', '2', { include: 'users' }), // de-duped + store.findRecord('person', '2', { include: '' }), // de-duped + store.findRecord('person', '2'), // de-duped + store.findRecord('person', '2', { include: 'users' }), // de-duped + store.findRecord('person', '2', { include: 'users.foo' }), // de-duped + ]; + let records = await all(promises); + let foundIdentifiers = records.map((record) => recordIdentifierFor(record)); + let expectedIdentifiers = [ + person1, + person1, + person1, + person1, + person1, + person1, + person1, + person1, + person2, + person2, + person2, + person2, + person2, + person2, + person2, + ]; + + assert.strictEqual(findRecordCalled, 8, 'findRecord is called 8x'); + assert.deepEqual(foundIdentifiers, expectedIdentifiers, 'each findRecord returns expected result'); + + const person1record = store.peekRecord('person', '1'); + const person2record = store.peekRecord('person', '2'); + assert.strictEqual(person1record.firstName, 'Gaurav', 'person 1 loaded'); + assert.strictEqual(person2record.firstName, 'Chris', 'person 2 loaded'); + }); + + test('Coalescing works with multiple includes options specified (uses findMany)', async function (assert) { let findRecordCalled = 0; let findManyCalled = 0; let groupRecordsForFindManyCalled = 0; @@ -201,6 +294,22 @@ module('integration/coalescing - Coalescing Tests', function (hooks) { { id: '2', type: 'person', + attributes: { + firstName: 'Wesley', + lastName: 'Thoburn', + }, + }, + { + id: '3', + type: 'person', + attributes: { + firstName: 'James', + lastName: 'Thoburn', + }, + }, + { + id: '4', + type: 'person', attributes: { firstName: 'Chris', lastName: 'Thoburn', @@ -217,6 +326,8 @@ module('integration/coalescing - Coalescing Tests', function (hooks) { findRecord() { findRecordCalled++; + + return { data: null }; } findMany(passedStore, type, ids, snapshots) { @@ -225,18 +336,9 @@ module('integration/coalescing - Coalescing Tests', function (hooks) { assert.strictEqual(passedStore, store, 'instance of store is passed to findMany'); assert.strictEqual(type, Person, 'model is passed to findMany'); - let expectedIds = ['1', '1', '1', '1', '1', '1', '2', '2']; - let expectedIncludes = [undefined, 'users', 'users', 'users', undefined, 'users.foo', 'users.foo', 'users']; - let expectedOptions = [ - undefined, - undefined, - { opt: '1' }, - { opt: '2' }, - { opt: '2' }, - undefined, - undefined, - undefined, - ]; + let expectedIds = ['1', '2', '3', '4']; + let expectedIncludes = [undefined, 'users', 'users.foo', ['comments']]; + let expectedOptions = [undefined, undefined, { opt: '1' }, { opt: '2' }]; let includes = snapshots.map((snapshot) => snapshot.include); let options = snapshots.map((snapshot) => snapshot.adapterOptions); assert.deepEqual(ids, expectedIds, 'ids are passed to findMany'); @@ -251,7 +353,7 @@ module('integration/coalescing - Coalescing Tests', function (hooks) { return resolve(expectedResults); } - groupRecordsForFindMany(store, snapshots) { + groupRecordsForFindMany(_store, snapshots) { groupRecordsForFindManyCalled++; return [snapshots]; } @@ -261,42 +363,17 @@ module('integration/coalescing - Coalescing Tests', function (hooks) { let person1 = store.identifierCache.getOrCreateRecordIdentifier({ type: 'person', id: '1' }); let person2 = store.identifierCache.getOrCreateRecordIdentifier({ type: 'person', id: '2' }); + let person3 = store.identifierCache.getOrCreateRecordIdentifier({ type: 'person', id: '3' }); + let person4 = store.identifierCache.getOrCreateRecordIdentifier({ type: 'person', id: '4' }); let promises = [ store.findRecord('person', '1'), - store.findRecord('person', '1', { include: '' }), - store.findRecord('person', '1', { include: 'users' }), - store.findRecord('person', '1', { include: 'users', adapterOptions: { opt: '1' } }), - store.findRecord('person', '1', { include: 'users', adapterOptions: { opt: '2' } }), - store.findRecord('person', '1', { include: 'users' }), - store.findRecord('person', '1', { adapterOptions: { opt: '2' } }), - store.findRecord('person', '1', { include: 'users.foo' }), - store.findRecord('person', '2', { include: 'users.foo' }), - store.findRecord('person', '2', { include: 'users' }), - store.findRecord('person', '2', { include: 'users' }), - store.findRecord('person', '2', { include: '' }), - store.findRecord('person', '2'), store.findRecord('person', '2', { include: 'users' }), - store.findRecord('person', '2', { include: 'users.foo' }), + store.findRecord('person', '3', { include: 'users.foo', adapterOptions: { opt: '1' } }), + store.findRecord('person', '4', { include: ['comments'], adapterOptions: { opt: '2' } }), ]; let records = await all(promises); let foundIdentifiers = records.map((record) => recordIdentifierFor(record)); - let expectedIdentifiers = [ - person1, - person1, - person1, - person1, - person1, - person1, - person1, - person1, - person2, - person2, - person2, - person2, - person2, - person2, - person2, - ]; + let expectedIdentifiers = [person1, person2, person3, person4]; expectedResults = expectedResults.data.map((result) => ({ data: result })); assert.strictEqual(findRecordCalled, 0, 'findRecord is not called'); @@ -306,8 +383,12 @@ module('integration/coalescing - Coalescing Tests', function (hooks) { const person1record = store.peekRecord('person', '1'); const person2record = store.peekRecord('person', '2'); + const person3record = store.peekRecord('person', '3'); + const person4record = store.peekRecord('person', '4'); assert.strictEqual(person1record.firstName, 'Gaurav', 'person 1 loaded'); - assert.strictEqual(person2record.firstName, 'Chris', 'person 2 loaded'); + assert.strictEqual(person2record.firstName, 'Wesley', 'person 2 loaded'); + assert.strictEqual(person3record.firstName, 'James', 'person 3 loaded'); + assert.strictEqual(person4record.firstName, 'Chris', 'person 4 loaded'); }); test('coalesceFindRequests is true and findMany is defined but groupRecordsForFindMany is undefined', async function (assert) { @@ -451,4 +532,223 @@ module('integration/coalescing - Coalescing Tests', function (hooks) { assert.strictEqual(groupRecordsForFindManyCalled, 0, 'groupRecordsForFindMany is not called'); assert.deepEqual(serializedRecords, expectedResults, 'each findRecord returns expected result'); }); + + test('Coalescing accounts for multiple findRecord calls with different options by de-duping and using findRecord', async function (assert) { + let findRecordCalled = 0; + + let { owner } = this; + let store = owner.lookup('service:store'); + const options = [ + undefined, // not de-duped since is first request seen + { reload: true }, // de-dupe + { backgroundReload: true }, // de-dupe + { reload: true, include: 'comments,friends' }, // should produce a request + { reload: true, include: ['friends', 'comments'] }, // de-dupe + { reload: true, include: 'friends,comments' }, // de-dupe + { reload: true, include: 'notFriends,comments' }, // should produce a request + { reload: true, include: 'comments' }, // de-dupe since included in comments,friends + { reload: true, include: 'friends' }, // de-dupe since included in comments,friends + ]; + + class TestFindRecordAdapter extends EmberObject { + coalesceFindRequests = true; + + async findRecord(_store, _schema, _id, snapshot) { + findRecordCalled++; + + if (findRecordCalled === 1) { + assert.strictEqual(snapshot.include, undefined, 'No include for first request'); + assert.strictEqual(snapshot.adapterOptions, undefined, 'No adapterOptions for first request'); + } else if (findRecordCalled === 2) { + assert.strictEqual(snapshot.include, 'comments,friends', 'include is correct for second request'); + assert.strictEqual(snapshot.adapterOptions, undefined, 'No adapterOptions for second request'); + } else if (findRecordCalled === 3) { + assert.strictEqual(snapshot.include, 'notFriends,comments', 'include is correct for third request'); + assert.strictEqual(snapshot.adapterOptions, undefined, 'No adapterOptions for third request'); + } + + return { + data: { + type: 'person', + id: '1', + attributes: { + firstName: 'Gaurav', + lastName: 'Munjal', + }, + }, + }; + } + + async findMany() { + throw new Error(`Expected findMany to not be called`); + } + + groupRecordsForFindMany() { + throw new Error(`Expected groupRecordsForFindMany to not be called`); + } + } + + owner.register('adapter:application', TestFindRecordAdapter); + + const request = store.findRecord('person', '1', options[0]); + const request2 = store.findRecord('person', '1', options[1]); + const request3 = store.findRecord('person', '1', options[2]); + const request4 = store.findRecord('person', '1', options[3]); + const request5 = store.findRecord('person', '1', options[4]); + const request6 = store.findRecord('person', '1', options[5]); + const request7 = store.findRecord('person', '1', options[6]); + const request8 = store.findRecord('person', '1', options[7]); + const request9 = store.findRecord('person', '1', options[8]); + + await all([request, request2, request3, request4, request5, request6, request7, request8, request9]); + + assert.strictEqual(store.peekAll('person').length, 1, 'only one record is in the store'); + + assert.strictEqual(findRecordCalled, 3, 'findRecord is called three times'); + }); + + test('Coalescing accounts for multiple findRecord calls with different options by de-duping and using findRecord (order scenario 2)', async function (assert) { + let findRecordCalled = 0; + + let { owner } = this; + let store = owner.lookup('service:store'); + const options = [ + { include: 'comments' }, // not de-duped since first request + { reload: true, include: 'comments,friends' }, // should produce a request + undefined, // de-dupe + { reload: true }, // de-dupe + { backgroundReload: true }, // de-dupe + { reload: true, include: ['friends', 'comments'] }, // de-dupe + { reload: true, include: 'friends,comments' }, // de-dupe + { reload: true, include: 'notFriends,comments' }, // should produce a request + { reload: true, include: 'friends' }, // de-dupe since included in comments,friends + ]; + + class TestFindRecordAdapter extends EmberObject { + coalesceFindRequests = true; + + async findRecord(_store, _schema, _id, snapshot) { + findRecordCalled++; + + if (findRecordCalled === 1) { + assert.strictEqual(snapshot.include, 'comments', 'include for first request'); + assert.strictEqual(snapshot.adapterOptions, undefined, 'No adapterOptions for first request'); + } else if (findRecordCalled === 2) { + assert.strictEqual(snapshot.include, 'comments,friends', 'include is correct for second request'); + assert.strictEqual(snapshot.adapterOptions, undefined, 'No adapterOptions for second request'); + } else if (findRecordCalled === 3) { + assert.strictEqual(snapshot.include, 'notFriends,comments', 'include is correct for third request'); + assert.strictEqual(snapshot.adapterOptions, undefined, 'No adapterOptions for third request'); + } + + return { + data: { + type: 'person', + id: '1', + attributes: { + firstName: 'Gaurav', + lastName: 'Munjal', + }, + }, + }; + } + + async findMany() { + throw new Error(`Expected findMany to not be called`); + } + + groupRecordsForFindMany() { + throw new Error(`Expected groupRecordsForFindMany to not be called`); + } + } + + owner.register('adapter:application', TestFindRecordAdapter); + + const request = store.findRecord('person', '1', options[0]); + const request2 = store.findRecord('person', '1', options[1]); + const request3 = store.findRecord('person', '1', options[2]); + const request4 = store.findRecord('person', '1', options[3]); + const request5 = store.findRecord('person', '1', options[4]); + const request6 = store.findRecord('person', '1', options[5]); + const request7 = store.findRecord('person', '1', options[6]); + const request8 = store.findRecord('person', '1', options[7]); + const request9 = store.findRecord('person', '1', options[8]); + + await all([request, request2, request3, request4, request5, request6, request7, request8, request9]); + + assert.strictEqual(store.peekAll('person').length, 1, 'only one record is in the store'); + + assert.strictEqual(findRecordCalled, 3, 'findRecord is called three times'); + }); + + test('Coalescing accounts for multiple findRecord calls with different options by de-duping and using findRecord (order scenario 3)', async function (assert) { + let findRecordCalled = 0; + + let { owner } = this; + let store = owner.lookup('service:store'); + const options = [ + { reload: true, include: 'comments,friends' }, // not de-duped since first request + undefined, // de-dupe + { reload: true }, // de-dupe + { backgroundReload: true }, // de-dupe + { reload: true, include: ['friends', 'comments'] }, // de-dupe + { reload: true, include: 'friends,comments' }, // de-dupe + { reload: true, include: 'notFriends,comments' }, // should produce a request + { include: 'comments' }, // de-dupe + { reload: true, include: 'friends' }, // de-dupe since included in comments,friends + ]; + + class TestFindRecordAdapter extends EmberObject { + coalesceFindRequests = true; + + async findRecord(_store, _schema, _id, snapshot) { + findRecordCalled++; + + if (findRecordCalled === 1) { + assert.strictEqual(snapshot.include, 'comments,friends', 'include is correct for second request'); + assert.strictEqual(snapshot.adapterOptions, undefined, 'No adapterOptions for second request'); + } else if (findRecordCalled === 2) { + assert.strictEqual(snapshot.include, 'notFriends,comments', 'include is correct for third request'); + assert.strictEqual(snapshot.adapterOptions, undefined, 'No adapterOptions for third request'); + } + + return { + data: { + type: 'person', + id: '1', + attributes: { + firstName: 'Gaurav', + lastName: 'Munjal', + }, + }, + }; + } + + async findMany() { + throw new Error(`Expected findMany to not be called`); + } + + groupRecordsForFindMany() { + throw new Error(`Expected groupRecordsForFindMany to not be called`); + } + } + + owner.register('adapter:application', TestFindRecordAdapter); + + const request = store.findRecord('person', '1', options[0]); + const request2 = store.findRecord('person', '1', options[1]); + const request3 = store.findRecord('person', '1', options[2]); + const request4 = store.findRecord('person', '1', options[3]); + const request5 = store.findRecord('person', '1', options[4]); + const request6 = store.findRecord('person', '1', options[5]); + const request7 = store.findRecord('person', '1', options[6]); + const request8 = store.findRecord('person', '1', options[7]); + const request9 = store.findRecord('person', '1', options[8]); + + await all([request, request2, request3, request4, request5, request6, request7, request8, request9]); + + assert.strictEqual(store.peekAll('person').length, 1, 'only one record is in the store'); + + assert.strictEqual(findRecordCalled, 2, 'findRecord is called twice'); + }); }); diff --git a/tests/graph/tests/integration/graph/unload-test.ts b/tests/graph/tests/integration/graph/unload-test.ts new file mode 100644 index 00000000000..e90aa7f8849 --- /dev/null +++ b/tests/graph/tests/integration/graph/unload-test.ts @@ -0,0 +1,1094 @@ +import { module, test } from 'qunit'; + +import { setupTest } from 'ember-qunit'; + +import { graphFor } from '@ember-data/graph/-private'; +import type { Graph } from '@ember-data/graph/-private/graph/graph'; +import BelongsToRelationship from '@ember-data/graph/-private/relationships/state/belongs-to'; +import Model, { attr, belongsTo } from '@ember-data/model'; +import type Store from '@ember-data/store'; +import { StableRecordIdentifier } from '@ember-data/types/q/identifier'; + +module('Integration | Graph | Unload', function (hooks) { + setupTest(hooks); + + let store: Store; + let graph: Graph; + hooks.beforeEach(function () { + const { owner } = this; + store = owner.lookup('service:store') as Store; + graph = graphFor(store); + }); + + module('Randomized Chaos', function () { + test('(sync relationships) can separately safely unload related identifiers from the graph', function (assert) { + const { owner } = this; + const { identifierCache } = store; + class User extends Model { + @attr declare name: string; + @belongsTo('user', { async: false, inverse: 'bestFriend' }) declare bestFriend: User | null; + @belongsTo('user', { async: false, inverse: null }) declare worstFriend: User | null; + } + owner.register('model:user', User); + + const identifier = identifierCache.getOrCreateRecordIdentifier({ type: 'user', id: '1' }); + const identifier2 = identifierCache.getOrCreateRecordIdentifier({ type: 'user', id: '2' }); + const identifier3 = identifierCache.getOrCreateRecordIdentifier({ type: 'user', id: '3' }); + + function permutation(order: StableRecordIdentifier[], unloadTogether: boolean) { + store._join(() => { + graph.push({ + op: 'updateRelationship', + record: identifier, + field: 'bestFriend', + value: { data: identifier2 }, + }); + graph.push({ + op: 'updateRelationship', + record: identifier, + field: 'worstFriend', + value: { data: identifier3 }, + }); + }); + + const bestFriend = graph.get(identifier, 'bestFriend') as BelongsToRelationship; + const bestFriend2 = graph.get(identifier2, 'bestFriend') as BelongsToRelationship; + const worstFriend = graph.get(identifier, 'worstFriend') as BelongsToRelationship; + + assert.strictEqual(bestFriend.localState, identifier2, 'precond - bestFriend is set'); + assert.strictEqual(bestFriend.remoteState, identifier2, 'precond - bestFriend is set'); + assert.strictEqual(worstFriend.localState, identifier3, 'precond - worstFriend is set'); + assert.strictEqual(worstFriend.remoteState, identifier3, 'precond - worstFriend is set'); + assert.strictEqual(bestFriend2.localState, identifier, 'precond - bestFriend is set'); + assert.strictEqual(bestFriend2.remoteState, identifier, 'precond - bestFriend is set'); + + if (unloadTogether) { + store._join(() => { + order.forEach((i) => graph.unload(i)); + }); + } else { + order.forEach((i) => { + store._join(() => { + graph.unload(i); + }); + }); + } + assert.ok( + true, + `did not throw when unloading identifiers in ${order.map((i) => i.id).join(',')} order during ${ + unloadTogether ? 'same run' : 'separate runs' + }` + ); + } + + permutation([identifier, identifier2, identifier3], true); + permutation([identifier, identifier3, identifier2], true); + permutation([identifier2, identifier, identifier3], true); + permutation([identifier2, identifier3, identifier], true); + permutation([identifier3, identifier, identifier2], true); + permutation([identifier3, identifier2, identifier], true); + permutation([identifier, identifier2, identifier3], false); + permutation([identifier, identifier3, identifier2], false); + permutation([identifier2, identifier, identifier3], false); + permutation([identifier2, identifier3, identifier], false); + permutation([identifier3, identifier, identifier2], false); + permutation([identifier3, identifier2, identifier], false); + }); + + test('(sync relationships) can separately safely unload related identifiers from the graph following a delete', function (assert) { + const { owner } = this; + const { identifierCache } = store; + class User extends Model { + @attr declare name: string; + @belongsTo('user', { async: false, inverse: 'bestFriend' }) declare bestFriend: User | null; + @belongsTo('user', { async: false, inverse: null }) declare worstFriend: User | null; + } + owner.register('model:user', User); + + const identifier = identifierCache.getOrCreateRecordIdentifier({ type: 'user', id: '1' }); + const identifier2 = identifierCache.getOrCreateRecordIdentifier({ type: 'user', id: '2' }); + const identifier3 = identifierCache.getOrCreateRecordIdentifier({ type: 'user', id: '3' }); + + function permutation(order: StableRecordIdentifier[], unloadTogether: boolean) { + store._join(() => { + graph.push({ + op: 'updateRelationship', + record: identifier, + field: 'bestFriend', + value: { data: identifier2 }, + }); + graph.push({ + op: 'updateRelationship', + record: identifier, + field: 'worstFriend', + value: { data: identifier3 }, + }); + }); + + const bestFriend = graph.get(identifier, 'bestFriend') as BelongsToRelationship; + const bestFriend2 = graph.get(identifier2, 'bestFriend') as BelongsToRelationship; + const worstFriend = graph.get(identifier, 'worstFriend') as BelongsToRelationship; + + assert.strictEqual(bestFriend.localState, identifier2, 'precond - bestFriend is set'); + assert.strictEqual(bestFriend.remoteState, identifier2, 'precond - bestFriend is set'); + assert.strictEqual(worstFriend.localState, identifier3, 'precond - worstFriend is set'); + assert.strictEqual(worstFriend.remoteState, identifier3, 'precond - worstFriend is set'); + assert.strictEqual(bestFriend2.localState, identifier, 'precond - bestFriend is set'); + assert.strictEqual(bestFriend2.remoteState, identifier, 'precond - bestFriend is set'); + + const first = order[0]; + const rest = order.slice(1); + if (unloadTogether) { + store._join(() => { + graph.push({ + op: 'deleteRecord', + record: first, + isNew: false, + }); + rest.forEach((i) => graph.unload(i)); + }); + } else { + store._join(() => { + graph.push({ + op: 'deleteRecord', + record: first, + isNew: false, + }); + }); + rest.forEach((i) => { + store._join(() => { + graph.unload(i); + }); + }); + } + assert.ok( + true, + `did not throw when deleting ${first.id!} then unloading identifiers in ${rest + .map((i) => i.id) + .join(',')} order during ${unloadTogether ? 'same run' : 'separate runs'}` + ); + } + + permutation([identifier, identifier2, identifier3], true); + permutation([identifier, identifier3, identifier2], true); + permutation([identifier2, identifier, identifier3], true); + permutation([identifier2, identifier3, identifier], true); + permutation([identifier3, identifier, identifier2], true); + permutation([identifier3, identifier2, identifier], true); + permutation([identifier, identifier2, identifier3], false); + permutation([identifier, identifier3, identifier2], false); + permutation([identifier2, identifier, identifier3], false); + permutation([identifier2, identifier3, identifier], false); + permutation([identifier3, identifier, identifier2], false); + permutation([identifier3, identifier2, identifier], false); + }); + + test('(sync relationships) can separately safely unload related identifiers from the graph multiple times', function (assert) { + const { owner } = this; + const { identifierCache } = store; + class User extends Model { + @attr declare name: string; + @belongsTo('user', { async: false, inverse: 'bestFriend' }) declare bestFriend: User | null; + @belongsTo('user', { async: false, inverse: null }) declare worstFriend: User | null; + } + owner.register('model:user', User); + + const identifier = identifierCache.getOrCreateRecordIdentifier({ type: 'user', id: '1' }); + const identifier2 = identifierCache.getOrCreateRecordIdentifier({ type: 'user', id: '2' }); + const identifier3 = identifierCache.getOrCreateRecordIdentifier({ type: 'user', id: '3' }); + + function permutation(order: StableRecordIdentifier[], unloadTogether: boolean) { + store._join(() => { + graph.push({ + op: 'updateRelationship', + record: identifier, + field: 'bestFriend', + value: { data: identifier2 }, + }); + graph.push({ + op: 'updateRelationship', + record: identifier, + field: 'worstFriend', + value: { data: identifier3 }, + }); + }); + + const bestFriend = graph.get(identifier, 'bestFriend') as BelongsToRelationship; + const bestFriend2 = graph.get(identifier2, 'bestFriend') as BelongsToRelationship; + const worstFriend = graph.get(identifier, 'worstFriend') as BelongsToRelationship; + + assert.strictEqual(bestFriend.localState, identifier2, 'precond - bestFriend is set'); + assert.strictEqual(bestFriend.remoteState, identifier2, 'precond - bestFriend is set'); + assert.strictEqual(worstFriend.localState, identifier3, 'precond - worstFriend is set'); + assert.strictEqual(worstFriend.remoteState, identifier3, 'precond - worstFriend is set'); + assert.strictEqual(bestFriend2.localState, identifier, 'precond - bestFriend is set'); + assert.strictEqual(bestFriend2.remoteState, identifier, 'precond - bestFriend is set'); + + if (unloadTogether) { + store._join(() => { + order.forEach((i) => graph.unload(i)); + order.forEach((i) => graph.unload(i)); + }); + } else { + order.forEach((i) => { + store._join(() => { + graph.unload(i); + }); + }); + order.forEach((i) => { + store._join(() => { + graph.unload(i); + }); + }); + } + assert.ok( + true, + `did not throw when unloading identifiers in ${order.map((i) => i.id).join(',')} order during ${ + unloadTogether ? 'same run' : 'separate runs' + }` + ); + } + + permutation([identifier, identifier2, identifier3], true); + permutation([identifier, identifier3, identifier2], true); + permutation([identifier2, identifier, identifier3], true); + permutation([identifier2, identifier3, identifier], true); + permutation([identifier3, identifier, identifier2], true); + permutation([identifier3, identifier2, identifier], true); + permutation([identifier, identifier2, identifier3], false); + permutation([identifier, identifier3, identifier2], false); + permutation([identifier2, identifier, identifier3], false); + permutation([identifier2, identifier3, identifier], false); + permutation([identifier3, identifier, identifier2], false); + permutation([identifier3, identifier2, identifier], false); + }); + + test('(sync relationships) can separately safely unload related identifiers from the graph following a delete multiple times', function (assert) { + const { owner } = this; + const { identifierCache } = store; + class User extends Model { + @attr declare name: string; + @belongsTo('user', { async: false, inverse: 'bestFriend' }) declare bestFriend: User | null; + @belongsTo('user', { async: false, inverse: null }) declare worstFriend: User | null; + } + owner.register('model:user', User); + + const identifier = identifierCache.getOrCreateRecordIdentifier({ type: 'user', id: '1' }); + const identifier2 = identifierCache.getOrCreateRecordIdentifier({ type: 'user', id: '2' }); + const identifier3 = identifierCache.getOrCreateRecordIdentifier({ type: 'user', id: '3' }); + + function permutation(order: StableRecordIdentifier[], unloadTogether: boolean) { + store._join(() => { + graph.push({ + op: 'updateRelationship', + record: identifier, + field: 'bestFriend', + value: { data: identifier2 }, + }); + graph.push({ + op: 'updateRelationship', + record: identifier, + field: 'worstFriend', + value: { data: identifier3 }, + }); + }); + + const bestFriend = graph.get(identifier, 'bestFriend') as BelongsToRelationship; + const bestFriend2 = graph.get(identifier2, 'bestFriend') as BelongsToRelationship; + const worstFriend = graph.get(identifier, 'worstFriend') as BelongsToRelationship; + + assert.strictEqual(bestFriend.localState, identifier2, 'precond - bestFriend is set'); + assert.strictEqual(bestFriend.remoteState, identifier2, 'precond - bestFriend is set'); + assert.strictEqual(worstFriend.localState, identifier3, 'precond - worstFriend is set'); + assert.strictEqual(worstFriend.remoteState, identifier3, 'precond - worstFriend is set'); + assert.strictEqual(bestFriend2.localState, identifier, 'precond - bestFriend is set'); + assert.strictEqual(bestFriend2.remoteState, identifier, 'precond - bestFriend is set'); + + const first = order[0]; + const rest = order.slice(1); + if (unloadTogether) { + store._join(() => { + graph.push({ + op: 'deleteRecord', + record: first, + isNew: false, + }); + rest.forEach((i) => graph.unload(i)); + order.forEach((i) => graph.unload(i)); + }); + } else { + store._join(() => { + graph.push({ + op: 'deleteRecord', + record: first, + isNew: false, + }); + }); + rest.forEach((i) => { + store._join(() => { + graph.unload(i); + }); + }); + order.forEach((i) => { + store._join(() => { + graph.unload(i); + }); + }); + } + assert.ok( + true, + `did not throw when deleting ${first.id!} then unloading identifiers in ${rest + .map((i) => i.id) + .join(',')} order during ${unloadTogether ? 'same run' : 'separate runs'}` + ); + } + + permutation([identifier, identifier2, identifier3], true); + permutation([identifier, identifier3, identifier2], true); + permutation([identifier2, identifier, identifier3], true); + permutation([identifier2, identifier3, identifier], true); + permutation([identifier3, identifier, identifier2], true); + permutation([identifier3, identifier2, identifier], true); + permutation([identifier, identifier2, identifier3], false); + permutation([identifier, identifier3, identifier2], false); + permutation([identifier2, identifier, identifier3], false); + permutation([identifier2, identifier3, identifier], false); + permutation([identifier3, identifier, identifier2], false); + permutation([identifier3, identifier2, identifier], false); + }); + + test('(Async relationships) can separately safely unload related identifiers from the graph', function (assert) { + const { owner } = this; + const { identifierCache } = store; + class User extends Model { + @attr declare name: string; + @belongsTo('user', { async: true, inverse: 'bestFriend' }) declare bestFriend: User | null; + @belongsTo('user', { async: true, inverse: null }) declare worstFriend: User | null; + } + owner.register('model:user', User); + + const identifier = identifierCache.getOrCreateRecordIdentifier({ type: 'user', id: '1' }); + const identifier2 = identifierCache.getOrCreateRecordIdentifier({ type: 'user', id: '2' }); + const identifier3 = identifierCache.getOrCreateRecordIdentifier({ type: 'user', id: '3' }); + + function permutation(order: StableRecordIdentifier[], unloadTogether: boolean) { + store._join(() => { + graph.push({ + op: 'updateRelationship', + record: identifier, + field: 'bestFriend', + value: { data: identifier2 }, + }); + graph.push({ + op: 'updateRelationship', + record: identifier, + field: 'worstFriend', + value: { data: identifier3 }, + }); + }); + + const bestFriend = graph.get(identifier, 'bestFriend') as BelongsToRelationship; + const bestFriend2 = graph.get(identifier2, 'bestFriend') as BelongsToRelationship; + const worstFriend = graph.get(identifier, 'worstFriend') as BelongsToRelationship; + + assert.strictEqual(bestFriend.localState, identifier2, 'precond - bestFriend is set'); + assert.strictEqual(bestFriend.remoteState, identifier2, 'precond - bestFriend is set'); + assert.strictEqual(worstFriend.localState, identifier3, 'precond - worstFriend is set'); + assert.strictEqual(worstFriend.remoteState, identifier3, 'precond - worstFriend is set'); + assert.strictEqual(bestFriend2.localState, identifier, 'precond - bestFriend is set'); + assert.strictEqual(bestFriend2.remoteState, identifier, 'precond - bestFriend is set'); + + if (unloadTogether) { + store._join(() => { + order.forEach((i) => graph.unload(i)); + }); + } else { + order.forEach((i) => { + store._join(() => { + graph.unload(i); + }); + }); + } + assert.ok( + true, + `did not throw when unloading identifiers in ${order.map((i) => i.id).join(',')} order during ${ + unloadTogether ? 'same run' : 'separate runs' + }` + ); + } + + permutation([identifier, identifier2, identifier3], true); + permutation([identifier, identifier3, identifier2], true); + permutation([identifier2, identifier, identifier3], true); + permutation([identifier2, identifier3, identifier], true); + permutation([identifier3, identifier, identifier2], true); + permutation([identifier3, identifier2, identifier], true); + permutation([identifier, identifier2, identifier3], false); + permutation([identifier, identifier3, identifier2], false); + permutation([identifier2, identifier, identifier3], false); + permutation([identifier2, identifier3, identifier], false); + permutation([identifier3, identifier, identifier2], false); + permutation([identifier3, identifier2, identifier], false); + }); + + test('(Async relationships) can separately safely unload related identifiers from the graph following a delete', function (assert) { + const { owner } = this; + const { identifierCache } = store; + class User extends Model { + @attr declare name: string; + @belongsTo('user', { async: true, inverse: 'bestFriend' }) declare bestFriend: User | null; + @belongsTo('user', { async: true, inverse: null }) declare worstFriend: User | null; + } + owner.register('model:user', User); + + const identifier = identifierCache.getOrCreateRecordIdentifier({ type: 'user', id: '1' }); + const identifier2 = identifierCache.getOrCreateRecordIdentifier({ type: 'user', id: '2' }); + const identifier3 = identifierCache.getOrCreateRecordIdentifier({ type: 'user', id: '3' }); + + function permutation(order: StableRecordIdentifier[], unloadTogether: boolean) { + store._join(() => { + graph.push({ + op: 'updateRelationship', + record: identifier, + field: 'bestFriend', + value: { data: identifier2 }, + }); + graph.push({ + op: 'updateRelationship', + record: identifier, + field: 'worstFriend', + value: { data: identifier3 }, + }); + }); + + const bestFriend = graph.get(identifier, 'bestFriend') as BelongsToRelationship; + const bestFriend2 = graph.get(identifier2, 'bestFriend') as BelongsToRelationship; + const worstFriend = graph.get(identifier, 'worstFriend') as BelongsToRelationship; + + assert.strictEqual(bestFriend.localState, identifier2, 'precond - bestFriend is set'); + assert.strictEqual(bestFriend.remoteState, identifier2, 'precond - bestFriend is set'); + assert.strictEqual(worstFriend.localState, identifier3, 'precond - worstFriend is set'); + assert.strictEqual(worstFriend.remoteState, identifier3, 'precond - worstFriend is set'); + assert.strictEqual(bestFriend2.localState, identifier, 'precond - bestFriend is set'); + assert.strictEqual(bestFriend2.remoteState, identifier, 'precond - bestFriend is set'); + + const first = order[0]; + const rest = order.slice(1); + if (unloadTogether) { + store._join(() => { + graph.push({ + op: 'deleteRecord', + record: first, + isNew: false, + }); + rest.forEach((i) => graph.unload(i)); + }); + } else { + store._join(() => { + graph.push({ + op: 'deleteRecord', + record: first, + isNew: false, + }); + }); + rest.forEach((i) => { + store._join(() => { + graph.unload(i); + }); + }); + } + assert.ok( + true, + `did not throw when deleting ${first.id!} then unloading identifiers in ${rest + .map((i) => i.id) + .join(',')} order during ${unloadTogether ? 'same run' : 'separate runs'}` + ); + } + + permutation([identifier, identifier2, identifier3], true); + permutation([identifier, identifier3, identifier2], true); + permutation([identifier2, identifier, identifier3], true); + permutation([identifier2, identifier3, identifier], true); + permutation([identifier3, identifier, identifier2], true); + permutation([identifier3, identifier2, identifier], true); + permutation([identifier, identifier2, identifier3], false); + permutation([identifier, identifier3, identifier2], false); + permutation([identifier2, identifier, identifier3], false); + permutation([identifier2, identifier3, identifier], false); + permutation([identifier3, identifier, identifier2], false); + permutation([identifier3, identifier2, identifier], false); + }); + + test('(Async relationships) can separately safely unload related identifiers from the graph multiple times', function (assert) { + const { owner } = this; + const { identifierCache } = store; + class User extends Model { + @attr declare name: string; + @belongsTo('user', { async: true, inverse: 'bestFriend' }) declare bestFriend: User | null; + @belongsTo('user', { async: true, inverse: null }) declare worstFriend: User | null; + } + owner.register('model:user', User); + + const identifier = identifierCache.getOrCreateRecordIdentifier({ type: 'user', id: '1' }); + const identifier2 = identifierCache.getOrCreateRecordIdentifier({ type: 'user', id: '2' }); + const identifier3 = identifierCache.getOrCreateRecordIdentifier({ type: 'user', id: '3' }); + + function permutation(order: StableRecordIdentifier[], unloadTogether: boolean) { + store._join(() => { + graph.push({ + op: 'updateRelationship', + record: identifier, + field: 'bestFriend', + value: { data: identifier2 }, + }); + graph.push({ + op: 'updateRelationship', + record: identifier, + field: 'worstFriend', + value: { data: identifier3 }, + }); + }); + + const bestFriend = graph.get(identifier, 'bestFriend') as BelongsToRelationship; + const bestFriend2 = graph.get(identifier2, 'bestFriend') as BelongsToRelationship; + const worstFriend = graph.get(identifier, 'worstFriend') as BelongsToRelationship; + + assert.strictEqual(bestFriend.localState, identifier2, 'precond - bestFriend is set'); + assert.strictEqual(bestFriend.remoteState, identifier2, 'precond - bestFriend is set'); + assert.strictEqual(worstFriend.localState, identifier3, 'precond - worstFriend is set'); + assert.strictEqual(worstFriend.remoteState, identifier3, 'precond - worstFriend is set'); + assert.strictEqual(bestFriend2.localState, identifier, 'precond - bestFriend is set'); + assert.strictEqual(bestFriend2.remoteState, identifier, 'precond - bestFriend is set'); + + if (unloadTogether) { + store._join(() => { + order.forEach((i) => graph.unload(i)); + order.forEach((i) => graph.unload(i)); + }); + } else { + order.forEach((i) => { + store._join(() => { + graph.unload(i); + }); + }); + order.forEach((i) => { + store._join(() => { + graph.unload(i); + }); + }); + } + assert.ok( + true, + `did not throw when unloading identifiers in ${order.map((i) => i.id).join(',')} order during ${ + unloadTogether ? 'same run' : 'separate runs' + }` + ); + } + + permutation([identifier, identifier2, identifier3], true); + permutation([identifier, identifier3, identifier2], true); + permutation([identifier2, identifier, identifier3], true); + permutation([identifier2, identifier3, identifier], true); + permutation([identifier3, identifier, identifier2], true); + permutation([identifier3, identifier2, identifier], true); + permutation([identifier, identifier2, identifier3], false); + permutation([identifier, identifier3, identifier2], false); + permutation([identifier2, identifier, identifier3], false); + permutation([identifier2, identifier3, identifier], false); + permutation([identifier3, identifier, identifier2], false); + permutation([identifier3, identifier2, identifier], false); + }); + + test('(Async relationships) can separately safely unload related identifiers from the graph following a delete multiple times', function (assert) { + const { owner } = this; + const { identifierCache } = store; + class User extends Model { + @attr declare name: string; + @belongsTo('user', { async: true, inverse: 'bestFriend' }) declare bestFriend: User | null; + @belongsTo('user', { async: true, inverse: null }) declare worstFriend: User | null; + } + owner.register('model:user', User); + + const identifier = identifierCache.getOrCreateRecordIdentifier({ type: 'user', id: '1' }); + const identifier2 = identifierCache.getOrCreateRecordIdentifier({ type: 'user', id: '2' }); + const identifier3 = identifierCache.getOrCreateRecordIdentifier({ type: 'user', id: '3' }); + + function permutation(order: StableRecordIdentifier[], unloadTogether: boolean) { + store._join(() => { + graph.push({ + op: 'updateRelationship', + record: identifier, + field: 'bestFriend', + value: { data: identifier2 }, + }); + graph.push({ + op: 'updateRelationship', + record: identifier, + field: 'worstFriend', + value: { data: identifier3 }, + }); + }); + + const bestFriend = graph.get(identifier, 'bestFriend') as BelongsToRelationship; + const bestFriend2 = graph.get(identifier2, 'bestFriend') as BelongsToRelationship; + const worstFriend = graph.get(identifier, 'worstFriend') as BelongsToRelationship; + + assert.strictEqual(bestFriend.localState, identifier2, 'precond - bestFriend is set'); + assert.strictEqual(bestFriend.remoteState, identifier2, 'precond - bestFriend is set'); + assert.strictEqual(worstFriend.localState, identifier3, 'precond - worstFriend is set'); + assert.strictEqual(worstFriend.remoteState, identifier3, 'precond - worstFriend is set'); + assert.strictEqual(bestFriend2.localState, identifier, 'precond - bestFriend is set'); + assert.strictEqual(bestFriend2.remoteState, identifier, 'precond - bestFriend is set'); + + const first = order[0]; + const rest = order.slice(1); + if (unloadTogether) { + store._join(() => { + graph.push({ + op: 'deleteRecord', + record: first, + isNew: false, + }); + rest.forEach((i) => graph.unload(i)); + order.forEach((i) => graph.unload(i)); + }); + } else { + store._join(() => { + graph.push({ + op: 'deleteRecord', + record: first, + isNew: false, + }); + }); + rest.forEach((i) => { + store._join(() => { + graph.unload(i); + }); + }); + order.forEach((i) => { + store._join(() => { + graph.unload(i); + }); + }); + } + assert.ok( + true, + `did not throw when deleting ${first.id!} then unloading identifiers in ${rest + .map((i) => i.id) + .join(',')} order during ${unloadTogether ? 'same run' : 'separate runs'}` + ); + } + + permutation([identifier, identifier2, identifier3], true); + permutation([identifier, identifier3, identifier2], true); + permutation([identifier2, identifier, identifier3], true); + permutation([identifier2, identifier3, identifier], true); + permutation([identifier3, identifier, identifier2], true); + permutation([identifier3, identifier2, identifier], true); + permutation([identifier, identifier2, identifier3], false); + permutation([identifier, identifier3, identifier2], false); + permutation([identifier2, identifier, identifier3], false); + permutation([identifier2, identifier3, identifier], false); + permutation([identifier3, identifier, identifier2], false); + permutation([identifier3, identifier2, identifier], false); + }); + + test('(Mixed relationships) can separately safely unload related identifiers from the graph', function (assert) { + const { owner } = this; + const { identifierCache } = store; + class User extends Model { + @attr declare name: string; + @belongsTo('user', { async: false, inverse: 'bestFriend' }) declare bestFriend: User | null; + @belongsTo('user', { async: true, inverse: null }) declare worstFriend: User | null; + } + owner.register('model:user', User); + + const identifier = identifierCache.getOrCreateRecordIdentifier({ type: 'user', id: '1' }); + const identifier2 = identifierCache.getOrCreateRecordIdentifier({ type: 'user', id: '2' }); + const identifier3 = identifierCache.getOrCreateRecordIdentifier({ type: 'user', id: '3' }); + + function permutation(order: StableRecordIdentifier[], unloadTogether: boolean) { + store._join(() => { + graph.push({ + op: 'updateRelationship', + record: identifier, + field: 'bestFriend', + value: { data: identifier2 }, + }); + graph.push({ + op: 'updateRelationship', + record: identifier, + field: 'worstFriend', + value: { data: identifier3 }, + }); + }); + + const bestFriend = graph.get(identifier, 'bestFriend') as BelongsToRelationship; + const bestFriend2 = graph.get(identifier2, 'bestFriend') as BelongsToRelationship; + const worstFriend = graph.get(identifier, 'worstFriend') as BelongsToRelationship; + + assert.strictEqual(bestFriend.localState, identifier2, 'precond - bestFriend is set'); + assert.strictEqual(bestFriend.remoteState, identifier2, 'precond - bestFriend is set'); + assert.strictEqual(worstFriend.localState, identifier3, 'precond - worstFriend is set'); + assert.strictEqual(worstFriend.remoteState, identifier3, 'precond - worstFriend is set'); + assert.strictEqual(bestFriend2.localState, identifier, 'precond - bestFriend is set'); + assert.strictEqual(bestFriend2.remoteState, identifier, 'precond - bestFriend is set'); + + if (unloadTogether) { + store._join(() => { + order.forEach((i) => graph.unload(i)); + }); + } else { + order.forEach((i) => { + store._join(() => { + graph.unload(i); + }); + }); + } + assert.ok( + true, + `did not throw when unloading identifiers in ${order.map((i) => i.id).join(',')} order during ${ + unloadTogether ? 'same run' : 'separate runs' + }` + ); + } + + permutation([identifier, identifier2, identifier3], true); + permutation([identifier, identifier3, identifier2], true); + permutation([identifier2, identifier, identifier3], true); + permutation([identifier2, identifier3, identifier], true); + permutation([identifier3, identifier, identifier2], true); + permutation([identifier3, identifier2, identifier], true); + permutation([identifier, identifier2, identifier3], false); + permutation([identifier, identifier3, identifier2], false); + permutation([identifier2, identifier, identifier3], false); + permutation([identifier2, identifier3, identifier], false); + permutation([identifier3, identifier, identifier2], false); + permutation([identifier3, identifier2, identifier], false); + }); + + test('(Mixed relationships) can separately safely unload related identifiers from the graph following a delete', function (assert) { + const { owner } = this; + const { identifierCache } = store; + class User extends Model { + @attr declare name: string; + @belongsTo('user', { async: false, inverse: 'bestFriend' }) declare bestFriend: User | null; + @belongsTo('user', { async: true, inverse: null }) declare worstFriend: User | null; + } + owner.register('model:user', User); + + const identifier = identifierCache.getOrCreateRecordIdentifier({ type: 'user', id: '1' }); + const identifier2 = identifierCache.getOrCreateRecordIdentifier({ type: 'user', id: '2' }); + const identifier3 = identifierCache.getOrCreateRecordIdentifier({ type: 'user', id: '3' }); + + function permutation(order: StableRecordIdentifier[], unloadTogether: boolean) { + store._join(() => { + graph.push({ + op: 'updateRelationship', + record: identifier, + field: 'bestFriend', + value: { data: identifier2 }, + }); + graph.push({ + op: 'updateRelationship', + record: identifier, + field: 'worstFriend', + value: { data: identifier3 }, + }); + }); + + const bestFriend = graph.get(identifier, 'bestFriend') as BelongsToRelationship; + const bestFriend2 = graph.get(identifier2, 'bestFriend') as BelongsToRelationship; + const worstFriend = graph.get(identifier, 'worstFriend') as BelongsToRelationship; + + assert.strictEqual(bestFriend.localState, identifier2, 'precond - bestFriend is set'); + assert.strictEqual(bestFriend.remoteState, identifier2, 'precond - bestFriend is set'); + assert.strictEqual(worstFriend.localState, identifier3, 'precond - worstFriend is set'); + assert.strictEqual(worstFriend.remoteState, identifier3, 'precond - worstFriend is set'); + assert.strictEqual(bestFriend2.localState, identifier, 'precond - bestFriend is set'); + assert.strictEqual(bestFriend2.remoteState, identifier, 'precond - bestFriend is set'); + + const first = order[0]; + const rest = order.slice(1); + if (unloadTogether) { + store._join(() => { + graph.push({ + op: 'deleteRecord', + record: first, + isNew: false, + }); + rest.forEach((i) => graph.unload(i)); + }); + } else { + store._join(() => { + graph.push({ + op: 'deleteRecord', + record: first, + isNew: false, + }); + }); + rest.forEach((i) => { + store._join(() => { + graph.unload(i); + }); + }); + } + assert.ok( + true, + `did not throw when deleting ${first.id!} then unloading identifiers in ${rest + .map((i) => i.id) + .join(',')} order during ${unloadTogether ? 'same run' : 'separate runs'}` + ); + } + + permutation([identifier, identifier2, identifier3], true); + permutation([identifier, identifier3, identifier2], true); + permutation([identifier2, identifier, identifier3], true); + permutation([identifier2, identifier3, identifier], true); + permutation([identifier3, identifier, identifier2], true); + permutation([identifier3, identifier2, identifier], true); + permutation([identifier, identifier2, identifier3], false); + permutation([identifier, identifier3, identifier2], false); + permutation([identifier2, identifier, identifier3], false); + permutation([identifier2, identifier3, identifier], false); + permutation([identifier3, identifier, identifier2], false); + permutation([identifier3, identifier2, identifier], false); + }); + + test('(Mixed relationships) can separately safely unload related identifiers from the graph multiple times', function (assert) { + const { owner } = this; + const { identifierCache } = store; + class User extends Model { + @attr declare name: string; + @belongsTo('user', { async: false, inverse: 'bestFriend' }) declare bestFriend: User | null; + @belongsTo('user', { async: true, inverse: null }) declare worstFriend: User | null; + } + owner.register('model:user', User); + + const identifier = identifierCache.getOrCreateRecordIdentifier({ type: 'user', id: '1' }); + const identifier2 = identifierCache.getOrCreateRecordIdentifier({ type: 'user', id: '2' }); + const identifier3 = identifierCache.getOrCreateRecordIdentifier({ type: 'user', id: '3' }); + + function permutation(order: StableRecordIdentifier[], unloadTogether: boolean) { + store._join(() => { + graph.push({ + op: 'updateRelationship', + record: identifier, + field: 'bestFriend', + value: { data: identifier2 }, + }); + graph.push({ + op: 'updateRelationship', + record: identifier, + field: 'worstFriend', + value: { data: identifier3 }, + }); + }); + + const bestFriend = graph.get(identifier, 'bestFriend') as BelongsToRelationship; + const bestFriend2 = graph.get(identifier2, 'bestFriend') as BelongsToRelationship; + const worstFriend = graph.get(identifier, 'worstFriend') as BelongsToRelationship; + + assert.strictEqual(bestFriend.localState, identifier2, 'precond - bestFriend is set'); + assert.strictEqual(bestFriend.remoteState, identifier2, 'precond - bestFriend is set'); + assert.strictEqual(worstFriend.localState, identifier3, 'precond - worstFriend is set'); + assert.strictEqual(worstFriend.remoteState, identifier3, 'precond - worstFriend is set'); + assert.strictEqual(bestFriend2.localState, identifier, 'precond - bestFriend is set'); + assert.strictEqual(bestFriend2.remoteState, identifier, 'precond - bestFriend is set'); + + if (unloadTogether) { + store._join(() => { + order.forEach((i) => graph.unload(i)); + order.forEach((i) => graph.unload(i)); + }); + } else { + order.forEach((i) => { + store._join(() => { + graph.unload(i); + }); + }); + order.forEach((i) => { + store._join(() => { + graph.unload(i); + }); + }); + } + assert.ok( + true, + `did not throw when unloading identifiers in ${order.map((i) => i.id).join(',')} order during ${ + unloadTogether ? 'same run' : 'separate runs' + }` + ); + } + + permutation([identifier, identifier2, identifier3], true); + permutation([identifier, identifier3, identifier2], true); + permutation([identifier2, identifier, identifier3], true); + permutation([identifier2, identifier3, identifier], true); + permutation([identifier3, identifier, identifier2], true); + permutation([identifier3, identifier2, identifier], true); + permutation([identifier, identifier2, identifier3], false); + permutation([identifier, identifier3, identifier2], false); + permutation([identifier2, identifier, identifier3], false); + permutation([identifier2, identifier3, identifier], false); + permutation([identifier3, identifier, identifier2], false); + permutation([identifier3, identifier2, identifier], false); + }); + + test('(Mixed relationships) can separately safely unload related identifiers from the graph following a delete multiple times', function (assert) { + const { owner } = this; + const { identifierCache } = store; + class User extends Model { + @attr declare name: string; + @belongsTo('user', { async: false, inverse: 'bestFriend' }) declare bestFriend: User | null; + @belongsTo('user', { async: true, inverse: null }) declare worstFriend: User | null; + } + owner.register('model:user', User); + + const identifier = identifierCache.getOrCreateRecordIdentifier({ type: 'user', id: '1' }); + const identifier2 = identifierCache.getOrCreateRecordIdentifier({ type: 'user', id: '2' }); + const identifier3 = identifierCache.getOrCreateRecordIdentifier({ type: 'user', id: '3' }); + + function permutation(order: StableRecordIdentifier[], unloadTogether: boolean) { + store._join(() => { + graph.push({ + op: 'updateRelationship', + record: identifier, + field: 'bestFriend', + value: { data: identifier2 }, + }); + graph.push({ + op: 'updateRelationship', + record: identifier, + field: 'worstFriend', + value: { data: identifier3 }, + }); + }); + + const bestFriend = graph.get(identifier, 'bestFriend') as BelongsToRelationship; + const bestFriend2 = graph.get(identifier2, 'bestFriend') as BelongsToRelationship; + const worstFriend = graph.get(identifier, 'worstFriend') as BelongsToRelationship; + + assert.strictEqual(bestFriend.localState, identifier2, 'precond - bestFriend is set'); + assert.strictEqual(bestFriend.remoteState, identifier2, 'precond - bestFriend is set'); + assert.strictEqual(worstFriend.localState, identifier3, 'precond - worstFriend is set'); + assert.strictEqual(worstFriend.remoteState, identifier3, 'precond - worstFriend is set'); + assert.strictEqual(bestFriend2.localState, identifier, 'precond - bestFriend is set'); + assert.strictEqual(bestFriend2.remoteState, identifier, 'precond - bestFriend is set'); + + const first = order[0]; + const rest = order.slice(1); + if (unloadTogether) { + store._join(() => { + graph.push({ + op: 'deleteRecord', + record: first, + isNew: false, + }); + rest.forEach((i) => graph.unload(i)); + order.forEach((i) => graph.unload(i)); + }); + } else { + store._join(() => { + graph.push({ + op: 'deleteRecord', + record: first, + isNew: false, + }); + }); + rest.forEach((i) => { + store._join(() => { + graph.unload(i); + }); + }); + order.forEach((i) => { + store._join(() => { + graph.unload(i); + }); + }); + } + assert.ok( + true, + `did not throw when deleting ${first.id!} then unloading identifiers in ${rest + .map((i) => i.id) + .join(',')} order during ${unloadTogether ? 'same run' : 'separate runs'}` + ); + } + + permutation([identifier, identifier2, identifier3], true); + permutation([identifier, identifier3, identifier2], true); + permutation([identifier2, identifier, identifier3], true); + permutation([identifier2, identifier3, identifier], true); + permutation([identifier3, identifier, identifier2], true); + permutation([identifier3, identifier2, identifier], true); + permutation([identifier, identifier2, identifier3], false); + permutation([identifier, identifier3, identifier2], false); + permutation([identifier2, identifier, identifier3], false); + permutation([identifier2, identifier3, identifier], false); + permutation([identifier3, identifier, identifier2], false); + permutation([identifier3, identifier2, identifier], false); + }); + }); + + module('Specific Scenarios', function () { + test('Unload of a record with a deleted implicitly related record', function (assert) { + const { owner } = this; + const { identifierCache } = store; + class User extends Model { + @attr declare name: string; + @belongsTo('user', { async: false, inverse: null }) declare bestFriend: User | null; + @belongsTo('user', { async: true, inverse: null }) declare worstFriend: User | null; + } + owner.register('model:user', User); + + const identifier = identifierCache.getOrCreateRecordIdentifier({ type: 'user', id: '1' }); + const identifier2 = identifierCache.getOrCreateRecordIdentifier({ type: 'user', id: '2' }); + const identifier3 = identifierCache.getOrCreateRecordIdentifier({ type: 'user', id: '3' }); + + store._join(() => { + graph.push({ + op: 'updateRelationship', + record: identifier2, + field: 'bestFriend', + value: { data: identifier }, + }); + graph.push({ + op: 'updateRelationship', + record: identifier3, + field: 'worstFriend', + value: { data: identifier }, + }); + }); + + const bestFriend = graph.get(identifier, 'bestFriend') as BelongsToRelationship; + const bestFriend2 = graph.get(identifier2, 'bestFriend') as BelongsToRelationship; + const worstFriend3 = graph.get(identifier3, 'worstFriend') as BelongsToRelationship; + + assert.strictEqual(bestFriend2.localState, identifier, 'precond - bestFriend is set'); + assert.strictEqual(bestFriend2.remoteState, identifier, 'precond - bestFriend is set'); + assert.strictEqual(worstFriend3.localState, identifier, 'precond - worstFriend is set'); + assert.strictEqual(worstFriend3.remoteState, identifier, 'precond - worstFriend is set'); + assert.strictEqual(bestFriend.localState, null, 'precond - bestFriend is not set'); + assert.strictEqual(bestFriend.remoteState, null, 'precond - bestFriend is not set'); + + store._join(() => { + graph.push({ + op: 'deleteRecord', + record: identifier2, + isNew: false, + }); + graph.push({ + op: 'deleteRecord', + record: identifier3, + isNew: false, + }); + }); + + store._join(() => { + graph.unload(identifier); + }); + + assert.ok(true, 'did not throw when unloading identifier'); + }); + }); +}); diff --git a/tests/main/tests/integration/record-array-manager-test.js b/tests/main/tests/integration/record-array-manager-test.js index 29535dd08b2..1d13fb62840 100644 --- a/tests/main/tests/integration/record-array-manager-test.js +++ b/tests/main/tests/integration/record-array-manager-test.js @@ -79,26 +79,26 @@ module('integration/record_array_manager', function (hooks) { let adapterPopulated = manager.createArray({ type: 'person', query }); let identifier = recordIdentifierFor(person); - assert.false(all.isDestroyed, 'initial: no calls to all.willDestroy'); - assert.false(adapterPopulated.isDestroyed, 'initial: no calls to adapterPopulated.willDestroy'); + assert.false(all.isDestroyed, 'initial: LiveArray is not destroyed'); + assert.false(adapterPopulated.isDestroyed, 'initial: Collection is not destroyed'); assert.strictEqual( manager._identifiers.get(identifier)?.size, undefined, - 'initial: expected the person to be a member of 0 AdapterPopulatedRecordArrays' + 'initial: expected the person to be a member of 0 Collections' ); assert.true(manager._live.has('person'), 'initial: we have a live array for person'); manager.destroy(); await settled(); - assert.true(all.isDestroyed, 'all.willDestroy called once'); + assert.true(all.isDestroyed, 'LiveArray is destroyed'); assert.false(manager._live.has('person'), 'no longer have a live array for person'); assert.strictEqual( manager._identifiers.get(identifier)?.size, undefined, 'expected the person to be a member of no recordArrays' ); - assert.true(adapterPopulated.isDestroyed, 'adapterPopulated.willDestroy called once'); + assert.true(adapterPopulated.isDestroyed, 'Collection is destroyed'); }); test('#GH-4041 store#query AdapterPopulatedRecordArrays are removed from their managers instead of retained when #destroy is called', async function (assert) { diff --git a/tests/main/tests/integration/records/new-record-unload-test.js b/tests/main/tests/integration/records/new-record-unload-test.js new file mode 100644 index 00000000000..f5a9f23548b --- /dev/null +++ b/tests/main/tests/integration/records/new-record-unload-test.js @@ -0,0 +1,358 @@ +import { settled } from '@ember/test-helpers'; + +import { module, test } from 'qunit'; + +import { setupTest } from 'ember-qunit'; + +import Model, { attr, hasMany } from '@ember-data/model'; +import testInDebug from '@ember-data/unpublished-test-infra/test-support/test-in-debug'; + +class Person extends Model { + @attr name; + @hasMany('person', { async: true, inverse: 'friends' }) friends; +} + +module('Integration | Records | New Record Unload', function (hooks) { + setupTest(hooks); + + hooks.beforeEach(function () { + this.owner.register('model:person', Person); + }); + + test('Rolling Back Attributes on a New Record unloads that record safely', async function (assert) { + const store = this.owner.lookup('service:store'); + const Matt = store.push({ + data: { + type: 'person', + id: '1', + attributes: { + name: 'Matthew Seidel', + }, + relationships: { + friends: { + data: [], + }, + }, + }, + }); + let Pat = store.createRecord('person', { name: 'Patrick Wachter' }); + const friends = Matt.hasMany('friends').value(); + friends.push(Pat); + let people = store.peekAll('person'); + + assert.strictEqual(friends.length, 1, 'Matt has friends'); + assert.strictEqual(people.length, 2, 'precond - two people records in the store'); + assert.true(Pat.hasDirtyAttributes, 'precond - record has dirty attributes'); + assert.true(Pat.isNew, 'precond - record is new'); + + Pat.rollbackAttributes(); + + assert.false(Pat.isDestroyed, 'record is not yet destroyed'); + assert.true(Pat.isDestroying, 'record is destroying'); + assert.strictEqual(friends.length, 0, 'Matt has no friends'); + assert.strictEqual(people.length, 1, 'precond - one person left in the store'); + + await settled(); + + assert.true(Pat.isDestroyed, 'record is destroyed'); + assert.true(Pat.isDestroying, 'record is destroying'); + assert.strictEqual(friends.length, 0, 'Matt has no friends'); + assert.strictEqual(people.length, 1, 'precond - one person left in the store'); + }); + + test('Unload on a New Record unloads that record safely', async function (assert) { + const store = this.owner.lookup('service:store'); + const Matt = store.push({ + data: { + type: 'person', + id: '1', + attributes: { + name: 'Matthew Seidel', + }, + relationships: { + friends: { + data: [], + }, + }, + }, + }); + let Pat = store.createRecord('person', { name: 'Patrick Wachter' }); + const friends = Matt.hasMany('friends').value(); + friends.push(Pat); + let people = store.peekAll('person'); + + assert.strictEqual(friends.length, 1, 'Matt has friends'); + assert.strictEqual(people.length, 2, 'precond - two people records in the store'); + assert.true(Pat.hasDirtyAttributes, 'precond - record has dirty attributes'); + assert.true(Pat.isNew, 'precond - record is new'); + + Pat.unloadRecord(); + + assert.false(Pat.isDestroyed, 'record is not yet destroyed'); + assert.true(Pat.isDestroying, 'record is destroying'); + assert.strictEqual(friends.length, 0, 'Matt has no friends'); + assert.strictEqual(people.length, 1, 'precond - one person left in the store'); + + await settled(); + + assert.true(Pat.isDestroyed, 'record is destroyed'); + assert.true(Pat.isDestroying, 'record is destroying'); + assert.strictEqual(friends.length, 0, 'Matt has no friends'); + assert.strictEqual(people.length, 1, 'precond - one person left in the store'); + }); + + testInDebug('Unload after a save that failed for missing data is safe', async function (assert) { + const store = this.owner.lookup('service:store'); + const adapter = store.adapterFor('application'); + adapter.createRecord = () => Promise.resolve({ data: null }); + const Matt = store.push({ + data: { + type: 'person', + id: '1', + attributes: { + name: 'Matthew Seidel', + }, + relationships: { + friends: { + data: [], + }, + }, + }, + }); + let Pat = store.createRecord('person', { name: 'Patrick Wachter' }); + const friends = Matt.hasMany('friends').value(); + friends.push(Pat); + let people = store.peekAll('person'); + + assert.strictEqual(friends.length, 1, 'Matt has friends'); + assert.strictEqual(people.length, 2, 'precond - two people records in the store'); + assert.true(Pat.hasDirtyAttributes, 'precond - record has dirty attributes'); + assert.true(Pat.isNew, 'precond - record is new'); + + try { + await Pat.save(); + assert.ok(false, 'save failed'); + } catch (e) { + assert.ok(true, 'save failed'); + } + + Pat.unloadRecord(); + + assert.false(Pat.isDestroyed, 'after unload (sync): record is not yet destroyed'); + assert.true(Pat.isDestroying, 'after unload (sync): record is destroying'); + assert.strictEqual(friends.length, 0, 'after unload (sync): Matt has no friends'); + assert.strictEqual(people.length, 1, 'after unload (sync): one person left in the store'); + + await settled(); + + assert.true(Pat.isDestroyed, 'final: record is destroyed'); + assert.true(Pat.isDestroying, 'final: record is destroying'); + assert.strictEqual(friends.length, 0, 'final: Matt has no friends'); + assert.strictEqual(people.length, 1, 'final: one person left in the store'); + }); + + testInDebug('Unload after a save that failed for missing id is safe', async function (assert) { + const store = this.owner.lookup('service:store'); + const adapter = store.adapterFor('application'); + adapter.createRecord = () => Promise.resolve({ data: { type: 'person' } }); + const Matt = store.push({ + data: { + type: 'person', + id: '1', + attributes: { + name: 'Matthew Seidel', + }, + relationships: { + friends: { + data: [], + }, + }, + }, + }); + let Pat = store.createRecord('person', { name: 'Patrick Wachter' }); + const friends = Matt.hasMany('friends').value(); + friends.push(Pat); + let people = store.peekAll('person'); + + assert.strictEqual(friends.length, 1, 'Matt has friends'); + assert.strictEqual(people.length, 2, 'precond - two people records in the store'); + assert.true(Pat.hasDirtyAttributes, 'precond - record has dirty attributes'); + assert.true(Pat.isNew, 'precond - record is new'); + + try { + await Pat.save(); + assert.ok(false, 'save failed'); + } catch (e) { + assert.ok(true, 'save failed'); + } + + Pat.unloadRecord(); + + assert.false(Pat.isDestroyed, 'after unload (sync): record is not yet destroyed'); + assert.true(Pat.isDestroying, 'after unload (sync): record is destroying'); + assert.strictEqual(friends.length, 0, 'after unload (sync): Matt has no friends'); + assert.strictEqual(people.length, 1, 'after unload (sync): one person left in the store'); + + await settled(); + + assert.true(Pat.isDestroyed, 'final: record is destroyed'); + assert.true(Pat.isDestroying, 'final: record is destroying'); + assert.strictEqual(friends.length, 0, 'final: Matt has no friends'); + assert.strictEqual(people.length, 1, 'final: one person left in the store'); + }); + + test('Unload after a failed save is safe', async function (assert) { + const store = this.owner.lookup('service:store'); + const adapter = store.adapterFor('application'); + adapter.createRecord = () => Promise.reject(new Error('Invalid Data')); + const Matt = store.push({ + data: { + type: 'person', + id: '1', + attributes: { + name: 'Matthew Seidel', + }, + relationships: { + friends: { + data: [], + }, + }, + }, + }); + let Pat = store.createRecord('person', { name: 'Patrick Wachter' }); + const friends = Matt.hasMany('friends').value(); + friends.push(Pat); + let people = store.peekAll('person'); + + assert.strictEqual(friends.length, 1, 'Matt has friends'); + assert.strictEqual(people.length, 2, 'precond - two people records in the store'); + assert.true(Pat.hasDirtyAttributes, 'precond - record has dirty attributes'); + assert.true(Pat.isNew, 'precond - record is new'); + + try { + await Pat.save(); + assert.ok(false, 'save failed'); + } catch (e) { + assert.ok(true, 'save failed'); + } + + Pat.unloadRecord(); + + assert.false(Pat.isDestroyed, 'after unload (sync): record is not yet destroyed'); + assert.true(Pat.isDestroying, 'after unload (sync): record is destroying'); + assert.strictEqual(friends.length, 0, 'after unload (sync): Matt has no friends'); + assert.strictEqual(people.length, 1, 'after unload (sync): one person left in the store'); + + await settled(); + + assert.true(Pat.isDestroyed, 'final: record is destroyed'); + assert.true(Pat.isDestroying, 'final: record is destroying'); + assert.strictEqual(friends.length, 0, 'final: Matt has no friends'); + assert.strictEqual(people.length, 1, 'final: one person left in the store'); + }); + + test('Unload after a save is safe', async function (assert) { + const store = this.owner.lookup('service:store'); + const adapter = store.adapterFor('application'); + adapter.createRecord = () => Promise.resolve({ data: { type: 'person', id: '2' } }); + const Matt = store.push({ + data: { + type: 'person', + id: '1', + attributes: { + name: 'Matthew Seidel', + }, + relationships: { + friends: { + data: [], + }, + }, + }, + }); + let Pat = store.createRecord('person', { name: 'Patrick Wachter' }); + const friends = Matt.hasMany('friends').value(); + friends.push(Pat); + let people = store.peekAll('person'); + + assert.strictEqual(friends.length, 1, 'Matt has friends'); + assert.strictEqual(people.length, 2, 'precond - two people records in the store'); + assert.true(Pat.hasDirtyAttributes, 'precond - record has dirty attributes'); + assert.true(Pat.isNew, 'precond - record is new'); + + try { + await Pat.save(); + assert.ok(true, 'save succeeded'); + } catch (e) { + assert.ok(false, 'save succeeded'); + } + + assert.strictEqual(friends.length, 1, 'after save: Matt has friends'); + assert.strictEqual(people.length, 2, 'after save: two people records in the store'); + assert.false(Pat.hasDirtyAttributes, 'after save: record is clean'); + assert.false(Pat.isNew, 'after save: record is not new'); + + Pat.unloadRecord(); + + assert.false(Pat.isDestroyed, 'after unload (sync): record is not yet destroyed'); + assert.true(Pat.isDestroying, 'after unload (sync): record is destroying'); + assert.strictEqual(friends.length, 0, 'after unload (sync): Matt has no friends'); + assert.strictEqual(people.length, 1, 'after unload (sync): one person left in the store'); + + await settled(); + + assert.true(Pat.isDestroyed, 'final: record is destroyed'); + assert.true(Pat.isDestroying, 'final: record is destroying'); + assert.strictEqual(friends.length, 0, 'final: Matt has no friends'); + assert.strictEqual(people.length, 1, 'final: one person left in the store'); + }); + + test('Unload after a save is safe (no access after save before unload)', async function (assert) { + const store = this.owner.lookup('service:store'); + const adapter = store.adapterFor('application'); + adapter.createRecord = () => Promise.resolve({ data: { type: 'person', id: '2' } }); + const Matt = store.push({ + data: { + type: 'person', + id: '1', + attributes: { + name: 'Matthew Seidel', + }, + relationships: { + friends: { + data: [], + }, + }, + }, + }); + let Pat = store.createRecord('person', { name: 'Patrick Wachter' }); + const friends = Matt.hasMany('friends').value(); + friends.push(Pat); + let people = store.peekAll('person'); + + assert.strictEqual(friends.length, 1, 'Matt has friends'); + assert.strictEqual(people.length, 2, 'precond - two people records in the store'); + assert.true(Pat.hasDirtyAttributes, 'precond - record has dirty attributes'); + assert.true(Pat.isNew, 'precond - record is new'); + + try { + await Pat.save(); + assert.ok(true, 'save succeeded'); + } catch (e) { + assert.ok(false, 'save succeeded'); + } + + Pat.unloadRecord(); + + assert.false(Pat.isDestroyed, 'after unload (sync): record is not yet destroyed'); + assert.true(Pat.isDestroying, 'after unload (sync): record is destroying'); + assert.strictEqual(friends.length, 0, 'after unload (sync): Matt has no friends'); + assert.strictEqual(people.length, 1, 'after unload (sync): one person left in the store'); + + await settled(); + + assert.true(Pat.isDestroyed, 'final: record is destroyed'); + assert.true(Pat.isDestroying, 'final: record is destroying'); + assert.strictEqual(friends.length, 0, 'final: Matt has no friends'); + assert.strictEqual(people.length, 1, 'final: one person left in the store'); + }); +}); diff --git a/tests/main/tests/integration/records/save-test.js b/tests/main/tests/integration/records/save-test.js index a03bbea78ac..8a42c989f84 100644 --- a/tests/main/tests/integration/records/save-test.js +++ b/tests/main/tests/integration/records/save-test.js @@ -220,6 +220,30 @@ module('integration/records/save - Save Record', function (hooks) { } }); + test('Will not unload record if it fails to save on create', async function (assert) { + const store = this.owner.lookup('service:store'); + const adapter = store.adapterFor('application'); + const post = store.createRecord('post', { title: 'toto' }); + const posts = store.peekAll('post'); + + assert.strictEqual(posts.length, 1, 'precond - store has one post'); + + adapter.createRecord = async function () { + const error = new InvalidError([{ title: 'not valid' }]); + return Promise.reject(error); + }; + + try { + await post.save(); + assert.ok(false, 'we should error'); + } catch { + assert.ok(true, 'save operation was rejected'); + } + + assert.false(post.isDestroyed, 'post is not destroyed'); + assert.strictEqual(posts.length, 1, 'store still has the post'); + }); + test('Will error when saving after unloading record via the store', async function (assert) { assert.expect(1); diff --git a/tests/main/tests/integration/records/unload-test.js b/tests/main/tests/integration/records/unload-test.js index 1fb394ab14f..34de2339e33 100644 --- a/tests/main/tests/integration/records/unload-test.js +++ b/tests/main/tests/integration/records/unload-test.js @@ -688,6 +688,36 @@ module('integration/unload - Unloading Records', function (hooks) { }; } + test('unloadAll() does not destroy the record array', function (assert) { + store.push({ + data: { + type: 'person', + id: '1', + attributes: { + name: 'Could be Anybody', + }, + }, + }); + const all = store.peekAll('person'); + assert.strictEqual(all.length, 1, 'precond - record array has one item'); + store.unloadAll(); + assert.strictEqual(all.length, 0, 'after unloadAll: record array has no items'); + assert.false(all.isDestroyed, 'after unloadAll: record array is not destroyed'); + store.push({ + data: { + type: 'person', + id: '1', + attributes: { + name: 'Could be Anybody', + }, + }, + }); + const all2 = store.peekAll('person'); + assert.strictEqual(all2.length, 1, 'after next push: record array has one item'); + // eslint-disable-next-line qunit/no-ok-equality + assert.true(all === all2, 'after next push: record array is the same'); + }); + test('unloadAll(type) does not leave stranded internalModels in relationships (rediscover via store.push)', async function (assert) { assert.expect(13);