API Docs for: 5.3.2+848cd73b
Show:

File: ../packages/store/src/-private/caches/identifier-cache.ts

/**
  @module @ember-data/store
*/
import { assert, warn } from '@ember/debug';

import { getOwnConfig, macroCondition } from '@embroider/macros';

import { LOG_IDENTIFIERS } from '@ember-data/debugging';
import { DEBUG } from '@ember-data/env';
import {
  CACHE_OWNER,
  DEBUG_CLIENT_ORIGINATED,
  DEBUG_IDENTIFIER_BUCKET,
  DEBUG_STALE_CACHE_OWNER,
  type Identifier,
  type IdentifierBucket,
  type RecordIdentifier,
  type StableDocumentIdentifier,
  type StableIdentifier,
  type StableRecordIdentifier,
} from '@warp-drive/core-types/identifier';
import type { ImmutableRequestInfo } from '@warp-drive/core-types/request';
import type { ExistingResourceObject, ResourceIdentifierObject } from '@warp-drive/core-types/spec/raw';

import type {
  ForgetMethod,
  GenerationMethod,
  ResetMethod,
  ResourceData,
  UpdateMethod,
} from '../../-types/q/identifier';
import coerceId from '../utils/coerce-id';
import normalizeModelName from '../utils/normalize-model-name';
import installPolyfill from '../utils/uuid-polyfill';
import { hasId, hasLid, hasType } from './resource-utils';

const IDENTIFIERS = new Set();
const DOCUMENTS = new Set();

export function isStableIdentifier(identifier: unknown): identifier is StableRecordIdentifier {
  return (identifier as StableRecordIdentifier)[CACHE_OWNER] !== undefined || IDENTIFIERS.has(identifier);
}

export function isDocumentIdentifier(identifier: unknown): identifier is StableDocumentIdentifier {
  return DOCUMENTS.has(identifier);
}

const isFastBoot = typeof FastBoot !== 'undefined';
const _crypto: Crypto = isFastBoot ? (FastBoot.require('crypto') as Crypto) : window.crypto;

if (macroCondition(getOwnConfig<{ polyfillUUID: boolean }>().polyfillUUID)) {
  installPolyfill();
}

function uuidv4(): string {
  assert(
    'crypto.randomUUID needs to be avaliable. Some browsers incorrectly disallow it in insecure contexts. You maybe want to enable the polyfill: https://github.com/emberjs/data#randomuuid-polyfill',
    typeof _crypto.randomUUID === 'function'
  );
  return _crypto.randomUUID();
}

function freeze<T>(obj: T): T {
  if (typeof Object.freeze === 'function') {
    return Object.freeze(obj);
  }
  return obj;
}

interface KeyOptions {
  lid: IdentifierMap;
  id: IdentifierMap;
}
type TypeMap = { [key: string]: KeyOptions };

// type IdentifierTypeLookup = { all: Set<StableRecordIdentifier>; id: Map<string, StableRecordIdentifier> };
// type IdentifiersByType = Map<string, IdentifierTypeLookup>;
type IdentifierMap = Map<string, StableRecordIdentifier>;
type KeyInfo = {
  id: string | null;
  type: string;
};
type StableCache = {
  resources: IdentifierMap;
  documents: Map<string, StableDocumentIdentifier>;
  resourcesByType: TypeMap;
  polymorphicLidBackMap: Map<string, string[]>;
};

export type KeyInfoMethod = (resource: unknown, known: StableRecordIdentifier | null) => KeyInfo;

export type MergeMethod = (
  targetIdentifier: StableRecordIdentifier,
  matchedIdentifier: StableRecordIdentifier,
  resourceData: unknown
) => StableRecordIdentifier;

let configuredForgetMethod: ForgetMethod | null;
let configuredGenerationMethod: GenerationMethod | null;
let configuredResetMethod: ResetMethod | null;
let configuredUpdateMethod: UpdateMethod | null;
let configuredKeyInfoMethod: KeyInfoMethod | 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;
}

export function setKeyInfoForResource(method: KeyInfoMethod | null): void {
  configuredKeyInfoMethod = method;
}

function assertIsRequest(request: unknown): asserts request is ImmutableRequestInfo {
  return;
}

