tinybase
Version:
A reactive data store and sync engine.
1,371 lines (1,350 loc) • 39.7 kB
JavaScript
const getTypeOf = (thing) => typeof thing;
const TINYBASE = 'tinybase';
const EMPTY_STRING = '';
const COMMA = ',';
const DOT = '.';
const STRING = getTypeOf(EMPTY_STRING);
const TRUE = 'true';
const UNDEFINED = '\uFFFC';
const strMatch = (str, regex) => str?.match(regex);
const strSplit = (str, separator = EMPTY_STRING, limit) =>
str.split(separator, limit);
const strReplace = (str, searchValue, replaceValue) =>
str.replace(searchValue, replaceValue);
const promise = Promise;
const GLOBAL = globalThis;
const THOUSAND = 1e3;
const startInterval = (callback, sec, immediate) => {
return setInterval(callback, sec * THOUSAND);
};
const stopInterval = clearInterval;
const isUndefined = (thing) => thing == void 0;
const ifNotUndefined = (value, then, otherwise) =>
isUndefined(value) ? otherwise?.() : then(value);
const isString = (thing) => getTypeOf(thing) == STRING;
const isArray = (thing) => Array.isArray(thing);
const slice = (arrayOrString, start, end) => arrayOrString.slice(start, end);
const size = (arrayOrString) => arrayOrString.length;
const test = (regex, subject) => regex.test(subject);
const promiseAll = async (promises) => promise.all(promises);
const errorNew = (message) => {
throw new Error(message);
};
const tryCatch = async (action, then1, then2) => {
try {
return await action();
} catch (error) {
/* istanbul ignore next */
then1?.(error);
}
};
const arrayForEach = (array, cb) => array.forEach(cb);
const arrayJoin = (array, sep = EMPTY_STRING) => array.join(sep);
const arrayMap = (array, cb) => array.map(cb);
const arrayIsEmpty = (array) => size(array) == 0;
const arrayFilter = (array, cb) => array.filter(cb);
const arrayClear = (array, to) => array.splice(0, to);
const arrayPush = (array, ...values) => array.push(...values);
const arrayShift = (array) => array.shift();
const collSize = (coll) => coll?.size ?? 0;
const collHas = (coll, keyOrValue) => coll?.has(keyOrValue) ?? false;
const collIsEmpty = (coll) => isUndefined(coll) || collSize(coll) == 0;
const collValues = (coll) => [...(coll?.values() ?? [])];
const collClear = (coll) => coll.clear();
const collForEach = (coll, cb) => coll?.forEach(cb);
const collDel = (coll, keyOrValue) => coll?.delete(keyOrValue);
const object = Object;
const getPrototypeOf = (obj) => object.getPrototypeOf(obj);
const objEntries = object.entries;
const isObject = (obj) =>
!isUndefined(obj) &&
ifNotUndefined(
getPrototypeOf(obj),
(objPrototype) =>
objPrototype == object.prototype ||
isUndefined(getPrototypeOf(objPrototype)),
/* istanbul ignore next */
() => true,
);
const objIds = object.keys;
const objFreeze = object.freeze;
const objNew = (entries = []) => object.fromEntries(entries);
const objMerge = (...objs) => object.assign({}, ...objs);
const objHas = (obj, id) => id in obj;
const objDel = (obj, id) => {
delete obj[id];
return obj;
};
const objToArray = (obj, cb) =>
arrayMap(objEntries(obj), ([id, value]) => cb(value, id));
const objMap = (obj, cb) =>
objNew(objToArray(obj, (value, id) => [id, cb(value, id)]));
const objValues = (obj) => object.values(obj);
const objSize = (obj) => size(objIds(obj));
const objIsEmpty = (obj) => isObject(obj) && objSize(obj) == 0;
const mapNew = (entries) => new Map(entries);
const mapGet = (map, key) => map?.get(key);
const mapForEach = (map, cb) =>
collForEach(map, (value, key) => cb(key, value));
const mapMap = (coll, cb) =>
arrayMap([...(coll?.entries() ?? [])], ([key, value]) => cb(value, key));
const mapSet = (map, key, value) =>
isUndefined(value) ? (collDel(map, key), map) : map?.set(key, value);
const mapEnsure = (map, key, getDefaultValue, hadExistingValue) => {
if (!collHas(map, key)) {
mapSet(map, key, getDefaultValue());
} else {
hadExistingValue?.(mapGet(map, key));
}
return mapGet(map, key);
};
const visitTree = (node, path, ensureLeaf, pruneLeaf, p = 0) =>
ifNotUndefined(
(ensureLeaf ? mapEnsure : mapGet)(
node,
path[p],
p > size(path) - 2 ? ensureLeaf : mapNew,
),
(nodeOrLeaf) => {
if (p > size(path) - 2) {
if (pruneLeaf?.(nodeOrLeaf)) {
mapSet(node, path[p]);
}
return nodeOrLeaf;
}
const leaf = visitTree(nodeOrLeaf, path, ensureLeaf, pruneLeaf, p + 1);
if (collIsEmpty(nodeOrLeaf)) {
mapSet(node, path[p]);
}
return leaf;
},
);
const INTEGER = /^\d+$/;
const getPoolFunctions = () => {
const pool = [];
let nextId = 0;
return [
(reuse) => (reuse ? arrayShift(pool) : null) ?? EMPTY_STRING + nextId++,
(id) => {
if (test(INTEGER, id) && size(pool) < 1e3) {
arrayPush(pool, id);
}
},
];
};
const setNew = (entryOrEntries) =>
new Set(
isArray(entryOrEntries) || isUndefined(entryOrEntries)
? entryOrEntries
: [entryOrEntries],
);
const setAdd = (set, value) => set?.add(value);
const getWildcardedLeaves = (deepIdSet, path = [EMPTY_STRING]) => {
const leaves = [];
const deep = (node, p) =>
p == size(path)
? arrayPush(leaves, node)
: path[p] === null
? collForEach(node, (node2) => deep(node2, p + 1))
: arrayForEach([path[p], null], (id) => deep(mapGet(node, id), p + 1));
deep(deepIdSet, 0);
return leaves;
};
const getListenerFunctions = (getThing) => {
let thing;
const [getId, releaseId] = getPoolFunctions();
const allListeners = mapNew();
const addListener = (
listener,
idSetNode,
path,
pathGetters = [],
extraArgsGetter = () => [],
) => {
thing ??= getThing();
const id = getId(1);
mapSet(allListeners, id, [
listener,
idSetNode,
path,
pathGetters,
extraArgsGetter,
]);
setAdd(visitTree(idSetNode, path ?? [EMPTY_STRING], setNew), id);
return id;
};
const callListeners = (idSetNode, ids, ...extraArgs) =>
arrayForEach(getWildcardedLeaves(idSetNode, ids), (set) =>
collForEach(set, (id) =>
mapGet(allListeners, id)[0](thing, ...(ids ?? []), ...extraArgs),
),
);
const delListener = (id) =>
ifNotUndefined(mapGet(allListeners, id), ([, idSetNode, idOrNulls]) => {
visitTree(idSetNode, idOrNulls ?? [EMPTY_STRING], void 0, (idSet) => {
collDel(idSet, id);
return collIsEmpty(idSet) ? 1 : 0;
});
mapSet(allListeners, id);
releaseId(id);
return idOrNulls;
});
const callListener = (id) =>
ifNotUndefined(
mapGet(allListeners, id),
([listener, , path = [], pathGetters, extraArgsGetter]) => {
const callWithIds = (...ids) => {
const index = size(ids);
if (index == size(path)) {
listener(thing, ...ids, ...extraArgsGetter(ids));
} else if (isUndefined(path[index])) {
arrayForEach(pathGetters[index]?.(...ids) ?? [], (id2) =>
callWithIds(...ids, id2),
);
} else {
callWithIds(...ids, path[index]);
}
};
callWithIds();
},
);
return [addListener, callListeners, delListener, callListener];
};
const Status = {
Idle: 0 /* Idle */,
Loading: 1 /* Loading */,
Saving: 2 /* Saving */,
};
const Persists = {
StoreOnly: 1 /* StoreOnly */,
MergeableStoreOnly: 2 /* MergeableStoreOnly */,
StoreOrMergeableStore: 3 /* StoreOrMergeableStore */,
};
const scheduleRunning = mapNew();
const scheduleActions = mapNew();
const getStoreFunctions = (
persist = 1 /* StoreOnly */,
store,
isSynchronizer,
) =>
persist != 1 /* StoreOnly */ && store.isMergeable()
? [
1,
store.getMergeableContent,
() => store.getTransactionMergeableChanges(!isSynchronizer),
([[changedTables], [changedValues]]) =>
!objIsEmpty(changedTables) || !objIsEmpty(changedValues),
store.setDefaultContent,
]
: persist != 2 /* MergeableStoreOnly */
? [
0,
store.getContent,
store.getTransactionChanges,
([changedTables, changedValues]) =>
!objIsEmpty(changedTables) || !objIsEmpty(changedValues),
store.setContent,
]
: errorNew('Store type not supported by this Persister');
const createCustomPersister = (
store,
getPersisted,
setPersisted,
addPersisterListener,
delPersisterListener,
onIgnoredError,
persist,
extra = {},
isSynchronizer = 0,
scheduleId = [],
) => {
let status = 0; /* Idle */
let loads = 0;
let saves = 0;
let action;
let autoLoadHandle;
let autoSaveListenerId;
mapEnsure(scheduleRunning, scheduleId, () => 0);
mapEnsure(scheduleActions, scheduleId, () => []);
const statusListeners = mapNew();
const [
isMergeableStore,
getContent,
getChanges,
hasChanges,
setDefaultContent,
] = getStoreFunctions(persist, store, isSynchronizer);
const [addListener, callListeners, delListenerImpl] = getListenerFunctions(
() => persister,
);
const setStatus = (newStatus) => {
if (newStatus != status) {
status = newStatus;
callListeners(statusListeners, void 0, status);
}
};
const run = async () => {
/* istanbul ignore else */
if (!mapGet(scheduleRunning, scheduleId)) {
mapSet(scheduleRunning, scheduleId, 1);
while (
!isUndefined((action = arrayShift(mapGet(scheduleActions, scheduleId))))
) {
await tryCatch(action, onIgnoredError);
}
mapSet(scheduleRunning, scheduleId, 0);
}
};
const setContentOrChanges = (contentOrChanges) => {
(isMergeableStore && isArray(contentOrChanges?.[0])
? contentOrChanges?.[2] === 1
? store.applyMergeableChanges
: store.setMergeableContent
: contentOrChanges?.[2] === 1
? store.applyChanges
: store.setContent)(contentOrChanges);
};
const load = async (initialContent) => {
/* istanbul ignore else */
if (status != 2 /* Saving */) {
setStatus(1 /* Loading */);
loads++;
await schedule(async () => {
await tryCatch(
async () => {
const content = await getPersisted();
if (isArray(content)) {
setContentOrChanges(content);
} else if (initialContent) {
setDefaultContent(initialContent);
} else {
errorNew(`Content is not an array: ${content}`);
}
},
() => {
if (initialContent) {
setDefaultContent(initialContent);
}
},
);
setStatus(0 /* Idle */);
});
}
return persister;
};
const startAutoLoad = async (initialContent) => {
stopAutoLoad();
await load(initialContent);
await tryCatch(
async () =>
(autoLoadHandle = await addPersisterListener(
async (content, changes) => {
if (changes || content) {
/* istanbul ignore else */
if (status != 2 /* Saving */) {
setStatus(1 /* Loading */);
loads++;
setContentOrChanges(changes ?? content);
setStatus(0 /* Idle */);
}
} else {
await load();
}
},
)),
onIgnoredError,
);
return persister;
};
const stopAutoLoad = async () => {
if (autoLoadHandle) {
await tryCatch(
() => delPersisterListener(autoLoadHandle),
onIgnoredError,
);
autoLoadHandle = void 0;
}
return persister;
};
const isAutoLoading = () => !isUndefined(autoLoadHandle);
const save = async (changes) => {
/* istanbul ignore else */
if (status != 1 /* Loading */) {
setStatus(2 /* Saving */);
saves++;
await schedule(async () => {
await tryCatch(() => setPersisted(getContent, changes), onIgnoredError);
setStatus(0 /* Idle */);
});
}
return persister;
};
const startAutoSave = async () => {
stopAutoSave();
await save();
autoSaveListenerId = store.addDidFinishTransactionListener(() => {
const changes = getChanges();
if (hasChanges(changes)) {
save(changes);
}
});
return persister;
};
const stopAutoSave = async () => {
if (autoSaveListenerId) {
store.delListener(autoSaveListenerId);
autoSaveListenerId = void 0;
}
return persister;
};
const isAutoSaving = () => !isUndefined(autoSaveListenerId);
const startAutoPersisting = async (
initialContent,
startSaveFirst = false,
) => {
const [call1, call2] = startSaveFirst
? [startAutoSave, startAutoLoad]
: [startAutoLoad, startAutoSave];
await call1(initialContent);
await call2(initialContent);
return persister;
};
const stopAutoPersisting = async (stopSaveFirst = false) => {
const [call1, call2] = stopSaveFirst
? [stopAutoSave, stopAutoLoad]
: [stopAutoLoad, stopAutoSave];
await call1();
await call2();
return persister;
};
const getStatus = () => status;
const addStatusListener = (listener) =>
addListener(listener, statusListeners);
const delListener = (listenerId) => {
delListenerImpl(listenerId);
return store;
};
const schedule = async (...actions) => {
arrayPush(mapGet(scheduleActions, scheduleId), ...actions);
await run();
return persister;
};
const getStore = () => store;
const destroy = () => {
arrayClear(mapGet(scheduleActions, scheduleId));
return stopAutoPersisting();
};
const getStats = () => ({loads, saves});
const persister = {
load,
startAutoLoad,
stopAutoLoad,
isAutoLoading,
save,
startAutoSave,
stopAutoSave,
isAutoSaving,
startAutoPersisting,
stopAutoPersisting,
getStatus,
addStatusListener,
delListener,
schedule,
getStore,
destroy,
getStats,
...extra,
};
return objFreeze(persister);
};
const jsonString = JSON.stringify;
const jsonParse = JSON.parse;
const jsonStringWithUndefined = (obj) =>
jsonString(obj, (_key, value) => (value === void 0 ? UNDEFINED : value));
const jsonParseWithUndefined = (str) =>
jsonParse(str, (_key, value) => (value === UNDEFINED ? void 0 : value));
const textEncoder = /* @__PURE__ */ new GLOBAL.TextEncoder();
const getHash = (string) => {
let hash = 2166136261;
arrayForEach(textEncoder.encode(string), (char) => {
hash ^= char;
hash +=
(hash << 1) + (hash << 4) + (hash << 7) + (hash << 8) + (hash << 24);
});
return hash >>> 0;
};
const SINGLE_ROW_ID = '_';
const DEFAULT_ROW_ID_COLUMN_NAME = '_id';
const SELECT = 'SELECT';
const WHERE = 'WHERE';
const TABLE = 'TABLE';
const INSERT = 'INSERT';
const DELETE = 'DELETE';
const UPDATE = 'UPDATE';
const ALTER_TABLE = 'ALTER ' + TABLE;
const FROM = 'FROM';
const DELETE_FROM = DELETE + ' ' + FROM;
const SELECT_STAR_FROM = SELECT + '*' + FROM;
const PRAGMA = 'pragma_';
const DATA_VERSION = 'data_version';
const SCHEMA_VERSION = 'schema_version';
const PRAGMA_TABLE = 'pragma_table_';
const CREATE = 'CREATE ';
const CREATE_TABLE = CREATE + TABLE;
const OR_REPLACE = 'OR REPLACE ';
const FUNCTION = 'FUNCTION';
const TABLE_NAME_PLACEHOLDER = '$tableName';
const getWrappedCommand = (executeCommand, onSqlCommand) =>
onSqlCommand
? async (sql, params) => {
onSqlCommand(sql, params);
return await executeCommand(sql, params);
}
: executeCommand;
const escapeId = (str) =>
arrayJoin(
arrayMap(strSplit(str, DOT), (part) => `"${strReplace(part, /"/g, '""')}"`),
DOT,
);
const escapeIds = (...ids) => escapeId(arrayJoin(ids, '_'));
const escapeColumnNames = (...columnNames) =>
arrayJoin(arrayMap(columnNames, escapeId), COMMA);
const getPlaceholders = (array, offset = [1]) =>
arrayJoin(
arrayMap(array, () => '$' + offset[0]++),
COMMA,
);
const getWhereCondition = (tableName, condition = TRUE) =>
WHERE +
`(${strReplace(condition, TABLE_NAME_PLACEHOLDER, escapeId(tableName))})`;
const COLUMN_NAME = 'ColumnName';
const STORE = 'store';
const JSON$1 = 'json';
const STORE_TABLE_NAME = STORE + 'TableName';
const STORE_ID_COLUMN_NAME = STORE + 'Id' + COLUMN_NAME;
const STORE_COLUMN_NAME = STORE + COLUMN_NAME;
const AUTO_LOAD_INTERVAL_SECONDS = 'autoLoadIntervalSeconds';
const ROW_ID_COLUMN_NAME = 'rowId' + COLUMN_NAME;
const TABLE_ID = 'tableId';
const TABLE_NAME = 'tableName';
const DELETE_EMPTY_COLUMNS = 'deleteEmptyColumns';
const DELETE_EMPTY_TABLE = 'deleteEmptyTable';
const CONDITION = 'condition';
const DEFAULT_CONFIG = {
mode: JSON$1,
[AUTO_LOAD_INTERVAL_SECONDS]: 1,
};
const DEFAULT_TABULAR_VALUES_CONFIG = {
load: 0,
save: 0,
[TABLE_NAME]: TINYBASE + '_values',
};
const getDefaultedConfig = (configOrStoreTableName) =>
objMerge(
DEFAULT_CONFIG,
isString(configOrStoreTableName)
? {[STORE_TABLE_NAME]: configOrStoreTableName}
: (configOrStoreTableName ?? {}),
);
const getDefaultedTabularConfigMap = (
configsObj,
defaultObj,
tableField,
exclude,
then,
) => {
const configMap = mapNew();
objMap(configsObj, (configObj, id) => {
const defaultedConfig = slice(
objValues(
objMerge(
defaultObj,
isString(configObj) ? {[tableField]: configObj} : configObj,
),
),
0,
objSize(defaultObj),
);
if (!isUndefined(defaultedConfig[0]) && !exclude(id, defaultedConfig[0])) {
then(id, defaultedConfig[0]);
mapSet(configMap, id, defaultedConfig);
}
});
return configMap;
};
const getConfigStructures = (configOrStoreTableName) => {
const config = getDefaultedConfig(configOrStoreTableName);
const autoLoadIntervalSeconds = config[AUTO_LOAD_INTERVAL_SECONDS];
if (config.mode == JSON$1) {
const storeTableName = config[STORE_TABLE_NAME] ?? TINYBASE;
return [
1,
autoLoadIntervalSeconds,
[
storeTableName,
config[STORE_ID_COLUMN_NAME] ?? DEFAULT_ROW_ID_COLUMN_NAME,
config[STORE_COLUMN_NAME] ?? STORE,
],
setNew(storeTableName),
];
}
const {tables: {load = {}, save = {}} = {}, values = {}} = config;
const valuesConfig = slice(
objValues(objMerge(DEFAULT_TABULAR_VALUES_CONFIG, values)),
0,
objSize(DEFAULT_TABULAR_VALUES_CONFIG),
);
const valuesTable = valuesConfig[2];
const managedTableNames = setNew(valuesTable);
const excludedTableNames = setNew(valuesTable);
const tablesLoadConfig = getDefaultedTabularConfigMap(
load,
{
[TABLE_ID]: null,
[ROW_ID_COLUMN_NAME]: DEFAULT_ROW_ID_COLUMN_NAME,
[CONDITION]: TRUE,
},
TABLE_ID,
(tableName) => collHas(excludedTableNames, tableName),
(tableName) => setAdd(managedTableNames, tableName),
);
const tablesSaveConfig = getDefaultedTabularConfigMap(
save,
{
[TABLE_NAME]: null,
[ROW_ID_COLUMN_NAME]: DEFAULT_ROW_ID_COLUMN_NAME,
[DELETE_EMPTY_COLUMNS]: 0,
[DELETE_EMPTY_TABLE]: 0,
[CONDITION]: null,
},
TABLE_NAME,
(_, tableName) => collHas(excludedTableNames, tableName),
(_, tableName) => setAdd(managedTableNames, tableName),
);
mapForEach(
tablesSaveConfig,
(_, tableSaveConfig) =>
(tableSaveConfig[4] ??=
mapGet(tablesLoadConfig, tableSaveConfig[0])?.[2] ?? TRUE),
);
return [
0,
autoLoadIntervalSeconds,
[tablesLoadConfig, tablesSaveConfig, valuesConfig],
managedTableNames,
];
};
const getCommandFunctions = (
databaseExecuteCommand,
managedTableNames,
querySchema,
onIgnoredError,
columnType,
upsert = defaultUpsert,
encode,
decode,
) => {
const schemaMap = mapNew();
const canSelect = (tableName, rowIdColumnName) =>
collHas(mapGet(schemaMap, tableName), rowIdColumnName);
const refreshSchema = async () => {
collClear(schemaMap);
arrayMap(
await querySchema(databaseExecuteCommand, managedTableNames),
({tn, cn}) => setAdd(mapEnsure(schemaMap, tn, setNew), cn),
);
};
const loadTable = async (tableName, rowIdColumnName, condition) =>
canSelect(tableName, rowIdColumnName)
? objNew(
arrayFilter(
arrayMap(
await databaseExecuteCommand(
SELECT_STAR_FROM +
escapeId(tableName) +
getWhereCondition(tableName, condition),
),
(row) => [
row[rowIdColumnName],
decode
? objMap(objDel(row, rowIdColumnName), decode)
: objDel(row, rowIdColumnName),
],
),
([rowId, row]) => !isUndefined(rowId) && !objIsEmpty(row),
),
)
: {};
const saveTable = async (
tableName,
rowIdColumnName,
content,
deleteEmptyColumns,
deleteEmptyTable,
partial = false,
condition = TRUE,
) => {
const settingColumnNameSet = setNew();
objMap(content ?? {}, (contentRow) =>
arrayMap(objIds(contentRow ?? {}), (cellOrValueId) =>
setAdd(settingColumnNameSet, cellOrValueId),
),
);
const settingColumnNames = collValues(settingColumnNameSet);
if (
!partial &&
deleteEmptyTable &&
condition == TRUE &&
arrayIsEmpty(settingColumnNames) &&
collHas(schemaMap, tableName)
) {
await databaseExecuteCommand('DROP ' + TABLE + escapeId(tableName));
mapSet(schemaMap, tableName);
return;
}
const currentColumnNames = mapGet(schemaMap, tableName);
const unaccountedColumnNames = setNew(collValues(currentColumnNames));
if (!arrayIsEmpty(settingColumnNames)) {
if (!collHas(schemaMap, tableName)) {
await databaseExecuteCommand(
CREATE_TABLE +
escapeId(tableName) +
`(${escapeId(rowIdColumnName)}${columnType} PRIMARY KEY${arrayJoin(
arrayMap(
settingColumnNames,
(settingColumnName) =>
COMMA + escapeId(settingColumnName) + columnType,
),
)});`,
);
mapSet(
schemaMap,
tableName,
setNew([rowIdColumnName, ...settingColumnNames]),
);
} else {
await promiseAll(
arrayMap(
[rowIdColumnName, ...settingColumnNames],
async (settingColumnName, index) => {
if (!collDel(unaccountedColumnNames, settingColumnName)) {
await databaseExecuteCommand(
ALTER_TABLE +
escapeId(tableName) +
'ADD' +
escapeId(settingColumnName) +
columnType,
);
if (index == 0) {
await databaseExecuteCommand(
'CREATE UNIQUE INDEX pk ON ' +
escapeId(tableName) +
`(${escapeId(rowIdColumnName)})`,
);
}
setAdd(currentColumnNames, settingColumnName);
}
},
),
);
}
}
await promiseAll([
...(!partial && deleteEmptyColumns
? arrayMap(
collValues(unaccountedColumnNames),
async (unaccountedColumnName) => {
if (unaccountedColumnName != rowIdColumnName) {
await databaseExecuteCommand(
ALTER_TABLE +
escapeId(tableName) +
'DROP' +
escapeId(unaccountedColumnName),
);
collDel(currentColumnNames, unaccountedColumnName);
}
},
)
: []),
]);
if (partial) {
if (isUndefined(content)) {
await databaseExecuteCommand(
DELETE_FROM +
escapeId(tableName) +
getWhereCondition(tableName, condition),
);
} else {
await promiseAll(
objToArray(content, async (row, rowId) => {
if (isUndefined(row)) {
await databaseExecuteCommand(
DELETE_FROM +
escapeId(tableName) +
getWhereCondition(tableName, condition) +
`AND(${escapeId(rowIdColumnName)}=$1)`,
[rowId],
);
} else if (!arrayIsEmpty(settingColumnNames)) {
await upsert(
databaseExecuteCommand,
tableName,
rowIdColumnName,
objIds(row),
{
[rowId]: encode
? arrayMap(objValues(row), encode)
: objValues(row),
},
currentColumnNames,
);
}
}),
);
}
} else {
if (!arrayIsEmpty(settingColumnNames)) {
const changingColumnNames = arrayFilter(
collValues(mapGet(schemaMap, tableName)),
(changingColumnName) => changingColumnName != rowIdColumnName,
);
const rows = {};
const deleteRowIds = [];
objMap(content ?? {}, (row, rowId) => {
rows[rowId] = arrayMap(changingColumnNames, (cellId) =>
encode ? encode(row?.[cellId]) : row?.[cellId],
);
arrayPush(deleteRowIds, rowId);
});
await upsert(
databaseExecuteCommand,
tableName,
rowIdColumnName,
changingColumnNames,
rows,
);
await databaseExecuteCommand(
DELETE_FROM +
escapeId(tableName) +
getWhereCondition(tableName, condition) + // eslint-disable-next-line max-len
`AND${escapeId(rowIdColumnName)}NOT IN(${getPlaceholders(deleteRowIds)})`,
deleteRowIds,
);
} else if (collHas(schemaMap, tableName)) {
await databaseExecuteCommand(
DELETE_FROM +
escapeId(tableName) +
getWhereCondition(tableName, condition),
);
}
}
};
const transaction = async (actions) => {
let result;
await databaseExecuteCommand('BEGIN');
await tryCatch(async () => (result = await actions()), onIgnoredError);
await databaseExecuteCommand('END');
return result;
};
return [refreshSchema, loadTable, saveTable, transaction];
};
const defaultUpsert = async (
executeCommand,
tableName,
rowIdColumnName,
changingColumnNames,
rows,
) => {
const offset = [1];
await executeCommand(
INSERT +
' INTO' +
escapeId(tableName) +
'(' +
escapeColumnNames(rowIdColumnName, ...changingColumnNames) +
')VALUES' +
arrayJoin(
objToArray(
rows,
(row) =>
'($' + offset[0]++ + ',' + getPlaceholders(row, offset) + ')',
),
COMMA,
) +
'ON CONFLICT(' +
escapeId(rowIdColumnName) +
`)DO ${UPDATE} SET` +
arrayJoin(
arrayMap(
changingColumnNames,
(columnName) =>
escapeId(columnName) + '=excluded.' + escapeId(columnName),
),
COMMA,
),
objToArray(rows, (row, id) => [
id,
...arrayMap(row, (value) => value ?? null),
]).flat(),
);
};
const createJsonPersister = (
store,
executeCommand,
addPersisterListener,
delPersisterListener,
onIgnoredError,
extraDestroy,
persist,
[storeTableName, storeIdColumnName, storeColumnName],
managedTableNames,
querySchema,
thing,
getThing,
columnType,
upsert,
) => {
const [refreshSchema, loadTable, saveTable, transaction] =
getCommandFunctions(
executeCommand,
managedTableNames,
querySchema,
onIgnoredError,
columnType,
upsert,
);
const getPersisted = () =>
transaction(async () => {
await refreshSchema();
return jsonParseWithUndefined(
(await loadTable(storeTableName, storeIdColumnName))[SINGLE_ROW_ID]?.[
storeColumnName
] ?? 'null',
);
});
const setPersisted = (getContent) =>
transaction(async () => {
await refreshSchema();
await saveTable(
storeTableName,
storeIdColumnName,
{
[SINGLE_ROW_ID]: {
[storeColumnName]: jsonStringWithUndefined(getContent() ?? null),
},
},
true,
true,
);
});
const destroy = async () => {
await persister.stopAutoPersisting();
extraDestroy();
return persister;
};
const persister = createCustomPersister(
store,
getPersisted,
setPersisted,
addPersisterListener,
delPersisterListener,
onIgnoredError,
persist,
{[getThing]: () => thing, destroy},
0,
thing,
);
return persister;
};
const createTabularPersister = (
store,
executeCommand,
addPersisterListener,
delPersisterListener,
onIgnoredError,
extraDestroy,
persist,
[
tablesLoadConfig,
tablesSaveConfig,
[valuesLoad, valuesSave, valuesTableName],
],
managedTableNames,
querySchema,
thing,
getThing,
columnType,
upsert,
encode,
decode,
) => {
const [refreshSchema, loadTable, saveTable, transaction] =
getCommandFunctions(
executeCommand,
managedTableNames,
querySchema,
onIgnoredError,
columnType,
upsert,
encode,
decode,
);
const saveTables = (tables, partial) =>
promiseAll(
mapMap(
tablesSaveConfig,
async (
[
tableName,
rowIdColumnName,
deleteEmptyColumns,
deleteEmptyTable,
condition,
],
tableId,
) => {
if (!partial || objHas(tables, tableId)) {
await saveTable(
tableName,
rowIdColumnName,
tables[tableId],
deleteEmptyColumns,
deleteEmptyTable,
partial,
condition,
);
}
},
),
);
const saveValues = async (values, partial) =>
valuesSave
? await saveTable(
valuesTableName,
DEFAULT_ROW_ID_COLUMN_NAME,
{[SINGLE_ROW_ID]: values},
true,
true,
partial,
)
: null;
const loadTables = async () =>
objNew(
arrayFilter(
await promiseAll(
mapMap(
tablesLoadConfig,
async ([tableId, rowIdColumnName, condition], tableName) => [
tableId,
await loadTable(tableName, rowIdColumnName, condition),
],
),
),
(pair) => !objIsEmpty(pair[1]),
),
);
const loadValues = async () =>
valuesLoad
? (await loadTable(valuesTableName, DEFAULT_ROW_ID_COLUMN_NAME))[
SINGLE_ROW_ID
]
: {};
const getPersisted = () =>
transaction(async () => {
await refreshSchema();
const tables = await loadTables();
const values = await loadValues();
return !objIsEmpty(tables) || !isUndefined(values)
? [tables, values]
: void 0;
});
const setPersisted = (getContent, changes) =>
transaction(async () => {
await refreshSchema();
if (!isUndefined(changes)) {
await saveTables(changes[0], true);
await saveValues(changes[1], true);
} else {
const [tables, values] = getContent();
await saveTables(tables);
await saveValues(values);
}
});
const destroy = async () => {
await persister.stopAutoPersisting();
extraDestroy();
return persister;
};
const persister = createCustomPersister(
store,
getPersisted,
setPersisted,
addPersisterListener,
delPersisterListener,
onIgnoredError,
persist,
{[getThing]: () => thing, destroy},
0,
thing,
);
return persister;
};
const TABLE_CREATED = 'c';
const DATA_CHANGED = 'd';
const EVENT_REGEX = /^([cd]:)(.+)/;
const createCustomPostgreSqlPersister = (
store,
configOrStoreTableName,
rawExecuteCommand,
addChangeListener,
delChangeListener,
onSqlCommand,
onIgnoredError,
destroy,
persist,
thing,
getThing = 'getDb',
) => {
const executeCommand = getWrappedCommand(rawExecuteCommand, onSqlCommand);
const [isJson, , defaultedConfig, managedTableNamesSet] = getConfigStructures(
configOrStoreTableName,
);
const configHash =
EMPTY_STRING + getHash(jsonStringWithUndefined(defaultedConfig));
const channel = TINYBASE + '_' + configHash;
const createFunction = async (
name,
body,
returnPrefix = '',
declarations = '',
) => {
const escapedFunctionName = escapeIds(TINYBASE, name, configHash);
await executeCommand(
CREATE +
OR_REPLACE +
FUNCTION +
escapedFunctionName +
`()RETURNS ${returnPrefix}trigger AS $$ ${declarations}BEGIN ${body}END;$$ LANGUAGE plpgsql;`,
);
return escapedFunctionName;
};
const createTrigger = async (
prefix,
escapedTriggerName,
body,
escapedFunctionName,
) => {
await executeCommand(
CREATE +
prefix +
'TRIGGER' +
escapedTriggerName +
body +
'EXECUTE ' +
FUNCTION +
escapedFunctionName +
`()`,
);
return escapedTriggerName;
};
const notify = (message) => `PERFORM pg_notify('${channel}',${message});`;
const when = (tableName, newOrOldOrBoth) =>
isJson
? TRUE
: newOrOldOrBoth === 2
? when(tableName, 0) + ' OR ' + when(tableName, 1)
: strReplace(
mapGet(defaultedConfig[0], tableName)?.[2] ?? TRUE,
TABLE_NAME_PLACEHOLDER,
newOrOldOrBoth == 0 ? 'NEW' : 'OLD',
);
const addDataChangedTriggers = (tableName, dataChangedFunction) =>
promiseAll(
arrayMap([INSERT, DELETE, UPDATE], (action, newOrOldOrBoth) =>
createTrigger(
OR_REPLACE,
escapeIds(TINYBASE, DATA_CHANGED, configHash, tableName, action),
`AFTER ${action} ON${escapeId(tableName)}FOR EACH ROW WHEN(${when(
tableName,
newOrOldOrBoth,
)})`,
dataChangedFunction,
),
),
);
const addPersisterListener = async (listener) => {
const tableCreatedFunctionName = await createFunction(
TABLE_CREATED,
// eslint-disable-next-line max-len
`FOR row IN SELECT object_identity FROM pg_event_trigger_ddl_commands()${WHERE} command_tag='${CREATE_TABLE}' LOOP ${notify(`'c:'||SPLIT_PART(row.object_identity,'.',2)`)}END LOOP;`,
'event_',
'DECLARE row record;',
);
await createTrigger(
'EVENT ',
escapeIds(TINYBASE, TABLE_CREATED, configHash),
`ON ddl_command_end WHEN TAG IN('${CREATE_TABLE}')`,
tableCreatedFunctionName,
);
const dataChangedFunctionName = await createFunction(
DATA_CHANGED,
notify(`'d:'||TG_TABLE_NAME`) + `RETURN NULL;`,
);
await promiseAll(
arrayMap(collValues(managedTableNamesSet), async (tableName) => {
await executeCommand(
CREATE_TABLE +
` IF NOT EXISTS${escapeId(tableName)}("_id"text PRIMARY KEY)`,
);
return await addDataChangedTriggers(tableName, dataChangedFunctionName);
}),
);
const listenerHandle = await addChangeListener(
channel,
(prefixAndTableName) =>
ifNotUndefined(
strMatch(prefixAndTableName, EVENT_REGEX),
async ([, eventType, tableName]) => {
if (collHas(managedTableNamesSet, tableName)) {
if (eventType == 'c:') {
await addDataChangedTriggers(
tableName,
dataChangedFunctionName,
);
}
listener();
}
},
),
);
return [
listenerHandle,
[tableCreatedFunctionName, dataChangedFunctionName],
];
};
const delPersisterListener = async ([listenerHandle, functionNames]) => {
delChangeListener(listenerHandle);
await executeCommand(
`DROP FUNCTION IF EXISTS${arrayJoin(functionNames, ',')}CASCADE`,
);
};
return (isJson ? createJsonPersister : createTabularPersister)(
store,
executeCommand,
addPersisterListener,
delPersisterListener,
onIgnoredError,
destroy,
persist,
defaultedConfig,
collValues(managedTableNamesSet),
async (executeCommand2, managedTableNames) =>
await executeCommand2(
SELECT + // eslint-disable-next-line max-len
` table_name tn,column_name cn FROM information_schema.columns ${WHERE} table_schema='public'AND table_name IN(${getPlaceholders(managedTableNames)})`,
managedTableNames,
),
thing,
getThing,
'text',
void 0,
(cellOrValue) => jsonString(cellOrValue),
(field) => jsonParse(field),
);
};
const createCustomSqlitePersister = (
store,
configOrStoreTableName,
rawExecuteCommand,
addChangeListener,
delChangeListener,
onSqlCommand,
onIgnoredError,
destroy,
persist,
thing,
getThing = 'getDb',
upsert,
) => {
let dataVersion;
let schemaVersion;
let totalChanges;
const executeCommand = getWrappedCommand(rawExecuteCommand, onSqlCommand);
const [
isJson,
autoLoadIntervalSeconds,
defaultedConfig,
managedTableNamesSet,
] = getConfigStructures(configOrStoreTableName);
const addPersisterListener = (listener) => {
let interval;
const startPolling = () =>
(interval = startInterval(
() =>
tryCatch(async () => {
const [{d, s, c}] = await executeCommand(
SELECT + // eslint-disable-next-line max-len
` ${DATA_VERSION} d,${SCHEMA_VERSION} s,TOTAL_CHANGES() c FROM ${PRAGMA}${DATA_VERSION} JOIN ${PRAGMA}${SCHEMA_VERSION}`,
);
if (d != dataVersion || s != schemaVersion || c != totalChanges) {
if (dataVersion != null) {
listener();
}
dataVersion = d;
schemaVersion = s;
totalChanges = c;
}
}),
autoLoadIntervalSeconds,
));
const stopPolling = () => {
dataVersion = schemaVersion = totalChanges = null;
stopInterval(interval);
};
const listeningHandle = addChangeListener((tableName) => {
if (managedTableNamesSet.has(tableName)) {
stopPolling();
listener();
startPolling();
}
});
startPolling();
return () => {
stopPolling();
delChangeListener(listeningHandle);
};
};
const delPersisterListener = (stopPollingAndDelUpdateListener) =>
stopPollingAndDelUpdateListener();
return (isJson ? createJsonPersister : createTabularPersister)(
store,
executeCommand,
addPersisterListener,
delPersisterListener,
onIgnoredError,
destroy,
persist,
defaultedConfig,
collValues(managedTableNamesSet),
async (executeCommand2, managedTableNames) =>
await executeCommand2(
SELECT + // eslint-disable-next-line max-len
` t.name tn,c.name cn FROM ${PRAGMA_TABLE}list()t,${PRAGMA_TABLE}info(t.name)c ${WHERE} t.schema='main'AND t.type IN('table','view')AND t.name IN(${getPlaceholders(managedTableNames)})ORDER BY t.name,c.name`,
managedTableNames,
),
thing,
getThing,
EMPTY_STRING,
upsert,
(cellOrValue) =>
cellOrValue === true ? 1 : cellOrValue === false ? 0 : cellOrValue,
void 0,
);
};
export {
Persists,
Status,
createCustomPersister,
createCustomPostgreSqlPersister,
createCustomSqlitePersister,
};