UNPKG

farmos

Version:

A JavaScript library for working with farmOS data structures and interacting with farmOS servers.

1,190 lines (1,119 loc) 43.3 kB
'use strict'; Object.defineProperty(exports, '__esModule', { value: true }); var clone = require('ramda/src/clone.js'); var dropLast = require('ramda/src/dropLast.js'); var map = require('ramda/src/map.js'); var uuid = require('uuid'); var addIndex = require('ramda/src/addIndex.js'); var evolve = require('ramda/src/evolve.js'); var identity = require('ramda/src/identity.js'); var mapObjIndexed = require('ramda/src/mapObjIndexed.js'); var rPath = require('ramda/src/path.js'); var anyPass = require('ramda/src/anyPass.js'); var compose = require('ramda/src/compose.js'); var has = require('ramda/src/has.js'); var append = require('ramda/src/append.js'); var curry = require('ramda/src/curry.js'); var reduce = require('ramda/src/reduce.js'); var mergeWith = require('ramda/src/mergeWith.js'); var compose$1 = require('ramda/src/compose'); var defaultTo = require('ramda/src/defaultTo'); var match = require('ramda/src/match'); var unary = require('ramda/src/unary'); var cond = require('ramda/src/cond.js'); var eqBy = require('ramda/src/eqBy.js'); var equals = require('ramda/src/equals.js'); var isNil = require('ramda/src/isNil.js'); var prop = require('ramda/src/prop.js'); function _interopDefaultLegacy (e) { return e && typeof e === 'object' && 'default' in e ? e : { 'default': e }; } var clone__default = /*#__PURE__*/_interopDefaultLegacy(clone); var dropLast__default = /*#__PURE__*/_interopDefaultLegacy(dropLast); var map__default = /*#__PURE__*/_interopDefaultLegacy(map); var addIndex__default = /*#__PURE__*/_interopDefaultLegacy(addIndex); var evolve__default = /*#__PURE__*/_interopDefaultLegacy(evolve); var identity__default = /*#__PURE__*/_interopDefaultLegacy(identity); var mapObjIndexed__default = /*#__PURE__*/_interopDefaultLegacy(mapObjIndexed); var rPath__default = /*#__PURE__*/_interopDefaultLegacy(rPath); var anyPass__default = /*#__PURE__*/_interopDefaultLegacy(anyPass); var compose__default = /*#__PURE__*/_interopDefaultLegacy(compose); var has__default = /*#__PURE__*/_interopDefaultLegacy(has); var append__default = /*#__PURE__*/_interopDefaultLegacy(append); var curry__default = /*#__PURE__*/_interopDefaultLegacy(curry); var reduce__default = /*#__PURE__*/_interopDefaultLegacy(reduce); var mergeWith__default = /*#__PURE__*/_interopDefaultLegacy(mergeWith); var compose__default$1 = /*#__PURE__*/_interopDefaultLegacy(compose$1); var defaultTo__default = /*#__PURE__*/_interopDefaultLegacy(defaultTo); var match__default = /*#__PURE__*/_interopDefaultLegacy(match); var unary__default = /*#__PURE__*/_interopDefaultLegacy(unary); var cond__default = /*#__PURE__*/_interopDefaultLegacy(cond); var eqBy__default = /*#__PURE__*/_interopDefaultLegacy(eqBy); var equals__default = /*#__PURE__*/_interopDefaultLegacy(equals); var isNil__default = /*#__PURE__*/_interopDefaultLegacy(isNil); var prop__default = /*#__PURE__*/_interopDefaultLegacy(prop); const URIre = /^(http[s]?:\/\/)?([^/\s:#]+)?(:[0-9]+)?((?:\/?\w?)+(?:\/?[\w\-.]+[^#?\s])?)?(\??[^#?\s]+)?(#(?:\/?[\w\-$])*)?$/; /** * @typedef {Object} UriComponents * @prop {?string} match - The full URI that matched the query. * @prop {?string} scheme - The protocol, either "http://" or "https://". * @prop {?string} domain - The domain and/or subdomain (eg, "api.example.com"). * @prop {?string} port - The port if specified (eg, ":80"). * @prop {?string} path - The relative directory path and/or file name (eg, "/foo/index.html"). * @prop {?string} query - Search params (eg, "?foo=42&bar=36"). * @prop {?string} fragment - The hash or JSON pointer (eg, "#Introduction", "#$defs/address"). */ /** * Parses a URI into its component strings. * @param {string} uri * @returns {UriComponents} */ function parseURI(uri) { const groups = uri.match(URIre) || []; const [ match, scheme, domain, port, path, query, fragment, ] = groups; return { match, scheme, domain, port, path, query, fragment, }; } const hasAny = compose__default["default"](anyPass__default["default"], map__default["default"](has__default["default"])); /** * @typedef {import('./reference').JsonSchema} JsonSchema * @typedef {import('./reference').JsonSchemaDereferenced} JsonSchemaDereferenced */ const logicalKeywords = ['allOf', 'anyOf', 'oneOf', 'not']; /** @type {(JsonSchema) => boolean} */ const hasLogicalKeyword = hasAny(logicalKeywords); /** @type {(x: any) => Boolean} */ const boolOrThrow = (x) => { if (typeof x === 'boolean') return x; throw new Error(`Invalid schema: ${x}`); }; /** @type {(x: any) => Boolean} */ const isObject = x => typeof x === 'object' && x !== null; const reduceObjIndexed = curry__default["default"]((fn, init, obj) => reduce__default["default"]( (acc, [key, val]) => fn(acc, val, key), init, Object.entries(obj), )); const createObserver = () => { const listeners = new Map(); const subscribe = ((callback) => { listeners.set(callback, callback); return () => { listeners.delete(callback); }; }); const next = (event) => { listeners.forEach((callback) => { callback(event); }); }; return { subscribe, next }; }; /** * @template D */ /** /** * @typedef {{ data: D, fulfilled: any[], rejected: any[] }} AltogetherResult * @property {D} data * @property {any[]} fulfilled * @property {any[]} rejected */ /** * @typedef {(promises: Promise[]) => AltogetherResult} AltogetherPartial */ /** * Handles a list of promises of compatible type that will be executed in parallel. * It wraps `Promise.allSettled()` and partitions the results based on their status, * 'fulfilled' or 'rejected', while also applying a transform function that iterates * through all fulfilled values and returns the cumulated result as 'data'. * @typedef {Function} altogether * @param {Function} transform * @param {D} [initData=null] * @param {Promise[]} [promises=[]] * @returns {Promise<AltogetherPartial|AltogetherResult>} */ curry__default["default"]((transform, initData, promises) => Promise.allSettled(promises || []).then(reduce__default["default"]((all, result) => { const { reason, value, status } = result; if (status === 'fulfilled') { return evolve__default["default"]({ data: d => transform(value, d), fulfilled: append__default["default"](value), }, all); } return evolve__default["default"]({ rejected: append__default["default"](reason), }, all); }, { data: initData || null, fulfilled: [], rejected: [] }))); /** * @template T * @param {(t: T, i: number) => T} transform * @param {Array.<T>} array * @returns {Array.<T>} */ const mapIndexed = addIndex__default["default"](map__default["default"]); /** * JSON Schema: A complete definition can probably be imported from a library. * @typedef {Object|Boolean} JsonSchema */ /** * JSON Schema Dereferenced: A JSON Schema, but w/o any $ref keywords. As such, * it may contain circular references that cannot be serialized. * @typedef {Object|Boolean} JsonSchemaDereferenced */ const trimPathRexEx = /^[/#\s]*|[/#\s]*$/g; /** @type {(path: string) => String} */ const trimPath = path => path.replace(trimPathRexEx, ''); /** * Resolve a schema definition from a JSON pointer reference. * @param {JsonSchema} schema * @param {string} pointer - A relative URI provided as the `$ref` keyword. * @returns {JsonSchema} */ const getDefinition = (schema, pointer) => { const pathSegments = trimPath(pointer).split('/'); const subschema = rPath__default["default"](pathSegments, schema); if (subschema === undefined) return true; return subschema; }; /** * Resolve the `$ref` keyword in given schema to its corresponding subschema. * @param {JsonSchema} root - The root schema that contained the reference. * @param {string} ref - The URI provided as the `$ref` keyword in the root * schema or one of its subschemas. * @param {Object} [options] * @param {string} [options.retrievalURI] - The URI where the schema was found. * @param {Object.<string, JsonSchemaDereferenced>} [options.knownReferences] - * An object mapping known references to their corresponding dereferenced schemas. * @returns {JsonSchema} */ const getReference = (root, ref, options = {}) => { if (typeof ref !== 'string' || ref === '') { const submsg = ref === '' ? '[empty string]' : ref; throw new Error(`Invalid reference: ${submsg}`); } const { retrievalURI, knownReferences = {} } = options; if (ref in knownReferences) return knownReferences[ref]; if (!isObject(root)) return boolOrThrow(root); // The $id keyword takes precedence according to the JSON Schema spec. const rootURI = root.$id || retrievalURI || null; const { scheme = '', domain = '', port = '', path = '', fragment = '', } = parseURI(ref); const baseURI = scheme + domain + port + path; const baseIsRoot = rootURI === baseURI || ref === fragment; let refRoot; if (baseIsRoot) refRoot = root; if (!baseIsRoot && baseURI in knownReferences) refRoot = knownReferences[baseURI]; if (refRoot === undefined) return true; if (fragment) return getDefinition(refRoot, fragment); return refRoot; }; const setInPlace = (obj, path = [], val) => { if (path.length < 1) return; const [i, ...tail] = path; if (!['string', 'number'].includes(typeof i)) throw new Error('Invalid path'); if (!(i in obj)) throw new Error('Path not found'); if (tail.length === 0) { obj[i] = val; // eslint-disable-line no-param-reassign return; } setInPlace(obj[i], tail, val); }; /** * Takes a schema which may contain the $ref keyword in it or in its subschemas, * and returns an equivalent schema where those references have been replaced * with the full schema document. * @param {JsonSchema} root - The root schema to be dereferenced. * @param {Object} [options] * @param {string} [options.retrievalURI] - The URI where the schema was found. * @param {string[]} [options.ignore] - A list of schemas to ignore. They will * subsequently be referenced as `true`. * @param {Object.<string, JsonSchema>} [options.knownReferences] - An object mapping * known references to their corresponding schemas. They will also be dereferenced. * @returns {JsonSchemaDereferenced} */ const dereference = (root, options = {}) => { const { retrievalURI, ignore = [], knownReferences = {} } = options; const knownRefsMap = new Map(); /** @type {(ref: string, refSchema: JsonSchema) => void} */ const setKnownRef = (ref, refSchema) => { const appliedSchema = ignore.includes(ref) ? true : refSchema; knownRefsMap.set(ref, appliedSchema); }; Object.entries(knownReferences).forEach(([ref, refSchema]) => { // We could just use setKnownRef here, but this prevents unnecessary recursion; const schema = ignore.includes(ref) ? true : dereference(refSchema); knownRefsMap.set(ref, schema); }); // Set ignore refs to true to start, so they don't have to be checked in every // call to `deref` below. ignore.forEach((ref) => { knownRefsMap.set(ref, true); }); const baseURI = root.$id || retrievalURI || null; const _root = clone__default["default"](root); /** @type {(schema: JsonSchema, path?: Array.<string|number>) => JsonSchemaDereferenced} */ const deref = (schema, path = []) => { if (!isObject(schema)) return boolOrThrow(schema); let _schema = schema; const set = (cb) => { _schema = cb(_schema); setInPlace(_root, path, _schema); }; const derefSubschema = keyword => sub => deref(sub, [...path, keyword]); const derefSubschemaObject = keyword => mapObjIndexed__default["default"]((sub, prop) => deref(sub, [...path, keyword, prop])); const derefSubschemaArray = keyword => mapIndexed((sub, i) => deref(sub, [...path, keyword, i])); const schemaTypes = { string: identity__default["default"], number: identity__default["default"], integer: identity__default["default"], object: evolve__default["default"]({ properties: derefSubschemaObject('properties'), patternProperties: derefSubschemaObject('patternProperties'), additionalProperties: derefSubschema('additionalProperties'), }), array: evolve__default["default"]({ items: derefSubschema('items'), contains: derefSubschema('contains'), prefixItems: derefSubschemaArray('prefixItems'), }), boolean: identity__default["default"], null: identity__default["default"], }; if ('type' in _schema && _schema.type in schemaTypes) { set(schemaTypes[_schema.type]); } if (hasLogicalKeyword(_schema)) { set(evolve__default["default"]({ allOf: derefSubschemaArray('allOf'), anyOf: derefSubschemaArray('allOf'), oneOf: derefSubschemaArray('allOf'), not: derefSubschema('not'), })); } if ('$ref' in _schema) { const { $ref } = _schema; // Anything beginning with # or /, the followed only by # or /. const rootHashRE = /^[/#]+[/#]?$/; const refIsRoot = rootHashRE.test($ref) || $ref === baseURI; const refKey = refIsRoot ? baseURI : $ref; if (knownRefsMap.has(refKey)) { set(() => knownRefsMap.get(refKey)); } else if (refIsRoot) { set(() => _root); setKnownRef(baseURI, _root); } else { const opts = { knownReferences: Object.fromEntries(knownRefsMap), retrievalURI, }; set(() => getReference(_root, $ref, opts)); set(sub => deref(sub, path)); setKnownRef($ref, _schema); } } if (isObject(_schema) && '$id' in _schema) setKnownRef(_schema.$id, _schema); return _schema; }; return deref(_root); }; /** * @typedef {import('./reference').JsonSchema} JsonSchema * @typedef {import('./reference').JsonSchemaDereferenced} JsonSchemaDereferenced */ /** * Provide a dereferenced schema and get back the object corresponding to the * "properties" keyword. A schema of type "array" will also be checked for the * "items" keyword and any corresponding properties it has. Properties found * under contitional keywords "allOf", "anyOf", "oneOf" and "not" will be * merged; however, the "$ref" keyword will NOT be handled and will throw an * error if encountered. * @param {JsonSchemaDereferenced} schema - Must NOT contain the "$ref" keyword, * nor subschemas containing "$ref". * @returns {Object.<string, JsonSchemaDereferenced>} */ const getProperties = (schema) => { if (!isObject(schema)) return {}; if ('$ref' in schema) { // It is the responsibility of the caller to dereference the schema first. const msg = `Unknown schema reference ($ref): "${schema.$ref}". ` + 'Try dereferencing the schema before trying to access its properties or defaults.'; throw new Error(msg); } if ('properties' in schema) { return schema.properties; } if ('items' in schema && 'properties' in schema.items) { return schema.items.properties; } if (hasLogicalKeyword(schema)) { const keyword = logicalKeywords.find(k => k in schema); if (keyword === 'not') { return map__default["default"](p => ({ not: p }), getProperties(schema.not)); } return schema[keyword].reduce((props, subschema) => { const subProps = getProperties(subschema); const strategy = (b, a) => { const aList = keyword in a ? a[keyword] : [a]; const bList = keyword in b ? b[keyword] : [b]; return { [keyword]: [...aList, ...bList] }; }; return mergeWith__default["default"](strategy, props, subProps); }, {}); } return {}; }; /** * Provide a dereferenced schema of type 'object', and get back the subschema * corresponding to the specified property name. * @param {JsonSchemaDereferenced} schema - Must NOT contain the `$ref` keyword, * nor subschemas containing `$ref`. * @param {string} property - The name of a property under the `properties` keyword. * @returns {JsonSchemaDereferenced} */ const getProperty = (schema, property) => { if (typeof schema === 'boolean') return {}; if (typeof property !== 'string') throw new Error(`Invalid property: ${property}`); const properties = getProperties(schema); if (property in properties) { return properties[property]; } return {}; }; /** * Provide a dereferenced schema of type 'object', and get back the subschema * corresponding to the specified property name, or to the specified path. * @param {JsonSchemaDereferenced} schema - Must NOT contain the `$ref` keyword, * nor subschemas containing `$ref`. * @param {...string|string[]} path - A property name, or array of property names. * @returns {JsonSchemaDereferenced} */ const getPath = (schema, ...path) => { if (typeof schema === 'boolean') return {}; const pathArray = path.flat(); if (pathArray.length === 0) return schema; const [head, ...tail] = pathArray; if (typeof head !== 'string') throw new Error(`Invalid path in subschema: ${head}`); const subschema = getProperty(schema, head); if (!isObject(subschema)) return {}; if (tail.length > 0) { return getPath(subschema, tail); } return subschema; }; /** * Provide a dereferenced schema of type 'object', and get back a list of all its * specified properties, or the properties of the subschema indicated by its path. * @param {JsonSchemaDereferenced} schema - Must NOT contain the `$ref` keyword, nor * subschemas containing `$ref`. * @param {...string|string[]} [path] - A property name, or array of property names. * @returns {string[]} */ const listProperties = (schema, ...path) => { if (typeof schema === 'boolean') return []; const subschema = path.length > 0 ? getPath(schema, path.flat()) : schema; if ('properties' in subschema) { return Object.keys(subschema.properties); } return []; }; /** * @typedef {import('./reference').JsonSchema} JsonSchema * @typedef {import('./reference').JsonSchemaDereferenced} JsonSchemaDereferenced */ /** Transform function * @typedef {(JsonSchemaDereferenced) => *} SchemaTransform */ /** * Get the default value at a given path for a given schema. * @param {JsonSchemaDereferenced} schema * @param {string[]|string} [path] - A property name or array of property names. * @param {Object} [options] * @param {Object.<string, SchemaTransform>} [options.byType] * @param {Object.<string, SchemaTransform>} [options.byFormat] * @param {Object.<string, SchemaTransform>|string|boolean} [options.byProperty] * @param {Object} [options.use] * @returns {*} */ const getDefault = (schema, path = [], options = {}) => { const subschema = getPath(schema, path); if (!isObject(subschema)) return undefined; if ('default' in subschema) return subschema.default; if ('const' in subschema) return subschema.const; // For recursive calls /** @type {(sub: JsonSchemaDereferenced) => *} */ const getDef = sub => getDefault(sub, [], options); /** @typedef {JsonSchemaDereferenced[]|Object.<string, JsonSchemaDereferenced>} SchemaFunctor */ /** @type {(sub: SchemaFunctor) => Array|Object} */ const mapGetDef = map__default["default"](getDef); if (hasLogicalKeyword(subschema) && subschema.type === 'object') { return evolve__default["default"]({ allOf: mapGetDef, anyOf: mapGetDef, oneOf: mapGetDef, not: getDef, }, subschema); } const { type } = subschema; if (type === 'null') { // This is the only case that should return null; if a default can't be // resolved, undefined should be returned, as below. return null; } const { byType, byFormat, use, } = options; if (type === 'string') { if (byFormat && 'format' in subschema && subschema.format in byFormat) { const { [subschema.format]: transform } = byFormat; return transform(subschema); } } if (use && ['number', 'integer'].includes(type)) { const keywords = ['minimum', 'maximum', 'multipleOf']; const useOptions = Array.isArray(use) ? use : [use]; const kw = useOptions.find(k => k in subschema && keywords.includes(k)); if (kw !== undefined) return subschema[kw]; } // Evaluate byType last, so options of higher specificity take precedence. if (byType && type in byType) { const { [type]: transform } = byType; return transform(subschema); } return undefined; }; /** * @type {RegExp} Identifies valid format for an entity type (eg 'log--activity) * and groups matches by type, entity & bundle. */ const entityTypeRegEx = /(\w+)--(\w+)/; /** * @type {Function} Validates a string as an entity type and parses it. * @param {String} type A possible entity type (eg, 'log--activity'). * @returns {{ type?: String, entity?: String, bundle?: String }} * */ const parseEntityType = unary__default["default"](compose__default$1["default"]( ([type, entity, bundle]) => ({ type, entity, bundle }), match__default["default"](entityTypeRegEx), defaultTo__default["default"](''), )); /** * @type {(fields?: Object) => Object} Takes any object containing entity data, * such as props or fields, then normalizes the type, bundle & field. * @param {{ type?: String, entity?: String, bundle?: string }} fields * @returns {{ type?: String, entity?: String, bundle?: String }} */ function parseTypeFromFields(fields = {}) { let { entity, bundle, type } = fields; if (type) ({ entity, bundle } = parseEntityType(type)); if (!type && entity && bundle) type = `${entity}--${bundle}`; return { entity, bundle, type }; } /** * @typedef {import('../entities.js').Entity} Entity */ /** * Create a farmOS entity that will validate against the schema for the * specified bundle (ie, the `type` prop). * @typedef {Function} createEntity * @param {Object.<String, any>|Entity} props * @property {String} props.type The only required prop. It must correspond to a * valid entity bundle (eg, 'activity') whose schema has been previously set. * @returns {Entity} */ /** * @param {import('./index.js').BundleSchemata} schemata * @returns {createEntity} */ const createEntity = (schemata, defaultOptions) => (props) => { const { id = uuid.v4() } = props; const { entity, bundle, type } = parseTypeFromFields(props); if (!uuid.validate(id)) { throw new Error(`Invalid ${entity} id: ${id}`); } const schema = schemata[entity] && schemata[entity][bundle]; if (!schema) { throw new Error(`Cannot find a schema for the ${entity} type: ${type}.`); } const { attributes = {}, relationships = {}, meta = {}, ...rest } = clone__default["default"](props); // Spread attr's and rel's like this so other entities can be passed as props, // but nesting props is still not required. const copyProps = { ...attributes, ...relationships, ...rest }; const { created = new Date().toISOString(), changed = created, remote: { lastSync = null, url = null, meta: remoteMeta = null, } = {}, } = meta; const fieldChanges = {}; const initFields = (fieldType) => { const fields = {}; listProperties(schema, fieldType).forEach((name) => { if (name in copyProps) { const changedProp = meta.fieldChanges && meta.fieldChanges[name]; fieldChanges[name] = changedProp || changed; fields[name] = copyProps[name]; } else { fieldChanges[name] = changed; fields[name] = getDefault(schema, [fieldType, name], defaultOptions); } }); return fields; }; return { id, type, attributes: initFields('attributes'), relationships: initFields('relationships'), meta: { created, changed, remote: { lastSync, url, meta: remoteMeta }, fieldChanges, conflicts: [], }, }; }; // Helpers for determining if a set of fields are equivalent. Attributes are // fairly straightforward, but relationships need to be compared strictly by // their id(s), b/c JSON:API gives a lot of leeway for how these references // can be ordered and structured. const setOfIds = compose__default["default"]( array => new Set(array), map__default["default"](prop__default["default"]('id')), ); const relsTransform = cond__default["default"]([ [isNil__default["default"], identity__default["default"]], [Array.isArray, setOfIds], [has__default["default"]('id'), prop__default["default"]('id')], ]); const eqFields = fieldType => (fieldType === 'relationships' ? eqBy__default["default"](relsTransform) : equals__default["default"]); /** * @typedef {import('../entities.js').Entity} Entity */ /** * Merge a local copy of a farmOS entity with an incoming remote copy. They must * share the same id (UUID v4) and type (aka, bundle). * @typedef {Function} mergeEntity * @param {Entity} [local] If the local is nullish, merging will dispatch to the * create method instead, creating a new local copy of the remote entity. * @param {Entity} [remote] If the remote is nullish, a clone of the local will be returned. * @returns {Entity} */ /** * @param {import('./index.js').BundleSchemata} schemata * @returns {mergeEntity} */ const mergeEntity = (schemata) => (local, remote) => { if (!remote) return clone__default["default"](local); const now = new Date().toISOString(); if (!local) { // A nullish local value represents the first time a remotely generated // entity was fetched, so all changes are considered synced with the remote. const resetLastSync = evolve__default["default"]({ meta: { remote: { lastSync: () => now } } }); return createEntity(schemata)(resetLastSync(remote)); } const { id } = local; const { entity, bundle, type } = parseTypeFromFields(local); if (!uuid.validate(id)) { throw new Error(`Invalid ${entity} id: ${id}`); } const schema = schemata[entity] && schemata[entity][bundle]; if (!schema) { throw new Error(`Cannot find a schema for the ${entity} type: ${type}.`); } const localName = local.attributes && `"${local.attributes.name || ''}" `; if (id !== remote.id) { throw new Error(`Cannot merge remote ${entity} with UUID ${remote.id} ` + `and local ${entity} ${localName}with UUID ${id}.`); } if (local.type !== remote.type) { throw new Error(`Cannot merge remote ${entity} of type ${remote.type} ` + `and local ${entity} ${localName}of type ${local.type}.`); } if (local.meta.conflicts.length > 0) { throw new Error(`Cannot merge local ${entity} ${localName}` + 'while it still has unresolved conflicts.'); } // Establish a consistent value for the current time. // const now = new Date().toISOString(); // Deep clone the local & destructure its metadata for internal reference. const localCopy = clone__default["default"](local); const { meta: { fieldChanges: localChanges, changed: localChanged = now, remote: { lastSync: localLastSync = null } = {}, }, } = localCopy; // Deep clone the remote & destructure its metadata for internal reference. const remoteCopy = clone__default["default"](remote); const { meta: { fieldChanges: remoteChanges, changed: remoteChanged = now, remote: { lastSync: remoteLastSync = null } = {}, }, } = remoteCopy; // These variables are for storing the values that will ultimately be returned // as metadata. They are all considered mutable within this function scope and // will be reassigned or appeneded to during the iterations of mergeFields, or // afterwards in the case of lastSync. let changed = localChanged; let lastSync = localLastSync; const fieldChanges = {}; const conflicts = []; const mergeFields = (fieldType) => { const checkEquality = eqFields(fieldType); const { [fieldType]: localFields } = localCopy; const { [fieldType]: remoteFields } = remoteCopy; // Spread localFields so lf.data and lf.changed aren't mutated when fields is. const fields = { ...localFields }; // This loop comprises the main algorithm for merging changes to concurrent // versions of the same entity that may exist on separate systems. It uses a // "Last Write Wins" (LWW) strategy, which applies to each field individually. listProperties(schema, fieldType).forEach((name) => { const lf = { // localField shorthand data: localFields[name], changed: localChanges[name] || localChanged, }; const rf = { // remoteField shorthand data: remoteFields[name], changed: remoteChanges[name] || remoteChanged, }; const localFieldHasBeenSent = !!localLastSync && localLastSync > lf.changed; // Use the local changed value as our default. fieldChanges[name] = lf.changed; // If the remote field changed more recently than the local field, and the // local was synced more recently than it changed, apply the remote changes. if (rf.changed > lf.changed && localFieldHasBeenSent) { fields[name] = rf.data; fieldChanges[name] = rf.changed; // Also update the global changed value if the remote field changed more recently. if (rf.changed > localChanged) ({ changed } = rf); } // If the remote field changed more recently than the local field, and the // local entity has NOT been synced since then, there may be a conflict. if (rf.changed > lf.changed && !localFieldHasBeenSent) { // Run one last check to make sure the data isn't actually the same. If // they are, there's no conflict, but apply the remote changed timestamps. if (checkEquality(lf.data, rf.data)) { fieldChanges[name] = rf.changed; if (rf.changed > localChanged) ({ changed } = rf); } else { // Otherwise keep the local values, but add the remote changes to the // list of conflicts. conflicts.push({ fieldType, field: name, changed: rf.changed, data: rf.data, }); } } // In all other cases, the local values will be retained. }); return fields; }; const attributes = mergeFields('attributes'); const relationships = mergeFields('relationships'); // These tests will set the lastSync value to the current timestamp if any one // of the following criteria can be met: 1) a remote entity is being merged // with a local entity whose changes have already been sent to that remote, // 2) the merge occurs after the very first time a locally generated entity // was sent to the remote system, 3) all changes from the remote have been // fetched since the most recent local change. Otherwise, the local lastSync // value will be retained. const localChangesHaveBeenSent = !!localLastSync && localLastSync >= localChanged; const remoteIsInitialSendResponse = !localLastSync && !!remoteLastSync; const remoteChangesHaveBeenFetched = !!remoteLastSync && remoteLastSync >= localChanged; const syncHasCompleted = localChangesHaveBeenSent || remoteIsInitialSendResponse || remoteChangesHaveBeenFetched; if (syncHasCompleted) lastSync = now; return { id, type, attributes, relationships, meta: { ...localCopy.meta, changed, fieldChanges, conflicts, remote: { ...remoteCopy.meta.remote, lastSync, }, }, }; }; /** * @typedef {import('../entities.js').Entity} Entity */ /** * Update a farmOS entity. * @typedef {Function} updateEntity * @param {Entity} entity * @param {Object.<String, any>} props * @returns {Entity} */ /** * @param {import('./index.js').BundleSchemata} schemata * @returns {updateEntity} */ const updateEntity = (schemata) => (entity, props) => { const { id } = entity; const { entity: entName, bundle, type } = parseTypeFromFields(entity); if (!uuid.validate(id)) { throw new Error(`Invalid ${entName} id: ${id}`); } const schema = schemata[entName] && schemata[entName][bundle]; if (!schema) { throw new Error(`Cannot find a schema for the ${entName} type: ${type}.`); } const now = new Date().toISOString(); const entityCopy = clone__default["default"](entity); const propsCopy = clone__default["default"](props); const { meta = {} } = entityCopy; let { changed = now } = meta; const { conflicts = [], created = now, fieldChanges = {}, remote = {}, } = meta; const { lastSync = null, url = null, meta: remoteMeta = null } = remote; const updateFields = (fieldType) => { const fields = { ...entityCopy[fieldType] }; listProperties(schema, fieldType).forEach((name) => { if (name in propsCopy) { fields[name] = propsCopy[name]; fieldChanges[name] = now; changed = now; } }); return fields; }; const attributes = updateFields('attributes'); const relationships = updateFields('relationships'); return { id, type, attributes, relationships, meta: { changed, conflicts, created, fieldChanges, remote: { lastSync, meta: remoteMeta, url, }, }, }; }; const byType = { string: () => '', boolean: () => false, object: () => null, array: () => [], }; const byFormat = { 'date-time': () => new Date().toISOString(), }; const defaultOptions = { byType, byFormat }; /** * @typedef {Object} EntityReference * @property {String} id A v4 UUID as specified by RFC 4122. * @property {String} type Corresponding to the entity bundle (eg, 'activity'). */ /** * @typedef {Object} Entity * @property {String} id A v4 UUID as specified by RFC 4122. * @property {String} type The combined form of entity & bundle (eg, 'log--activity'). * @property {Object} attributes Values directly attributable to this entity. * @property {Object.<String, EntityReference|Array.<EntityReference>>} relationships * References to other entities that define a one-to-one or one-to-many relationship. * @property {Object} meta Non-domain information associated with the creation, * modification, storage and transmission of the entity. * @property {String} meta.created An ISO 8601 date-time string indicating when * the entity was first created, either locally or remotely. * @property {String} meta.changed An ISO 8601 date-time string indicating when * the entity was last changed, either locally or remotely. * @property {Object} meta.remote * @property {Object} meta.fieldChanges * @property {Array} meta.conflicts */ // Configuration objects for the entities supported by this library. /** * @typedef {Object} EntityConfig * @property {Object} nomenclature * @property {Object} nomenclature.name * @property {Object} nomenclature.shortName * @property {Object} nomenclature.plural * @property {Object} nomenclature.shortPlural * @property {Object} nomenclature.display * @property {Object} nomenclature.displayPlural * @property {Object} defaultOptions * @property {Object} defaultOptions.byType * @property {Object} defaultOptions.byFormat */ /** @type {Object.<String, EntityConfig>} */ /** * @typedef {Object.<String, EntityConfig>} DefaultEntities * @property {EntityConfig} asset * @property {EntityConfig} log * @property {EntityConfig} plan * @property {EntityConfig} quantity * @property {EntityConfig} taxonomy_term * @property {EntityConfig} user */ var defaultEntities = { asset: { nomenclature: { name: 'asset', shortName: 'asset', plural: 'assets', shortPlural: 'assets', display: 'Asset', displayPlural: 'Assets', }, defaultOptions, }, file: { nomenclature: { name: 'file', shortName: 'file', plural: 'files', shortPlural: 'files', display: 'File', displayPlural: 'Files', }, defaultOptions, }, log: { nomenclature: { name: 'log', shortName: 'log', plural: 'logs', shortPlural: 'logs', display: 'Log', displayPlural: 'Logs', }, defaultOptions, }, plan: { nomenclature: { name: 'plan', shortName: 'plan', plural: 'plans', shortPlural: 'plans', display: 'Plan', displayPlural: 'Plans', }, defaultOptions, }, quantity: { nomenclature: { name: 'quantity', shortName: 'quantity', plural: 'quantities', shortPlural: 'quantities', display: 'Quantity', displayPlural: 'Quantities', }, defaultOptions, }, taxonomy_term: { nomenclature: { name: 'taxonomy_term', shortName: 'term', plural: 'taxonomy_terms', shortPlural: 'terms', display: 'Taxonomy Term', displayPlural: 'Taxonomy Terms', }, defaultOptions, }, user: { nomenclature: { name: 'user', shortName: 'user', plural: 'users', shortPlural: 'users', display: 'User', displayPlural: 'Users', }, defaultOptions, }, }; const entityMethods = (fn, allConfigs) => reduceObjIndexed((methods, config) => ({ ...methods, [config.nomenclature.shortName]: { ...fn(config), }, }), {}, allConfigs); /** * JSON Schema for defining the entities supported by a farmOS instance. * @see {@link https://json-schema.org/understanding-json-schema/index.html} * @typedef {import('../json-schema/reference').JsonSchema} JsonSchema */ /** * JSON Schema Dereferenced: A JSON Schema, but w/o any $ref keywords. As such, * it may contain circular references that cannot be serialized. * @typedef {import('../json-schema/reference').JsonSchemaDereferenced} JsonSchemaDereferenced */ /** * An object containing the schemata for the bundles of a farmOS entity, with * the bundle name as key and its corresponding schema as its value. * @typedef {Object.<string, JsonSchema>} BundleSchemata */ /** * An object containing the schemata for the bundles of a farmOS entity, with * the bundle name as key and its corresponding schema as its value. * @typedef {Object.<string, BundleSchemata>} EntitySchemata */ /** The methods for writing to local copies of farmOS data structures, such as * assets, logs, etc. * @typedef {Object} ModelEntityMethods * @property {import('./create.js').createEntity} create * @property {import('./update.js').updateEntity} update * @property {import('./merge.js').mergeEntity} merge */ /** A collection of functions for working with farmOS data structures, their * associated metadata and schemata. * @typedef {Object} FarmModel * @property {Object} schema * @property {Function} schema.get * @property {Function} schema.set * @property {Function} schema.on * @property {Object} meta * @property {Function} meta.isUnsynced * @property {ModelEntityMethods} asset * @property {ModelEntityMethods} log * @property {ModelEntityMethods} plan * @property {ModelEntityMethods} quantity * @property {ModelEntityMethods} term * @property {ModelEntityMethods} user */ /** * @typedef {import('../entities.js').EntityConfig} EntityConfig */ /** * Create a farm model for generating and manipulating farmOS data structures. * @typedef {Function} model * @param {Object} options * @property {EntitySchemata} [options.schemata] * @property {Object<String, EntityConfig>} [options.entities=defaultEntities] * @returns {FarmModel} */ function model(options = {}) { const { entities = defaultEntities } = options; const schemata = map__default["default"](() => ({}), entities); const observers = { schema: { set: createObserver(), }, }; /** * Retrieve all schemata that have been previously set, or the schemata of a * particular entity, or one bundle's schema, if specified. * @param {...String} args * @returns {EntitySchemata|BundleSchemata|JsonSchemaDereferenced} */ function getSchemata(...args) { let entity = args[0]; let bundle = args[1]; if (args.length === 2 && typeof args[1] === 'string') { const parsed = parseEntityType(args[1]); if (parsed.type) ({ bundle } = parsed); } if (args.length === 1 && typeof args[0] === 'string') { const parsed = parseEntityType(args[0]); if (parsed.type) ({ entity, bundle } = parsed); } if (schemata[entity] && schemata[entity][bundle]) { return clone__default["default"](schemata[entity][bundle]); } if (schemata[entity]) { return clone__default["default"](schemata[entity]); } return clone__default["default"](schemata); } /** * Load all schemata, the schemata of a particular entity, or one bundle's * schema, if specified. * @param {...String|EntitySchemata|BundleSchemata|JsonSchema} args * @void */ function setSchemata(...args) { if (args.length === 0) { throw new Error('At least one valid argument is required for setting ' + 'farm schemata but none was provided.'); } if (args.length === 1 && isObject(args[0])) { const [entitySchemata] = args; Object.entries(entitySchemata).forEach(([entity, bundleSchemata]) => { if (entity in schemata) { setSchemata(entity, bundleSchemata); } }); return clone__default["default"](schemata); } if (args.length === 2 && isObject(args[1])) { const type0 = parseEntityType(args[0]); if (type0.entity in schemata && type0.bundle) { const { entity, bundle } = type0; const [, schema] = args; setSchemata(entity, bundle, schema); return clone__default["default"](schemata)[entity][bundle]; } const [entity, bundleSchemata] = args; Object.entries(bundleSchemata).forEach(([type1, schema]) => { const { bundle = type1 } = parseEntityType(type1); setSchemata(entity, bundle, schema); }); return clone__default["default"](schemata)[entity]; } if (args.length === 3 && args[0] in schemata && typeof args[1] === 'string' && isObject(args[2])) { const [entity, type, schema] = args; const { bundle = type } = parseEntityType(type); schemata[entity][bundle] = dereference(schema); return clone__default["default"](schemata)[entity][bundle]; } const description = 'One or more invalid arguments for setting farm schemata'; throw new Error(`${description}: ${args.join(', ')}.`); } if (options.schemata) setSchemata(options.schemata); const addListeners = namespace => (name, callback) => { if (name in observers[namespace]) { return observers[namespace][name].subscribe(callback); } throw new Error(`Invalid method name for ${namespace} listener: ${name}`); }; return { schema: { get: getSchemata, /** @param {...String|EntitySchemata|BundleSchemata|JsonSchema} args */ set(...args) { setSchemata(...args); const getterArgs = dropLast__default["default"](1, args); observers.schema.set.next(getSchemata(...getterArgs)); }, on: addListeners('schema'), }, meta: { isUnsynced(entity) { const { changed, remote: { lastSync = null } = {} } = entity.meta; return lastSync === null || changed > lastSync; }, }, ...entityMethods(({ defaultOptions }) => ({ create: createEntity(schemata, defaultOptions), merge: mergeEntity(schemata), update: updateEntity(schemata), }), entities), }; } exports["default"] = model;