// Map<type, Map<id, lid>>
type TypeIdMap = Map<string, Map<string, string>>;
const NEW_IDENTIFIERS: TypeIdMap = new Map();
let IDENTIFIER_CACHE_ID = 0;

function updateTypeIdMapping(typeMap: TypeIdMap, identifier: StableRecordIdentifier, id: string): void {
  let idMap = typeMap.get(identifier.type);
  if (!idMap) {
    idMap = new Map();
    typeMap.set(identifier.type, idMap);
  }
  idMap.set(id, identifier.lid);
}

function defaultUpdateMethod(identifier: StableRecordIdentifier, data: unknown, bucket: 'record'): void;
function defaultUpdateMethod(identifier: StableIdentifier, newData: unknown, bucket: never): void;
function defaultUpdateMethod(
  identifier: StableIdentifier | StableRecordIdentifier,
  data: unknown,
  bucket: 'record'
): void {
  if (bucket === 'record') {
    assert(`Expected identifier to be a StableRecordIdentifier`, isStableIdentifier(identifier));
    if (!identifier.id && hasId(data)) {
      updateTypeIdMapping(NEW_IDENTIFIERS, identifier, data.id);
    }
  }
}

function defaultKeyInfoMethod(resource: unknown, known: StableRecordIdentifier | null): KeyInfo {
  // TODO RFC something to make this configurable
  const id = hasId(resource) ? coerceId(resource.id) : null;
  const type = hasType(resource) ? normalizeModelName(resource.type) : known ? known.type : null;

  assert(`Expected keyInfoForResource to provide a type for the resource`, type);

  return { type, id };
}

function defaultGenerationMethod(data: ImmutableRequestInfo, bucket: 'document'): string | null;
function defaultGenerationMethod(data: ResourceData | { type: string }, bucket: 'record'): string;
function defaultGenerationMethod(
  data: ImmutableRequestInfo | ResourceData | { type: string },
  bucket: IdentifierBucket
): string | null {
  if (bucket === 'record') {
    if (hasLid(data)) {
      return data.lid;
    }

    assert(`Cannot generate an identifier for a resource without a type`, hasType(data));

    if (hasId(data)) {
      const type = normalizeModelName(data.type);
      const lid = NEW_IDENTIFIERS.get(type)?.get(data.id);

      return lid || `@lid:${type}-${data.id}`;
    }

    return uuidv4();
  } else if (bucket === 'document') {
    assertIsRequest(data);
    if (!data.url) {
      return null;
    }
    if (!data.method || data.method.toUpperCase() === 'GET') {
      return data.url;
    }
    return null;
  }
  assert(`Unknown bucket ${bucket as string}`, false);
}

function defaultEmptyCallback(...args: unknown[]): void {}
function defaultMergeMethod(
  a: StableRecordIdentifier,
  _b: StableRecordIdentifier,
  _c: unknown
): StableRecordIdentifier {
  return a;
}

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

/**
 * Each instance of {Store} receives a unique instance of a IdentifierCache.
 *
 * This cache is responsible for assigning or retrieving the unique identify
 * for arbitrary resource data encountered by the store. Data representing
 * a unique resource or record should always be represented by the same
 * identifier.
 *
 * It can be configured by consuming applications.
 *
 * @class IdentifierCache
   @public
 */
