UNPKG

@data-client/normalizr

Version:

Normalizes and denormalizes JSON according to schema for Redux and Flux applications

932 lines (869 loc) 29.5 kB
'use strict'; class LocalCache { localCache = new Map(); getEntity(pk, schema, entity, computeValue) { const key = schema.key; if (!this.localCache.has(key)) { this.localCache.set(key, new Map()); } const localCacheKey = this.localCache.get(key); if (!localCacheKey.get(pk)) { computeValue(localCacheKey); } return localCacheKey.get(pk); } getResults(input, cachable, computeValue) { return { data: computeValue(), paths: [] }; } } const INVALID = Symbol('INVALID'); const UNDEF = {}; function isEntity(schema) { return schema !== null && schema.pk !== undefined; } const validateSchema = definition => { /* istanbul ignore else */ if (process.env.NODE_ENV !== 'production') { const isArray = Array.isArray(definition); if (isArray && definition.length > 1) { throw new Error(`Expected schema definition to be a single schema, but found ${definition.length}.`); } } return definition[0]; }; const getValues = input => Array.isArray(input) ? input : Object.keys(input).map(key => input[key]); const filterEmpty = item => item !== undefined && typeof item !== 'symbol'; const normalize$2 = (schema, input, parent, key, args, visit) => { schema = validateSchema(schema); const values = getValues(input); // Special case: Arrays pass *their* parent on to their children, since there // is not any special information that can be gathered from themselves directly return values.map(value => visit(schema, value, parent, key, args)); }; const denormalize$2 = (schema, input, args, unvisit) => { schema = validateSchema(schema); return input.map ? input.map(entityOrId => unvisit(schema, entityOrId)).filter(filterEmpty) : input; }; function queryKey$1() { return undefined; } /** * Helpers to enable Immutable compatibility *without* bringing in * the 'immutable' package as a dependency. */ /** * Check if an object is immutable by checking if it has a key specific * to the immutable library. * * @param {any} object * @return {bool} */ function isImmutable(object) { return !!(typeof object.hasOwnProperty === 'function' && (Object.hasOwnProperty.call(object, '__ownerID') || // Immutable.Map object._map && Object.hasOwnProperty.call(object._map, '__ownerID'))); // Immutable.Record } /** * Denormalize an immutable entity. * * @param {Schema} schema * @param {Immutable.Map|Immutable.Record} input * @param {function} unvisit * @param {function} getDenormalizedEntity * @return {Immutable.Map|Immutable.Record} */ function denormalizeImmutable(schema, input, args, unvisit) { let deleted = false; const obj = Object.keys(schema).reduce((object, key) => { // Immutable maps cast keys to strings on write so we need to ensure // we're accessing them using string keys. const stringKey = `${key}`; const item = unvisit(schema[stringKey], object.get(stringKey)); if (typeof item === 'symbol') { deleted = true; } if (object.has(stringKey)) { return object.set(stringKey, item); } else { return object; } }, input); return deleted ? INVALID : obj; } const normalize$1 = (schema, input, parent, key, args, visit) => { const object = { ...input }; const keys = Object.keys(schema); for (let i = 0; i < keys.length; i++) { const k = keys[i]; const localSchema = schema[k]; const value = visit(localSchema, input[k], input, k, args); if (value === undefined) { delete object[k]; } else { object[k] = value; } } return object; }; const denormalize$1 = (schema, input, args, unvisit) => { if (isImmutable(input)) { return denormalizeImmutable(schema, input, args, unvisit); } const object = { ...input }; let deleted = false; const keys = Object.keys(schema); for (let i = 0; i < keys.length; i++) { const k = keys[i]; const item = unvisit(schema[k], object[k]); if (object[k] !== undefined) { object[k] = item; } if (typeof item === 'symbol') { deleted = true; } } return deleted ? INVALID : object; }; function queryKey(schema, args, unvisit, delegate) { const resultObject = {}; for (const k of Object.keys(schema)) { resultObject[k] = unvisit(schema[k], args, delegate); } return resultObject; } const getUnvisitEntity = (getEntity, cache, args, unvisit) => { return function unvisitEntity(schema, entityOrId) { const inputIsId = typeof entityOrId !== 'object'; const entity = inputIsId ? getEntity({ key: schema.key, pk: entityOrId }) : entityOrId; if (typeof entity === 'symbol') { return schema.denormalize(entity, args, unvisit); } if (entity === undefined && inputIsId && // entityOrId cannot be undefined literal as this function wouldn't be called in that case // however the blank strings can still occur entityOrId !== '' && entityOrId !== 'undefined') { // we cannot perform WeakMap lookups with `undefined`, so we use a special object to represent undefined // we're actually using this call to ensure we update the cache if a nested schema changes from `undefined` // this is because cache.getEntity adds this key,pk as a dependency of anything it is nested under return cache.getEntity(entityOrId, schema, UNDEF, localCacheKey => { localCacheKey.set(entityOrId, undefined); }); } if (typeof entity !== 'object' || entity === null) { return entity; } let pk = inputIsId ? entityOrId : schema.pk(entity, undefined, undefined, args); // if we can't generate a working pk we cannot do cache lookups properly, // so simply denormalize without caching if (pk === undefined || pk === '' || pk === 'undefined') { return noCacheGetEntity(localCacheKey => unvisitEntityObject(schema, entity, '', localCacheKey, args, unvisit)); } // just an optimization to make all cache usages of pk monomorphic if (typeof pk !== 'string') pk = `${pk}`; // last function computes if it is not in any caches return cache.getEntity(pk, schema, entity, localCacheKey => unvisitEntityObject(schema, entity, pk, localCacheKey, args, unvisit)); }; }; function unvisitEntityObject(schema, entity, pk, localCacheKey, args, unvisit) { const entityCopy = schema.createIfValid(entity); if (entityCopy === undefined) { // undefined indicates we should suspense (perhaps failed validation) localCacheKey.set(pk, INVALID); } else { // set before we recurse to prevent cycles causing infinite loops localCacheKey.set(pk, entityCopy); // we still need to set in case denormalize recursively finds INVALID localCacheKey.set(pk, schema.denormalize(entityCopy, args, unvisit)); } } function noCacheGetEntity(computeValue) { const localCacheKey = new Map(); computeValue(localCacheKey); return localCacheKey.get(''); } const getUnvisit = (getEntity, cache, args) => { // we don't inline this as making this function too big inhibits v8's JIT const unvisitEntity = getUnvisitEntity(getEntity, cache, args, unvisit); function unvisit(schema, input) { if (!schema) return input; if (input === null || input === undefined) { return input; } if (typeof schema.denormalize !== 'function') { // deserialize fields (like Temporal.Instant) if (typeof schema === 'function') { return schema(input); } // shorthand for object, array if (typeof schema === 'object') { const method = Array.isArray(schema) ? denormalize$2 : denormalize$1; return method(schema, input, args, unvisit); } } else { if (isEntity(schema)) { return unvisitEntity(schema, input); } return schema.denormalize(input, args, unvisit); } return input; } return (schema, input) => { // in the case where WeakMap cannot be used // this test ensures null is properly excluded from WeakMap const cachable = Object(input) === input && Object(schema) === schema; return cache.getResults(input, cachable, () => unvisit(schema, input)); }; }; /** Basic state interfaces for normalize side */ class BaseDelegate { constructor({ entities, indexes }) { this.entities = entities; this.indexes = indexes; } // we must expose the entities object to track in our WeakDependencyMap // however, this should not be part of the public API // only used in buildQueryKey tracked(schema) { // eslint-disable-next-line @typescript-eslint/no-this-alias const base = this; const dependencies = [{ path: [''], entity: schema }]; const delegate = { INVALID, getIndex(...path) { const entity = base.getIndex(...path); dependencies.push({ path, entity }); return base.getIndexEnd(entity, path[2]); }, getEntity(...path) { const entity = base.getEntity(...path); dependencies.push({ path, entity }); return entity; }, getEntities(key) { const entity = base.getEntitiesObject(key); dependencies.push({ path: [key], entity }); return base.getEntities(key); } }; return [delegate, dependencies]; } } const getDependency = delegate => path => delegate[['', 'getEntitiesObject', 'getEntity', 'getIndex'][path.length]](...path); /** Basic POJO state interfaces for normalize side * Used directly as QueryDelegate, and inherited by NormalizeDelegate */ class POJODelegate extends BaseDelegate { constructor(state) { super(state); } // we must expose the entities object to track in our WeakDependencyMap // however, this should not be part of the public API getEntitiesObject(key) { return this.entities[key]; } getEntities(key) { const entities = this.entities[key]; if (entities === undefined) return undefined; return { keys() { return Object.keys(entities); }, entries() { return Object.entries(entities); } }; } getEntity(key, pk) { var _this$entities$key; return (_this$entities$key = this.entities[key]) == null ? void 0 : _this$entities$key[pk]; } // this is different return value than QuerySnapshot getIndex(key, field) { var _this$indexes$key; return (_this$indexes$key = this.indexes[key]) == null ? void 0 : _this$indexes$key[field]; } getIndexEnd(entity, value) { return entity == null ? void 0 : entity[value]; } } /** Handles POJO state for MemoCache methods */ const MemoPolicy = { QueryDelegate: POJODelegate, getEntities(entities) { return ({ key, pk }) => { var _entities$key; return (_entities$key = entities[key]) == null ? void 0 : _entities$key[pk]; }; } }; function denormalize(schema, input, entities, args = []) { // undefined means don't do anything if (schema === undefined || input === undefined) { return input; } return getUnvisit(MemoPolicy.getEntities(entities), new LocalCache(), args)(schema, input).data; } /** Maps a (ordered) list of dependencies to a value. * * Useful as a memoization cache for flat/normalized stores. * * All dependencies are only weakly referenced, allowing automatic garbage collection * when any dependencies are no longer used. */ class WeakDependencyMap { next = new WeakMap(); nextPath = undefined; get(entity, getDependency) { let curLink = this.next.get(entity); if (!curLink) return EMPTY; while (curLink.nextPath) { var _getDependency; // we cannot perform lookups with `undefined`, so we use a special object to represent undefined const nextDependency = (_getDependency = getDependency(curLink.nextPath)) != null ? _getDependency : UNDEF; curLink = curLink.next.get(nextDependency); if (!curLink) return EMPTY; } // curLink exists, but has no path - so must have a value return [curLink.value, curLink.journey]; } set(dependencies, value) { if (dependencies.length < 1) throw new KeySize(); let curLink = this; for (const { path, entity } of dependencies) { let nextLink = curLink.next.get(entity); if (!nextLink) { nextLink = new Link(); // void members are represented as a symbol so we can lookup curLink.next.set(entity != null ? entity : UNDEF, nextLink); } curLink.nextPath = path; curLink = nextLink; } // in case there used to be more curLink.nextPath = undefined; curLink.value = value; // we could recompute this on get, but it would have a cost and we optimize for `get` curLink.journey = dependencies.map(dep => dep.path); } } const EMPTY = [undefined, undefined]; /** Link in a chain */ class Link { next = new WeakMap(); nextPath = undefined; value = undefined; journey = []; } class KeySize extends Error { message = 'Keys must include at least one member'; } const getVisit = delegate => { const visit = (schema, value, parent, key, args) => { if (!value || !schema) { return value; } if (schema.normalize && typeof schema.normalize === 'function') { if (typeof value !== 'object') { if (schema.pk) return `${value}`; return value; } return schema.normalize(value, parent, key, args, visit, delegate); } if (typeof value !== 'object' || typeof schema !== 'object') return value; const method = Array.isArray(schema) ? normalize$2 : normalize$1; return method(schema, value, parent, key, args, visit); }; return visit; }; function getCheckLoop() { const visitedEntities = new Map(); /* Returns true if a circular reference is found */ return function checkLoop(entityKey, pk, input) { let entitiesOneType = visitedEntities.get(entityKey); if (!entitiesOneType) { entitiesOneType = new Map(); visitedEntities.set(entityKey, entitiesOneType); } let visitedEntitySet = entitiesOneType.get(pk); if (!visitedEntitySet) { visitedEntitySet = new Set(); entitiesOneType.set(pk, visitedEntitySet); } if (visitedEntitySet.has(input)) { return true; } visitedEntitySet.add(input); return false; }; } /** Full normalize() logic for POJO state */ class NormalizeDelegate extends POJODelegate { newEntities = new Map(); newIndexes = new Map(); constructor(state, actionMeta) { super(state); this.entitiesMeta = state.entitiesMeta; this.meta = actionMeta; this.checkLoop = getCheckLoop(); } getNewEntity(key, pk) { return this.getNewEntities(key).get(pk); } getNewEntities(key) { // first time we come across this type of entity if (!this.newEntities.has(key)) { this.newEntities.set(key, new Map()); // we will be editing these, so we need to clone them first this.entities[key] = { ...this.entities[key] }; this.entitiesMeta[key] = { ...this.entitiesMeta[key] }; } return this.newEntities.get(key); } getNewIndexes(key) { if (!this.newIndexes.has(key)) { this.newIndexes.set(key, new Map()); this.indexes[key] = { ...this.indexes[key] }; } return this.newIndexes.get(key); } /** Updates an entity using merge lifecycles when it has previously been set */ mergeEntity(schema, pk, incomingEntity) { const key = schema.key; // default when this is completely new entity let nextEntity = incomingEntity; let nextMeta = this.meta; // if we already processed this entity during this normalization (in another nested place) let entity = this.getNewEntity(key, pk); if (entity) { nextEntity = schema.merge(entity, incomingEntity); } else { // if we find it in the store entity = this.getEntity(key, pk); if (entity) { const meta = this.getMeta(key, pk); nextEntity = schema.mergeWithStore(meta, nextMeta, entity, incomingEntity); nextMeta = schema.mergeMetaWithStore(meta, nextMeta, entity, incomingEntity); } } // once we have computed the merged values, set them this.setEntity(schema, pk, nextEntity, nextMeta); } /** Sets an entity overwriting any previously set values */ setEntity(schema, pk, entity, meta = this.meta) { const key = schema.key; const newEntities = this.getNewEntities(key); const updateMeta = !newEntities.has(pk); newEntities.set(pk, entity); // update index if (schema.indexes) { handleIndexes(pk, schema.indexes, this.getNewIndexes(key), this.indexes[key], entity, this.entities[key]); } // set this after index updates so we know what indexes to remove from this._setEntity(key, pk, entity); if (updateMeta) this._setMeta(key, pk, meta); } /** Invalidates an entity, potentially triggering suspense */ invalidate({ key }, pk) { // set directly: any queued updates are meaningless with delete this.setEntity({ key }, pk, INVALID); } _setEntity(key, pk, entity) { this.entities[key][pk] = entity; } _setMeta(key, pk, meta) { this.entitiesMeta[key][pk] = meta; } getMeta(key, pk) { return this.entitiesMeta[key][pk]; } } function handleIndexes(id, schemaIndexes, indexes, storeIndexes, entity, storeEntities) { for (const index of schemaIndexes) { if (!indexes.has(index)) { indexes.set(index, storeIndexes[index] = {}); } const indexMap = indexes.get(index); if (storeEntities[id]) { delete indexMap[storeEntities[id][index]]; } // entity already in cache but the index changed if (storeEntities && storeEntities[id] && storeEntities[id][index] !== entity[index]) { indexMap[storeEntities[id][index]] = INVALID; } if (index in entity) { indexMap[entity[index]] = id; } /* istanbul ignore next */else if (process.env.NODE_ENV !== 'production') { console.warn(`Index not found in entity. Indexes must be top-level members of your entity. Index: ${index} Entity: ${JSON.stringify(entity, undefined, 2)}`); } } } const normalize = (schema, input, args = [], { entities, indexes, entitiesMeta } = emptyStore, meta = { fetchedAt: 0, date: Date.now(), expiresAt: Infinity }) => { // no schema means we don't process at all if (schema === undefined || schema === null) return { result: input, entities, indexes, entitiesMeta }; const schemaType = expectedSchemaType(schema); if (input === null || typeof input !== schemaType && // we will allow a Invalidate schema to be a string or object !(schema.key !== undefined && schema.pk === undefined && typeof input === 'string')) { /* istanbul ignore else */ if (process.env.NODE_ENV !== 'production') { const parseWorks = input => { try { return typeof JSON.parse(input) !== 'string'; } catch (e) { return false; } }; if (typeof input === 'string' && parseWorks(input)) { throw new Error(`Normalizing a string, but this does match schema. Parsing this input string as JSON worked. This likely indicates fetch function did not parse the JSON. By default, this only happens if "content-type" header includes "json". See https://dataclient.io/rest/api/RestEndpoint#parseResponse for more information Schema: ${JSON.stringify(schema, undefined, 2)} Input: "${input}"`); } else { throw new Error(`Unexpected input given to normalize. Expected type to be "${schemaType}", found "${input === null ? 'null' : typeof input}". Schema: ${JSON.stringify(schema, undefined, 2)} Input: "${input}"`); } } else { throw new Error(`Unexpected input given to normalize. Expected type to be "${schemaType}", found "${input === null ? 'null' : typeof input}".`); } } const ret = { result: '', entities: { ...entities }, indexes: { ...indexes }, entitiesMeta: { ...entitiesMeta } }; const visit = getVisit(new NormalizeDelegate(ret, meta)); ret.result = visit(schema, input, input, undefined, args); return ret; }; function expectedSchemaType(schema) { return ['object', 'function'].includes(typeof schema) ? 'object' : typeof schema; } const emptyStore = { entities: {}, indexes: {}, entitiesMeta: {} }; class GlobalCache { dependencies = []; cycleCache = new Map(); cycleIndex = -1; localCache = new Map(); constructor(getEntity, getCache, resultCache) { this._getEntity = getEntity; this._getCache = getCache; this._resultCache = resultCache; } getEntity(pk, schema, entity, computeValue) { const key = schema.key; const { localCacheKey, cycleCacheKey } = this.getCacheKey(key); if (!localCacheKey.get(pk)) { const globalCache = this._getCache(pk, schema); const [cacheValue, cachePath] = globalCache.get(entity, this._getEntity); // TODO: what if this just returned the deps - then we don't need to store them if (cachePath) { localCacheKey.set(pk, cacheValue.value); // TODO: can we store the cache values instead of tracking *all* their sources? // this is only used for setting endpoints cache correctly. if we got this far we will def need to set as we would have already tried getting it this.dependencies.push(...cacheValue.dependencies); return cacheValue.value; } // if we don't find in denormalize cache then do full denormalize else { const trackingIndex = this.dependencies.length; cycleCacheKey.set(pk, trackingIndex); this.dependencies.push({ path: { key, pk }, entity }); /** NON-GLOBAL_CACHE CODE */ computeValue(localCacheKey); /** /END NON-GLOBAL_CACHE CODE */ cycleCacheKey.delete(pk); // if in cycle, use the start of the cycle to track all deps // otherwise, we use our own trackingIndex const localKey = this.dependencies.slice(this.cycleIndex === -1 ? trackingIndex : this.cycleIndex); const cacheValue = { dependencies: localKey, value: localCacheKey.get(pk) }; globalCache.set(localKey, cacheValue); // start of cycle - reset cycle detection if (this.cycleIndex === trackingIndex) { this.cycleIndex = -1; } } } else { // cycle detected if (cycleCacheKey.has(pk)) { this.cycleIndex = cycleCacheKey.get(pk); } else { // with no cycle, globalCacheEntry will have already been set this.dependencies.push({ path: { key, pk }, entity }); } } return localCacheKey.get(pk); } getCacheKey(key) { if (!this.localCache.has(key)) { this.localCache.set(key, new Map()); } if (!this.cycleCache.has(key)) { this.cycleCache.set(key, new Map()); } const localCacheKey = this.localCache.get(key); const cycleCacheKey = this.cycleCache.get(key); return { localCacheKey, cycleCacheKey }; } /** Cache varies based on input (=== aka reference) */ getResults(input, cachable, computeValue) { if (!cachable) { return { data: computeValue(), paths: this.paths() }; } let [data, paths] = this._resultCache.get(input, this._getEntity); if (paths === undefined) { data = computeValue(); // we want to do this before we add our 'input' entry paths = this.paths(); // for the first entry, `path` is ignored so empty members is fine this.dependencies.unshift({ path: { key: '', pk: '' }, entity: input }); this._resultCache.set(this.dependencies, data); } else { paths.shift(); } return { data, paths }; } paths() { return this.dependencies.map(dep => dep.path); } } /** * Build the result parameter to denormalize from schema alone. * Tries to compute the entity ids from params. */ function buildQueryKey(delegate) { return function queryKey$2(schema, args) { // schema classes if (canQuery(schema)) { return schema.queryKey(args, queryKey$2, delegate); } // plain case if (typeof schema === 'object' && schema) { const method = Array.isArray(schema) ? queryKey$1 : queryKey; return method(schema, args, queryKey$2, delegate); } // fallback for things like null or undefined return schema; }; } function canQuery(schema) { return !!schema && typeof schema.queryKey === 'function'; } // this only works if entity does a lookup first to see if its entity is 'found' function validateQueryKey(queryKey) { if (queryKey === undefined) return false; if (queryKey && typeof queryKey === 'object' && !Array.isArray(queryKey)) { return Object.values(queryKey).every(validateQueryKey); } return true; } const getEntityCaches = entityCache => { return (pk, schema) => { var _ref; const key = schema.key; // collections should use the entities they collect over // TODO: this should be based on a public interface const entityInstance = (_ref = schema.cacheWith) != null ? _ref : schema; if (!entityCache.has(key)) { entityCache.set(key, new Map()); } const entityCacheKey = entityCache.get(key); if (!entityCacheKey.has(pk)) entityCacheKey.set(pk, new WeakMap()); const entityCachePk = entityCacheKey.get(pk); let wem = entityCachePk.get(entityInstance); if (!wem) { wem = new WeakDependencyMap(); entityCachePk.set(entityInstance, wem); } return wem; }; }; // TODO: make MemoCache generic on the arguments sent to Delegate constructor /** Singleton to store the memoization cache for denormalization methods */ class MemoCache { /** Cache for every entity based on its dependencies and its own input */ /** Caches the final denormalized form based on input, entities */ endpoints = new WeakDependencyMap(); /** Caches the queryKey based on schema, args, and any used entities or indexes */ queryKeys = new Map(); constructor(policy = MemoPolicy) { this.policy = policy; this._getCache = getEntityCaches(new Map()); } /** Compute denormalized form maintaining referential equality for same inputs */ denormalize(schema, input, entities, args = []) { // we already vary based on input, so we don't need endpointKey? TODO: verify // if (!this.endpoints[endpointKey]) // this.endpoints[endpointKey] = new WeakDependencyMap<EntityPath>(); // undefined means don't do anything if (schema === undefined) { return { data: input, paths: [] }; } if (input === undefined) { return { data: undefined, paths: [] }; } const getEntity = this.policy.getEntities(entities); return getUnvisit(getEntity, new GlobalCache(getEntity, this._getCache, this.endpoints), args)(schema, input); } /** Compute denormalized form maintaining referential equality for same inputs */ query(schema, args, state, // NOTE: different orders can result in cache busting here; but since it's just a perf penalty we will allow for now argsKey = JSON.stringify(args)) { const input = this.buildQueryKey(schema, args, state, argsKey); if (!input) { return { data: undefined, paths: [] }; } return this.denormalize(schema, input, state.entities, args); } buildQueryKey(schema, args, state, // NOTE: different orders can result in cache busting here; but since it's just a perf penalty we will allow for now argsKey = JSON.stringify(args)) { // This is redundant for buildQueryKey checks, but that was is used for recursion so we still need the checks there // TODO: If we make each recursive call include cache lookups, we combine these checks together // null is object so we need double check if (typeof schema !== 'object' && typeof schema.queryKey !== 'function' || !schema) return schema; // cache lookup: argsKey -> schema -> ...touched indexes or entities let queryCache = this.queryKeys.get(argsKey); if (!queryCache) { queryCache = new WeakDependencyMap(); this.queryKeys.set(argsKey, queryCache); } const baseDelegate = new this.policy.QueryDelegate(state); // eslint-disable-next-line prefer-const let [value, paths] = queryCache.get(schema, getDependency(baseDelegate)); // paths undefined is the only way to truly tell nothing was found (the value could have actually been undefined) if (!paths) { const [delegate, dependencies] = baseDelegate.tracked(schema); value = buildQueryKey(delegate)(schema, args); queryCache.set(dependencies, value); } return value; } } var ExpiryStatus = { Invalid: 1, InvalidIfStale: 2, Valid: 3 }; // looser version to allow for cross-package version compatibility exports.BaseDelegate = BaseDelegate; exports.ExpiryStatus = ExpiryStatus; exports.INVALID = INVALID; exports.MemoCache = MemoCache; exports.MemoPolicy = MemoPolicy; exports.WeakDependencyMap = WeakDependencyMap; exports.denormalize = denormalize; exports.isEntity = isEntity; exports.normalize = normalize; exports.validateQueryKey = validateQueryKey;