svelte-specma
Version:
Svelte store for data validation using Specma
907 lines (781 loc) • 24.4 kB
JavaScript
import { writable, readable, derived, get as get$1 } from 'svelte/store';
import fastEquals from 'fast-deep-equal';
import { tick } from 'svelte';
function _extends() {
_extends = Object.assign || function (target) {
for (var i = 1; i < arguments.length; i++) {
var source = arguments[i];
for (var key in source) {
if (Object.prototype.hasOwnProperty.call(source, key)) {
target[key] = source[key];
}
}
}
return target;
};
return _extends.apply(this, arguments);
}
function _objectWithoutPropertiesLoose(source, excluded) {
if (source == null) return {};
var target = {};
var sourceKeys = Object.keys(source);
var key, i;
for (i = 0; i < sourceKeys.length; i++) {
key = sourceKeys[i];
if (excluded.indexOf(key) >= 0) continue;
target[key] = source[key];
}
return target;
}
const identity$1 = x => x;
const noop = () => {};
function flexDerived(initialStores = [], fn = identity$1, initialValue) {
/* Used for ordering values */
let _stores = initialStores;
/* Store of stores. When last subscriber unsubscribes,
* will stop all stores subs. */
const $stores = writable(initialStores);
/* Callback function can use a second argument, a `publish` function to set result asynchronously.
* If not used, the return value of the function is the actual result to publish.
* Otherwise, the return value might be a function to call before each execution
* and when store is unsubscribed. */
const auto = fn.length < 2;
/* Use a readable store to manage subscribers. */
const mainStore = readable(initialValue, publish => {
let initialized = false;
let cleanup = noop;
let pending = 0;
let unsubs = new Map();
let values = new Map();
/* Unsubscribe saved stores not included in a new list of stores
* and create a new Map of unsub by store, reusing old ones when they exist. */
function updateUnsubs(stores = []) {
stopUnusedSubs(stores);
unsubs = new Map(stores.map(store => [store, unsubs.get(store)]));
}
function stopUnusedSubs(usedStores = []) {
unsubs.forEach((unsub, store) => {
if (!usedStores.includes(store) && unsub) unsub();
});
}
function stopSubscriptions() {
unsubs.forEach(unsub => unsub && unsub());
}
/* Create a new map of values by store, reusing old ones when they exist. */
function updateValues(stores = []) {
values = new Map(stores.map(store => [store, values.get(store)]));
}
/* Recompute and publish the combined result on values,
* using `_stores` to ensure values order consistency. */
function sync() {
if (pending) return;
cleanup();
const vals = _stores.reduce((acc, store) => values.has(store) ? [...acc, values.get(store)] : acc, []);
const result = fn(vals, publish);
if (auto) {
publish(result);
} else {
cleanup = typeof result === "function" ? result : noop;
}
}
const unsubscribe = $stores.subscribe(stores => {
updateUnsubs(stores);
updateValues(stores);
/* Subscribe to each store if not alreay done.
* When a any store value changes,
* combined result will be updated and published. */
_stores = [];
stores.forEach((store, i) => {
_stores[i] = store;
if (!unsubs.get(store)) {
unsubs.set(store, store.subscribe(value => {
values.set(store, value);
pending &= ~(1 << i);
if (initialized) sync();
}, () => {
pending |= 1 << i;
}));
}
});
if (initialized) sync();
});
initialized = true;
sync();
return function stop() {
unsubscribe();
stopSubscriptions();
cleanup();
};
});
/* Exclude one or mode stores from the list. */
function exclude(...stores) {
$stores.update(prev => prev.filter(store => !stores.includes(store)));
}
/* Include one or more stores into the list. */
function include(...stores) {
$stores.update(prev => [...prev, ...stores]);
}
return {
exclude,
include,
set: $stores.set,
stores: $stores,
subscribe: mainStore.subscribe,
update: $stores.update
};
}
const identity = x => x;
const isColl = x => ["array", "map", "object"].includes(typeOf(x));
const isFunc = x => typeof x === "function";
const isStore = x => x && x.subscribe && isFunc(x.subscribe);
const typeOf = obj => ({}).toString.call(obj).split(" ")[1].slice(0, -1).toLowerCase();
function entries(coll) {
const fn = {
array: () => coll.map((v, i) => [i, v]),
map: () => [...coll.entries()],
object: () => Object.entries(coll)
}[typeOf(coll)];
return fn ? fn(coll) : [];
}
function fromEntries(entries, toType) {
const fn = {
array: () => entries.map(([, val]) => val),
map: () => new Map(entries),
object: () => Object.fromEntries(entries)
}[toType];
return fn ? fn() : fromEntries(entries, "map");
}
function values(coll) {
const fn = {
array: () => [...coll],
map: () => [...coll.values()],
object: () => Object.values(coll)
}[typeOf(coll)];
return fn ? fn(coll) : [];
}
function keys(coll) {
const fn = {
array: () => coll.map((v, i) => i),
map: () => [...coll.keys()],
object: () => Object.keys(coll)
}[typeOf(coll)];
return fn ? fn(coll) : [];
}
/* Merge multiple collections of identical type with right to left arguments precedence. */
function merge(...args) {
const colls = args.filter(isColl);
const type = typeOf(colls[0]);
if (!colls.every(coll => typeOf(coll) === type)) {
const collTypes = colls.map(typeOf).join(", ");
throw new TypeError(`Collections must be of same type. Received '${collTypes}'.`);
}
const fn = {
array: () => colls.reduce((acc, coll) => {
if (coll.length >= acc.length) return coll;
return [...coll, ...acc.slice(coll.length)];
}, []),
map: () => new Map(colls.map(coll => coll.entries())),
object: () => Object.assign({}, ...colls)
}[type];
if (!fn) throw new Error(`'merge' not implemented yet for ${type}`);
return fn();
}
function genRandomId() {
return (Math.random() * 1e9).toFixed(0);
}
function get(key, coll) {
const fn = {
array: () => coll[key],
map: () => coll.get(key),
object: () => coll[key]
}[typeOf(coll)];
return fn ? fn(key, coll) : undefined;
}
function getPath(path = [], value) {
return path.reduce((parent, key) => get(key, parent), value);
}
function countPathAncestors(str = "") {
return (str.match(/\.\.\/|\.\.$/g) || []).length;
}
function keepForwardPath(str = "") {
return str.split("/").reduce((acc, node) => {
if (!node || node.startsWith(".")) return acc;
const index = parseInt(node, 10);
return [...acc, isNaN(index) ? node : index];
}, []);
}
function equals(a, b, eqBy = defaultEqBy) {
const [_a, _b] = [a, b].map(eqBy);
return _a === _b || fastEquals(_a, _b);
}
function defaultEqBy(x) {
if (x instanceof Date) return x.valueOf();
if (isColl(x)) return removeUndefined(x);
return x;
}
function removeUndefined(x) {
if (!isColl(x)) return x;
return fromEntries(entries(x).reduce((acc, [key, val]) => val === undefined ? acc : [...acc, [key, removeUndefined(val)]], []), typeOf(x));
}
function deriveState(coll) {
const storesEntries = entries(coll);
return {
coll,
collType: typeOf(coll),
storesEntries,
keys: storesEntries.map(([key]) => key),
stores: storesEntries.map(([, store]) => store)
};
}
function collDerived(initialColl, fn = identity, initialValue) {
let state = deriveState(initialColl);
/* The callback passed to `flexDerived` can have arity 1 or 2.
* The arity should match the one of the provided function.
* When using arity 2, a set function is provided so that
* result can be used asynchronously */
const auto = fn.length < 2;
const index$stores = $stores => fromEntries($stores.map((store, idx) => [state.keys[idx], store]), state.collType);
const values = flexDerived(state.stores, auto ? $stores => fn(index$stores($stores)) : ($stores, _set) => fn(index$stores($stores), _set), initialValue);
function set(newColl) {
state = deriveState(newColl);
values.set(state.stores);
}
return {
set,
subscribe: values.subscribe
};
}
const ALWAYS_VALID = {
valid: true
};
const REQUIRED_SPECMA_FNS = ["and", "getMessage", "getPred", "getSpread", "isOpt", "validatePred"];
const CONFIG_ERROR_MSG = "SvelteSpecma must be configured with a valid Specma version.";
let specma = undefined;
function ensureConfigured() {
if (!specma) {
throw new TypeError(CONFIG_ERROR_MSG);
}
}
function configure(specmaFns) {
if (!specmaFns || REQUIRED_SPECMA_FNS.some(key => typeof specmaFns[key] !== "function")) {
throw new TypeError(CONFIG_ERROR_MSG);
}
specma = specmaFns;
}
/* Limited version of `writable` store that updates only
* if the new set value is different by value (deep equality) */
function wriableByValue(initialValue, ...rest) {
let _value = initialValue;
const store = writable(initialValue, ...rest);
return {
set(newValue) {
if (fastEquals(newValue, _value)) return;
_value = newValue;
store.set(_value);
},
subscribe: store.subscribe
};
}
const alwaysTrue = () => true;
const isMissing = x => [undefined, null, ""].includes(x);
const defaultChangePred = (a, b) => !equals(a, b);
const reqSpec = x => !isMissing(x) || specma.getMessage("isRequired");
function predSpecable(initialValue, {
changePred = defaultChangePred,
id,
required,
spec,
onSubmit
} = {}, _extra = {}) {
ensureConfigured();
const {
and,
getPred,
validatePred
} = specma;
const {
getAncestor
} = _extra;
const pred = getPred(spec) || alwaysTrue;
const isRequired = !!required;
const ownSpec = isRequired ? and(reqSpec, pred) : pred;
const contextStores = {};
const context = collDerived(contextStores);
function addContext(relPath) {
/* If `getFrom` has already been called once,
* context store is already tracking the value. */
if (contextStores[relPath]) return;
const ancestor = getAncestor(countPathAncestors(relPath));
if (!ancestor) return;
const pathSinceAncestor = keepForwardPath(relPath);
contextStores[relPath] = derived(ancestor, ($ancestor, set) => {
const ancestorValue = $ancestor.value;
if (!ancestorValue) return;
const curr = contextStores[relPath].value;
const next = getPath(pathSinceAncestor, ancestorValue);
if (!equals(curr, next)) {
contextStores[relPath].value = next;
set(next);
}
});
context.set(contextStores);
/* If context has just been created, it won't be accessible
* in the derived store at first.
* In that case, return the static store value. */
return getPath(pathSinceAncestor, get$1(ancestor).value);
}
let currPromise;
let _initialValue = initialValue;
const active = writable(false);
const submitting = writable(false);
const value = wriableByValue(_initialValue);
const store = derived([active, value, context, submitting], ([$active, $value, $context, $submitting], set) => {
currPromise = undefined;
function getFrom(relPath) {
if (!contextStores[relPath]) {
return addContext(relPath);
}
return $context[relPath];
}
const shouldValidate = $active && ($value !== undefined || required);
const result = enhanceResult(shouldValidate ? validatePred(ownSpec, $value, getFrom) : ALWAYS_VALID);
const baseArgs = {
active: $active,
changePred,
initialValue: _initialValue,
id,
result,
submitting: $submitting,
value: $value
};
currPromise = result.promise;
set(interpretState(baseArgs));
if (result.valid === null) {
result.promise.then(resolvedResult => {
/* Promise might be outdated */
if (result.promise !== currPromise) return;
set(interpretState(_extends({}, baseArgs, {
result: resolvedResult
})));
});
}
});
async function activate(bool = true) {
active.set(bool);
await tick();
const res = await currPromise;
return res.valid;
}
async function submit() {
if (!onSubmit) return;
submitting.set(true);
const valid = await activate();
if (valid) {
const currValue = get$1(value);
await onSubmit(currValue);
}
submitting.set(false);
}
return {
id,
isRequired,
spec: pred,
activate,
reset(newValue = _initialValue) {
_initialValue = newValue;
this.activate(false);
this.set(newValue);
},
set: (newValue, shouldActivate = false) => {
value.set(newValue);
if (shouldActivate) activate();
},
submit,
subscribe: store.subscribe
};
}
function enhanceResult(res) {
return _extends({}, res, {
promise: res.promise ? res.promise.then(promised => enhanceResult(promised)) : Promise.resolve(res)
});
}
function interpretState({
active,
changePred,
id,
initialValue,
result,
submitting,
value
}) {
const changed = changePred(value, initialValue);
return {
active,
changed,
error: result.valid === false && result.reason,
id,
initialValue,
promise: result.promise || Promise.resolve(result),
submitting,
valid: !!result.valid,
validating: result.valid === null,
value: changed ? value : initialValue
};
}
function collSpecable(initialValue, {
changePred,
fields,
getId,
id,
required,
spec,
onSubmit
} = {}, _extra = {}) {
ensureConfigured();
const {
getPred,
getSpread,
isOpt
} = specma;
let collValue = initialValue; // For static properties
let isUndef = collValue === undefined;
const {
getAncestor
} = _extra;
const collDefiner = [fields, spec, initialValue].find(isColl);
const collType = typeOf(collDefiner);
const isRequired = required && !isOpt(required);
const spreadGetId = getSpread(getId);
const spreadSpec = getSpread(spec);
const spreadFields = getSpread(fields);
const spreadRequired = getSpread(required);
const isSpread = spreadSpec || spreadFields || spreadRequired || spreadGetId || collType === "array";
const valueKeys = isSpread ? keys(initialValue) : [];
const allKeys = new Set(fields ? [...keys(fields), ...valueKeys] : [...keys(spec), ...keys(required), ...valueKeys]);
const ownGetId = getPred(getId);
const idGen = (v, k) => {
if (ownGetId) return ownGetId(v, k);
if (collType === "array") return genRandomId();
return k;
};
const ownSpecable = predSpecable(initialValue, {
changePred: getPred(changePred),
id,
required: isRequired,
spec
}, _extra);
const createChildEntry = (key, val) => {
const subChangePred = get(key, changePred) || getSpread(changePred);
const subVal = val;
const subSpec = get(key, spec) || spreadSpec;
const subGetId = get(key, getId) || spreadGetId;
const subId = idGen(subVal, key);
const subFields = get(key, fields) || spreadFields;
const subRequired = get(key, required) || spreadRequired;
const subStore = _extra.specable(subVal, {
spec: subSpec,
changePred: subChangePred,
id: subId,
getId: subGetId,
fields: subFields,
required: subRequired
}, {
getAncestor: n => n <= 1 || !getAncestor ? ownSpecable : getAncestor(n - 1)
});
return [key, _extends({}, subStore, {
id: subId
})];
};
let childrenStores = fromEntries([...allKeys].map(key => createChildEntry(key, get(key, initialValue))), collType);
const children = writable(childrenStores);
const submitting = writable(false);
const derivedValue = collDerived(childrenStores, $childrenStores => {
if (isUndef) return undefined;
const $childrenEntries = entries($childrenStores);
const $childrenValues = $childrenEntries.map(([key, state]) => [key, state.value]);
const childrenValue = fromEntries($childrenValues, collType);
const value = isSpread ? childrenValue : merge(collValue, childrenValue);
isUndef = value === undefined;
return value;
});
const aggregateStatusStores = () => [submitting, ownSpecable, ...values(childrenStores)];
const status = flexDerived(aggregateStatusStores(), $statusStores => {
const [$submitting, $ownSpecable, ...$children] = $statusStores;
const combined = isUndef ? $ownSpecable : [$ownSpecable, ...$children].reduce(combineChildren);
if (combined.active !== false) ownSpecable.activate();
const {
value,
error
} = $ownSpecable;
const details = Object.fromEntries([["_", $ownSpecable], ...(isUndef ? [] : $children.map(child => [child.id, child]))]);
const errors = detailsToErrors(details, id);
const collErrors = errors.filter(({
isColl
}) => isColl);
return _extends({}, combined, {
id,
initialValue: $ownSpecable.initialValue,
value,
error,
errors,
collErrors,
details,
submitting: $submitting
});
});
function setChildrenStores(newChildrenStores) {
childrenStores = newChildrenStores;
children.set(newChildrenStores);
derivedValue.set(newChildrenStores);
status.set(aggregateStatusStores());
}
function addChildren(coll) {
if (!coll) return;
const newEntries = keys(coll).map(key => createChildEntry(key, get(key, coll)));
const updatedStores = fromEntries([...entries(childrenStores), ...newEntries], collType);
setChildrenStores(updatedStores);
}
function removeChildrenById(idsToRemove = []) {
if (idsToRemove.length < 1) return;
const updatedStores = fromEntries(entries(childrenStores).filter(([, store]) => !idsToRemove.includes(store.id)), collType);
setChildrenStores(updatedStores);
}
function setValue(coll, {
partial = false,
reset = false
} = {}) {
const setMethod = reset ? "reset" : "set";
collValue = !reset && partial && !isSpread ? merge(collValue, coll) : coll;
isUndef = collValue === undefined;
ownSpecable[setMethod](collValue);
const childrenEntries = entries(childrenStores);
childrenEntries.forEach(([key, store]) => {
const newValue = get(key, coll);
if (partial && newValue === undefined) return;
store[setMethod](newValue, partial);
});
if (!isSpread) return;
/* If collection allows spread children... */
/* Add `coll` entries that are not yet part of the children stores. */
const childrenKeys = keys(childrenStores);
const missingChildrenEntries = entries(coll).filter(([key]) => !childrenKeys.includes(key));
if (missingChildrenEntries.length > 0) {
addChildren(fromEntries(missingChildrenEntries, collType));
}
if (partial) return;
/* If update is not partial, remove children stores that do not store
* a collection value anymore (garbage collection). */
const collKeys = keys(coll);
const unusedIds = childrenEntries.reduce((acc, [key, childStore]) => {
return collKeys.includes(key) ? acc : [...acc, childStore.id];
}, []);
removeChildrenById(unusedIds);
}
function activate(bool = true) {
const storesToActivate = [ownSpecable, ...(isUndef ? [] : values(childrenStores))];
const promises = storesToActivate.map(store => {
const promise = store.activate(bool);
return promise.then(valid => {
if (valid) return valid;
throw valid;
});
});
return Promise.all(promises).then(() => true).catch(() => false);
}
async function submit() {
if (!onSubmit) return;
submitting.set(true);
const valid = await activate();
if (valid) {
const currValue = get$1(ownSpecable).value;
await onSubmit(currValue);
}
submitting.set(false);
}
return {
id,
isRequired,
spec,
stores: childrenStores,
activate,
add(coll) {
if (coll !== undefined) isUndef = false;
addChildren(coll);
return this;
},
getChild(path = []) {
const reduced = path.reduce((acc, key) => {
const {
children
} = acc;
if (!children) return {
res: null
};
const childStore = get(key, children);
if (!childStore) return {
res: null
};
return {
res: childStore,
children: childStore.getChildren ? childStore.getChildren() : []
};
}, {
children: childrenStores
});
return reduced.res;
},
getChildren() {
return childrenStores;
},
remove(idsToRemove = []) {
removeChildrenById(idsToRemove);
return this;
},
reset(newInitialValue = initialValue) {
setValue(newInitialValue, {
reset: true
});
activate(false);
return this;
},
set(coll, partial = false, shouldActivate = false) {
setValue(coll, {
partial
});
if (shouldActivate) activate();
return this;
},
update: fn => {
setChildrenStores(fn(childrenStores));
return this;
},
children: {
subscribe: children.subscribe
},
submit,
subscribe: fn => {
const unsub1 = derivedValue.subscribe(value => ownSpecable.set(value));
const unsub2 = status.subscribe(fn);
return () => {
unsub1();
unsub2();
};
}
};
}
function combineChildren(a, b) {
const validating = a.validating || b.validating;
return {
active: a.active === b.active ? b.active : null,
changed: a.changed || b.changed,
valid: validating ? null : a.valid && b.valid,
validating
};
}
const liftError = parentId => _ref => {
let {
path,
error
} = _ref,
rest = _objectWithoutPropertiesLoose(_ref, ["path", "error"]);
const newPath = parentId === undefined ? path : [parentId, ...path];
return _extends({}, rest, {
path: newPath,
which: newPath.join("."),
error
});
};
function detailsToErrors(details, parentId) {
return Object.entries(details).flatMap(([key, status]) => {
if (!status.details) {
if (!status.error) return [];
if (key === "_") {
return [{
path: [],
error: status.error,
isColl: true
}];
}
return [summarizeStatusError(status)];
}
const subErrors = detailsToErrors(status.details, details.id);
return subErrors.map(liftError(status.id));
}).map(liftError(parentId));
}
function summarizeStatusError({
id,
error
}) {
return {
path: [id],
which: id,
error
};
}
function register(el, storeOrArgs) {
let args = normalizeArgs(storeOrArgs);
if (!el || !args.store) return;
let unsub;
listen();
function blurHandler() {
args.store.activate();
}
function inputHandler(e) {
args.store.set(args.toValue(e.target.value));
}
function listen() {
unsub = args.store.subscribe(({
value
}) => {
const elValue = args.toInput(value);
if (el.value !== elValue) el.value = elValue;
});
el.addEventListener("blur", blurHandler);
el.addEventListener("input", inputHandler);
}
function unlisten() {
unsub();
el.removeEventListener("blur", blurHandler);
el.removeEventListener("input", inputHandler);
}
return {
destroy: unlisten,
update(newArgs) {
unlisten();
if (!newArgs) return;
args = normalizeArgs(newArgs);
listen();
}
};
}
function normalizeArgs(storeOrArgs) {
if (!storeOrArgs) return {};
if (!Array.isArray(storeOrArgs)) {
return {
store: storeOrArgs,
toInput: (x = "") => x,
toValue: identity
};
}
const [store, {
toInput = identity,
toValue = identity
} = {}] = storeOrArgs;
return {
store,
toInput,
toValue
};
}
function specable(initialValue, options = {}, _extra) {
if (isStore(initialValue)) return initialValue;
const collCandidate = options.fields || options.spec || initialValue;
if (isColl(collCandidate)) {
return collSpecable(initialValue, options, _extends({}, _extra, {
specable
}));
}
return predSpecable(initialValue, options, _extra);
}
export { collSpecable, configure, predSpecable, register, specable };
//# sourceMappingURL=svelte-specma.modern.js.map