export class IdentifierCache {
  declare _cache: StableCache;
  declare _generate: GenerationMethod;
  declare _update: UpdateMethod;
  declare _forget: ForgetMethod;
  declare _reset: ResetMethod;
  declare _merge: MergeMethod;
  declare _keyInfoForResource: KeyInfoMethod;
  declare _isDefaultConfig: boolean;
  declare _id: number;

  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 as GenerationMethod);
    this._update = configuredUpdateMethod || defaultUpdateMethod;
    this._forget = configuredForgetMethod || defaultEmptyCallback;
    this._reset = configuredResetMethod || defaultEmptyCallback;
    this._merge = defaultMergeMethod;
    this._keyInfoForResource = configuredKeyInfoMethod || defaultKeyInfoMethod;
    this._isDefaultConfig = !configuredGenerationMethod;
    this._id = IDENTIFIER_CACHE_ID++;

    this._cache = {
      resources: new Map<string, StableRecordIdentifier>(),
      resourcesByType: Object.create(null) as TypeMap,
      documents: new Map<string, StableDocumentIdentifier>(),
      polymorphicLidBackMap: new Map<string, string[]>(),
    };
  }

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

  upgradeIdentifier(resource: { type: string; id: string | null; lid?: string }): StableRecordIdentifier {
    return this._getRecordIdentifier(resource, 2);
  }

  /**
   * @method _getRecordIdentifier
   * @private
   */
  _getRecordIdentifier(
    resource: { type: string; id: string | null; lid?: string },
    shouldGenerate: 2
  ): StableRecordIdentifier;
  _getRecordIdentifier(resource: unknown, shouldGenerate: 1): StableRecordIdentifier;
  _getRecordIdentifier(resource: unknown, shouldGenerate: 0): StableRecordIdentifier | undefined;
  _getRecordIdentifier(resource: unknown, shouldGenerate: 0 | 1 | 2): StableRecordIdentifier | undefined {
    if (LOG_IDENTIFIERS) {
      // eslint-disable-next-line no-console
      console.groupCollapsed(`Identifiers: ${shouldGenerate ? 'Generating' : 'Peeking'} Identifier`, resource);
    }
    // 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 (!this._cache.resources.has(resource.lid) || this._cache.resources.get(resource.lid) !== resource) {
          throw new Error(`The supplied identifier ${JSON.stringify(resource)} does not belong to this store instance`);
        }
      }
      if (LOG_IDENTIFIERS) {
        // eslint-disable-next-line no-console
        console.log(`Identifiers: cache HIT - Stable ${resource.lid}`);
        // eslint-disable-next-line no-console
        console.groupEnd();
      }
      return resource;
    }

    // the resource is unknown, ask the application to identify this data for us
    const lid = this._generate(resource, 'record');
    if (LOG_IDENTIFIERS) {
      // eslint-disable-next-line no-console
      console.log(`Identifiers: ${lid ? 'no ' : ''}lid ${lid ? lid + ' ' : ''}determined for resource`, resource);
    }

    let identifier: StableRecordIdentifier | null = /*#__NOINLINE__*/ getIdentifierFromLid(this._cache, lid, resource);
    if (identifier !== null) {
      if (LOG_IDENTIFIERS) {
        // eslint-disable-next-line no-console
        console.groupEnd();
      }
      return identifier;
    }

    if (shouldGenerate === 0) {
      if (LOG_IDENTIFIERS) {
        // eslint-disable-next-line no-console
        console.groupEnd();
      }
      return;
    }

    // if we still don't have an identifier, time to generate one
    if (shouldGenerate === 2) {
      (resource as StableRecordIdentifier).lid = lid;
      (resource as StableRecordIdentifier)[CACHE_OWNER] = this._id;
      identifier = /*#__NOINLINE__*/ makeStableRecordIdentifier(resource as StableRecordIdentifier, 'record', false);
    } else {
      // we lie a bit here as a memory optimization
      const keyInfo = this._keyInfoForResource(resource, null) as StableRecordIdentifier;
      keyInfo.lid = lid;
      keyInfo[CACHE_OWNER] = this._id;
      identifier = /*#__NOINLINE__*/ makeStableRecordIdentifier(keyInfo, 'record', false);
    }

    addResourceToCache(this._cache, identifier);

    if (LOG_IDENTIFIERS) {
      // eslint-disable-next-line no-console
      console.groupEnd();
    }

    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
   *
   * @method peekRecordIdentifier
   * @param resource
   * @return {StableRecordIdentifier | undefined}
   * @private
   */
  peekRecordIdentifier(resource: ResourceIdentifierObject | Identifier): StableRecordIdentifier | undefined {
    return this._getRecordIdentifier(resource, 0);
  }

  /**
    Returns the DocumentIdentifier for the given Request, creates one if it does not yet exist.
    Returns `null` if the request does not have a `cacheKey` or `url`.

    @method getOrCreateDocumentIdentifier
    @param request
    @return {StableDocumentIdentifier | null}
    @public
  */
  getOrCreateDocumentIdentifier(request: ImmutableRequestInfo): StableDocumentIdentifier | null {
    let cacheKey: string | null | undefined = request.cacheOptions?.key;

    if (!cacheKey) {
      cacheKey = this._generate(request, 'document');
    }

    if (!cacheKey) {
      return null;
    }

    let identifier = this._cache.documents.get(cacheKey);

    if (identifier === undefined) {
      identifier = { lid: cacheKey };
      if (DEBUG) {
        Object.freeze(identifier);
      }
      DOCUMENTS.add(identifier);
      this._cache.documents.set(cacheKey, identifier);
    }

    return identifier;
  }

  /**
    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

    @method getOrCreateRecordIdentifier
    @param resource
    @return {StableRecordIdentifier}
    @public
  */
  getOrCreateRecordIdentifier(resource: unknown): StableRecordIdentifier {
    return this._getRecordIdentifier(resource, 1);
  }

  /**
   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')`.

   @method createIdentifierForNewRecord
   @param data
   @return {StableRecordIdentifier}
   @public
  */
  createIdentifierForNewRecord(data: { type: string; id?: string | null }): StableRecordIdentifier {
    const newLid = this._generate(data, 'record');
    const identifier = /*#__NOINLINE__*/ makeStableRecordIdentifier(
      { id: data.id || null, type: data.type, lid: newLid, [CACHE_OWNER]: this._id },
      'record',
      true
    );

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

    /*#__NOINLINE__*/ addResourceToCache(this._cache, identifier);

    if (LOG_IDENTIFIERS) {
      // eslint-disable-next-line no-console
      console.log(`Identifiers: created identifier ${String(identifier)} for newly generated resource`, data);
    }

    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.

    @method updateRecordIdentifier
    @param identifierObject
    @param data
    @return {StableRecordIdentifier}
    @public
  */
  updateRecordIdentifier(identifierObject: RecordIdentifier, data: unknown): StableRecordIdentifier {
    let identifier = this.getOrCreateRecordIdentifier(identifierObject);

    const keyInfo = this._keyInfoForResource(data, identifier);
    let existingIdentifier = /*#__NOINLINE__*/ detectMerge(this._cache, keyInfo, identifier, data);
    const hadLid = hasLid(data);

    if (!existingIdentifier) {
      // If the incoming type does not match the identifier type, we need to create an identifier for the incoming
      // data so we can merge the incoming data with the existing identifier, see #7325 and #7363
      if (identifier.type !== keyInfo.type) {
        if (hadLid) {
          // Strip the lid to ensure we force a new identifier creation
          delete (data as { lid?: string }).lid;
        }
        existingIdentifier = this.getOrCreateRecordIdentifier(data);
      }
    }

    if (existingIdentifier) {
      const generatedIdentifier = identifier;
      identifier = this._mergeRecordIdentifiers(keyInfo, generatedIdentifier, existingIdentifier, data);

      // make sure that the `lid` on the data we are processing matches the lid we kept
      if (hadLid) {
        data.lid = identifier.lid;
      }

      if (LOG_IDENTIFIERS) {
        // eslint-disable-next-line no-console
        console.log(
          `Identifiers: merged identifiers ${generatedIdentifier.lid} and ${existingIdentifier.lid} for resource into ${identifier.lid}`,
          data
        );
      }
    }

    const id = identifier.id;
    /*#__NOINLINE__*/ performRecordIdentifierUpdate(identifier, keyInfo, data, this._update);
    const newId = identifier.id;

    // add to our own secondary lookup table
    if (id !== newId && newId !== null) {
      if (LOG_IDENTIFIERS) {
        // eslint-disable-next-line no-console
        console.log(
          `Identifiers: updated id for identifier ${identifier.lid} from '${String(id)}' to '${String(
            newId
          )}' for resource`,
          data
        );
      }

      const typeSet = this._cache.resourcesByType[identifier.type];
      assert(`Expected to find a typeSet for ${identifier.type}`, typeSet);
      typeSet.id.set(newId, identifier);

      if (id !== null) {
        typeSet.id.delete(id);
      }
    } else if (LOG_IDENTIFIERS) {
      // eslint-disable-next-line no-console
      console.log(`Identifiers: updated identifier ${identifier.lid} resource`, data);
    }

    return identifier;
  }

  /**
   * @method _mergeRecordIdentifiers
   * @private
   */
  _mergeRecordIdentifiers(
    keyInfo: KeyInfo,
    identifier: StableRecordIdentifier,
    existingIdentifier: StableRecordIdentifier,
    data: unknown
  ): StableRecordIdentifier {
    assert(`Expected keyInfo to contain an id`, hasId(keyInfo));
    // delegate determining which identifier to keep to the configured MergeMethod
    const kept = this._merge(identifier, existingIdentifier, data);
    const abandoned = kept === identifier ? existingIdentifier : identifier;

    // get any backreferences before forgetting this identifier, as it will be removed from the cache
    // and we will no longer be able to find them
    const abandonedBackReferences = this._cache.polymorphicLidBackMap.get(abandoned.lid);
    // delete the backreferences for the abandoned identifier so that forgetRecordIdentifier
    // does not try to remove them.
    if (abandonedBackReferences) this._cache.polymorphicLidBackMap.delete(abandoned.lid);

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

    // ensure a secondary cache entry for the original lid for the abandoned identifier
    this._cache.resources.set(abandoned.lid, kept);

    // backReferences let us know which other identifiers are pointing at this identifier
    // so we can delete them later if we forget this identifier
    const keptBackReferences = this._cache.polymorphicLidBackMap.get(kept.lid) ?? [];
    keptBackReferences.push(abandoned.lid);

    // update the backreferences from the abandoned identifier to be for the kept identifier
    if (abandonedBackReferences) {
      abandonedBackReferences.forEach((lid) => {
        keptBackReferences.push(lid);
        this._cache.resources.set(lid, kept);
      });
    }

    this._cache.polymorphicLidBackMap.set(kept.lid, keptBackReferences);
    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.

   @method forgetRecordIdentifier
   @param identifierObject
   @public
  */
  forgetRecordIdentifier(identifierObject: RecordIdentifier): void {
    const identifier = this.getOrCreateRecordIdentifier(identifierObject);
    const typeSet = this._cache.resourcesByType[identifier.type];
    assert(`Expected to find a typeSet for ${identifier.type}`, typeSet);

    if (identifier.id !== null) {
      typeSet.id.delete(identifier.id);
    }
    this._cache.resources.delete(identifier.lid);
    typeSet.lid.delete(identifier.lid);

    const backReferences = this._cache.polymorphicLidBackMap.get(identifier.lid);
    if (backReferences) {
      backReferences.forEach((lid) => {
        this._cache.resources.delete(lid);
      });
      this._cache.polymorphicLidBackMap.delete(identifier.lid);
    }

    if (DEBUG) {
      identifier[DEBUG_STALE_CACHE_OWNER] = identifier[CACHE_OWNER];
    }
    identifier[CACHE_OWNER] = undefined;
    IDENTIFIERS.delete(identifier);
    this._forget(identifier, 'record');
    if (LOG_IDENTIFIERS) {
      // eslint-disable-next-line no-console
      console.log(`Identifiers: released identifier ${identifierObject.lid}`);
    }
  }

  destroy() {
    NEW_IDENTIFIERS.clear();
    this._cache.documents.forEach((identifier) => {
      DOCUMENTS.delete(identifier);
    });
    this._reset();
  }
}

function makeStableRecordIdentifier(
  recordIdentifier: {
    type: string;
    id: string | null;
    lid: string;
    [CACHE_OWNER]: number | undefined;
  },
  bucket: IdentifierBucket,
  clientOriginated: boolean
): StableRecordIdentifier {
  IDENTIFIERS.add(recordIdentifier);

  if (DEBUG) {
    // we enforce immutability in dev
    //  but preserve our ability to do controlled updates to the reference
    let wrapper: StableRecordIdentifier = {
      get lid() {
        return recordIdentifier.lid;
      },
      get id() {
        return recordIdentifier.id;
      },
      get type() {
        return recordIdentifier.type;
      },
      get [CACHE_OWNER](): number | undefined {
        return recordIdentifier[CACHE_OWNER];
      },
      set [CACHE_OWNER](value: number) {
        recordIdentifier[CACHE_OWNER] = value;
      },
      get [DEBUG_STALE_CACHE_OWNER](): number | undefined {
        return (recordIdentifier as StableRecordIdentifier)[DEBUG_STALE_CACHE_OWNER];
      },
      set [DEBUG_STALE_CACHE_OWNER](value: number | undefined) {
        (recordIdentifier as StableRecordIdentifier)[DEBUG_STALE_CACHE_OWNER] = value;
      },
    };
    Object.defineProperty(wrapper, 'toString', {
      enumerable: false,
      value: () => {
        const { type, id, lid } = recordIdentifier;
        return `${clientOriginated ? '[CLIENT_ORIGINATED] ' : ''}${String(type)}:${String(id)} (${lid})`;
      },
    });
    Object.defineProperty(wrapper, 'toJSON', {
      enumerable: false,
      value: () => {
        const { type, id, lid } = recordIdentifier;
        return { type, id, lid };
      },
    });
    wrapper[DEBUG_CLIENT_ORIGINATED] = clientOriginated;
    wrapper[DEBUG_IDENTIFIER_BUCKET] = bucket;
    IDENTIFIERS.add(wrapper);
    DEBUG_MAP.set(wrapper, recordIdentifier);
    wrapper = freeze(wrapper);
    return wrapper;
  }

  return recordIdentifier;
}

function performRecordIdentifierUpdate(
  identifier: StableRecordIdentifier,
  keyInfo: KeyInfo,
  data: unknown,
  updateFn: UpdateMethod
) {
  if (DEBUG) {
    const { id, type } = keyInfo;

    // get the mutable instance behind our proxy wrapper
    const wrapper = identifier;
    identifier = DEBUG_MAP.get(wrapper)!;

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

    if (id && identifier.id !== null && identifier.id !== id) {
      // 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.lid}' to '${id}'.`,
        false,
        { id: 'ember-data:multiple-ids-for-identifier' }
      );
    }

    // TODO consider just ignoring here to allow flexible polymorphic support
    if (type && 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.lid}' 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 ((data as ExistingResourceObject).id !== undefined) {
    identifier.id = coerceId((data as ExistingResourceObject).id);
  }
}

