UNPKG

hybrids

Version:

The simplest way to create web components with plain objects and pure functions!

1,523 lines (1,293 loc) 41.1 kB
/* eslint-disable no-use-before-define */ import * as cache from "./cache.js"; import { storePointer } from "./utils.js"; const connect = Symbol("store.connect"); const definitions = new WeakMap(); const stales = new WeakMap(); const refs = new WeakSet(); function resolve(config, model, lastModel) { if (lastModel) { definitions.set(lastModel, null); stales.set(lastModel, model); } definitions.set(model, config); return model; } function shallowEqual(target, compare) { return Object.keys(target).every(key => target[key] === compare[key]); } function resolveWithInvalidate(config, model, lastModel) { resolve(config, model, lastModel); if ( config.invalidate && (!lastModel || error(model) || !config.isInstance(lastModel) || !shallowEqual(model, lastModel)) ) { config.invalidate(); } return model; } function syncCache(config, id, model, invalidate = true) { cache.set(config, id, invalidate ? resolveWithInvalidate : resolve, model); return model; } let currentTimestamp; function getCurrentTimestamp() { if (!currentTimestamp) { currentTimestamp = Date.now(); requestAnimationFrame(() => { currentTimestamp = undefined; }); } return currentTimestamp; } const timestamps = new WeakMap(); function getTimestamp(model) { let timestamp = timestamps.get(model); if (!timestamp) { timestamp = getCurrentTimestamp(); timestamps.set(model, timestamp); } return timestamp; } function setTimestamp(model) { timestamps.set(model, getCurrentTimestamp()); return model; } function invalidateTimestamp(model) { timestamps.set(model, 1); return model; } function hashCode(str) { return window.btoa( Array.from(str).reduce( // eslint-disable-next-line no-bitwise (s, c) => (Math.imul(31, s) + c.charCodeAt(0)) | 0, 0, ), ); } const offlinePrefix = "hybrids:store:cache"; const offlineKeys = {}; let clearPromise; function setupOfflineKey(config, threshold) { const key = `${offlinePrefix}:${hashCode(JSON.stringify(config.model))}`; offlineKeys[key] = getCurrentTimestamp() + threshold; if (!clearPromise) { clearPromise = Promise.resolve().then(() => { const previousKeys = JSON.parse(window.localStorage.getItem(offlinePrefix)) || {}; const timestamp = getCurrentTimestamp(); Object.keys(previousKeys).forEach(k => { /* istanbul ignore next */ if (!offlineKeys[k] && previousKeys[k] < timestamp) { window.localStorage.removeItem(k); delete previousKeys[k]; } }); window.localStorage.setItem( offlinePrefix, JSON.stringify({ ...previousKeys, ...offlineKeys }), ); clearPromise = null; }); } return key; } function setupStorage(config, options) { if (typeof options === "function") options = { get: options }; const result = { cache: true, loose: false, ...options }; if (result.cache === false || result.cache === 0) { result.validate = cachedModel => !cachedModel || getTimestamp(cachedModel) === getCurrentTimestamp(); } else if (typeof result.cache === "number") { result.validate = cachedModel => !cachedModel || getTimestamp(cachedModel) + result.cache > getCurrentTimestamp(); } else if (result.cache !== true) { throw TypeError( `Storage cache property must be a boolean or number: ${typeof result.cache}`, ); } if (!result.get) { result.get = id => { throw notFoundError(stringifyId(id)); }; } if (result.offline) { const isBool = result.offline === true; const threshold = isBool ? 1000 * 60 * 60 * 24 * 30 /* 30 days */ : result.offline; const offlineKey = setupOfflineKey(config, threshold); try { const items = JSON.parse(window.localStorage.getItem(offlineKey)) || {}; let flush; result.offline = Object.freeze({ key: offlineKey, threshold, get: isBool ? id => { if (hasOwnProperty.call(items, id)) { return JSON.parse(items[id][1]); } return null; } : id => { if (hasOwnProperty.call(items, id)) { const item = items[id]; if (item[0] + threshold < getCurrentTimestamp()) { delete items[id]; return null; } return JSON.parse(item[1]); } return null; }, set(id, values) { if (values) { items[id] = [ getCurrentTimestamp(), JSON.stringify(values, function replacer(key, value) { if (value === this[""]) return value; if (value && typeof value === "object") { const valueConfig = definitions.get(value); const offline = valueConfig && valueConfig.storage.offline; if (offline) { if (valueConfig.list) { return value.map(model => { configs .get(valueConfig.model) .storage.offline.set(model.id, model); return `${model}`; }); } valueConfig.storage.offline.set(value.id, value); return `${value}`; } } return value; }), ]; } else { delete items[id]; } if (!flush) { flush = Promise.resolve().then(() => { const timestamp = getCurrentTimestamp(); Object.keys(items).forEach(key => { if (items[key][0] + threshold < timestamp) { delete items[key]; } }); window.localStorage.setItem(offlineKey, JSON.stringify(items)); flush = null; }); } return values; }, }); } catch (e) /* istanbul ignore next */ { console.error(e); result.offline = false; } } return Object.freeze(result); } function memoryStorage(config) { return { get: config.enumerable ? () => {} : () => config.create({}), set: config.enumerable ? (id, values) => values : (id, values) => (values === null ? { id } : values), list: config.enumerable && function list(id) { if (id) { throw TypeError(`Memory-based model definition does not support id`); } return cache.getEntries(config).reduce((acc, { key, value }) => { if (key === config) return acc; if (value && !error(value)) acc.push(key); return acc; }, []); }, loose: true, }; } function bootstrap(Model, nested) { if (Array.isArray(Model)) { return setupListModel(Model[0], nested); } return setupModel(Model, nested); } function getTypeConstructor(type, key) { switch (type) { case "string": return v => (v !== undefined && v !== null ? String(v) : ""); case "number": return Number; case "boolean": return Boolean; default: throw TypeError( `The value of the '${key}' must be a string, number or boolean: ${type}`, ); } } const stateSetter = (_, value, lastValue) => { if (value.state === "error") { return { state: "error", error: value.value }; } value.error = !!lastValue && lastValue.error; return value; }; function setModelState(model, state, value = model) { cache.set(model, "state", stateSetter, { state, value }); return model; } const stateGetter = ( model, v = { state: "ready", value: model, error: false }, ) => v; function getModelState(model) { return cache.get(model, "state", stateGetter); } // UUID v4 generator thanks to https://gist.github.com/jed/982883 function uuid(temp) { return temp ? // eslint-disable-next-line no-bitwise, no-mixed-operators (temp ^ ((Math.random() * 16) >> (temp / 4))).toString(16) : ([1e7] + -1e3 + -4e3 + -8e3 + -1e11).replace(/[018]/g, uuid); } function ref(fn) { if (typeof fn !== "function") { throw TypeError(`The first argument must be a funtion: ${typeof fn}`); } refs.add(fn); return fn; } const validationMap = new WeakMap(); function resolveKey(Model, key, config) { let defaultValue = config.model[key]; if (refs.has(defaultValue)) defaultValue = defaultValue(); let type = typeof defaultValue; if (defaultValue instanceof String || defaultValue instanceof Number) { const check = validationMap.get(defaultValue); if (!check) { throw TypeError( stringifyModel( Model, `You must use primitive ${typeof defaultValue.valueOf()} value for '${key}' property of the provided model definition`, ), ); } defaultValue = defaultValue.valueOf(); type = typeof defaultValue; config.checks.set(key, check); } return { defaultValue, type }; } function stringifyModel(Model, msg) { return `${msg}\n\nModel = ${JSON.stringify(Model, null, 2)}\n`; } const resolvedPromise = Promise.resolve(); export const configs = new WeakMap(); function setupModel(Model, nested) { if (typeof Model !== "object" || Model === null) { throw TypeError(`Model definition must be an object: ${typeof Model}`); } let config = configs.get(Model); if (config && !config.enumerable) { if (nested && !config.nested) { throw TypeError( stringifyModel( Model, "Provided model definition for nested object already used as a root definition", ), ); } if (!nested && config.nested) { throw TypeError( stringifyModel( Model, "Nested model definition cannot be used outside of the parent definition", ), ); } } if (!config) { const storage = Model[connect]; if (typeof storage === "object") Object.freeze(storage); let invalidatePromise; const enumerable = hasOwnProperty.call(Model, "id"); const external = !!storage; const checks = new Map(); const proto = { toString() { return this.id || undefined; }, }; const placeholder = Object.create(proto); config = { model: Model, external, enumerable, nested: !enumerable && !external && nested, placeholder: id => { const model = Object.create(placeholder); definitions.set(model, config); if (enumerable) model.id = id; return Object.freeze(model); }, isInstance: model => Object.getPrototypeOf(model) !== placeholder, invalidate: () => { if (!invalidatePromise) { invalidatePromise = resolvedPromise.then(() => { cache.invalidate(config, config, { clearValue: true }); invalidatePromise = null; }); } }, checks, }; configs.set(Model, config); config.storage = setupStorage(config, storage || memoryStorage(config)); const transform = Object.keys(Object.freeze(Model)).map(key => { if (key !== "id") { Object.defineProperty(placeholder, key, { get() { throw Error( `Model instance in ${ getModelState(this).state } state - use store.pending(), store.error(), or store.ready() guards`, ); }, enumerable: true, }); } if (key === "id") { if (Model[key] !== true) { throw TypeError( "The 'id' property in model definition must be set to 'true' or not be defined", ); } return (model, data, lastModel) => { let id; if (hasOwnProperty.call(data, "id")) { id = stringifyId(data.id); } else if (lastModel) { id = lastModel.id; } else { id = uuid(); } Object.defineProperty(model, "id", { value: id, enumerable: true }); }; } const { defaultValue, type } = resolveKey(Model, key, config); switch (type) { case "function": return model => { let resolved; let value; Object.defineProperty(model, key, { get() { if (!resolved) { value = defaultValue(this); resolved = true; } return value; }, }); }; case "object": { if (defaultValue === null) { throw TypeError( `The value for the '${key}' must be an object instance: ${defaultValue}`, ); } const isArray = Array.isArray(defaultValue); if (isArray) { const nestedType = typeof defaultValue[0]; if (nestedType !== "object") { const Constructor = getTypeConstructor(nestedType, key); const defaultArray = Object.freeze(defaultValue.map(Constructor)); return (model, data, lastModel) => { if (hasOwnProperty.call(data, key)) { if (!Array.isArray(data[key])) { throw TypeError( `The value for '${key}' property must be an array: ${typeof data[ key ]}`, ); } model[key] = Object.freeze(data[key].map(Constructor)); } else if (lastModel && hasOwnProperty.call(lastModel, key)) { model[key] = lastModel[key]; } else { model[key] = defaultArray; } }; } const localConfig = bootstrap(defaultValue, true); if ( localConfig.external && config.storage.offline && localConfig.storage.offline && localConfig.storage.offline.threshold < config.storage.offline.threshold ) { throw Error( `External nested model for '${key}' property has lower offline threshold (${localConfig.storage.offline.threshold} ms) than the parent definition (${config.storage.offline.threshold} ms)`, ); } if (localConfig.enumerable && defaultValue[1]) { const nestedOptions = defaultValue[1]; if (typeof nestedOptions !== "object") { throw TypeError( `Options for '${key}' array property must be an object instance: ${typeof nestedOptions}`, ); } if (nestedOptions.loose) { config.contexts = config.contexts || new Set(); config.contexts.add(bootstrap(defaultValue[0])); } } return (model, data, lastModel) => { if (hasOwnProperty.call(data, key)) { if (!Array.isArray(data[key])) { throw TypeError( `The value for '${key}' property must be an array: ${typeof data[ key ]}`, ); } model[key] = localConfig.create(data[key], true); } else { model[key] = (lastModel && lastModel[key]) || (!localConfig.enumerable && localConfig.create(defaultValue)) || []; } }; } const nestedConfig = bootstrap(defaultValue, true); if (nestedConfig.enumerable || nestedConfig.external) { if ( config.storage.offline && nestedConfig.storage.offline && nestedConfig.storage.offline.threshold < config.storage.offline.threshold ) { throw Error( `External nested model for '${key}' property has lower offline threshold (${nestedConfig.storage.offline.threshold} ms) than the parent definition (${config.storage.offline.threshold} ms)`, ); } return (model, data, lastModel) => { let resultModel; if (hasOwnProperty.call(data, key)) { const nestedData = data[key]; if (typeof nestedData !== "object" || nestedData === null) { if (nestedData !== undefined && nestedData !== null) { resultModel = { id: nestedData }; } } else { const dataConfig = definitions.get(nestedData); if (dataConfig) { if (dataConfig.model !== defaultValue) { throw TypeError( "Model instance must match the definition", ); } resultModel = nestedData; } else { resultModel = nestedConfig.create(nestedData); syncCache(nestedConfig, resultModel.id, resultModel); } } } else { resultModel = lastModel && lastModel[key]; } if (resultModel) { const id = resultModel.id; Object.defineProperty(model, key, { get() { return cache.get(this, key, () => get(defaultValue, id)); }, enumerable: true, }); } else { model[key] = undefined; } }; } return (model, data, lastModel) => { if (hasOwnProperty.call(data, key)) { model[key] = nestedConfig.create( data[key], lastModel && lastModel[key], ); } else { model[key] = lastModel ? lastModel[key] : nestedConfig.create({}); } }; } // eslint-disable-next-line no-fallthrough default: { const Constructor = getTypeConstructor(type, key); return (model, data, lastModel) => { if (hasOwnProperty.call(data, key)) { model[key] = Constructor(data[key]); } else if (lastModel && hasOwnProperty.call(lastModel, key)) { model[key] = lastModel[key]; } else { model[key] = defaultValue; } }; } } }); config.create = function create(data, lastModel) { if (data === null) return null; if (typeof data !== "object") { throw TypeError(`Model values must be an object instance: ${data}`); } const model = transform.reduce((acc, fn) => { fn(acc, data, lastModel); return acc; }, Object.create(proto)); definitions.set(model, config); storePointer.set(model, store); return Object.freeze(model); }; Object.freeze(placeholder); Object.freeze(config); } return config; } const listPlaceholderPrototype = Object.getOwnPropertyNames( Array.prototype, ).reduce((acc, key) => { if (key === "length" || key === "constructor") return acc; Object.defineProperty(acc, key, { get() { throw Error( `Model list instance in ${ getModelState(this).state } state - use store.pending(), store.error(), or store.ready() guards`, ); }, }); return acc; }, []); export const lists = new WeakMap(); function setupListModel(Model, nested) { let config = lists.get(Model); if (config && !config.enumerable) { if (!nested && config.nested) { throw TypeError( stringifyModel( Model, "Nested model definition cannot be used outside of the parent definition", ), ); } } if (!config) { const modelConfig = setupModel(Model); const contexts = new Set(); if (modelConfig.storage.loose) contexts.add(modelConfig); if (!nested) { if (!modelConfig.enumerable) { throw TypeError( stringifyModel( Model, "Provided model definition does not support listing (it must be enumerable - set `id` property to `true`)", ), ); } if (!modelConfig.storage.list) { throw TypeError( stringifyModel( Model, "Provided model definition storage does not support `list` action", ), ); } } nested = !modelConfig.enumerable && !modelConfig.external && nested; config = { list: true, nested, model: Model, contexts, enumerable: modelConfig.enumerable, external: modelConfig.external, storage: Object.freeze({ ...setupStorage(config, { cache: modelConfig.storage.cache, get: !nested && (id => modelConfig.storage.list(id)), }), offline: modelConfig.storage.offline && { threshold: modelConfig.storage.offline.threshold, get: id => { const result = modelConfig.storage.offline.get( hashCode(String(stringifyId(id))), ); return result ? result.map(item => modelConfig.storage.offline.get(item)) : null; }, set: (id, values) => { modelConfig.storage.offline.set( hashCode(String(stringifyId(id))), values.map(item => { modelConfig.storage.offline.set(item.id, item); return item.id; }), ); }, }, }), placeholder: () => { const model = Object.create(listPlaceholderPrototype); definitions.set(model, config); return Object.freeze(model); }, isInstance: model => Object.getPrototypeOf(model) !== listPlaceholderPrototype, create(items, invalidate = false) { if (items === null) return null; const result = items.reduce((acc, data) => { let id = data; if (typeof data === "object" && data !== null) { id = data.id; const dataConfig = definitions.get(data); let model = data; if (dataConfig) { if (dataConfig.model !== Model) { throw TypeError("Model instance must match the definition"); } } else { model = modelConfig.create(data); if (modelConfig.enumerable) { id = model.id; syncCache(modelConfig, id, model, invalidate); } } if (!modelConfig.enumerable) { acc.push(model); } } else if (!modelConfig.enumerable) { throw TypeError(`Model instance must be an object: ${typeof data}`); } if (modelConfig.enumerable) { const key = acc.length; Object.defineProperty(acc, key, { get() { return cache.get(this, key, () => get(Model, id)); }, enumerable: true, }); } return acc; }, []); definitions.set(result, config); storePointer.set(result, store); return Object.freeze(result); }, }; lists.set(Model, Object.freeze(config)); } return config; } function resolveTimestamp(h, v) { return v || getCurrentTimestamp(); } function stringifyId(id) { switch (typeof id) { case "object": return JSON.stringify( Object.keys(id) .sort() .reduce((acc, key) => { if (typeof id[key] === "object" && id[key] !== null) { throw TypeError( `You must use primitive value for '${key}' key: ${typeof id[ key ]}`, ); } acc[key] = id[key]; return acc; }, {}), ); case "undefined": return undefined; default: return String(id); } } const notFoundErrors = new WeakSet(); function notFoundError(Model, stringId) { const err = Error( stringifyModel( Model, `Model instance ${ stringId !== undefined ? `with '${stringId}' id ` : "" }does not exist`, ), ); notFoundErrors.add(err); return err; } function mapError(model, err, suppressLog) { if (suppressLog !== false && !notFoundErrors.has(err)) { // eslint-disable-next-line no-console console.error(err); } return setModelState(model, "error", err); } function get(Model, id) { const config = bootstrap(Model); let stringId; if (config.enumerable) { stringId = stringifyId(id); if (!config.list && !stringId) { throw TypeError( stringifyModel( Model, `Provided model definition requires non-empty id: "${stringId}"`, ), ); } } else if (id !== undefined) { throw TypeError( stringifyModel(Model, "Provided model definition does not support id"), ); } const validate = config.storage.validate; if (validate) { const entry = cache.getEntry(config, stringId); if (entry.value && !validate(entry.value)) { entry.resolved = false; entry.depState = 0; } } const offline = config.storage.offline; return cache.get(config, stringId, (h, cachedModel) => { if (cachedModel && pending(cachedModel)) return cachedModel; let validContexts = true; if (config.contexts) { config.contexts.forEach(context => { if ( cache.get(context, context, resolveTimestamp) === getCurrentTimestamp() ) { validContexts = false; } }); } if ( validContexts && cachedModel && (config.storage.cache === true || config.storage.validate(cachedModel)) ) { return cachedModel; } const fallback = () => cachedModel || (offline && config.create(offline.get(stringId))) || config.placeholder(stringId); try { let result = config.storage.get(id); if (typeof result !== "object" || result === null) { if (offline) offline.set(stringId, null); throw notFoundError(Model, stringId); } if (result instanceof Promise) { result = result .then(data => { if (typeof data !== "object" || data === null) { if (offline) offline.set(stringId, null); throw notFoundError(Model, stringId); } const model = config.create( !config.list && stringId ? { ...data, id: stringId } : data, ); if (offline) offline.set(stringId, model); return syncCache(config, stringId, setTimestamp(model)); }) .catch(e => syncCache(config, stringId, mapError(fallback(), e))); return setModelState(fallback(), "pending", result); } if (cachedModel) definitions.set(cachedModel, null); const model = config.create( !config.list && stringId ? { ...result, id: stringId } : result, ); if (offline) offline.set(stringId, model); return setTimestamp(model); } catch (e) { return setTimestamp(mapError(fallback(), e)); } }); } const draftMap = new WeakMap(); function getValidationError(errors) { const keys = Object.keys(errors); const e = Error( `Model validation failed (${keys.join( ", ", )}) - read the details from 'errors' property`, ); e.errors = errors; return e; } function set(model, values = {}) { let config = definitions.get(model); if (config === null) { model = stales.get(model); config = definitions.get(model); } if (config === null) { throw Error( "Provided model instance has expired. Haven't you used stale value?", ); } const isInstance = !!config; if (!config) config = bootstrap(model); const isDraft = draftMap.get(config); if (config.nested) { throw stringifyModel( config.model, TypeError( "Setting provided nested model instance is not supported, use the root model instance", ), ); } if (config.list) { throw TypeError("Listing model definition does not support 'set' method"); } if (!config.storage.set) { throw stringifyModel( config.model, TypeError( "Provided model definition storage does not support 'set' method", ), ); } if (isInstance) { const promise = pending(model); if (promise) { return promise.then(m => set(m, values)); } } let id; const setState = (state, value) => { if (isInstance) { setModelState(model, state, value); } else { const entry = cache.getEntry(config, id); if (entry.value) { setModelState(entry.value, state, value); } } }; try { if ( config.enumerable && !isInstance && (!values || typeof values !== "object") ) { throw TypeError(`Values must be an object instance: ${values}`); } if (!isDraft && values && hasOwnProperty.call(values, "id")) { throw TypeError(`Values must not contain 'id' property: ${values.id}`); } const localModel = config.create(values, isInstance ? model : undefined); const keys = values ? Object.keys(values) : []; const errors = {}; const lastError = isInstance && isDraft && error(model); let hasErrors = false; if (localModel) { config.checks.forEach((fn, key) => { if (keys.indexOf(key) === -1) { if (lastError && lastError.errors && lastError.errors[key]) { hasErrors = true; errors[key] = lastError.errors[key]; } // eslint-disable-next-line eqeqeq if (isDraft && localModel[key] == config.model[key]) { return; } } let checkResult; try { checkResult = fn(localModel[key], key, localModel); } catch (e) { checkResult = e; } if (checkResult !== true && checkResult !== undefined) { hasErrors = true; errors[key] = checkResult || true; } }); if (hasErrors && !isDraft) { throw getValidationError(errors); } } id = localModel ? localModel.id : model.id; const result = Promise.resolve( config.storage.set(isInstance ? id : undefined, localModel, keys), ) .then(data => { const resultModel = data === localModel ? localModel : config.create(data); if (isInstance && resultModel && id !== resultModel.id) { throw TypeError( `Local and storage data must have the same id: '${id}', '${resultModel.id}'`, ); } let resultId = resultModel ? resultModel.id : id; if (hasErrors && isDraft) { setModelState(resultModel, "error", getValidationError(errors)); } if ( isDraft && isInstance && hasOwnProperty.call(data, "id") && (!localModel || localModel.id !== model.id) ) { resultId = model.id; } else if (config.storage.offline) { config.storage.offline.set(resultId, resultModel); } return syncCache( config, resultId, resultModel || mapError( config.placeholder(resultId), notFoundError(config.model, id), false, ), true, ); }) .catch(err => { err = err !== undefined ? err : Error("Undefined error"); setState("error", err); throw err; }); setState("pending", result); return result; } catch (e) { setState("error", e); return Promise.reject(e); } } function sync(model, values) { if (typeof values !== "object") { throw TypeError(`Values must be an object instance: ${values}`); } let config = definitions.get(model); if (config === null) { model = stales.get(model); config = definitions.get(model); } if (config === null) { throw Error( "Provided model instance has expired. Haven't you used stale value?", ); } if (config === undefined) { if (!values) { throw TypeError("Values must be defined for usage with model definition"); } config = bootstrap(model); model = undefined; } else if (values && hasOwnProperty.call(values, "id")) { throw TypeError(`Values must not contain 'id' property: ${values.id}`); } if (config.list) { throw TypeError("Listing model definition is not supported in sync method"); } const resultModel = config.create(values, model); const id = values ? resultModel.id : model.id; return syncCache( config, id, resultModel || mapError( config.placeholder(id), Error( `Model instance ${ id !== undefined ? ` with '${id}' id` : "" }does not exist`, ), false, ), ); } function clear(model, clearValue = true) { if (typeof model !== "object" || model === null) { throw TypeError( `The first argument must be a model instance or a model definition: ${model}`, ); } let config = definitions.get(model); if (config === null) { throw Error( "Provided model instance has expired. Haven't you used stale value from the outer scope?", ); } if (config) { const offline = clearValue && config.storage.offline; if (offline) offline.set(model.id, null); invalidateTimestamp(model); cache.invalidate(config, model.id, { clearValue, deleteEntry: true }); } else { if (!configs.get(model) && !lists.get(model[0])) { throw Error( "Model definition must be used before - passed argument is probably not a model definition", ); } config = bootstrap(model); const offline = clearValue && config.storage.offline; cache.getEntries(config).forEach(entry => { if (offline) offline.set(entry.key, null); if (entry.value) invalidateTimestamp(entry.value); }); cache.invalidateAll(config, { clearValue, deleteEntry: true }); } } function pending(...models) { let isPending = false; const result = models.map(model => { try { const { state, value } = getModelState(model); if (state === "pending") { isPending = true; return value; } } catch (e) {} // eslint-disable-line no-empty return Promise.resolve(model); }); return isPending && (models.length > 1 ? Promise.all(result) : result[0]); } function resolveToLatest(model) { model = stales.get(model) || model; const promise = pending(model); if (!promise) { const e = error(model); return e ? Promise.reject(e) : Promise.resolve(model); } return promise.then(m => resolveToLatest(m)); } function error(model, property) { if (model === null || typeof model !== "object") return false; const state = getModelState(model); if ( property !== undefined && typeof state.error === "object" && state.error ) { return state.error.errors && state.error.errors[property]; } return state.error; } function ready(...models) { return ( models.length > 0 && models.every(model => { const config = definitions.get(model); return !!(config && config.isInstance(model)); }) ); } function mapValueWithState(lastValue, nextValue) { const result = Object.freeze( Object.keys(lastValue).reduce((acc, key) => { Object.defineProperty(acc, key, { get: () => lastValue[key], enumerable: true, }); return acc; }, Object.create(lastValue)), ); definitions.set(result, definitions.get(lastValue)); cache.set(result, "state", () => getModelState(nextValue)); return result; } function getValuesFromModel(model, values) { model = { ...model, ...values }; delete model.id; return model; } function submit(draft, values = {}) { const config = definitions.get(draft); if (!config || !draftMap.has(config)) { throw TypeError(`Provided model instance is not a draft: ${draft}`); } if (pending(draft)) { throw Error("Model draft in pending state"); } const options = draftMap.get(config); let result; if (!options.id) { result = set(options.model, getValuesFromModel(draft, values)); } else { const model = get(options.model, draft.id); result = Promise.resolve(pending(model) || model).then(resolvedModel => set(resolvedModel, getValuesFromModel(draft, values)), ); } result = result .then(resultModel => { setModelState(draft, "ready"); return set(draft, resultModel).then(() => resultModel); }) .catch(e => { setModelState(draft, "error", e); return Promise.reject(e); }); setModelState(draft, "pending", result); return result; } function required(value, key) { return !!value || `${key} is required`; } function valueWithValidation( defaultValue, validate = required, errorMessage = "", ) { switch (typeof defaultValue) { case "string": // eslint-disable-next-line no-new-wrappers defaultValue = new String(defaultValue); break; case "number": // eslint-disable-next-line no-new-wrappers defaultValue = new Number(defaultValue); break; default: throw TypeError( `Default value must be a string or a number: ${typeof defaultValue}`, ); } let fn; if (validate instanceof RegExp) { fn = value => validate.test(value) || errorMessage; } else if (typeof validate === "function") { fn = (...args) => { const result = validate(...args); return result !== true && result !== undefined ? result || errorMessage : result; }; } else { throw TypeError( `The second argument must be a RegExp instance or a function: ${typeof validate}`, ); } validationMap.set(defaultValue, fn); return defaultValue; } function store(Model, options = {}) { const config = bootstrap(Model); if (typeof options !== "object") { options = { id: options }; } if (options.id !== undefined && typeof options.id !== "function") { const id = options.id; options.id = host => host[id]; } if (options.draft) { if (config.list) { throw TypeError( "Draft mode is not supported for listing model definition", ); } Model = { ...Model, [connect]: { get(id) { const model = get(config.model, id); return ready(model) ? model : pending(model); }, set(id, values) { return values === null ? { id } : values; }, }, }; options.draft = bootstrap(Model); draftMap.set(options.draft, { model: config.model, id: options.id }); } const createMode = options.draft && ((config.enumerable && !options.id) || (!config.enumerable && config.external)); const desc = { get: (host, lastValue) => { if (createMode && !lastValue) { const nextValue = options.draft.create({}); syncCache(options.draft, nextValue.id, nextValue, false); return get(Model, nextValue.id); } const id = (options.draft || options.id === undefined) && lastValue ? lastValue.id : options.id && options.id(host); const nextValue = get(Model, id); if (lastValue && nextValue !== lastValue && !ready(nextValue)) { return mapValueWithState(lastValue, nextValue); } return nextValue; }, connect: options.draft ? (host, key) => () => { cache.invalidate(host, key, { clearValue: true }); clear(Model, false); } : undefined, }; if (!options.id && !options.draft && (config.enumerable || config.list)) { desc.set = (host, values) => { const valueConfig = definitions.get(values); if (valueConfig) { if (valueConfig === config) return values; throw TypeError("Model instance must match the definition"); } return store.get(Model, values); }; } else if (!config.list) { desc.set = (host, values, lastValue) => { if (!lastValue || !ready(lastValue)) lastValue = desc.get(host); store.set(lastValue, values).catch(/* istanbul ignore next */ () => {}); return lastValue; }; } return desc; } export default Object.assign(store, { // storage connect, // actions get, set, sync, clear, // guards pending, error, ready, // helpers submit, value: valueWithValidation, resolve: resolveToLatest, ref, });