UNPKG

haystack-nclient

Version:

Project Haystack Network Client

1,674 lines (1,663 loc) 161 kB
import { valueIsKind, Kind, HAYSON_MIME_TYPE, isHVal, makeValue, ZincReader, HStr, HGrid, HDict, HMarker, HRef, HList, HFilter, HNum, HNamespace } from 'haystack-core'; /* * Copyright (c) 2020, J2 Innovations. All Rights Reserved */ /** * @module HTTP utility methods. */ /** * Returns the URL for an general API. * * @param params.origin The origin. * @param params.path The path prefix to use. * @returns A URL. */ const getServiceUrl = ({ origin, path, }) => `${origin}/api/${path}`; /** * Returns the URL for an op. * * @param params.origin The origin. * @param params.pathPrefix The path prefix to use. * @param params.project The project name. * @param params.op The op name. * @returns A URL. */ const getOpUrl = ({ origin, pathPrefix, project, op, }) => `${origin}${pathPrefix}/api/${project}/${op}`; /** * Returns the URL for a Haystack REST service. * * @param origin The origin for the service. * @param pathPrefix The path prefix to use. * @param project The project for the service. May be empty and if so shouldn't be included. * @param path The service path. * @returns A URL. */ const getHaystackServiceUrl = ({ origin, pathPrefix, project, path, }) => `${origin}${pathPrefix}/api/haystack${project ? `/${project}` : ''}/${path}`; /** * Returns the URL for a Host REST service. * * @param origin The origin for the service. * @param pathPrefix The path prefix to use. * @param path The service path. * @returns A URL. */ const getHostServiceUrl = ({ origin, pathPrefix, path, }) => `${origin}${pathPrefix}/api/host/${path}`; /** * Encode the object as a URI query segment. * * https://en.wikipedia.org/wiki/Uniform_Resource_Identifier * * @param obj The object to encode. * @returns A query or empty string if there's noting to encode. */ function encodeQuery(obj) { let query = ''; for (const key of Object.keys(obj)) { const value = obj[key]; if (value !== undefined) { if (!query) { query = '?'; } else { query += `&`; } let encodedValue; if (valueIsKind(value, Kind.Ref)) { encodedValue = value.value; } else if (valueIsKind(value, Kind.List)) { encodedValue = value.values.map((val) => val.value).join('|'); } else { encodedValue = Array.isArray(value) ? value.join('|') : String(value); } query += `${key}=${encodeURIComponent(encodedValue)}`; } } return query; } /** * The CSRF key that needs to be added to all outgoing writable requests. */ const SKYARC_ATTEST_KEY = 'skyarc-attest-key'; /** * The FIN authorization key that contains a requested CSRF key. */ const FIN_AUTH_KEY = 'fin-stack-auth'; /** * The FIN authorization path to request a CSRF token. */ const FIN_AUTH_PATH = '/finStackAuth'; /** * The HTTP accept header. */ const ACCEPT_HEADER = 'accept'; /** * The HTTP content type header. */ const CONTENT_TYPE_HEADER = 'content-type'; /** * The zinc mime type. */ const ZINC_MIME_TYPE = 'text/zinc'; /** * Return the origin for the specified resource. * * If the path is relative then an empty string is returned. * * @param resource The resouce to get the host from. * @returns The host. */ function getOrigin(resource) { try { return new URL(resource).origin; } catch { return ''; } } /** * Return true if the target is a valid headers object. * * @param headers The headers object to test. * @returns True if the target is a headers object. */ function isHeaders(headers) { return !!(headers && typeof headers.entries === 'function' && typeof headers.keys === 'function' && typeof headers.values === 'function'); } /** * Return true if the headers object has the specified header. * * @param headers The headers object. * @param headerName The header name to look for. * @returns True if the header is present. */ function hasHeader(headers, headerName) { if (!headers) { return false; } // Handle Headers object. if (isHeaders(headers)) { return headers.has(headerName); } // Handle object literal. const header = headerName.toLowerCase(); for (const name in headers) { if (name.toLowerCase() === header) { return true; } } return false; } /** * Return the header value from the headers object. Return undefined if the * value can't be found. * * @param headers The headers object. * @param headerName The headers name to look for. * @returns The header value as a string or undefined if it can't be found. */ function getHeader(headers, headerName) { if (!headers) { return undefined; } // Handle Headers object. if (isHeaders(headers)) { return headers.get(headerName) ?? undefined; } // Handle object literal. const header = headerName.toLowerCase(); for (const name in headers) { if (name.toLowerCase() === header) { return headers[name]; } } return undefined; } /** * Add the header and its value to an options object. * * @param options The options object that has a headers object. * @param headerName The header name. * @param headerValue The header value. */ function addHeader(options, headerName, headerValue) { if (isHeaders(options.headers)) { options.headers.set(headerName, headerValue); } else { const headers = (options.headers ?? (options.headers = {})); headers[headerName] = headerValue; } } /** * Remove the header value from the headers object by name. * * @param headers The headers object. * @param headerName The headers name to look for. * @returns The headers object */ function removeHeader(headers, headerName) { if (!headers) { return headers; } // Handle Headers object. if (isHeaders(headers)) { headers.delete(headerName); return headers; } // Handle Array Case if (Array.isArray(headers)) { const index = headers.findIndex((item) => item[0] === headerName); if (index > -1) { headers.splice(index, 1); return headers; } } else { // Handle object literal. const header = headerName.toLowerCase(); for (const name in headers) { if (name.toLowerCase() === header) { delete headers[name]; return headers; } } } return headers; } /** * Adds a starting slash and removes any ending slash. * * @param path The path to update. * @returns The updated path. */ function addStartSlashRemoveEndSlash(path) { path = path.trim(); if (path && !path.startsWith('/')) { path = `/${path}`; } if (path.endsWith('/')) { path = path.substring(0, path.length - 1); } return path; } /* * Copyright (c) 2020, J2 Innovations. All Rights Reserved */ /* eslint @typescript-eslint/no-explicit-any: "off" */ /** * The http status code for an invalid attest key. */ const INVALID_ATTEST_KEY_STATUS = 400; /** * A map that holds cached attest keys promises (CSRF tokens) to use * in any potential HTTP requests. */ const originAttestKeyPromises = new Map(); /** * Represents a CSRF error. */ class CsrfError extends Error { /** * Used for a type guard check. */ _isCsrfError = true; /** * HTTP response code. */ status; constructor(response, message) { super(message || response.statusText); this.status = response.status; } } /** * A type guard for an CSRF error. * * @param value The value to check. * @returns The result of the type guard check. */ function isCsrfError(value) { return !!value?._isCsrfError; } /** * Clear any cached CSRF tokens. * * Calling this will force all CSRF tokens to be re-requested on subsequent network calls. */ function clearFinCsrfTokens() { originAttestKeyPromises.clear(); } /** * Asynchronously return the CSRF token for the specified host. * * @param host The host. * @returns Resolves to the CSRF token or an empty string if it can't be found. */ async function getFinCsrfToken(host) { return originAttestKeyPromises.get(host) ?? ''; } /** * Asynchronously request the attest key from a server. * * @param origin The origin to request the attest key from. * @param options The `fetch` options to use when making an HTTP request. * @param fetchFunc Fetch function to use instead of global fetch. * @returns A promise that resolves to the attest key or an empty string. * @throws Error if we cannot obtain the key. */ async function requestFinAttestKey(origin, options, fetchFunc) { const opt = { ...options, method: 'POST', credentials: 'include', }; const attestRequestUri = isCsrfRequestInit(options) ? options.attestRequestUri ?? FIN_AUTH_PATH : FIN_AUTH_PATH; const attestResponseHeaderName = isCsrfRequestInit(options) ? options.attestResponseHeaderName ?? FIN_AUTH_KEY : FIN_AUTH_KEY; const resp = await fetchFunc(`${origin}${attestRequestUri}`, opt); const auth = getHeader(resp.headers, attestResponseHeaderName); if (!auth) { throw new CsrfError(resp, 'Cannot acquire attest key'); } return auth; } /** * Asynchronously return the attest key for the specified header. * * @param resource The resource being request. * @param cors Indicates whether the originating request was using cors. * @param fetchFunc Fetch function to use instead of global fetch. * @returns A promise that resolves to the attest key. */ async function getAttestKey(resource, options, fetchFunc) { const host = getOrigin(resource); let promise = originAttestKeyPromises.get(host); if (!promise) { // Cache the promise to get the attest key. This handles // multiple requests being concurrently made on start up. promise = requestFinAttestKey(host, options, fetchFunc); originAttestKeyPromises.set(host, promise); } try { return await promise; } catch (err) { // If the promise fails then clear the entry from the cache so it can be requested again in future. originAttestKeyPromises.delete(host); throw err; } } /** * Clear the cached attest key. * * @param resource The resouce being sent. */ function clearAttestKey(resource) { originAttestKeyPromises.delete(getOrigin(resource)); } /** * An enhanced fetch API for CSRF token management with the FIN framework. * * Transparently handles CSRF token management (a.k.a. attest keys) and provides additional * features for working with haystack data. * * @link https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/fetch * * @param resource The resource to request. * @param options Optional object containing any custom settings. * @param fetchFunc Optional fetch function to use instead of global fetch. * @returns A promise that resolves to a response object. */ async function finCsrfFetch(resource, options, fetchFunc) { const hsOptions = options ?? {}; const attestHeaderName = isCsrfRequestInit(options) ? options.attestHeaderName ?? SKYARC_ATTEST_KEY : SKYARC_ATTEST_KEY; const fetchImpl = fetchFunc ?? fetch; async function addAttestKeyHeader() { removeHeader(hsOptions.headers, attestHeaderName); const attestKey = await getAttestKey(String(resource), hsOptions, fetchImpl); // Only add the attest key if we have one. Some haystack servers may not use this key. if (attestKey) { addHeader(hsOptions, attestHeaderName, attestKey); return true; } else { return false; } } let hasAttestKey = hasHeader(hsOptions.headers, attestHeaderName); // Lazily add the attest key to this request if not already specified. if (!hasAttestKey) { await addAttestKeyHeader(); hasAttestKey = true; } let resp = await fetchImpl(resource, hsOptions); // If we get a 400 response back then it's likely the attest key is no longer // valid. Perhaps the server has restarted. In this case, clear the attest key // and attempt to re-request it. if (resp.status === INVALID_ATTEST_KEY_STATUS && hasAttestKey) { clearAttestKey(String(resource)); if (await addAttestKeyHeader()) { resp = await fetchImpl(resource, hsOptions); } } return resp; } function isCsrfRequestInit(value) { return !!value && 'attestHeaderName' in value; } /* * Copyright (c) 2022, J2 Innovations. All Rights Reserved */ /** * A general authentication error. */ class AuthenticationError extends Error { /** * Used for a type guard check. */ _isAuthenticationError = true; /** * Error that caused this authentication error to occur */ cause; constructor(cause, message) { super(message ?? cause?.message); this.cause = cause; } } /** * A type guard for an authentication error. * * @param value The value to check. * @returns The result of the type guard check. */ function isAuthenticationError(value) { return !!value?._isAuthenticationError; } /** * The default fallback fetch method. */ const defaultFetch = finCsrfFetch; /** * An enhanced fetch API for a pluggable authentication mechanism. * * Transparently handles pre-authentication, authentication fault detection, authentication, and * will replay the requested resource upon successful authentication. * * By default, this fetch function will utilize the finCsrfFetch function internally to execute the request. * * For example... * * ```typescript * const result = finAuthFetch(request, { * authenticator: { * isAuthenticated: (response: Response) => response.status !== 401, * preauthenticate: async (request: RequestInfo) => new Request(request, {headers: {auth_header: '12345'}}), * authenticate: (response: Response) => return execute_authentication(username, password), * maxTries: 3 * } * }) * ``` * * @link https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/fetch * * @param resource The resource to request. * @param options Optional object containing custom settings and the authenticator. * @returns A promise that resolves to a response object. * @throws Exception on failed authentication */ async function finAuthFetch(resource, options) { let resp; let fetchMethod = defaultFetch; if (options && isAuthInit(options)) { const authenticator = options.authenticator; // Pre authenticate if suppprted by authenticator if (authenticator.preAuthenticate) { resource = await authenticator.preAuthenticate(resource, options); } fetchMethod = options.fetch ?? defaultFetch; // Pipe request to the fetch method try { resp = await fetchMethod(resource, options); // Check if response is an authentication fault if (!(await authenticator.isAuthenticated?.(resp))) { throw new AuthenticationError(new Error('Request not authenticated')); } // Response was already authenticated, return response return resp; } catch (error) { if (isCsrfError(error) || isAuthenticationError(error)) { // An http error was thrown, attempt to authenticate const authSuccessful = await authenticateResponse(resource, resp, options); // if authentication failed, throw authentication error if (!authSuccessful) { throw new AuthenticationError(new Error('Authentication failed')); } } else { throw error; } } } // If request doesn't contain an authenticator or authentication was successful, play request. resp = await fetchMethod(resource, options); return resp; } async function authenticateResponse(request, response, options) { const authenticator = options?.authenticator; if (!authenticator) { return false; } const maxTries = authenticator.maxTries ?? 3; let authSuccessful = false; // Attempt to authenticate until we have reached the max try threshold for (let i = 0; i < maxTries; i++) { const result = await authenticator.authenticate(request, response, options); if (result) { authSuccessful = true; break; } } return authSuccessful; } function isAuthInit(init) { return 'authenticator' in init; } /* * Copyright (c) 2020, J2 Innovations. All Rights Reserved */ /** * An error that encapsulates a grid. */ class GridError extends Error { grid; _isGridError = true; constructor(message, grid) { super(message); this.grid = grid; } toGrid() { return this.grid; } } /** * Return ture if the error is a grid error. * * @param error The error to test. * @returns True if the error is a grid error. */ function isGridError(error) { return !!(error && error._isGridError); } /* * Copyright (c) 2020, J2 Innovations. All Rights Reserved */ const HS_ACCEPT_HEADER_VALUE = `${ZINC_MIME_TYPE},${HAYSON_MIME_TYPE}`; /** * Validate the HTTP response object. * * @param resp The response to validate. * @param val The associated value. * @throws Error if the response is invalid. */ function validateResponse(resp, val) { if (resp.status !== 200 && resp.status !== 201) { throw new Error(val ? String(val) : 'Error in decoding haystack response'); } } /** * Validate the haystack value. * * @param hval The value to validate or a decoded string. * @returns The haystack value. * @throws Throws an error if we don't have a valid haystack value. */ function validateValue(val, resp) { // 1. First check to see if the decoded value is a grid with an error. if (valueIsKind(val, Kind.Grid)) { // Throw an error if the returned grid contains one. const err = val.getError(); if (err) { throw new GridError(err.dis, val); } } // 2. Check response codes and throw an error with anything read. validateResponse(resp, val); // 3. Check we do have a haystack value decoded. if (!isHVal(val)) { throw new Error('Unable to decode haystack value'); } return val; } async function parseResponse(resp) { const contentType = resp.headers.get(CONTENT_TYPE_HEADER); if (contentType?.includes(HAYSON_MIME_TYPE)) { return makeValue(await resp.json()); } else if (contentType?.includes(ZINC_MIME_TYPE)) { return ZincReader.readValue(await resp.text()); } else { return HStr.make(await resp.text()); } } /** * Reads a grid from a response. * * @param resp The response to read. * @returns The read grid. */ async function readVal(resp) { const val = (await parseResponse(resp)); return validateValue(val, resp); } /** * Make some haystack value options. * * @param options The fetch options. * @returns The updated fetch optons. */ function makeHsOptions(options) { const hsOptions = options ?? {}; // Always include the credentials if not specified. if (!hsOptions.credentials) { hsOptions.credentials = 'include'; } // Default accept header for Hayson and Zinc. if (!hasHeader(hsOptions.headers, ACCEPT_HEADER)) { addHeader(hsOptions, ACCEPT_HEADER, HS_ACCEPT_HEADER_VALUE); } // Automatically add the content type if not specified. // Base it on the inspecting the body. if (!hasHeader(hsOptions.headers, CONTENT_TYPE_HEADER) && hsOptions?.body) { addHeader(hsOptions, CONTENT_TYPE_HEADER, String(hsOptions?.body).startsWith('ver') ? ZINC_MIME_TYPE : HAYSON_MIME_TYPE); } return hsOptions; } /** * Convience method to fetch a haystack value from the server. * * If the returned value is a grid and has an error then the returned promise * will be rejected with a `GridError` instance. * * @param resource The resource to request. * @param options Optional object containing any custom settings. * @param fetchFunc Optional fetch function to use instead of global fetch. * @returns A promise that resolves to a value. * @throws A fetch or grid error. */ async function fetchVal(resource, options, fetchFunc) { // If there is `fetch` specified in the options then use it instead of global fetch. // This enables `fetch` to be cross cut and have different behaviors injected into it. const fetchImpl = fetchFunc ?? fetch; return readVal(await fetchImpl(resource, makeHsOptions(options))); } /* * Copyright (c) 2020, J2 Innovations. All Rights Reserved */ /** * Validate the value to ensure it's a grid. * * @param hval The value to validate or a decoded string. * @returns The haystack value. * @throws Throws an error if we don't have a valid haystack value. */ function validateGrid(val) { return valueIsKind(val, Kind.Grid) ? val : new HGrid({ meta: new HDict({ err: HMarker.make(), dis: 'Expected grid in response', errType: 'error', errTrace: '', }), }); } /** * Reads a number of grids from a response. * * @param resp The response to read the grids from. * @param gridCount The number of expected grids to read in the response. * @returns The read grids. */ async function readAllGrids(resp, gridCount) { validateResponse(resp); // Please note, due to each grid being a separate part of the response object, // this should probably be always encoded in Zinc and never JSON. const zincReader = new ZincReader(await resp.text()); const allGrids = []; for (let i = 0; i < gridCount; ++i) { allGrids.push(validateGrid(zincReader.readValue())); } return allGrids; } /** * Convenience method to fetch multiple grids from a response. * * A caller will have to check each grid to see if it's in error. * * Please note: fetching multiple grids from a response is unorthodox. This * was added to support the rather antiquated `evalAll` that has multiple grids * encoded into its response. * * @param resource The resource to request. * @param gridCount The number of expected grids to read. * @param options Optional object containing any custom settings. * @param fetchFunc Optional fetch function to use instead of global fetch. * @returns A promise that resolves to a number of grids. * @throws A fetch error. */ async function fetchAllGrids(resource, gridCount, options, fetchFunc) { if (gridCount <= 0) { throw new Error('Must request one of more grids'); } const fetchImpl = fetchFunc ?? fetch; return readAllGrids(await fetchImpl(resource, makeHsOptions(options)), gridCount); } /* * Copyright (c) 2020, J2 Innovations. All Rights Reserved */ /** * Return the id string value from the dict. * * If an id can't be found then return an empty string. * * @param dict The dict to get the id from. * @returns The id as a string value or an empty string. */ function getId(dict) { return dict.get('id')?.value ?? ''; } function addRefToArray(ref, list) { if (ref.value) { list.push(ref.value); } } function addDictToArray(dict, list) { const id = dict.get('id')?.value; if (id) { list.push(id); } } function addDictsToArray(dicts, list) { for (const dict of dicts) { addDictToArray(dict, list); } } /** * Convert the ids to an array of id strings. * * @param ids The ids to create the id array from. * @returns An array of id strings. */ function idsToArray(ids) { const array = []; if (valueIsKind(ids, Kind.Grid)) { addDictsToArray(ids.getRows(), array); } else if (valueIsKind(ids, Kind.List)) { for (const id of ids) { if (valueIsKind(id, Kind.Dict)) { addDictToArray(id, array); } else { addRefToArray(id, array); } } } else if (valueIsKind(ids, Kind.Dict)) { addDictToArray(ids, array); } else if (valueIsKind(ids, Kind.Ref)) { addRefToArray(ids, array); } else if (typeof ids === 'string') { array.push(HRef.make(ids).value); } else if (Array.isArray(ids)) { for (const id of ids) { if (valueIsKind(id, Kind.Dict)) { addDictToArray(id, array); } else if (valueIsKind(id, Kind.Ref)) { addRefToArray(id, array); } else { array.push(HRef.make(id).value); } } } else { throw new Error('Unrecognized ids'); } return array; } /** * Return a list of refs from the arguments. * * @param ids The ids to convert to a list. * @returns A list of refs. */ function toIdsList(ids) { return valueIsKind(ids, Kind.List) ? ids : HList.make(ids.map((val) => HRef.make(val))); } /** * Convert the dict arguments to a grid. * * @param dicts The dicts to convert to a grid. * @returns A grid containing the dicts. */ function dictsToGrid(dicts) { let grid; if (valueIsKind(dicts, Kind.Grid)) { grid = dicts; } else if (valueIsKind(dicts, Kind.List)) { grid = HGrid.make(dicts.values); } else if (Array.isArray(dicts)) { grid = HGrid.make(dicts); } else { grid = HDict.make(dicts).toGrid(); } return grid; } /** * Convert an array of ids into an array of dicts with ids. * * @param ids The ids. * @returns An array of dicts with ids. */ function idsToDicts(ids) { return ids.map((id) => HDict.make({ id: HRef.make(id) })); } /* * Copyright (c) 2020, J2 Innovations. All Rights Reserved */ /** * An implementation of the FIN record service. * * Please note, this is currently not part of the official Haystack standard. */ class RecordService { /** * The client service configuration. */ #serviceConfig; /** * The url for the record service. */ #url; /** * Constructs a new record service object. * * @param serviceConfig Service configuration. */ constructor(serviceConfig) { this.#serviceConfig = serviceConfig; this.#url = serviceConfig.getHaystackServiceUrl('records'); } /** * Read a record via its id. * * @param id The id of the record to read. * @returns The record. * @throws An error if the record can't be found. */ async readById(id) { const record = await fetchVal(`${this.#url}/${HRef.make(id).value}`, { ...this.#serviceConfig.getDefaultOptions(), }, this.#serviceConfig.fetch); return record; } /** * Read multiple records via their ids. * * @param id The ids of the records to read. * @param options Optional options for read operation. * @returns The result of the read operation. */ async readByIds(ids, options) { return fetchVal(`${this.#url}${encodeQuery({ ids, ...(options ?? {}), })}`, { ...this.#serviceConfig.getDefaultOptions(), }, this.#serviceConfig.fetch); } /** * Query some records via a haystack filter. * * @param filter The haystack filter to query by. * @param options Optional options for read operation. * @returns The result of the read operation. */ async readByFilter(filter, options) { return fetchVal(`${this.#url}${encodeQuery({ filter, ...(options ?? {}), })}`, { ...this.#serviceConfig.getDefaultOptions(), }, this.#serviceConfig.fetch); } /** * Query some records via a haystack filter and return the record count. * * @param filter The haystack filter to query by. * @returns The number of records counted. */ async readCount(filter) { const grid = await fetchVal(`${this.#url}${encodeQuery({ count: filter, })}`, { ...this.#serviceConfig.getDefaultOptions(), }, this.#serviceConfig.fetch); return grid.meta.get('count')?.value ?? 0; } /** * Create some records on the server. * * @param dicts An array of dicts, array of hayson dicts, list of dicts * or a grid. * @returns The resultant grid of the create operation. The grid contains a * 'created' number property for the number of records created in the meta. */ async create(dicts) { return fetchVal(this.#url, { ...this.#serviceConfig.getDefaultOptions(), method: 'POST', body: JSON.stringify(dictsToGrid(dicts).toJSON()), }, this.#serviceConfig.fetch); } /** * Create a single record on the server. * * @param dict The record to create. * @returns The resultant dict of the create operation. */ async createRecord(dict) { return fetchVal(this.#url, { ...this.#serviceConfig.getDefaultOptions(), method: 'POST', body: JSON.stringify(HDict.make(dict).toJSON()), }, this.#serviceConfig.fetch); } /** * Delete a record via its id. * * @param id The id of the record to delete. * @returns If the record */ async deleteById(id) { const record = await fetchVal(`${this.#url}/${HRef.make(id).value}`, { ...this.#serviceConfig.getDefaultOptions(), method: 'DELETE', }, this.#serviceConfig.fetch); return record; } /** * Delete multiple records via their ids. * * @param id The ids of the records to delete. * @returns A grid with the records that were deleted. Each record only * contains the `id` and `mod` properties. The grid meta contains an * `deleted` number property for the total number of records deleted. */ async deleteByIds(ids) { return fetchVal(`${this.#url}${encodeQuery({ ids, })}`, { ...this.#serviceConfig.getDefaultOptions(), method: 'DELETE', }, this.#serviceConfig.fetch); } /** * Delete some records via a haystack filter. * * This method should be used with extreme caution! This command * could unintentionally delete critical records in a database. * * @param filter The haystack filter to query by. * @returns A grid with the records that were deleted. Each record only * contains the `id` and `mod` properties. The grid meta contains an * `deleted` number property for the total number of records deleted. */ async deleteByFilter(filter) { return fetchVal(`${this.#url}${encodeQuery({ filter, })}`, { ...this.#serviceConfig.getDefaultOptions(), method: 'DELETE', }, this.#serviceConfig.fetch); } /** * Update by a filter. * * This method should be used with extreme caution! This command * could unintentionally update critical records in a database. * * @param dict The dict to be applied to all records found. * @param options Optional options for the update operation. * @returns A grid with the records that were updated. Each record only * contains the `id` and `mod` properties. The grid meta contains an * `updated` number property for the total number of records updated. */ async updateByFilter(filter, dict) { return fetchVal(`${this.#url}${encodeQuery({ filter, })}`, { ...this.#serviceConfig.getDefaultOptions(), method: 'PATCH', body: JSON.stringify(HDict.make(dict).toJSON()), }, this.#serviceConfig.fetch); } /** * Update some records via their ids. * * @param dicts The dicts to update. Each record must specify an id. * @returns A grid with the records that were updated. Each record only * contains the `id` and `mod` properties. The grid meta contains an * `updated` number property for the total number of records updated. */ async update(dicts) { return fetchVal(this.#url, { ...this.#serviceConfig.getDefaultOptions(), method: 'PATCH', body: JSON.stringify(dictsToGrid(dicts).toJSON()), }, this.#serviceConfig.fetch); } /** * Duplicate an existing record in the database. * * @param options The duplicate options. * @param options.id The id of the record to duplicate. * @param options.count The number of times to duplicate a record. * @param options.includeChildren Whether to also duplicate any contained child records. * @returns A list of top level record ids that were duplicated. This does not include * any child record ids that were duplicated. */ async duplicate({ id, count, includeChildren, }) { return fetchVal(`${this.#url}/${HRef.make(id).value}/duplicate`, { ...this.#serviceConfig.getDefaultOptions(), method: 'POST', body: JSON.stringify(new HDict({ count, includeChildren, })), }, this.#serviceConfig.fetch); } } /* * Copyright (c) 2020, J2 Innovations. All Rights Reserved */ /** * Watch events to listen too. */ var WatchEventType; (function (WatchEventType) { /** * A watch has been closed and is no longer active. */ WatchEventType["Closed"] = "closed"; /** * A watch's records have changed. */ WatchEventType["Changed"] = "changed"; /** * A watch has been refreshed. */ WatchEventType["Refreshed"] = "refreshed"; /** * A watch has records added to it. */ WatchEventType["Added"] = "added"; /** * A watch has records removed from it. */ WatchEventType["Removed"] = "removed"; /** * A watch is attempting to watch records * that don't exist. */ WatchEventType["Error"] = "error"; })(WatchEventType || (WatchEventType = {})); /* * Copyright (c) 2020, J2 Innovations. All Rights Reserved */ /** * Delete an object's properties. * * @param obj The object to clear of properties. */ function clear(obj) { for (const key of Object.keys(obj)) { delete obj[key]; } } /* * Copyright (c) 2020, J2 Innovations. All Rights Reserved */ /** * The default poll rate in seconds. */ const DEFAULT_POLL_RATE_SECS = 5; /** * A list of open all watches. * * This list is held for debugging purposes. */ const watches = new Set(); /** * A haystack Watch. */ class Watch { /** * The subject the watch is observing. */ #subject; /** * The grid for the watch. */ grid; /** * The ids of records that are currently in error and are not being watched. */ #errors = new Set(); /** * The display name for the watch. */ #watchDisplay; /** * Flag indicating whether the watch is closed. */ #closed = false; /** * An object that holds dict indexes in the grid. */ #idsToGridIndexes = {}; /** * The poll rate for this watch. */ #pollRate = DEFAULT_POLL_RATE_SECS; /** * Watch event callbacks. */ #callbacks = new Map(); /** * Constructs a new watch. * * Please note, all watches must be closed when no longer used. * * @param watchDisplay A client side display name for the watch. * @param subject The watch will observe this subject. * @param grid An empty grid to use for the watch. */ constructor(watchDisplay, subject, grid = HGrid.make({})) { this.#watchDisplay = watchDisplay; this.#subject = subject; this.grid = grid; this.#subject.on(this.$onChanged); } /** * Returns a list of watches. * * ```typescript * hs.Watch.watches.forEach((watch: Watch): void => { * console.log(`We have watch: ${watch.display}`) * }) * ``` * * @returns A list of all open watches. */ static get watches() { return [...watches]; } /** * Inspect all the watches available. * * ```typescript * hs.Watch.inspectAll() * ``` */ static inspectAll() { this.watches.forEach((watch) => { watch.inspect(); }); } /** * Dump the watch to the local console output. * * ```typescript * watch.inspect() * ``` * * @returns The value instance. */ inspect() { this.grid.inspect(this.display); return this; } /** * Dump the watch's subject to the local console output. * * ```typescript * watch.inspectSubject() * ``` * * @returns The value instance. */ inspectSubject() { this.#subject.inspect(); return this; } /** * ```typescript * console.log(watch.display) * ``` * * @returns The display name of the watch. */ get display() { return `${this.#watchDisplay} @ ${this.#subject.display}`; } /** * @returns A string representation of a watch. */ toString() { return this.display; } /** * Returns true if the watch is closed. * * ```typescript * if (watch.isClosed()) { * // Do something * } * ``` * * @returns True if the watch is closed. A closed watch * can no longer be used. */ isClosed() { return this.#closed; } /** * @throws An error if the watch is closed. */ throwErrorIfClosed() { if (this.#closed) { throw new Error('Watch is closed'); } } /** * Return a new opened watch. * * Please note, all watches must be closed when no longer used. * * @param options.subject The watch subject. * @param options.ids The ids to watch. * @param options.display Display name for the watch. * @param options.grid Optional grid to use for the watch. * @returns An opened watch. */ static async open({ subject, ids, display, grid, }) { const watch = new Watch(display, subject, grid); await watch.add(ids); return watch; } /** * Close all watches for the given subject. * * Please note, this method doesn't normally need to be called * and is designed to be used internally. If you want * to close a watch then please just call `Watch.#close()` instead. * * @param subject The subject to close watches for. */ static async close(subject) { await Promise.all([...watches] .filter((watch) => watch.#subject === subject) .map((watch) => watch.close())); } /** * Add records to watch. * * ```typescript * await watch.add('@someid') * ``` * * @param ids The ids to add. */ async add(ids) { this.throwErrorIfClosed(); watches.add(this); this.computeSubjectPollRate(); // Only add dicts that aren't already added. const toAdd = idsToArray(ids).filter((id) => this.#idsToGridIndexes[id] === undefined); if (toAdd.length) { await this.#subject.add(toAdd); const { addedIds, errorIds } = this.addDictsToGrid(toAdd); if (addedIds.length) { const event = { type: WatchEventType.Added, ids: addedIds, }; this.fire(event); } if (errorIds.length) { const event = { type: WatchEventType.Error, ids: errorIds, }; this.fire(event); } } } /** * Add new dicts to the watch's grid. * * @param toAdd The ids to add. * @returns The ids added and those in error. */ addDictsToGrid(toAdd) { const addedIds = []; const errorIds = []; const dictsToAdd = []; let index = this.grid.length; for (const id of toAdd) { // Ignore duplicates. if (this.#idsToGridIndexes[id] === undefined) { // Always create a new copy because we want to // track the changes in our local // grid when updates are made. const dict = this.#subject.get(id)?.newCopy(); if (dict) { // If we find the dict then we don't have an error. this.#errors.delete(id); addedIds.push(id); dictsToAdd.push(dict); this.#idsToGridIndexes[id] = index++; } else if (!this.#errors.has(id)) { // If the subject doesn't have a record then we can // assume it's an error. this.#errors.add(id); errorIds.push(id); } } } if (dictsToAdd.length) { this.addToGrid(dictsToAdd); } return { addedIds, errorIds }; } /** * Remove records to watch. * * This is called to stop watching records. * * ```typescript * await watch.remove('@someid') * ``` * * @param ids The ids to remove. */ async remove(ids) { this.throwErrorIfClosed(); const idArray = idsToArray(ids); // Only remove dicts that are already added. const toRemove = idArray.filter((id) => this.#idsToGridIndexes[id] !== undefined); // Remove any errors. idArray.forEach(this.#errors.delete, this.#errors); if (toRemove.length) { await this.#subject.remove(toRemove); const event = { type: WatchEventType.Removed, ids: this.removeDictsFromGrid(toRemove), }; this.fire(event); } } /** * Removes dicts from the watch's grid. * * @param toRemove The ids to remove. * @returns The removed ids. */ removeDictsFromGrid(toRemove) { const ids = []; for (const id of toRemove) { if (this.#idsToGridIndexes[id] !== undefined) { ids.push(id); this.removeFromGrid(`id == @${id}`); delete this.#idsToGridIndexes[id]; } } return ids; } /** * Completely refresh the watch. * * ```typescript * await watch.refresh() * ``` */ async refresh() { this.throwErrorIfClosed(); await this.#subject.refresh(); // Rebuild the grid using the ids we're interested in. this.clearGrid(); const dictsToAdd = []; for (const id of Object.keys(this.#idsToGridIndexes)) { const dict = this.#subject.get(id); if (dict) { // Always create a new copy so we can track local // changes to the watch's grid. dictsToAdd.push(dict.newCopy()); } } if (dictsToAdd.length) { this.addToGrid(dictsToAdd); } this.rebuildDictCache(); this.fire({ type: WatchEventType.Refreshed }); } /** * Rebuild the cache of dicts. */ rebuildDictCache() { clear(this.#idsToGridIndexes); // Create a map of dicts for quick look up. for (let i = 0; i < this.grid.length; ++i) { const dict = this.grid.get(i); this.#idsToGridIndexes[getId(dict)] = i; } } /** * Changed event callback. */ $onChanged = (event) => { let ids; for (const id in event.ids) { const index = this.#idsToGridIndexes[id]; if (index !== undefined) { ids = ids ?? {}; const ev = event.ids[id]; ids[id] = ev; } } if (ids) { this.updateGrid({ idsToGridIndexes: this.#idsToGridIndexes, events: ids, }); const event = { type: WatchEventType.Changed, ids, }; this.fire(event); } }; /** * Clear all watched items from the watch. * * Please note, this will not close the watch or remove any * associated method handlers. * * ```typescript * await watch.clear() * ``` */ async clear() { this.remove(this.grid); } /** * Close the watch. * * After this has been called, the underlying watch will be destroyed * and will be no longer active. The watch is effectively 'dead' after this * has been called. * * ```typescript * // We must always close a watch once we've finished using it. * await watch.close() * ``` */ async close() { try { if (this.isClosed()) { return; } this.#closed = true; await this.#subject.remove(Object.keys(this.#idsToGridIndexes)); } finally { this.fire({ type: WatchEventType.Closed }); this.clearGrid(); clear(this.#idsToGridIndexes); this.clearCallbacks(); watches.delete(this); this.computeSubjectPollRate(); } } /** * Returns the poll rate in seconds. * * ```typescript * const pollRate = watch.pollRate * ``` * * @returns The poll rate for this watch. */ get pollRate() { return this.#pollRate; } /** * Attempt to set a new poll rate in seconds for the watch. * * Please note, this value may be ignored. * * ```typescript * // Set the poll rate to 10 seconds. * watch.pollRate = 10 * ``` * * @param pollRate The poll rate. */ set pollRate(pollRate) { this.#pollRate = pollRate; this.computeSubjectPollRate(); } /** * Add an event handler for the specified event type. * * This is used to listen for watch events. * * ```typescript * watch.on(WatchEventType.Changed, (event: WatchEvent, emitter: WatchEventEmitter): void { * // Do something with the event! * }) * ``` * * @param eventType The event type to add the event for. * @param callback The callback handler. * @return The emitter instance. * @throws An error if the watch is already closed. */ on(eventType, callback) { this.throwErrorIfClosed(); let allCallbacks = this.#callbacks.get(eventType); if (!allCallbacks) { allCallbacks = new Set(); this.#callbacks.set(eventType, allCallbacks); } allCallbacks.add(callback); return this; } /** * Remove an event handler from a watch. * * ```typescript * watch.off(WatchEventType.Changed, cb) * ``` * * @param eventType event type to remove. * @param callback callback to remove. * @return The emitter instance. */ off(eventType, callback) { const allCallbacks = this.#callbacks.get(eventType); if (allCallbacks) { allCallbacks.delete(callback); if (!allCallbacks.size) { this.#callbacks.delete(eventType); } } return this; } /** * Fire an event callback. * * @param event The event object. * @return The emitter instance. */ fire(event) { this.#callbacks .get(event.type) ?.forEach((callback) => { try { callback(event, this); } catch (err) { // We don't want errors to bubble up and effect other // callbacks so just log the. console.error(err); } }); return this; } /** * Return the callbacks for the event type or all callbacks if * the event type is not specified. * * ```typescript * const anArrayOfCallbacks = watch.getCallbacks() * ``` * * @param eventType Optional event type. * @returns The callbacks. */ getCallbacks(eventType) { if (eventType) { return [...(this.#callbacks.get(eventType) ?? [])]; } else { const callbacks = new Set(); for (const cbs of this.#callbacks.values()) { for (const c of cbs) { callbacks.add(c); } } return [...callbacks]; } } /** * Return true if there are callback handlers for the specified event type. * * If there event type is not specified then check to see if there are any callback handlers. * * ```typescript * if (watch.hasCallbacks()) { * // Do something... * } * ``` * * @returns True if there are callbacks. */ hasCallbacks(eventType) { return ((eventType ? this.#callbacks.get(eventType)?.size || 0 : this.#callbacks.size) > 0); } /** * Clear all callback event handlers o