function detectMerge(
  cache: StableCache,
  keyInfo: KeyInfo,
  identifier: StableRecordIdentifier,
  data: unknown
): StableRecordIdentifier | false {
  const newId = keyInfo.id;
  const { id, type, lid } = identifier;
  const typeSet = cache.resourcesByType[identifier.type];

  // if the IDs are present but do not match
  // then check if we have an existing identifier
  // for the newer ID.
  if (id !== null && id !== newId && newId !== null) {
    const existingIdentifier = typeSet && typeSet.id.get(newId);

    return existingIdentifier !== undefined ? existingIdentifier : false;
  } else {
    const newType = keyInfo.type;

    // If the ids and type are the same but lid is not the same, we should trigger a merge of the identifiers
    // we trigger a merge of the identifiers
    // though probably we should just throw an error here
    if (id !== null && id === newId && newType === type && hasLid(data) && data.lid !== lid) {
      return getIdentifierFromLid(cache, data.lid, data) || false;

      // If the lids are the same, and ids are the same, but types are different we should trigger a merge of the identifiers
    } else if (id !== null && id === newId && newType && newType !== type && hasLid(data) && data.lid === lid) {
      const newTypeSet = cache.resourcesByType[newType];
      const existingIdentifier = newTypeSet && newTypeSet.id.get(newId);

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

  return false;
}

function getIdentifierFromLid(cache: StableCache, lid: string, resource: unknown): StableRecordIdentifier | null {
  const identifier = cache.resources.get(lid);
  if (LOG_IDENTIFIERS) {
    // eslint-disable-next-line no-console
    console.log(`Identifiers: cache ${identifier ? 'HIT' : 'MISS'} - Non-Stable ${lid}`, resource);
  }
  return identifier || null;
}

function addResourceToCache(cache: StableCache, identifier: StableRecordIdentifier): void {
  cache.resources.set(identifier.lid, identifier);
  let typeSet = cache.resourcesByType[identifier.type];

  if (!typeSet) {
    typeSet = { lid: new Map(), id: new Map() };
    cache.resourcesByType[identifier.type] = typeSet;
  }

  typeSet.lid.set(identifier.lid, identifier);
  if (identifier.id) {
    typeSet.id.set(identifier.id, identifier);
  }
}