UNPKG

@nymphjs/client

Version:

Nymph.js - Client

768 lines (697 loc) 20.1 kB
import { difference, isEqual } from 'lodash-es'; import type Nymph from './Nymph.js'; import { EntityConstructor, EntityData, EntityInterface, EntityJson, EntityPatch, EntityReference, } from './Entity.types.js'; import { uniqueStrings, entitiesToReferences, referencesToEntities, sortObj, } from './utils.js'; export type EntityDataType<T> = T extends Entity<infer DataType> ? DataType : never; export type EntityInstanceType<T extends EntityConstructor> = T extends new () => infer E ? E & EntityDataType<E> : never; export default class Entity<T extends EntityData = EntityData> implements EntityInterface { /** * The instance of Nymph to use for queries. */ public static nymph = {} as Nymph; /** * The lookup name for this entity. * * This is used for reference arrays (and sleeping references) and server * requests. */ public static class = 'Entity'; /** * The instance of Nymph to use for queries. */ public $nymph: Nymph; /** * The entity's Globally Unique ID. */ public guid: string | null = null; /** * The creation date of the entity as a high precision Unix timestamp. */ public cdate: number | null = null; /** * The modified date of the entity as a high precision Unix timestamp. */ public mdate: number | null = null; /** * Array of the entity's tags. */ public tags: string[] = []; /** * Array of the entity's original tags (for patch). */ protected $originalTags: string[] = []; /** * A map of props to whether they're dirty (for patch). */ protected $dirty: { [k: string]: boolean } = {}; /** * The data proxy handler. */ protected $dataHandler: Object; /** * The actual data store. */ protected $dataStore: T; /** * The data proxy object. */ protected $data: T; /** * Whether this instance is a sleeping reference. */ protected $isASleepingReference = false; /** * The reference to use to wake. */ protected $sleepingReference: EntityReference | null = null; /** * A promise that resolved when the entity's data is wake. */ protected $wakePromise: Promise<Entity<T>> | null = null; /** * Initialize an entity. */ public constructor(..._rest: any[]) { this.$nymph = (this.constructor as EntityConstructor).nymph; this.$dataHandler = { has: (data: EntityData, name: string) => { if (typeof name !== 'symbol' && this.$isASleepingReference) { console.error(`Tried to check data on a sleeping reference: ${name}`); return false; } return name in data; }, get: (data: EntityData, name: string) => { if (typeof name !== 'symbol' && this.$isASleepingReference) { console.error(`Tried to get data on a sleeping reference: ${name}`); return undefined; } if (data.hasOwnProperty(name)) { return data[name]; } return undefined; }, set: (data: EntityData, name: string, value: any) => { if (typeof name !== 'symbol' && this.$isASleepingReference) { console.error(`Tried to set data on a sleeping reference: ${name}`); return false; } if (typeof name !== 'symbol') { this.$dirty[name] = true; } data[name] = value; return true; }, deleteProperty: (data: EntityData, name: string) => { if (typeof name !== 'symbol' && this.$isASleepingReference) { console.error( `Tried to delete data on a sleeping reference: ${name}`, ); return false; } if (data.hasOwnProperty(name)) { this.$dirty[name] = true; return delete data[name]; } return true; }, defineProperty: ( data: EntityData, name: string, descriptor: PropertyDescriptor, ) => { if (typeof name !== 'symbol' && this.$isASleepingReference) { console.error( `Tried to define data on a sleeping reference: ${name}`, ); return false; } if (typeof name !== 'symbol') { this.$dirty[name] = true; } Object.defineProperty(data, name, descriptor); return true; }, getOwnPropertyDescriptor: (data: EntityData, name: string) => { if (typeof name !== 'symbol' && this.$isASleepingReference) { console.error( `Tried to get property descriptor on a sleeping reference: ${name}`, ); return undefined; } return Object.getOwnPropertyDescriptor(data, name); }, ownKeys: (data: EntityData) => { if (this.$isASleepingReference) { console.error(`Tried to enumerate data on a sleeping reference.`); return undefined; } return Object.getOwnPropertyNames(data); }, }; this.$dataStore = {} as T; this.$data = new Proxy(this.$dataStore, this.$dataHandler); return new Proxy(this, { has(entity: Entity, name: string) { if ( typeof name !== 'string' || name in entity || name.substring(0, 1) === '$' ) { return name in entity; } return name in entity.$data; }, get(entity: Entity, name: string) { if ( typeof name !== 'string' || name in entity || name.substring(0, 1) === '$' ) { return (entity as EntityInterface)[name]; } if (name in entity.$data) { return entity.$data[name]; } return undefined; }, set(entity: Entity, name: string, value: any) { if ( typeof name !== 'string' || name in entity || name.substring(0, 1) === '$' ) { (entity as EntityInterface)[name] = value; } else { entity.$data[name] = value; } return true; }, deleteProperty(entity: Entity, name: string) { if (name in entity) { return delete (entity as EntityInterface)[name]; } else if (name in entity.$data) { return delete entity.$data[name]; } return true; }, getPrototypeOf(entity: Entity) { return entity.constructor.prototype; }, defineProperty( entity: Entity, name: string, descriptor: PropertyDescriptor, ) { if ( typeof name !== 'string' || name in entity || name.substring(0, 1) === '$' ) { Object.defineProperty(entity, name, descriptor); } else { Object.defineProperty(entity.$data, name, descriptor); } return true; }, getOwnPropertyDescriptor(entity: Entity, name: string) { if ( typeof name !== 'string' || name in entity || name.substring(0, 1) === '$' ) { return Object.getOwnPropertyDescriptor(entity, name); } else { return Object.getOwnPropertyDescriptor(entity.$data, name); } }, ownKeys(entity: Entity) { return Object.getOwnPropertyNames(entity).concat( Object.getOwnPropertyNames(entity.$data), ); }, }) as Entity<T>; } /** * Create or retrieve a new entity instance. * * Note that this will always return an entity, even if the GUID is not found. * * @param guid An optional GUID to retrieve. */ public static async factory<E extends Entity>( this: { new (): E; }, guid?: string, ): Promise<E & EntityDataType<E>> { const cacheEntity = ( guid ? (this as unknown as EntityConstructor).nymph.getEntityFromCache( this as unknown as EntityConstructor, guid, ) : null ) as Entity | null; if (cacheEntity) { return cacheEntity as E & EntityDataType<E>; } const entity = new this(); if (guid != null) { entity.guid = guid; entity.$isASleepingReference = true; entity.$sleepingReference = [ 'nymph_entity_reference', guid, (this as unknown as EntityConstructor).class, ]; await entity.$wake(); } return entity as E & EntityDataType<E>; } /** * Create a new entity instance. */ public static factorySync<E extends Entity>(this: { new (): E; }): E & EntityDataType<E> { return new this() as E & EntityDataType<E>; } /** * Create a new sleeping reference instance. * * Sleeping references won't retrieve their data from the server until they * are readied with `$wake()` or a parent's `$wakeAll()`. * * @param reference The Nymph Entity Reference to use to wake. * @returns The new instance. */ public static factoryReference<E extends Entity>( this: { new (): E; }, reference: EntityReference, ): E & EntityDataType<E> { const cacheEntity = ( reference[1] ? (this as unknown as EntityConstructor).nymph.getEntityFromCache( this as unknown as EntityConstructor, reference[1], ) : null ) as Entity | null; const entity = cacheEntity || new this(); if (!cacheEntity) { entity.$referenceSleep(reference); } return entity as E & EntityDataType<E>; } /** * Call a static method on the server version of this entity. * * @param method The name of the method. * @param params The parameters to call the method with. * @returns The value that the method on the server returned. */ public static async serverCallStatic(method: string, params: Iterable<any>) { const data = await this.nymph.serverCallStatic( this.class, method, // Turn the params into a real array, in case an arguments object was // passed. Array.prototype.slice.call(params), ); return data.return; } /** * Call a static iterator method on the server version of this entity. * * @param method The name of the method. * @param params The parameters to call the method with. * @returns An iterator that iterates over values that the method on the server yields. */ public static async serverCallStaticIterator( method: string, params: Iterable<any>, ) { return await this.nymph.serverCallStaticIterator( this.class, method, // Turn the params into a real array, in case an arguments object was // passed. Array.prototype.slice.call(params), ); } public toJSON() { this.$check(); const obj: EntityJson = { class: (this.constructor as any).class as string, guid: this.guid, cdate: this.cdate, mdate: this.mdate, tags: [...this.tags], data: {}, }; for (let [key, value] of Object.entries(this.$dataStore)) { obj.data[key] = entitiesToReferences(value); } return obj; } public $init(entityJson: EntityJson | null) { if (entityJson == null) { return this; } this.$isASleepingReference = false; this.$sleepingReference = null; this.guid = entityJson.guid; this.cdate = entityJson.cdate; this.mdate = entityJson.mdate; this.tags = entityJson.tags; this.$originalTags = entityJson.tags.slice(0); this.$dirty = {}; this.$dataStore = Object.entries(entityJson.data) .map(([key, value]) => { this.$dirty[key] = false; return { key, value: referencesToEntities(value, this.$nymph) }; }) .reduce( (obj, { key, value }) => Object.assign(obj, { [key]: value }), {}, ) as T; this.$data = new Proxy(this.$dataStore, this.$dataHandler); this.$nymph.setEntityToCache(this.constructor as EntityConstructor, this); return this as Entity<T>; } public $addTag(...tags: string[]) { this.$check(); if (tags.length < 1) { return; } this.tags = uniqueStrings([...this.tags, ...tags]); } public $arraySearch(array: any[], strict = false) { this.$check(); if (!Array.isArray(array)) { return -1; } for (let i = 0; i < array.length; i++) { const curEntity = array[i]; if (strict ? this.$equals(curEntity) : this.$is(curEntity)) { return i; } } return -1; } public async $delete(): Promise<boolean> { this.$check(); const guid = this.guid; return (await this.$nymph.deleteEntity(this)) === guid; } public $equals(object: any) { this.$check(); if (!(object instanceof Entity)) { return false; } if (this.guid || object.guid) { if (this.guid !== object.guid) { return false; } } if (object.cdate !== this.cdate) { return false; } if (object.mdate !== this.mdate) { return false; } const obData = sortObj(object.toJSON()); obData.tags?.sort(); // obData.data = sortObj(obData.data); const myData = sortObj(this.toJSON()); myData.tags?.sort(); // myData.data = sortObj(myData.data); return isEqual(obData, myData); } public $getPatch(): EntityPatch { this.$check(); if (this.guid == null) { throw new InvalidStateError( "You can't make a patch from an unsaved entity.", ); } const patch: EntityPatch = { guid: this.guid, mdate: this.mdate, class: (this.constructor as EntityConstructor).class, addTags: this.tags.filter( (tag) => this.$originalTags.indexOf(tag) === -1, ), removeTags: this.$originalTags.filter( (tag) => this.tags.indexOf(tag) === -1, ), unset: [], set: {}, }; for (let [key, dirty] of Object.entries(this.$dirty)) { if (dirty) { if (key in this.$data) { patch.set[key] = entitiesToReferences(this.$data[key]); } else { patch.unset.push(key); } } } return patch; } public $hasTag(...tags: string[]) { this.$check(); if (!tags.length) { return false; } for (let i = 0; i < tags.length; i++) { if (this.tags.indexOf(tags[i]) === -1) { return false; } } return true; } public $isDirty(property: string) { return property in this.$dirty ? this.$dirty[property] : null; } public $inArray(array: any[], strict = false) { return this.$arraySearch(array, strict) !== -1; } public $is(object: any) { this.$check(); if (!(object instanceof Entity)) { return false; } if (this.guid || object.guid) { return this.guid === object.guid; } else if (typeof object.toJSON !== 'function') { return false; } else { const obData = sortObj(object.toJSON()); obData.tags?.sort(); // obData.data = sortObj(obData.data); const myData = sortObj(this.toJSON()); myData.tags?.sort(); // myData.data = sortObj(myData.data); return isEqual(obData, myData); } } public async $patch() { this.$check(); const mdate = this.mdate; await this.$nymph.patchEntity(this); return mdate !== this.mdate; } /** * Check if this is a sleeping reference and throw an error if so. */ protected $check() { if (this.$isASleepingReference || this.$sleepingReference != null) { throw new EntityIsSleepingReferenceError( 'This entity is in a sleeping reference state. You must use .$wake() to wake it.', ); } } /** * Check if this is a sleeping reference. */ public $asleep() { return this.$isASleepingReference || this.$sleepingReference != null; } /** * Wake from a sleeping reference. */ public $wake() { if (!this.$isASleepingReference) { this.$wakePromise = null; return Promise.resolve(this); } if (this.$sleepingReference?.[1] == null) { throw new InvalidStateError( 'Tried to wake a sleeping reference with no GUID.', ); } if (!this.$wakePromise) { this.$wakePromise = this.$nymph .getEntityData( { class: this.constructor as EntityConstructor }, { type: '&', guid: this.$sleepingReference[1] }, ) .then((data) => { if (data == null) { const errObj = { data, textStatus: 'No data returned.' }; return Promise.reject(errObj); } return this.$init(data); }) .finally(() => { this.$wakePromise = null; }); } return this.$wakePromise; } public $wakeAll(level?: number) { return new Promise((resolve, reject) => { // Run this once this entity is awake. const wakeProps = () => { let newLevel; // If level is undefined, keep going forever, otherwise, stop once we've // gone deep enough. if (level !== undefined) { newLevel = level - 1; } if (newLevel !== undefined && newLevel < 0) { resolve(this); return; } const promises = []; // Go through data looking for entities to wake. for (let [key, value] of Object.entries(this.$data)) { if (value instanceof Entity && value.$isASleepingReference) { promises.push(value.$wakeAll(newLevel)); } else if (Array.isArray(value)) { for (let i = 0; i < value.length; i++) { if ( value[i] instanceof Entity && value[i].$isASleepingReference ) { promises.push(value[i].$wakeAll(newLevel)); } } } } if (promises.length) { Promise.all(promises).then( () => resolve(this), (errObj) => reject(errObj), ); } else { resolve(this); } }; if (this.$isASleepingReference) { this.$wake().then(wakeProps, (errObj) => reject(errObj)); } else { wakeProps(); } }) as Promise<Entity<T>>; } protected $referenceSleep(reference: EntityReference) { this.$isASleepingReference = true; this.guid = reference[1]; this.$sleepingReference = [...reference]; } public async $refresh() { if (this.$isASleepingReference) { await this.$wake(); return true; } if (this.guid == null) { return false; } const data = await this.$nymph.getEntityData( { class: this.constructor as EntityConstructor, }, { type: '&', guid: this.guid, }, ); this.$init(data); return this.guid == null ? 0 : true; } public $removeTag(...tags: string[]) { this.$check(); this.tags = difference(this.tags, tags); } public async $save() { this.$check(); await this.$nymph.saveEntity(this); return !!this.guid; } public async $serverCall( method: string, params: Iterable<any>, stateless = false, ) { this.$check(); // Turn the params into a real array, in case an arguments object was // passed. const paramArray = Array.prototype.slice.call(params); const data: any = await this.$nymph.serverCall( this, method, paramArray, stateless, ); if (!stateless && data.entity) { this.$init(data.entity); } return data.return; } public $toReference(): EntityReference | EntityInterface { if (this.$isASleepingReference && this.$sleepingReference) { return this.$sleepingReference; } if (this.guid == null) { return this as EntityInterface; } return [ 'nymph_entity_reference', this.guid, (this.constructor as EntityConstructor).class, ] as EntityReference; } } export class EntityIsSleepingReferenceError extends Error { constructor(message: string) { super(message); this.name = 'EntityIsSleepingReferenceError'; } } export class InvalidStateError extends Error { constructor(message: string) { super(message); this.name = 'InvalidStateError'; } }