UNPKG

@data-client/endpoint

Version:

Declarative Network Interface Definitions

495 lines (455 loc) 14.9 kB
import type { Schema, GetIndex, GetEntity, CheckLoop, Visit, } from '../interface.js'; import { AbstractInstanceType } from '../normal.js'; import { INVALID } from '../special.js'; import type { IEntityClass, IEntityInstance, EntityOptions, RequiredPKOptions, IDClass, Constructor, PKClass, } from './EntityTypes.js'; /** * Turns any class into an Entity. * @see https://dataclient.io/rest/api/EntityMixin */ export default function EntityMixin<TBase extends PKClass>( Base: TBase, opt?: EntityOptions<InstanceType<TBase>>, ): IEntityClass<TBase> & TBase; // id is in Instance, so we default to that as pk export default function EntityMixin<TBase extends IDClass>( Base: TBase, opt?: EntityOptions<InstanceType<TBase>>, ): IEntityClass<TBase> & TBase & (new (...args: any[]) => IEntityInstance); // pk was specified in options, so we don't need to redefine export default function EntityMixin<TBase extends Constructor>( Base: TBase, opt: RequiredPKOptions<InstanceType<TBase>>, ): IEntityClass<TBase> & TBase & (new (...args: any[]) => IEntityInstance); export default function EntityMixin<TBase extends Constructor>( Base: TBase, options: EntityOptions<InstanceType<TBase>> = {}, ) { /** * Entity defines a single (globally) unique object. * @see https://dataclient.io/rest/api/Entity */ abstract class EntityMixin extends Base { static toString() { return this.key; } static toJSON() { return { key: this.key, schema: this.schema, }; } /** Defines nested entities */ declare static schema: { [k: string]: Schema }; /** * A unique identifier for each Entity * * @see https://dataclient.io/rest/api/Entity#pk * @param [parent] When normalizing, the object which included the entity * @param [key] When normalizing, the key where this entity was found * @param [args] ...args sent to Endpoint */ declare pk: ( parent?: any, key?: string, args?: readonly any[], ) => string | number | undefined; /** Returns the globally unique identifier for the static Entity */ declare static key: string; // default implementation in class static block at bottom of definition /** Defines indexes to enable lookup by */ declare static indexes?: readonly string[]; /** * A unique identifier for each Entity * * @see https://dataclient.io/rest/api/Entity#pk * @param [value] POJO of the entity or subset used * @param [parent] When normalizing, the object which included the entity * @param [key] When normalizing, the key where this entity was found * @param [args] ...args sent to Endpoint */ static pk<T extends typeof EntityMixin>( this: T, value: Partial<AbstractInstanceType<T>>, parent?: any, key?: string, args?: readonly any[], ): string | number | undefined { return this.prototype.pk.call(value, parent, key, args); } /** Return true to merge incoming data; false keeps existing entity * * @see https://dataclient.io/rest/api/Entity#shouldUpdate */ static shouldUpdate( existingMeta: { date: number; fetchedAt: number }, incomingMeta: { date: number; fetchedAt: number }, existing: any, incoming: any, ) { return true; } /** Determines the order of incoming entity vs entity already in store\ * * @see https://dataclient.io/rest/api/Entity#shouldReorder * @returns true if incoming entity should be first argument of merge() */ static shouldReorder( existingMeta: { date: number; fetchedAt: number }, incomingMeta: { date: number; fetchedAt: number }, existing: any, incoming: any, ) { return incomingMeta.fetchedAt < existingMeta.fetchedAt; } /** Creates new instance copying over defined values of arguments * * @see https://dataclient.io/rest/api/Entity#merge */ static merge(existing: any, incoming: any) { return { ...existing, ...incoming, }; } /** Run when an existing entity is found in the store * * @see https://dataclient.io/rest/api/Entity#mergeWithStore */ static mergeWithStore( existingMeta: { date: number; fetchedAt: number; }, incomingMeta: { date: number; fetchedAt: number }, existing: any, incoming: any, ) { const shouldUpdate = this.shouldUpdate( existingMeta, incomingMeta, existing, incoming, ); if (shouldUpdate) { // distinct types are not mergeable (like delete symbol), so just replace if (typeof incoming !== typeof existing) { return incoming; } else { return ( this.shouldReorder(existingMeta, incomingMeta, existing, incoming) ) ? this.merge(incoming, existing) : this.merge(existing, incoming); } } else { return existing; } } /** Run when an existing entity is found in the store * * @see https://dataclient.io/rest/api/Entity#mergeMetaWithStore */ static mergeMetaWithStore( existingMeta: { fetchedAt: number; date: number; expiresAt: number; }, incomingMeta: { fetchedAt: number; date: number; expiresAt: number }, existing: any, incoming: any, ) { return ( this.shouldReorder(existingMeta, incomingMeta, existing, incoming) ) ? existingMeta : incomingMeta; } /** Factory method to convert from Plain JS Objects. * * @param [props] Plain Object of properties to assign. */ static fromJS<T extends typeof EntityMixin>( this: T, // TODO: this should only accept members that are not functions props: Partial<AbstractInstanceType<T>> = {}, ): AbstractInstanceType<T> { // we type guarded abstract case above, so ok to force typescript to allow constructor call const instance = new (this as any)(props) as AbstractInstanceType<T>; // we can't rely on constructors and override the defaults provided as property assignments // all occur after the constructor Object.assign(instance, props); return instance; } /** Called when denormalizing an entity to create an instance when 'valid' * * @see https://dataclient.io/rest/api/Entity#createIfValid * @param [props] Plain Object of properties to assign. */ static createIfValid<T extends typeof EntityMixin>( this: T, // TODO: this should only accept members that are not functions props: Partial<AbstractInstanceType<T>>, ): AbstractInstanceType<T> | undefined { if (this.validate(props)) { return undefined as any; } return this.fromJS(props); } /** Do any transformations when first receiving input * * @see https://dataclient.io/rest/api/Entity#process */ static process( input: any, parent: any, key: string | undefined, args: any, ): any { return { ...input }; } static normalize( input: any, parent: any, key: string | undefined, args: readonly any[], visit: Visit, addEntity: (...args: any) => any, getEntity: GetEntity, checkLoop: CheckLoop, ): any { const processedEntity = this.process(input, parent, key, args); let id: string | number | undefined; if (typeof processedEntity === 'undefined') { id = this.pk(input, parent, key, args); addEntity(this, INVALID, id); return id; } id = this.pk(processedEntity, parent, key, args); if (id === undefined || id === '' || id === 'undefined') { // create a random id if a valid one cannot be computed // this is useful for optimistic creates that don't need real ids - just something to hold their place id = `MISS-${Math.random()}`; // 'creates' conceptually should allow missing PK to make optimistic creates easy if (process.env.NODE_ENV !== 'production' && !visit.creating) { let why: string; if ( !('pk' in options) && EntityMixin.prototype.pk === this.prototype.pk && !('id' in processedEntity) ) { why = `'id' missing but needed for default pk(). Try defining pk() for your Entity.`; } else { why = `This is likely due to a malformed response. Try inspecting the network response or fetch() return value. Or use debugging tools: https://dataclient.io/docs/getting-started/debugging`; } const error = new Error( `Missing usable primary key when normalizing response. ${why} Learn more about primary keys: https://dataclient.io/rest/api/Entity#pk Entity: ${this.key} Value (processed): ${ processedEntity && JSON.stringify(processedEntity, null, 2) } `, ); (error as any).status = 400; throw error; } } else { id = `${id}`; } /* Circular reference short-circuiter */ if (checkLoop(this.key, id, input)) return id; const errorMessage = this.validate(processedEntity); throwValidationError(errorMessage); Object.keys(this.schema).forEach(key => { if (Object.hasOwn(processedEntity, key)) { processedEntity[key] = visit( this.schema[key], processedEntity[key], processedEntity, key, args, ); } }); addEntity(this, processedEntity, id); return id; } static validate(processedEntity: any): string | undefined { return; } static queryKey( args: readonly any[], queryKey: any, getEntity: GetEntity, getIndex: GetIndex, ): any { if (!args[0]) return; const id = queryKeyCandidate(this, args, getIndex); // ensure this actually has entity or we shouldn't try to use it in our query if (getEntity(this.key, id)) return id; } static denormalize<T extends typeof EntityMixin>( this: T, input: any, args: any[], unvisit: (schema: any, input: any) => any, ): AbstractInstanceType<T> { if (typeof input === 'symbol') { return input as any; } // note: iteration order must be stable for (const key of Object.keys(this.schema)) { const schema = this.schema[key]; const value = unvisit(schema, input[key]); if (typeof value === 'symbol') { // if default is not 'falsy', then this is required, so propagate INVALID symbol if (this.defaults[key]) { return value as any; } input[key] = undefined; } else { input[key] = value; } } return input; } /** All instance defaults set */ static get defaults() { // we use hasOwn because we don't want to use a parents' defaults if (!Object.hasOwn(this, '__defaults')) Object.defineProperty(this, '__defaults', { value: new (this as any)(), writable: true, configurable: true, }); return (this as any).__defaults; } } const { pk, schema, key, ...staticProps } = options; // remaining options Object.assign(EntityMixin, staticProps); if ('schema' in options) { EntityMixin.schema = options.schema as any; } else if (!(Base as any).schema) { EntityMixin.schema = {}; } if ('pk' in options) { if (typeof options.pk === 'function') { EntityMixin.prototype.pk = function (parent?: any, key?: string) { return (options.pk as any)(this, parent, key); }; } else { EntityMixin.prototype.pk = function () { return (this as any)[options.pk]; }; } // default to 'id' field if the base class doesn't have a pk } else if (typeof Base.prototype.pk !== 'function') { EntityMixin.prototype.pk = function () { return (this as any).id; }; } if ('key' in options) { Object.defineProperty(EntityMixin, 'key', { value: options.key, configurable: true, writable: true, enumerable: true, }); } else if (!('key' in Base)) { function set(this: any, value: string) { Object.defineProperty(this, 'key', { value, writable: true, enumerable: true, configurable: true, }); } const baseGet = function (this: { name: string }): string { const name = this.name === 'EntityMixin' ? Base.name : this.name; /* istanbul ignore next */ if ( process.env.NODE_ENV !== 'production' && (name === '' || name === 'EntityMixin' || name === '_temp') ) throw new Error( 'Entity classes without a name must define `static key`\nSee: https://dataclient.io/rest/api/Entity#key', ); return name; }; const get = /* istanbul ignore if */ typeof document !== 'undefined' && (document as any).CLS_MANGLE ? /* istanbul ignore next */ function (this: { name: string; key: string; }): string { (document as any).CLS_MANGLE?.(this); Object.defineProperty(EntityMixin, 'key', { get: baseGet, set, enumerable: true, configurable: true, }); return baseGet.call(this); } : baseGet; Object.defineProperty(EntityMixin, 'key', { get, set, enumerable: true, configurable: true, }); } return EntityMixin as any; } function indexFromParams<I extends string>( params: Readonly<object>, indexes?: Readonly<I[]>, ) { if (!indexes) return undefined; return indexes.find(index => Object.hasOwn(params, index)); } // part of the reason for pulling this out is that all functions that throw are deoptimized function throwValidationError(errorMessage: string | undefined) { if (errorMessage) { const error = new Error(errorMessage); (error as any).status = 400; throw error; } } function queryKeyCandidate( schema: any, args: readonly any[], getIndex: GetIndex, ) { if (['string', 'number'].includes(typeof args[0])) { return `${args[0]}`; } const id = schema.pk(args[0], undefined, '', args); // Was able to infer the entity's primary key from params if (id !== undefined && id !== '') return id; // now attempt lookup in indexes const indexName = indexFromParams(args[0], schema.indexes); if (!indexName) return; const value = (args[0] as Record<string, any>)[indexName]; return getIndex(schema.key, indexName, value)[value]; }