hybrids
Version:
A JavaScript framework for creating fully-featured web applications, components libraries, and single web components with unique declarative and functional architecture
1,793 lines (1,518 loc) • 47.6 kB
JavaScript
import * as cache from "./cache.js";
import { storePointer, deferred, isDebugMode } from "./utils.js";
const connect = Symbol("store.connect");
const definitions = new WeakMap();
const stales = new WeakMap();
const observers = new WeakMap();
function resolve(config, model, lastModel) {
if (lastModel) {
definitions.set(lastModel, null);
stales.set(lastModel, model);
}
definitions.set(model, config);
if (observers.has(config)) {
const modelValue = model && config.isInstance(model) ? model : null;
const lastModelValue =
lastModel && config.isInstance(lastModel) ? lastModel : null;
if (modelValue !== lastModelValue) {
let id = lastModelValue ? lastModelValue.id : modelValue.id;
observers.get(config).forEach((fn) => {
try {
fn(id, modelValue, lastModelValue);
} catch (e) {
console.error(e);
}
});
}
}
return model;
}
function resolveWithInvalidate(config, model, lastModel) {
resolve(config, model, lastModel);
if (
config.invalidate &&
(config.storage.loose || !lastModel || !config.isInstance(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();
deferred.then(() => {
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 globalThis.btoa(
Array.from(str).reduce(
(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(globalThis.localStorage.getItem(offlinePrefix)) || {};
const timestamp = getCurrentTimestamp();
/* istanbul ignore next */
for (const k of Object.keys(previousKeys)) {
if (!offlineKeys[k] && previousKeys[k] < timestamp) {
globalThis.localStorage.removeItem(k);
delete previousKeys[k];
}
}
globalThis.localStorage.setItem(
offlinePrefix,
JSON.stringify({ ...previousKeys, ...offlineKeys }),
);
clearPromise = null;
});
}
return key;
}
export function observe(Model, fn) {
const config = bootstrap(Model);
if (typeof fn !== "function") {
throw TypeError(`The second argument must be a function: ${typeof fn}`);
}
const set = observers.get(config) || new Set();
set.add(fn);
observers.set(config, set);
return () => {
set.delete(fn);
if (set.size === 0) observers.delete(config);
};
}
function setupStorage(config, options) {
if (typeof options === "function") options = { get: options };
const result = {
cache: true,
loose: false,
...options,
};
if (result.observe) {
const fn = result.observe;
if (typeof fn !== "function") {
throw TypeError(
`Storage 'observe' property must be a function: ${typeof fn}`,
);
}
const set = observers.get(config) || new Set();
set.add(fn);
observers.set(config, set);
}
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}`,
);
}
result.validate = (cachedModel) => getTimestamp(cachedModel) !== 1;
}
if (!result.get) {
result.get = (id) => {
throw notFoundError(stringifyId(id));
};
}
if (result.offline) {
try {
const isBool = result.offline === true;
const threshold = isBool
? 1000 * 60 * 60 * 24 * 30 /* 30 days */
: result.offline;
const offlineKey = setupOfflineKey(config, threshold);
const items =
JSON.parse(globalThis.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);
if (valueConfig === config && value.id === id) {
return String(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();
for (const key of Object.keys(items)) {
if (items[key][0] + threshold < timestamp) {
delete items[key];
}
}
globalThis.localStorage.setItem(
offlineKey,
JSON.stringify(items),
);
flush = null;
});
}
return values;
},
});
} catch (e) /* istanbul ignore next */ {
console.error("Error while setup offline cache", e);
result.offline = false;
}
}
return Object.freeze(result);
}
function memoryStorage(config) {
return {
get: config.enumerable ? () => null : () => 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`);
}
const result = [];
for (const { key, value } of cache.getEntries(config)) {
if (key !== config && value && !error(value)) result.push(key);
}
return result;
},
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}`,
);
}
}
function setModelState(model, state, value) {
const lastConfig = cache.getEntry(model, "state").value;
cache.assert(
model,
"state",
{
state,
value,
error: (state === "error" ? value : lastConfig?.error) || false,
},
true,
);
return model;
}
function getModelState(model) {
return cache.get(
model,
"state",
(model, config = { state: "ready", error: false }) => config,
);
}
// UUID v4 generator thanks to https://gist.github.com/jed/982883
function uuid(temp) {
return temp
? (temp ^ ((Math.random() * 16) >> (temp / 4))).toString(16)
: ([1e7] + -1e3 + -4e3 + -8e3 + -1e11).replace(/[018]/g, uuid);
}
const refs = new WeakSet();
const records = new WeakMap();
function ref(fn) {
if (typeof fn !== "function") {
throw TypeError(`The first argument must be a function: ${typeof fn}`);
}
refs.add(fn);
return fn;
}
function record(value) {
if (value === undefined || value === null) {
throw TypeError(`The value must be defined: ${value}`);
}
if (!refs.has(value) && typeof value === "function") {
throw TypeError(`A function is not supported as the value of the record`);
}
const model = Object.freeze({});
records.set(model, value);
return model;
}
const validationMap = new WeakMap();
function resolveKey(Model, key, config) {
let defaultValue = config.model[key];
if (refs.has(defaultValue)) defaultValue = defaultValue();
if (records.has(defaultValue)) {
const value = records.get(defaultValue);
if (typeof value === "function") {
throw TypeError(
`A function is not supported as the value of the record for '${key}' property`,
);
}
return {
defaultValue: { id: true, value },
type: "record",
};
}
let type = typeof defaultValue;
if (
defaultValue instanceof String ||
defaultValue instanceof Number ||
defaultValue instanceof Boolean
) {
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 definition:\n\n${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 = {};
Object.defineProperty(proto, "toString", {
value: function () {
return this.id;
},
});
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);
storePointer.set(model, store);
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(
stringifyModel(
Model,
`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(
stringifyModel(
Model,
"The 'id' property in the model definition must be set to 'true' or not be defined",
),
);
}
return (model, data, lastModel) => {
let id;
if (hasOwnProperty.call(data, "id")) {
id = normalizeId(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) => {
Object.defineProperty(model, key, {
get() {
return cache.get(this, key, () => defaultValue(this));
},
});
};
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 === "undefined") {
throw TypeError(
`The first item of the '${key}' array must be defined`,
);
}
if (nestedType !== "object") {
if (
nestedType === "function" &&
![String, Number, Boolean].includes(defaultValue[0])
) {
throw TypeError(
`The array item for the '${key}' must be one of the primitive types constructor: String, Number, or Boolean`,
);
}
const Constructor =
nestedType === "function"
? defaultValue[0]
: getTypeConstructor(nestedType, key);
const defaultArray =
nestedType === "function"
? []
: 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)) ||
[];
}
};
}
if (Object.keys(defaultValue).length === 0) {
throw TypeError(
`The object for the '${key}' must have at least one property`,
);
}
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 {
const lastNestedModel = cache.getEntry(
nestedConfig,
data[key].id,
).value;
resultModel = nestedConfig.create(
nestedData,
lastNestedModel &&
nestedConfig.isInstance(lastNestedModel)
? lastNestedModel
: undefined,
);
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] =
data[key] === null
? nestedConfig.create({})
: nestedConfig.create(data[key], lastModel && lastModel[key]);
} else {
model[key] = lastModel ? lastModel[key] : nestedConfig.create({});
}
};
}
case "record": {
const localConfig = bootstrap(defaultValue, true);
return (model, data, lastModel) => {
if (data[key] === null) {
model[key] = {};
return;
}
if (data[key] === undefined) {
model[key] = lastModel?.[key] ?? {};
return;
}
if (typeof data[key] !== "object") {
throw TypeError(
`The value for the '${key}' must be an object instance: ${typeof data[key]}`,
);
}
const record = data[key];
let result = {};
if (lastModel) {
for (const id of Object.keys(lastModel[key])) {
if (!hasOwnProperty.call(record, id)) {
Object.defineProperty(result, id, {
get() {
return lastModel[key][id];
},
enumerable: true,
configurable: true,
});
}
}
}
for (const id of Object.keys(record)) {
if (record[id] === null) {
continue;
}
const item = localConfig.create(
{ id, value: record[id] },
{ id, value: lastModel && lastModel[key][id] },
);
Object.defineProperty(result, id, {
get() {
return cache.get(this, id, () => item.value);
},
enumerable: true,
});
}
model[key] = result;
};
}
// 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 = Object.create(proto);
for (const fn of transform) {
fn(model, data, lastModel);
}
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(
stringifyModel(
get(definitions.get(this).model),
`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,
placeholder: (id) => {
const model = Object.create(listPlaceholderPrototype);
definitions.set(model, config);
Object.defineProperties(model, {
id: { value: id },
toString: {
value: function () {
return this.id;
},
},
});
return Object.freeze(model);
},
isInstance: (model) =>
Object.getPrototypeOf(model) !== listPlaceholderPrototype,
create(items, invalidate = false) {
if (items === null) return null;
const result = [];
for (const data of items) {
let id;
if (typeof data === "object" && data !== null) {
id = stringifyId(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 {
const lastModel =
modelConfig.enumerable && cache.getEntry(modelConfig, id).value;
model = modelConfig.create(
data,
lastModel && modelConfig.isInstance(lastModel)
? lastModel
: undefined,
);
if (modelConfig.enumerable) {
id = stringifyId(model.id);
syncCache(modelConfig, id, model, invalidate);
}
}
if (!modelConfig.enumerable) {
result.push(model);
}
} else {
if (!modelConfig.enumerable) {
throw TypeError(
`Model instance must be an object: ${typeof data}`,
);
}
id = stringifyId(data);
}
if (modelConfig.enumerable) {
const key = result.length;
Object.defineProperty(result, key, {
get() {
return cache.get(this, key, () => get(Model, id));
},
enumerable: true,
});
}
}
Object.defineProperties(result, {
id: { value: items.id },
toString: {
value: function () {
return this.id;
},
},
});
definitions.set(result, config);
storePointer.set(result, store);
return Object.freeze(result);
},
};
config.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 stringId = stringifyId(id);
let result = modelConfig.storage.offline.get(
hashCode(String(stringId)),
);
if (result) {
result = result.map((item) =>
modelConfig.storage.offline.get(item),
);
result.id = stringId;
return result;
}
return 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;
}),
);
},
},
});
lists.set(Model, Object.freeze(config));
}
return config;
}
function normalizeId(id) {
if (typeof id !== "object") return id !== undefined ? String(id) : id;
const result = {};
for (const key of Object.keys(id).sort()) {
if (typeof id[key] === "object" && id[key] !== null) {
throw TypeError(
`You must use primitive value for '${key}' key: ${typeof id[key]}`,
);
}
result[key] = id[key];
}
return result;
}
function stringifyId(id) {
id = normalizeId(id);
return typeof id === "object" ? JSON.stringify(id) : 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) {
if (isDebugMode() && !notFoundErrors.has(err)) {
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 (!stringId && !config.list && !draftMap.get(config)) {
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: ${JSON.stringify(id)}`,
),
);
}
const { offline, validate } = config.storage;
const entry = cache.getEntry(config, stringId);
const cachedModel = entry.value;
if (
cachedModel &&
getModelState(cachedModel).state !== "pending" &&
!validate(cachedModel)
) {
entry.resolved = false;
}
return cache.get(config, stringId, () => {
id = normalizeId(id);
let validContexts = true;
if (config.contexts) {
for (const context of config.contexts) {
if (
cache.get(context, context, () => getCurrentTimestamp()) ===
getCurrentTimestamp()
) {
validContexts = false;
}
}
}
if (validContexts && cachedModel && validate(cachedModel)) {
return cachedModel;
}
const fallback = () =>
cachedModel ||
(offline && config.create(offline.get(stringId))) ||
config.placeholder(id);
let result;
try {
result = config.storage.get(id);
} catch (e) {
return setTimestamp(mapError(fallback(), e));
}
if (
!(result instanceof Promise) &&
result !== undefined &&
typeof result !== "object"
) {
throw TypeError(
stringifyModel(
Model,
`Storage 'get' method must return a Promise, an instance, or null: ${result}`,
),
);
}
try {
if (typeof result !== "object" || result === null) {
if (offline) offline.set(stringId, null);
throw notFoundError(Model, stringId);
}
} catch (e) {
return setTimestamp(mapError(fallback(), e));
}
if (result instanceof Promise) {
result = result
.then((data) => {
if (data !== undefined && typeof data !== "object") {
throw TypeError(
stringifyModel(
Model,
`Storage 'get' method must resolve to an instance, or null: ${data}`,
),
);
}
if (typeof data !== "object" || data === null) {
if (offline) offline.set(stringId, null);
throw notFoundError(Model, stringId);
}
if (data.id !== id) data.id = id;
const model = config.create(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 (result.id !== id) result.id = id;
const model = config.create(result);
if (offline) {
Promise.resolve().then(() => {
offline.set(stringId, model);
});
}
return resolve(config, setTimestamp(model), cachedModel);
});
}
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?",
);
}
let isInstance = !!config;
if (!config) config = bootstrap(model);
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 && !config.enumerable) {
isInstance = true;
model = get(model);
}
if (isInstance) {
const promise = pending(model);
if (promise) {
return promise.then((m) => set(m, values));
}
}
const isDraft = draftMap.get(config);
let id;
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.size) {
for (const [key, fn] of config.checks.entries()) {
if (keys.indexOf(key) === -1) {
if (lastError && lastError.errors && lastError.errors[key]) {
hasErrors = true;
errors[key] = lastError.errors[key];
}
if (isDraft && localModel[key] == config.model[key]) {
continue;
}
}
let checkResult;
try {
checkResult = fn(localModel[key], key, localModel);
} catch (e) {
checkResult = e;
}
if (checkResult !== true && checkResult !== undefined) {
hasErrors = true;
errors[key] = checkResult || true;
}
}
}
let result;
try {
if (hasErrors && !isDraft) {
throw getValidationError(errors);
}
id = localModel ? localModel.id : model.id;
result = config.storage.set(isInstance ? id : undefined, localModel, keys);
} catch (e) {
if (isInstance) setModelState(model, "error", e);
return Promise.reject(e);
}
if (
!(result instanceof Promise) &&
result !== undefined &&
typeof result !== "object"
) {
throw TypeError(
stringifyModel(
config.model,
`Storage 'set' method must return a Promise, an instance, or null: ${result}`,
),
);
}
result = Promise.resolve(result)
.then((data) => {
if (data !== undefined && typeof data !== "object") {
throw TypeError(
stringifyModel(
config.model,
`Storage 'set' method must resolve to an instance or null: ${data}`,
),
);
}
const resultModel =
data === localModel ? localModel : config.create(data);
if (isInstance && resultModel && id !== resultModel.id) {
throw TypeError(
stringifyModel(
config.model,
`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),
),
true,
);
})
.catch((err) => {
err = err !== undefined ? err : Error("Undefined error");
if (isInstance) setModelState(model, "error", err);
throw err;
});
if (isInstance) setModelState(model, "pending", result);
return result;
}
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), notFoundError(config.model, id)),
);
}
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: clearValue });
} else {
if (!configs.get(model) && !lists.get(model[0])) return;
config = bootstrap(model);
const offline = clearValue && config.storage.offline;
for (const entry of cache.getEntries(config)) {
if (entry.key === config) continue;
if (offline) offline.set(entry.key, null);
invalidateTimestamp(entry.value);
}
cache.invalidateAll(config, { clearValue, deleteEntry: clearValue });
}
}
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, id) {
model = stales.get(model) || model;
if (!definitions.get(model)) model = get(model, id);
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) {
const errors =
typeof state.error === "object" && state.error && state.error.errors;
return property === null ? !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 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 modelConfig = draftMap.get(config);
let result;
if (cache.getEntry(modelConfig, draft.id).value) {
const model = get(modelConfig.model, draft.id);
result = Promise.resolve(pending(model) || model).then((resolvedModel) =>
set(resolvedModel, getValuesFromModel(draft, values)),
);
} else {
result = set(modelConfig.model, 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":
defaultValue = new String(defaultValue);
break;
case "number":
defaultValue = new Number(defaultValue);
break;
case "boolean":
defaultValue = new Boolean(defaultValue);
break;
default:
throw TypeError(
`Default value must be a string, number or boolean: ${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
? errorMessage || result
: result;
};
} else {
throw TypeError(
`The second argument must be a RegExp instance or a function: ${typeof validate}`,
);
}
validationMap.set(defaultValue, fn);
return defaultValue;
}
function resolveId(value) {
if (value && definitions.has(value)) return value.id;
return value ?? undefined;
}
function resolveModel(Model, config, id) {
id = resolveId(id);
if (!config.enumerable && !config.list) {
return get(Model, id);
}
const lastModel = cache.getCurrentValue();
const nextModel =
id !== undefined || config.list ? get(Model, id) : undefined;
if (
lastModel &&
nextModel &&
nextModel.id !== lastModel.id &&
ready(lastModel) &&
!ready(nextModel)
) {
const config = definitions.get(lastModel);
const clone = Object.freeze(Object.create(lastModel));
definitions.set(clone, config);
cache.assert(clone, "state", getModelState(nextModel), true);
return clone;
}
return nextModel;
}
function resolveDraft(Model, config, id, value) {
const lastValue = cache.getCurrentValue();
id = resolveId(id ?? lastValue?.id);
if (
id === undefined &&
!lastValue &&
(value === undefined || value === null)
) {
if (config.enumerable) {
const draftModel = config.create({});
id = draftModel.id;
syncCache(config, draftModel.id, draftModel, false);
} else {
clear(config.model);
}
}
return get(Model, id);
}
function store(Model, options = {}) {
const config = bootstrap(Model);
if (options.id !== undefined && typeof options.id !== "function") {
const id = options.id;
options.id = (host) => host[id];
}
if (options.id && !config.enumerable) {
throw TypeError(
"Store factory for singleton model definition does not support 'id' option",
);
}
let draft;
if (options.draft) {
if (config.list) {
throw TypeError(
"Draft mode is not supported for listing model definition",
);
}
draft = bootstrap({
...Model,
[connect]: {
get(id) {
const model = get(config.model, id);
return pending(model) || model;
},
set(id, values) {
return values === null ? { id } : values;
},
},
});
draftMap.set(draft, config);
Model = draft.model;
return {
value: options.id
? (host, value) => resolveDraft(Model, draft, options.id(host), value)
: (host, value) => resolveDraft(Model, draft, value, value),
connect: config.enumerable
? (host, key) => () => {
clear(host[key], true);
}
: undefined,
};
}
return {
value: options.id
? (host) => resolveModel(Model, config, options.id(host))
: (host, value) => resolveModel(Model, config, value),
};
}
export default Object.freeze(
Object.assign(store, {
// storage
connect,
// actions
get,
set,
sync,
clear,
// guards
pending,
error,
ready,
// helpers
submit,
value: valueWithValidation,
resolve: resolveToLatest,
ref,
record,
observe,
}),
);