prostgles-types
Version:
Shared TypeScript object definitions for prostgles-client and prostgles-server
625 lines • 21.1 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.getSerialisableError = exports.safeStringify = exports.extractTypeUtil = exports.isEqual = exports.reverseParsedPath = exports.reverseJoinOn = exports.getJoinHandlers = exports.tryCatchV2 = exports.tryCatch = exports.getObjectEntries = exports.WAL = exports.pickKeys = void 0;
exports.asName = asName;
exports.omitKeys = omitKeys;
exports.filter = filter;
exports.find = find;
exports.includes = includes;
exports.stableStringify = stableStringify;
exports.getTextPatch = getTextPatch;
exports.unpatchText = unpatchText;
exports.isEmpty = isEmpty;
exports.get = get;
exports.isObject = isObject;
exports.isDefined = isDefined;
exports.getKeys = getKeys;
const md5_1 = require("./md5");
function asName(str) {
if (str === null || str === undefined || !str.toString || !str.toString())
throw "Expecting a non empty string";
return `"${str.toString().replace(/"/g, `""`)}"`;
}
const pickKeys = (obj, keys = [], onlyIfDefined = true) => {
if (!keys.length) {
return {};
}
if (obj && keys.length) {
let res = {};
keys.forEach((k) => {
if (onlyIfDefined && obj[k] === undefined) {
}
else {
res[k] = obj[k];
}
});
return res;
}
return obj;
};
exports.pickKeys = pickKeys;
function omitKeys(obj, exclude) {
//@ts-ignore
return (0, exports.pickKeys)(obj,
//@ts-ignore
getKeys(obj).filter((k) => !exclude.includes(k)));
}
function filter(array, arrFilter) {
return array.filter((d) => Object.entries(arrFilter).every(([k, v]) => d[k] === v));
}
function find(array, arrFilter) {
return filter(array, arrFilter)[0];
}
function includes(array, elem
// | T
// | null
// | undefined
// | (T extends string ? string
// : T extends number ? number
// : never)
) {
return array.some((v) => v === elem);
}
function stableStringify(data, opts) {
if (!opts)
opts = {};
if (typeof opts === "function")
opts = { cmp: opts };
var cycles = typeof opts.cycles === "boolean" ? opts.cycles : false;
var cmp = opts.cmp &&
(function (f) {
return function (node) {
return function (a, b) {
var aobj = { key: a, value: node[a] };
var bobj = { key: b, value: node[b] };
return f(aobj, bobj);
};
};
})(opts.cmp);
var seen = [];
return (function stringify(node) {
if (node && node.toJSON && typeof node.toJSON === "function") {
node = node.toJSON();
}
if (node === undefined)
return;
if (typeof node == "number")
return isFinite(node) ? "" + node : "null";
if (typeof node !== "object")
return JSON.stringify(node);
var i, out;
if (Array.isArray(node)) {
out = "[";
for (i = 0; i < node.length; i++) {
if (i)
out += ",";
out += stringify(node[i]) || "null";
}
return out + "]";
}
if (node === null)
return "null";
if (seen.indexOf(node) !== -1) {
if (cycles)
return JSON.stringify("__cycle__");
throw new TypeError("Converting circular structure to JSON");
}
var seenIndex = seen.push(node) - 1;
var keys = Object.keys(node).sort(cmp && cmp(node));
out = "";
for (i = 0; i < keys.length; i++) {
var key = keys[i];
var value = stringify(node[key]);
if (!value)
continue;
if (out)
out += ",";
out += JSON.stringify(key) + ":" + value;
}
seen.splice(seenIndex, 1);
return "{" + out + "}";
})(data);
}
function getTextPatch(oldStr, newStr) {
/* Big change, no point getting diff */
if (!oldStr || !newStr || !oldStr.trim().length || !newStr.trim().length)
return newStr;
/* Return no change if matching */
if (oldStr === newStr)
return {
from: 0,
to: 0,
text: "",
md5: (0, md5_1.md5)(newStr),
};
function findLastIdx(direction = 1) {
let idx = direction < 1 ? -1 : 0, found = false;
while (!found && Math.abs(idx) <= newStr.length) {
const args = direction < 1 ? [idx] : [0, idx];
let os = oldStr.slice(...args), ns = newStr.slice(...args);
if (os !== ns)
found = true;
else
idx += Math.sign(direction) * 1;
}
return idx;
}
let from = findLastIdx() - 1, to = oldStr.length + findLastIdx(-1) + 1, toNew = newStr.length + findLastIdx(-1) + 1;
return {
from,
to,
text: newStr.slice(from, toNew),
md5: (0, md5_1.md5)(newStr),
};
}
function unpatchText(original, patch) {
if (!patch || typeof patch === "string")
return patch;
const { from, to, text, md5: md5Hash } = patch;
if (text === null || original === null)
return text;
let res = original.slice(0, from) + text + original.slice(to);
if (md5Hash && (0, md5_1.md5)(res) !== md5Hash)
throw ("Patch text error: Could not match md5 hash: (original/result) \n" + original + "\n" + res);
return res;
}
/**
* Used to throttle and combine updates sent to server
* This allows a high rate of optimistic updates on the client
*/
class WAL {
constructor(args) {
/**
* Instantly merged records for prepared for update
*/
this.changed = {};
/**
* Batch of records (removed from this.changed) that are currently being sent
*/
this.sending = {};
/**
* Historic data used to reduce data pushes from server to client
*/
this.sentHistory = {};
this.callbacks = [];
this.sort = (a, b) => {
const { orderBy } = this.options;
if (!orderBy || !a || !b)
return 0;
return (orderBy
.map((ob) => {
/* TODO: add fullData to changed items + ensure orderBy is in select */
if (!(ob.fieldName in a) || !(ob.fieldName in b)) {
throw `Replication error: \n some orderBy fields missing from data`;
}
let v1 = ob.asc ? a[ob.fieldName] : b[ob.fieldName], v2 = ob.asc ? b[ob.fieldName] : a[ob.fieldName];
let vNum = +v1 - +v2, vStr = v1 < v2 ? -1
: v1 == v2 ? 0
: 1;
return ob.tsDataType === "number" && Number.isFinite(vNum) ? vNum : vStr;
})
.find((v) => v) || 0);
};
/**
* Used by server to avoid unnecessary data push to client.
* This can happen due to the same data item having been previously pushed by the client
* @param item data item
* @returns boolean
*/
this.isInHistory = (item) => {
if (!item)
throw "Provide item";
const itemSyncVal = item[this.options.synced_field];
if (!Number.isFinite(+itemSyncVal))
throw "Provided item Synced field value is missing/invalid ";
const existing = this.sentHistory[this.getIdStr(item)];
const existingSyncVal = existing?.[this.options.synced_field];
if (existing) {
if (!Number.isFinite(+existingSyncVal))
throw "Provided historic item Synced field value is missing/invalid";
if (+existingSyncVal === +itemSyncVal) {
return true;
}
}
return false;
};
this.addData = (data) => {
if (isEmpty(this.changed) && this.options.onSendStart)
this.options.onSendStart();
data.map((d) => {
var _a;
const { initial, current, delta } = { ...d };
if (!current)
throw "Expecting { current: object, initial?: object }";
const idStr = this.getIdStr(current);
this.changed ?? (this.changed = {});
(_a = this.changed)[idStr] ?? (_a[idStr] = { initial, current, delta });
this.changed[idStr].current = {
...this.changed[idStr].current,
...current,
};
this.changed[idStr].delta = {
...this.changed[idStr].delta,
...delta,
};
});
this.sendItems();
};
this.isOnSending = false;
this.isSendingTimeout = undefined;
this.willDeleteHistory = undefined;
this.sendItems = async () => {
const { DEBUG_MODE, onSend, onSendEnd, batch_size, throttle, historyAgeSeconds = 2, } = this.options;
// Sending data. stop here
if (this.isSendingTimeout || (this.sending && !isEmpty(this.sending)))
return;
// Nothing to send. stop here
if (!this.changed || isEmpty(this.changed))
return;
// Prepare batch to send
let batchItems = [], walBatch = [], batchObj = {};
/**
* Prepare and remove a batch from this.changed
*/
Object.keys(this.changed)
.sort((a, b) => this.sort(this.changed[a].current, this.changed[b].current))
.slice(0, batch_size)
.map((key) => {
let item = { ...this.changed[key] };
this.sending[key] = { ...item };
walBatch.push({ ...item });
/* Used for history */
batchObj[key] = { ...item.current };
delete this.changed[key];
});
batchItems = walBatch.map((d) => {
let result = {};
Object.keys(d.current).map((k) => {
const oldVal = d.initial?.[k];
const newVal = d.current[k];
/** Send only id fields and delta */
if ([this.options.synced_field, ...this.options.id_fields].includes(k) ||
!areEqual(oldVal, newVal)) {
result[k] = newVal;
}
});
return result;
});
if (DEBUG_MODE) {
console.log(this.options.id, " SENDING lr->", batchItems[batchItems.length - 1]);
}
// Throttle next data send
if (!this.isSendingTimeout) {
this.isSendingTimeout = setTimeout(() => {
this.isSendingTimeout = undefined;
if (!isEmpty(this.changed)) {
this.sendItems();
}
}, throttle);
}
let error;
this.isOnSending = true;
try {
/* Deleted data should be sent normally through await db.table.delete(...) */
await onSend(batchItems, walBatch); //, deletedData);
/**
* Keep history if required
*/
if (historyAgeSeconds) {
this.sentHistory = {
...this.sentHistory,
...batchObj,
};
/**
* Delete history after some time
*/
if (!this.willDeleteHistory) {
this.willDeleteHistory = setTimeout(() => {
this.willDeleteHistory = undefined;
this.sentHistory = {};
}, historyAgeSeconds * 1000);
}
}
}
catch (err) {
error = err;
console.error("WAL onSend failed:", err, batchItems, walBatch);
}
this.isOnSending = false;
/* Fire any callbacks */
if (this.callbacks.length) {
const ids = Object.keys(this.sending);
this.callbacks.forEach((c, i) => {
c.idStrs = c.idStrs.filter((id) => ids.includes(id));
if (!c.idStrs.length) {
c.cb(error);
}
});
this.callbacks = this.callbacks.filter((cb) => cb.idStrs.length);
}
this.sending = {};
if (DEBUG_MODE) {
console.log(this.options.id, " SENT lr->", batchItems[batchItems.length - 1]);
}
if (!isEmpty(this.changed)) {
this.sendItems();
}
else {
if (onSendEnd)
onSendEnd(batchItems, walBatch, error);
}
};
this.options = { ...args };
if (!this.options.orderBy) {
const { synced_field, id_fields } = args;
this.options.orderBy = [synced_field, ...id_fields.sort()].map((fieldName) => ({
fieldName,
tsDataType: fieldName === synced_field ? "number" : "string",
asc: true,
}));
}
}
isSending() {
const result = this.isOnSending || !(isEmpty(this.sending) && isEmpty(this.changed));
if (this.options.DEBUG_MODE) {
console.log(this.options.id, " CHECKING isSending ->", result);
}
return result;
}
getIdStr(d) {
return this.options.id_fields
.sort()
.map((key) => `${d[key] || ""}`)
.join(".");
}
getIdObj(d) {
let res = {};
this.options.id_fields.sort().map((key) => {
res[key] = d[key];
});
return res;
}
getDeltaObj(d) {
let res = {};
Object.keys(d).map((key) => {
if (!this.options.id_fields.includes(key)) {
res[key] = d[key];
}
});
return res;
}
}
exports.WAL = WAL;
function isEmpty(obj) {
for (var v in obj)
return false;
return true;
}
/* Get nested property from an object */
function get(obj, propertyPath) {
let p = propertyPath, o = obj;
if (!obj)
return obj;
if (typeof p === "string")
p = p.split(".");
return p.reduce((xs, x) => {
if (xs && xs[x]) {
return xs[x];
}
else {
return undefined;
}
}, o);
}
const getObjectEntries = (obj) => {
return Object.entries(obj);
};
exports.getObjectEntries = getObjectEntries;
function areEqual(a, b) {
if (a === b)
return true;
if (["number", "string", "boolean"].includes(typeof a)) {
return a === b;
}
return JSON.stringify(a) === JSON.stringify(b);
}
function isObject(obj) {
return Boolean(obj && typeof obj === "object" && !Array.isArray(obj));
}
function isDefined(v) {
return v !== undefined && v !== null;
}
function getKeys(o) {
return Object.keys(o);
}
/**
* @deprecated
* use tryCatchV2 instead
*/
const tryCatch = async (func) => {
const startTime = Date.now();
try {
const res = await func();
return {
...res,
duration: Date.now() - startTime,
};
}
catch (error) {
return {
error,
hasError: true,
duration: Date.now() - startTime,
};
}
};
exports.tryCatch = tryCatch;
const tryCatchV2 = (func) => {
const startTime = Date.now();
try {
const dataOrResult = func();
if (dataOrResult instanceof Promise) {
return new Promise(async (resolve, reject) => {
const result = await dataOrResult
.then((data) => ({ data }))
.catch((error) => {
return {
error,
hasError: true,
};
});
resolve({
...result,
duration: Date.now() - startTime,
});
});
}
return {
data: dataOrResult,
duration: Date.now() - startTime,
};
}
catch (error) {
return {
error,
hasError: true,
duration: Date.now() - startTime,
};
}
};
exports.tryCatchV2 = tryCatchV2;
const getJoinHandlers = (tableName) => {
const getJoinFunc = (isLeft, expectsOne) => {
return (filter, select, options = {}) => {
// return makeJoin(isLeft, filter, select, expectsOne? { ...options, limit: 1 } : options);
return {
[isLeft ? "$leftJoin" : "$innerJoin"]: options.path ?? tableName,
filter,
...omitKeys(options, ["path", "select"]),
select,
};
};
};
return {
innerJoin: getJoinFunc(false, false),
leftJoin: getJoinFunc(true, false),
innerJoinOne: getJoinFunc(false, true),
leftJoinOne: getJoinFunc(true, true),
};
};
exports.getJoinHandlers = getJoinHandlers;
const reverseJoinOn = (on) => {
return on.map((constraint) => Object.fromEntries(Object.entries(constraint).map(([left, right]) => [right, left])));
};
exports.reverseJoinOn = reverseJoinOn;
/**
* result = [
* { table, on: parsedPath[0] }
* ...parsedPath.map(p => ({ table: p.table, on: reversedOn(parsedPath[i+1].on) }))
* ]
*/
const reverseParsedPath = (parsedPath, table) => {
const newPPath = [{ table, on: [{}] }, ...(parsedPath ?? [])];
return newPPath
.map((pp, i) => {
const nextPath = newPPath[i + 1];
if (!nextPath)
return undefined;
return {
table: pp.table,
on: (0, exports.reverseJoinOn)(nextPath.on),
};
})
.filter(isDefined)
.reverse();
};
exports.reverseParsedPath = reverseParsedPath;
/**
* Compare two objects for equality
* Returns false if any circular references are detected
*/
const isEqual = function (x, y, seen = new WeakSet()) {
if (x === y) {
return true;
}
if (typeof x !== typeof y) {
return false;
}
if (typeof x === "object" && x !== null && typeof y === "object" && y !== null) {
const xKeys = Object.keys(x);
if (xKeys.length !== Object.keys(y).length) {
return false;
}
if (seen.has(x) || seen.has(y)) {
console.trace("Circular reference detected in isEqual", x, y, seen);
return false;
}
seen.add(x);
seen.add(y);
for (const key of xKeys) {
if (key in y) {
//.hasOwnProperty(prop)
const xProp = x[key];
const yProp = y[key];
if (!(0, exports.isEqual)(xProp, yProp, seen)) {
return false;
}
}
else {
return false;
}
}
return true;
}
return false;
};
exports.isEqual = isEqual;
const extractTypeUtil = (obj, objSubType) => {
if (Object.entries(objSubType).every(([k, v]) => obj[k] === v)) {
return obj;
}
return undefined;
};
exports.extractTypeUtil = extractTypeUtil;
const safeStringify = (obj) => {
const seen = new WeakSet();
return JSON.stringify(obj, (key, value) => {
if (typeof value === "object" && value !== null) {
if (seen.has(value)) {
return "[Circular]";
}
seen.add(value);
}
return value;
});
};
exports.safeStringify = safeStringify;
const getSerialisableError = (rawError, includeStack = false) => {
if (rawError === null || rawError === undefined) {
return rawError;
}
if (typeof rawError === "string" ||
typeof rawError === "boolean" ||
typeof rawError === "bigint" ||
typeof rawError === "undefined" ||
typeof rawError === "number") {
return rawError?.toString();
}
if (rawError instanceof Error) {
const errorObj = Object.getOwnPropertyNames(rawError).reduce((acc, key) => ({
...acc,
[key]: rawError[key],
}), {});
const result = JSON.parse((0, exports.safeStringify)(errorObj));
if (!includeStack) {
return omitKeys(result, ["stack"]);
}
return result;
}
if (Array.isArray(rawError)) {
return rawError.map((item) => (0, exports.getSerialisableError)(item, includeStack));
}
return rawError;
};
exports.getSerialisableError = getSerialisableError;
//# sourceMappingURL=util.js.map