#

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

# File: ../store/addon/-private/identifiers/cache.ts

    import { DEBUG } from '@glimmer/env';
    import { warn } from '@ember/debug';
    import { ConfidentDict } from '../ts-interfaces/utils';
    import { ResourceIdentifierObject, ExistingResourceObject } from '../ts-interfaces/ember-data-json-api';
    import {
      StableRecordIdentifier,
      DEBUG_CLIENT_ORIGINATED,
      DEBUG_IDENTIFIER_BUCKET,
      GenerationMethod,
      UpdateMethod,
      ForgetMethod,
      ResetMethod,
      RecordIdentifier,
    } from '../ts-interfaces/identifier';
    import coerceId from '../system/coerce-id';
    import uuidv4 from './utils/uuid-v4';
    import normalizeModelName from '../system/normalize-model-name';
    import isStableIdentifier, { markStableIdentifier, unmarkStableIdentifier } from './is-stable-identifier';
    import isNonEmptyString from '../utils/is-non-empty-string';
    import CoreStore from '../system/core-store';

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

    interface KeyOptions {
      lid: IdentifierMap;
      id: IdentifierMap;
      _allIdentifiers: StableRecordIdentifier[];
    }

    type IdentifierMap = ConfidentDict<StableRecordIdentifier>;
    type TypeMap = ConfidentDict<KeyOptions>;
    export type MergeMethod = (
      targetIdentifier: StableRecordIdentifier,
      matchedIdentifier: StableRecordIdentifier,
      resourceData: ResourceIdentifierObject | ExistingResourceObject
    ) => StableRecordIdentifier;

    let configuredForgetMethod: ForgetMethod | null;
    let configuredGenerationMethod: GenerationMethod | null;
    let configuredResetMethod: ResetMethod | null;
    let configuredUpdateMethod: UpdateMethod | null;

    export function setIdentifierGenerationMethod(method: GenerationMethod | null): void {
      configuredGenerationMethod = method;
    }

    export function setIdentifierUpdateMethod(method: UpdateMethod | null): void {
      configuredUpdateMethod = method;
    }

    export function setIdentifierForgetMethod(method: ForgetMethod | null): void {
      configuredForgetMethod = method;
    }

    export function setIdentifierResetMethod(method: ResetMethod | null): void {
      configuredResetMethod = method;
    }

    function defaultGenerationMethod(data: ResourceIdentifierObject, bucket: string): string {
      if (isNonEmptyString(data.lid)) {
        return data.lid;
      }
      let { type, id } = data;
      if (isNonEmptyString(id)) {
        return `@ember-data:lid-${normalizeModelName(type)}-${id}`;
      }
      return uuidv4();
    }

    const IdentifierCaches = new WeakMap<CoreStore, IdentifierCache>();

    export function identifierCacheFor(store: CoreStore): IdentifierCache {
      let cache = IdentifierCaches.get(store);

      if (cache === undefined) {
        cache = new IdentifierCache();
        IdentifierCaches.set(store, cache);
      }

      return cache;
    }

    function defaultEmptyCallback(...args: any[]): any {}

    let DEBUG_MAP;
    if (DEBUG) {
      DEBUG_MAP = new WeakMap<StableRecordIdentifier, StableRecordIdentifier>();
    }

    export class IdentifierCache {
      // Typescript still leaks private properties in the final
      // compiled class, so we may want to move these from _underscore
      // to a WeakMap to avoid leaking
      // currently we leak this for test purposes
      _cache = {
        lids: Object.create(null) as IdentifierMap,
        types: Object.create(null) as TypeMap,
      };
      private _generate: GenerationMethod;
      private _update: UpdateMethod;
      private _forget: ForgetMethod;
      private _reset: ResetMethod;
      private _merge: MergeMethod;

      constructor() {
        // we cache the user configuredGenerationMethod at init because it must
        // be configured prior and is not allowed to be changed
        this._generate = configuredGenerationMethod || defaultGenerationMethod;
        this._update = configuredUpdateMethod || defaultEmptyCallback;
        this._forget = configuredForgetMethod || defaultEmptyCallback;
        this._reset = configuredResetMethod || defaultEmptyCallback;
        this._merge = defaultEmptyCallback;
      }

      /**
       * hook to allow management of merge conflicts with identifiers.
       *
       * we allow late binding of this private internal merge so that `internalModelFactory`
       * can insert itself here to handle elimination of duplicates
       *
       * @internal
       */
      __configureMerge(method: MergeMethod | null) {
        this._merge = method || defaultEmptyCallback;
      }

      /**
       * @internal
       */
      private _getRecordIdentifier(resource: ResourceIdentifierObject, shouldGenerate: true): StableRecordIdentifier;
      private _getRecordIdentifier(
        resource: ResourceIdentifierObject,
        shouldGenerate: false
      ): StableRecordIdentifier | undefined;
      private _getRecordIdentifier(
        resource: ResourceIdentifierObject,
        shouldGenerate: boolean = false
      ): StableRecordIdentifier | undefined {
        // short circuit if we're already the stable version
        if (isStableIdentifier(resource)) {
          if (DEBUG) {
            // TODO should we instead just treat this case as a new generation skipping the short circuit?
            if (!(resource.lid in this._cache.lids) || this._cache.lids[resource.lid] !== resource) {
              throw new Error(`The supplied identifier ${resource} does not belong to this store instance`);
            }
          }
          return resource;
        }

        // `type` must always be present
        if (DEBUG) {
          if (!isNonEmptyString(resource.type)) {
            throw new Error('resource.type needs to be a string');
          }
        }

        let type = normalizeModelName(resource.type);
        let keyOptions = getTypeIndex(this._cache.types, type);
        let identifier: StableRecordIdentifier | undefined;
        let lid = coerceId(resource.lid);
        let id = coerceId(resource.id);

        // go straight for the stable RecordIdentifier key'd to `lid`
        if (lid !== null) {
          identifier = keyOptions.lid[lid];
        }

        // we may have not seen this resource before
        // but just in case we check our own secondary lookup (`id`)
        if (identifier === undefined && id !== null) {
          identifier = keyOptions.id[id];
        }

        if (identifier === undefined) {
          // we have definitely not seen this resource before
          // so we allow the user configured `GenerationMethod` to tell us
          let newLid = this._generate(resource, 'record');

          // we do this _even_ when `lid` is present because secondary lookups
          // may need to be populated, but we enforce not giving us something
          // different than expected
          if (lid !== null && newLid !== lid) {
            throw new Error(`You should not change the <lid> of a RecordIdentifier`);
          } else if (lid === null) {
            // allow configuration to tell us that we have
            // seen this `lid` before. E.g. a secondary lookup
            // connects this resource to a previously seen
            // resource.
            identifier = keyOptions.lid[newLid];
          }

          if (shouldGenerate === true) {
            if (identifier === undefined) {
              // if we still don't have an identifier, time to generate one
              identifier = makeStableRecordIdentifier(id, type, newLid, 'record', false);

              // populate our unique table
              if (DEBUG) {
                // realistically if you hit this it means you changed `type` :/
                // TODO consider how to handle type change assertions more gracefully
                if (identifier.lid in this._cache.lids) {
                  throw new Error(`You should not change the <type> of a RecordIdentifier`);
                }
              }
              this._cache.lids[identifier.lid] = identifier;

              // populate our primary lookup table
              // TODO consider having the `lid` cache be
              // one level up
              keyOptions.lid[identifier.lid] = identifier;
              // TODO exists temporarily to support `peekAll`
              // but likely to move
              keyOptions._allIdentifiers.push(identifier);
            }

            // populate our own secondary lookup table
            // even for the "successful" secondary lookup
            // by `_generate()`, since we missed the cache
            // previously
            // we use identifier.id instead of id here
            // because they may not match and we prefer
            // what we've set via resource data
            if (identifier.id !== null) {
              keyOptions.id[identifier.id] = identifier;

              // TODO allow filling out of `id` here
              // for the `username` non-client created
              // case.
            }
          }
        }

        return identifier;
      }

      /**
       * allows us to peek without generating when needed
       * useful for the "create" case when we need to see if
       * we are accidentally overwritting something
       *
       * @internal
       */
      peekRecordIdentifier(resource: ResourceIdentifierObject): StableRecordIdentifier | undefined {
        return this._getRecordIdentifier(resource, false);
      }

      /*
        Returns the Identifier for the given Resource, creates one if it does not yet exist.

        Specifically this means that we:

        - validate the `id` `type` and `lid` combo against known identifiers
        - return an object with an `lid` that is stable (repeated calls with the same
          `id` + `type` or `lid` will return the same `lid` value)
        - this referential stability of the object itself is guaranteed
      */
      getOrCreateRecordIdentifier(resource: ResourceIdentifierObject | ExistingResourceObject): StableRecordIdentifier {
        return this._getRecordIdentifier(resource, true);
      }

      /*
       Returns a new Identifier for the supplied data. Call this method to generate
       an identifier when a new resource is being created local to the client and
       potentially does not have an `id`.

       Delegates generation to the user supplied `GenerateMethod` if one has been provided
       with the signature `generateMethod({ type }, 'record')`.

      */
      createIdentifierForNewRecord(data: { type: string; id?: string | null }): StableRecordIdentifier {
        let newLid = this._generate(data, 'record');
        let identifier = makeStableRecordIdentifier(data.id || null, data.type, newLid, 'record', true);
        let keyOptions = getTypeIndex(this._cache.types, data.type);

        // populate our unique table
        if (DEBUG) {
          if (identifier.lid in this._cache.lids) {
            throw new Error(`The lid generated for the new record is not unique as it matches an existing identifier`);
          }
        }
        this._cache.lids[identifier.lid] = identifier;

        // populate the type+lid cache
        keyOptions.lid[newLid] = identifier;
        // ensure a peekAll sees our new identifier too
        // TODO move this outta here?
        keyOptions._allIdentifiers.push(identifier);

        return identifier;
      }

      /*
       Provides the opportunity to update secondary lookup tables for existing identifiers
       Called after an identifier created with `createIdentifierForNewRecord` has been
       committed.

       Assigned `id` to an `Identifier` if `id` has not previously existed; however,
       attempting to change the `id` or calling update without providing an `id` when
       one is missing will throw an error.

        - sets `id` (if `id` was previously `null`)
        - `lid` and `type` MUST NOT be altered post creation

        If a merge occurs, it is possible the returned identifier does not match the originally
        provided identifier. In this case the abandoned identifier will go through the usual
        `forgetRecordIdentifier` codepaths.
      */
      updateRecordIdentifier(
        identifierObject: RecordIdentifier,
        data: ResourceIdentifierObject | ExistingResourceObject
      ): StableRecordIdentifier {
        let identifier = this.getOrCreateRecordIdentifier(identifierObject);

        let id = identifier.id;
        let newId = coerceId(data.id);

        const keyOptions = getTypeIndex(this._cache.types, identifier.type);
        let existingIdentifier = detectMerge(keyOptions, identifier, newId);

        if (existingIdentifier) {
          identifier = this._mergeRecordIdentifiers(keyOptions, identifier, existingIdentifier, data, newId as string);
        }

        id = identifier.id;
        performRecordIdentifierUpdate(identifier, data, this._update);
        newId = identifier.id;

        // add to our own secondary lookup table
        if (id !== newId && newId !== null) {
          let keyOptions = getTypeIndex(this._cache.types, identifier.type);
          keyOptions.id[newId] = identifier;

          if (id !== null) {
            delete keyOptions.id[id];
          }
        }

        return identifier;
      }

      _mergeRecordIdentifiers(
        keyOptions: KeyOptions,
        identifier: StableRecordIdentifier,
        existingIdentifier: StableRecordIdentifier,
        data: ResourceIdentifierObject | ExistingResourceObject,
        newId: string
      ): StableRecordIdentifier {
        // delegate determining which identifier to keep to the configured MergeMethod
        let kept = this._merge(identifier, existingIdentifier, data);
        let abandoned = kept === identifier ? existingIdentifier : identifier;

        // cleanup the identifier we no longer need
        this.forgetRecordIdentifier(abandoned);

        // ensure a secondary cache entry for this id for the identifier we do keep
        keyOptions.id[newId] = kept;

        // make sure that the `lid` on the data we are processing matches the lid we kept
        data.lid = kept.lid;

        return kept;
      }

      /*
       Provides the opportunity to eliminate an identifier from secondary lookup tables
       as well as eliminates it from ember-data's own lookup tables and book keeping.

       Useful when a record has been deleted and the deletion has been persisted and
       we do not care about the record anymore. Especially useful when an `id` of a
       deleted record might be reused later for a new record.
      */
      forgetRecordIdentifier(identifierObject: RecordIdentifier): void {
        let identifier = this.getOrCreateRecordIdentifier(identifierObject);
        let keyOptions = getTypeIndex(this._cache.types, identifier.type);
        if (identifier.id !== null) {
          delete keyOptions.id[identifier.id];
        }
        delete this._cache.lids[identifier.lid];
        delete keyOptions.lid[identifier.lid];

        let index = keyOptions._allIdentifiers.indexOf(identifier);
        keyOptions._allIdentifiers.splice(index, 1);

        unmarkStableIdentifier(identifierObject);
        this._forget(identifier, 'record');
      }

      destroy() {
        this._reset();
      }
    }

    function getTypeIndex(typeMap: TypeMap, type: string): KeyOptions {
      let typeIndex: KeyOptions = typeMap[type];

      if (typeIndex === undefined) {
        typeIndex = {
          lid: Object.create(null),
          id: Object.create(null),
          _allIdentifiers: [],
        };
        typeMap[type] = typeIndex;
      }

      return typeIndex;
    }

    function makeStableRecordIdentifier(
      id: string | null,
      type: string,
      lid: string,
      bucket: string,
      clientOriginated: boolean = false
    ): Readonly<StableRecordIdentifier> {
      let recordIdentifier = {
        lid,
        id,
        type,
      };
      markStableIdentifier(recordIdentifier);

      if (DEBUG) {
        // we enforce immutability in dev
        //  but preserve our ability to do controlled updates to the reference
        let wrapper = Object.freeze({
          [DEBUG_CLIENT_ORIGINATED]: clientOriginated,
          [DEBUG_IDENTIFIER_BUCKET]: bucket,
          get lid() {
            return recordIdentifier.lid;
          },
          get id() {
            return recordIdentifier.id;
          },
          get type() {
            return recordIdentifier.type;
          },
          toString() {
            let { type, id, lid } = recordIdentifier;
            return `${clientOriginated ? '[CLIENT_ORIGINATED] ' : ''}${type}:${id} (${lid})`;
          },
        });
        markStableIdentifier(wrapper);
        DEBUG_MAP.set(wrapper, recordIdentifier);
        return wrapper;
      }

      return recordIdentifier;
    }

    function performRecordIdentifierUpdate(
      identifier: StableRecordIdentifier,
      data: ResourceIdentifierObject,
      updateFn: UpdateMethod
    ) {
      let { id, lid } = data;
      let type = normalizeModelName(data.type);

      if (DEBUG) {
        // get the mutable instance behind our proxy wrapper
        let wrapper = identifier;
        identifier = DEBUG_MAP.get(wrapper);

        if (lid !== undefined) {
          let newLid = coerceId(lid);
          if (newLid !== identifier.lid) {
            throw new Error(
              `The 'lid' for a RecordIdentifier cannot be updated once it has been created. Attempted to set lid for '${wrapper}' to '${lid}'.`
            );
          }
        }

        if (id !== undefined) {
          let newId = coerceId(id);

          if (identifier.id !== null && identifier.id !== newId) {
            // here we warn and ignore, as this may be a mistake, but we allow the user
            // to have multiple cache-keys pointing at a single lid so we cannot error
            warn(
              `The 'id' for a RecordIdentifier should not be updated once it has been set. Attempted to set id for '${wrapper}' to '${newId}'.`,
              false,
              { id: 'ember-data:multiple-ids-for-identifier' }
            );
          }
        }

        // TODO consider just ignoring here to allow flexible polymorphic support
        if (type !== identifier.type) {
          throw new Error(
            `The 'type' for a RecordIdentifier cannot be updated once it has been set. Attempted to set type for '${wrapper}' to '${type}'.`
          );
        }

        updateFn(wrapper, data, 'record');
      } else {
        updateFn(identifier, data, 'record');
      }

      // upgrade the ID, this is a "one time only" ability
      // for the multiple-cache-key scenario we "could"
      // use a heuristic to guess the best id for display
      // (usually when `data.id` is available and `data.attributes` is not)
      if (id !== undefined) {
        identifier.id = coerceId(id);
      }
    }

    function detectMerge(
      keyOptions: KeyOptions,
      identifier: StableRecordIdentifier,
      newId: string | null
    ): StableRecordIdentifier | false {
      const { id } = identifier;
      if (id !== null && id !== newId && newId !== null) {
        const existingIdentifier = keyOptions.id[newId];

        return existingIdentifier !== undefined ? existingIdentifier : false;
      }

      return false;
    }