haystack-nclient
Version:
Project Haystack Network Client
1,674 lines (1,663 loc) • 161 kB
JavaScript
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