#

API Docs for: v3.16.0-alpha.2
Show: Inherited Protected Private Deprecated

# File: ../store/addon/-private/system/store/internal-model-factory.ts

    import { assert, warn } from '@ember/debug';
    import { IdentifierCache, identifierCacheFor } from '../../identifiers/cache';
    import InternalModel from '../model/internal-model';
    import IdentityMap from '../identity-map';
    import { StableRecordIdentifier } from '../../ts-interfaces/identifier';
    import InternalModelMap from '../internal-model-map';
    import { isNone } from '@ember/utils';
    import { IDENTIFIERS } from '@ember-data/canary-features';
    import { Record } from '../../ts-interfaces/record';
    import {
      ResourceIdentifierObject,
      ExistingResourceObject,
      NewResourceIdentifierObject,
    } from '../../ts-interfaces/ember-data-json-api';
    import { DEBUG } from '@glimmer/env';
    import CoreStore from '../core-store';
    import constructResource from '../../utils/construct-resource';

    /**
      @module @ember-data/store
    */

    const FactoryCache = new WeakMap<CoreStore, InternalModelFactory>();
    type NewResourceInfo = { type: string; id: string | null };

    const RecordCache = new WeakMap<Record, StableRecordIdentifier>();

    export function peekRecordIdentifier(record: any): StableRecordIdentifier | undefined {
      return RecordCache.get(record);
    }

    export function recordIdentifierFor(record: Record): StableRecordIdentifier {
      let identifier = RecordCache.get(record);

      if (DEBUG && identifier === undefined) {
        throw new Error(`${record} is not a record instantiated by @ember-data/store`);
      }

      return identifier as StableRecordIdentifier;
    }

    export function setRecordIdentifier(record: Record, identifier: StableRecordIdentifier): void {
      if (DEBUG && RecordCache.has(record)) {
        throw new Error(`${record} was already assigned an identifier`);
      }

      /*
      It would be nice to do a reverse check here that an identifier has not
      previously been assigned a record; however, unload + rematerialization
      prevents us from having a great way of doing so when CustomRecordClasses
      don't necessarily give us access to a `isDestroyed` for dematerialized
      instance.
      */

      RecordCache.set(record, identifier);
    }

    export function internalModelFactoryFor(store: CoreStore): InternalModelFactory {
      let factory = FactoryCache.get(store);

      if (factory === undefined) {
        factory = new InternalModelFactory(store);
        FactoryCache.set(store, factory);
      }

      return factory;
    }

    /**
     * The InternalModelFactory handles the lifecyle of
     * instantiating, caching, and destroying InternalModel
     * instances.
     *
     * @internal
     */
    export default class InternalModelFactory {
      private _identityMap: IdentityMap;
      private _newlyCreated: IdentityMap;
      public identifierCache: IdentifierCache;

      constructor(public store: CoreStore) {
        this.identifierCache = identifierCacheFor(store);
        this.identifierCache.__configureMerge((identifier, matchedIdentifier, resourceData) => {
          const intendedIdentifier = identifier.id === resourceData.id ? identifier : matchedIdentifier;
          const altIdentifier = identifier.id === resourceData.id ? matchedIdentifier : identifier;

          // check for duplicate InternalModel's
          const map = this.modelMapFor(identifier.type);
          let im = map.get(intendedIdentifier.lid);
          let otherIm = map.get(altIdentifier.lid);

          // we cannot merge internalModels when both have records
          // (this may not be strictly true, we could probably swap the internalModel the record points at)
          if (im && otherIm && im.hasRecord && otherIm.hasRecord) {
            throw new Error(
              `Failed to update the 'id' for the RecordIdentifier '${identifier}' to '${resourceData.id}', because that id is already in use by '${matchedIdentifier}'`
            );
          }

          // remove otherIm from cache
          if (otherIm) {
            map.remove(otherIm, altIdentifier.lid);
          }

          if (im === null && otherIm === null) {
            // nothing more to do
            return intendedIdentifier;

            // only the other has an InternalModel
            // OR only the other has a Record
          } else if ((im === null && otherIm !== null) || (im && !im.hasRecord && otherIm && otherIm.hasRecord)) {
            if (im) {
              // TODO check if we are retained in any async relationships
              map.remove(im, intendedIdentifier.lid);
              // im.destroy();
            }
            im = otherIm;
            // TODO do we need to notify the id change?
            im._id = intendedIdentifier.id;
            map.add(im, intendedIdentifier.lid);

            // just use im
          } else {
            // otherIm.destroy();
          }

          return intendedIdentifier;
        });
        this._identityMap = new IdentityMap();
        if (!IDENTIFIERS) {
          this._newlyCreated = new IdentityMap();
        }
      }

      /**
       * Retrieve the InternalModel for a given { type, id, lid }.
       *
       * If an InternalModel does not exist, it instantiates one.
       *
       * If an InternalModel does exist bus has a scheduled destroy,
       *   the scheduled destroy will be cancelled.
       *
       * @internal
       */
      lookup(resource: ResourceIdentifierObject, data?: ExistingResourceObject): InternalModel {
        if (IDENTIFIERS && data !== undefined) {
          // if we've been given data associated with this lookup
          // we must first give secondary-caches for LIDs the
          // opportunity to populate based on it
          this.identifierCache.getOrCreateRecordIdentifier(data);
        }

        const identifier = this.identifierCache.getOrCreateRecordIdentifier(resource);
        const internalModel = this.peek(identifier);

        if (internalModel) {
          // unloadRecord is async, if one attempts to unload + then sync push,
          //   we must ensure the unload is canceled before continuing
          //   The createRecord path will take _existingInternalModelForId()
          //   which will call `destroySync` instead for this unload + then
          //   sync createRecord scenario. Once we have true client-side
          //   delete signaling, we should never call destroySync
          if (internalModel.hasScheduledDestroy()) {
            internalModel.cancelDestroy();
          }

          return internalModel;
        }

        return this._build(identifier, false);
      }

      /**
       * Peek the InternalModel for a given { type, id, lid }.
       *
       * If an InternalModel does not exist, return `null`.
       *
       * @internal
       */
      peek(identifier: StableRecordIdentifier): InternalModel | null {
        if (IDENTIFIERS) {
          return this.modelMapFor(identifier.type).get(identifier.lid);
        } else {
          let internalModel: InternalModel | null = null;

          internalModel = this._newlyCreatedModelsFor(identifier.type).get(identifier.lid);

          if (!internalModel && identifier.id) {
            internalModel = this.modelMapFor(identifier.type).get(identifier.id);
          }

          return internalModel;
        }
      }

      getByResource(resource: ResourceIdentifierObject): InternalModel {
        if (IDENTIFIERS) {
          const normalizedResource = constructResource(resource.type, resource.id, resource.lid);

          return this.lookup(normalizedResource);
        } else {
          let res = resource as { type: string; clientId?: string; id: string | null; lid?: string };
          let internalModel: InternalModel | null = null;

          if (res.clientId) {
            internalModel = this._newlyCreatedModelsFor(resource.type).get(res.clientId);
          }

          if (internalModel === null) {
            internalModel = this.lookup(resource);
          }

          return internalModel;
        }
      }

      setRecordId(type: string, id: string, lid: string) {
        const resource: NewResourceIdentifierObject = { type, id: null, lid };
        const identifier = this.identifierCache.getOrCreateRecordIdentifier(resource);
        const internalModel = this.peek(identifier);

        if (internalModel === null) {
          throw new Error(`Cannot set the id ${id} on the record ${type}:${lid} as there is no such record in the cache.`);
        }

        let oldId = internalModel.id;
        let modelName = internalModel.modelName;

        // ID absolutely can't be missing if the oldID is empty (missing Id in response for a new record)
        assert(
          `'${modelName}' was saved to the server, but the response does not have an id and your record does not either.`,
          !(id === null && oldId === null)
        );

        // ID absolutely can't be different than oldID if oldID is not null
        // TODO this assertion and restriction may not strictly be needed in the identifiers world
        assert(
          `Cannot update the id for '${modelName}:${lid}' from '${oldId}' to '${id}'.`,
          !(oldId !== null && id !== oldId)
        );

        // ID can be null if oldID is not null (altered ID in response for a record)
        // however, this is more than likely a developer error.
        if (oldId !== null && id === null) {
          warn(
            `Your ${modelName} record was saved to the server, but the response does not have an id.`,
            !(oldId !== null && id === null)
          );
          return;
        }

        let existingInternalModel = this.peekById(modelName, id);

        assert(
          `'${modelName}' was saved to the server, but the response returned the new id '${id}', which has already been used with another record.'`,
          isNone(existingInternalModel) || existingInternalModel === internalModel
        );

        if (!IDENTIFIERS) {
          this.modelMapFor(type).set(id, internalModel);
          this._newlyCreatedModelsFor(type).remove(internalModel, lid);
        }

        if (identifier.id === null) {
          this.identifierCache.updateRecordIdentifier(identifier, { type, id });
        }

        internalModel.setId(id);
      }

      peekById(type: string, id: string): InternalModel | null {
        const identifier = this.identifierCache.peekRecordIdentifier({ type, id });

        let internalModel: InternalModel | null;

        if (IDENTIFIERS) {
          internalModel = identifier ? this.modelMapFor(type).get(identifier.lid) : null;
        } else {
          internalModel = this.modelMapFor(type).get(id);
        }

        if (internalModel && internalModel.hasScheduledDestroy()) {
          // unloadRecord is async, if one attempts to unload + then sync create,
          //   we must ensure the unload is complete before starting the create
          //   The push path will take this.lookup()
          //   which will call `cancelDestroy` instead for this unload + then
          //   sync push scenario. Once we have true client-side
          //   delete signaling, we should never call destroySync
          internalModel.destroySync();
          internalModel = null;
        }
        return internalModel;
      }

      build(newResourceInfo: NewResourceInfo): InternalModel {
        return this._build(newResourceInfo, true);
      }

      _build(resource: StableRecordIdentifier, isCreate: false): InternalModel;
      _build(resource: NewResourceInfo, isCreate: true): InternalModel;
      _build(resource: StableRecordIdentifier | NewResourceInfo, isCreate: boolean = false): InternalModel {
        if (isCreate === true && resource.id) {
          let existingInternalModel = this.peekById(resource.type, resource.id);

          assert(
            `The id ${resource.id} has already been used with another '${resource.type}' record.`,
            !existingInternalModel
          );
        }

        const { identifierCache } = this;
        let identifier: StableRecordIdentifier;

        if (isCreate === true) {
          identifier = identifierCache.createIdentifierForNewRecord(resource);
        } else {
          identifier = resource as StableRecordIdentifier;
        }

        // lookupFactory should really return an object that creates
        // instances with the injections applied
        let internalModel = new InternalModel(this.store, identifier);

        if (IDENTIFIERS) {
          this.modelMapFor(resource.type).add(internalModel, identifier.lid);
        } else {
          if (isCreate === true) {
            this._newlyCreatedModelsFor(identifier.type).add(internalModel, identifier.lid);
          }
          // TODO @runspired really?!
          this.modelMapFor(resource.type).add(internalModel, identifier.id);
        }

        return internalModel;
      }

      remove(internalModel: InternalModel): void {
        let recordMap = this.modelMapFor(internalModel.modelName);
        let clientId = internalModel.identifier.lid;

        if (IDENTIFIERS) {
          recordMap.remove(internalModel, clientId);
        } else {
          if (internalModel.id) {
            recordMap.remove(internalModel, internalModel.id);
          }
          this._newlyCreatedModelsFor(internalModel.modelName).remove(internalModel, clientId);
        }

        const { identifier } = internalModel;
        this.identifierCache.forgetRecordIdentifier(identifier);
      }

      modelMapFor(type: string): InternalModelMap {
        return this._identityMap.retrieve(type);
      }

      _newlyCreatedModelsFor(type: string): InternalModelMap {
        return this._newlyCreated.retrieve(type);
      }

      clear(type?: string) {
        if (type === undefined) {
          this._identityMap.clear();
        } else {
          this.modelMapFor(type).clear();
        }
      }
    }