tinybase
Version:
A reactive data store and sync engine.
671 lines (655 loc) • 20.3 kB
JavaScript
const EMPTY_STRING = '';
const strSplit = (str, separator = EMPTY_STRING, limit) =>
str.split(separator, limit);
const promise = Promise;
const GLOBAL = globalThis;
const THOUSAND = 1e3;
const startTimeout = (callback, sec = 0) =>
setTimeout(callback, sec * THOUSAND);
const math = Math;
const mathFloor = math.floor;
const isUndefined = (thing) => thing == void 0;
const ifNotUndefined = (value, then, otherwise) =>
isUndefined(value) ? otherwise?.() : then(value);
const isArray = (thing) => Array.isArray(thing);
const size = (arrayOrString) => arrayOrString.length;
const test = (regex, subject) => regex.test(subject);
const promiseNew = (resolver) => new promise(resolver);
const errorNew = (message) => {
throw new Error(message);
};
const arrayForEach = (array, cb) => array.forEach(cb);
const arrayMap = (array, cb) => array.map(cb);
const arrayReduce = (array, cb, initial) => array.reduce(cb, initial);
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 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 objHas = (obj, id) => id in obj;
const objForEach = (obj, cb) =>
arrayForEach(objEntries(obj), ([id, value]) => cb(value, id));
const objSize = (obj) => size(objIds(obj));
const objIsEmpty = (obj) => isObject(obj) && objSize(obj) == 0;
const objEnsure = (obj, id, getDefaultValue) => {
if (!objHas(obj, id)) {
obj[id] = getDefaultValue();
}
return obj[id];
};
const mapNew = (entries) => new Map(entries);
const mapGet = (map, key) => map?.get(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 MASK6 = 63;
const ENCODE = /* @__PURE__ */ strSplit(
'-0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ_abcdefghijklmnopqrstuvwxyz',
);
const encode = (num) => ENCODE[num & MASK6];
const getRandomValues = GLOBAL.crypto
? (array) => GLOBAL.crypto.getRandomValues(array)
: /* istanbul ignore next */
(array) => arrayMap(array, () => mathFloor(math.random() * 256));
const getUniqueId = (length = 16) =>
arrayReduce(
getRandomValues(new Uint8Array(length)),
(uniqueId, number) => uniqueId + encode(number),
'',
);
const stampNew = (value, time) => (time ? [value, time] : [value]);
const getLatestTime = (time1, time2) =>
/* istanbul ignore next */
((time1 ?? '') > (time2 ?? '') ? time1 : time2) ?? '';
const stampNewObj = (time = EMPTY_STRING) => stampNew(objNew(), time);
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 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))))
) {
try {
await action();
} catch (error) {
/* istanbul ignore next */
onIgnoredError?.(error);
}
}
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 () => {
try {
const content = await getPersisted();
if (isArray(content)) {
setContentOrChanges(content);
} else if (initialContent) {
setDefaultContent(initialContent);
} else {
errorNew(`Content is not an array: ${content}`);
}
} catch (error) {
onIgnoredError?.(error);
if (initialContent) {
setDefaultContent(initialContent);
}
}
setStatus(0 /* Idle */);
});
}
return persister;
};
const startAutoLoad = async (initialContent) => {
stopAutoLoad();
await load(initialContent);
try {
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();
}
});
} catch (error) {
/* istanbul ignore next */
onIgnoredError?.(error);
}
return persister;
};
const stopAutoLoad = () => {
if (autoLoadHandle) {
delPersisterListener(autoLoadHandle);
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 () => {
try {
await setPersisted(getContent, changes);
} catch (error) {
/* istanbul ignore next */
onIgnoredError?.(error);
}
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 = () => {
if (autoSaveListenerId) {
store.delListener(autoSaveListenerId);
autoSaveListenerId = void 0;
}
return persister;
};
const isAutoSaving = () => !isUndefined(autoSaveListenerId);
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 stopAutoLoad().stopAutoSave();
};
const getStats = () => ({loads, saves});
const persister = {
load,
startAutoLoad,
stopAutoLoad,
isAutoLoading,
save,
startAutoSave,
stopAutoSave,
isAutoSaving,
getStatus,
addStatusListener,
delListener,
schedule,
getStore,
destroy,
getStats,
...extra,
};
return objFreeze(persister);
};
const Message = {
Response: 0 /* Response */,
GetContentHashes: 1 /* GetContentHashes */,
ContentHashes: 2 /* ContentHashes */,
ContentDiff: 3 /* ContentDiff */,
GetTableDiff: 4 /* GetTableDiff */,
GetRowDiff: 5 /* GetRowDiff */,
GetCellDiff: 6 /* GetCellDiff */,
GetValueDiff: 7 /* GetValueDiff */,
};
const createCustomSynchronizer = (
store,
send,
registerReceive,
destroyImpl,
requestTimeoutSeconds,
onSend,
onReceive,
onIgnoredError,
extra = {},
) => {
let syncing = 0;
let persisterListener;
let sends = 0;
let receives = 0;
const pendingRequests = mapNew();
const getTransactionId = () => getUniqueId(11);
const sendImpl = (toClientId, requestId, message, body) => {
sends++;
onSend?.(toClientId, requestId, message, body);
send(toClientId, requestId, message, body);
};
const request = async (toClientId, message, body, transactionId) =>
promiseNew((resolve, reject) => {
const requestId = transactionId + '.' + getUniqueId(4);
const timeout = startTimeout(() => {
collDel(pendingRequests, requestId);
reject(
`No response from ${toClientId ?? 'anyone'} to ${requestId}, ` +
message,
);
}, requestTimeoutSeconds);
mapSet(pendingRequests, requestId, [
toClientId,
(response, fromClientId) => {
clearTimeout(timeout);
collDel(pendingRequests, requestId);
resolve([response, fromClientId, transactionId]);
},
]);
sendImpl(toClientId, requestId, message, body);
});
const mergeTablesStamps = (tablesStamp, [tableStamps2, tablesTime2]) => {
objForEach(tableStamps2, ([rowStamps2, tableTime2], tableId) => {
const tableStamp = objEnsure(tablesStamp[0], tableId, stampNewObj);
objForEach(rowStamps2, ([cellStamps2, rowTime2], rowId) => {
const rowStamp = objEnsure(tableStamp[0], rowId, stampNewObj);
objForEach(
cellStamps2,
([cell2, cellTime2], cellId) =>
(rowStamp[0][cellId] = stampNew(cell2, cellTime2)),
);
rowStamp[1] = getLatestTime(rowStamp[1], rowTime2);
});
tableStamp[1] = getLatestTime(tableStamp[1], tableTime2);
});
tablesStamp[1] = getLatestTime(tablesStamp[1], tablesTime2);
};
const getChangesFromOtherStore = async (
otherClientId = null,
otherContentHashes,
transactionId = getTransactionId(),
) => {
try {
if (isUndefined(otherContentHashes)) {
[otherContentHashes, otherClientId, transactionId] = await request(
null,
1 /* GetContentHashes */,
EMPTY_STRING,
transactionId,
);
}
const [otherTablesHash, otherValuesHash] = otherContentHashes;
const [tablesHash, valuesHash] = store.getMergeableContentHashes();
let tablesChanges = stampNewObj();
if (tablesHash != otherTablesHash) {
const [newTables, differentTableHashes] = (
await request(
otherClientId,
4 /* GetTableDiff */,
store.getMergeableTableHashes(),
transactionId,
)
)[0];
tablesChanges = newTables;
if (!objIsEmpty(differentTableHashes)) {
const [newRows, differentRowHashes] = (
await request(
otherClientId,
5 /* GetRowDiff */,
store.getMergeableRowHashes(differentTableHashes),
transactionId,
)
)[0];
mergeTablesStamps(tablesChanges, newRows);
if (!objIsEmpty(differentRowHashes)) {
const newCells = (
await request(
otherClientId,
6 /* GetCellDiff */,
store.getMergeableCellHashes(differentRowHashes),
transactionId,
)
)[0];
mergeTablesStamps(tablesChanges, newCells);
}
}
}
return [
tablesChanges,
valuesHash == otherValuesHash
? stampNewObj()
: (
await request(
otherClientId,
7 /* GetValueDiff */,
store.getMergeableValueHashes(),
transactionId,
)
)[0],
1,
];
} catch (error) {
onIgnoredError?.(error);
}
};
const getPersisted = async () => {
const changes = await getChangesFromOtherStore();
return changes && (!objIsEmpty(changes[0][0]) || !objIsEmpty(changes[1][0]))
? changes
: void 0;
};
const setPersisted = async (_getContent, changes) =>
changes
? sendImpl(null, getTransactionId(), 3 /* ContentDiff */, changes)
: sendImpl(
null,
getTransactionId(),
2 /* ContentHashes */,
store.getMergeableContentHashes(),
);
const addPersisterListener = (listener) => (persisterListener = listener);
const delPersisterListener = () => (persisterListener = void 0);
const startSync = async (initialContent) => {
syncing = 1;
return await (
await persister.startAutoLoad(initialContent)
).startAutoSave();
};
const stopSync = () => {
syncing = 0;
return persister.stopAutoLoad().stopAutoSave();
};
const destroy = () => {
destroyImpl();
return persister.stopSync();
};
const getSynchronizerStats = () => ({sends, receives});
const persister = createCustomPersister(
store,
getPersisted,
setPersisted,
addPersisterListener,
delPersisterListener,
onIgnoredError,
2,
// MergeableStoreOnly
{startSync, stopSync, destroy, getSynchronizerStats, ...extra},
1,
);
registerReceive((fromClientId, transactionOrRequestId, message, body) => {
const isAutoLoading = syncing || persister.isAutoLoading();
receives++;
onReceive?.(fromClientId, transactionOrRequestId, message, body);
if (message == 0 /* Response */) {
ifNotUndefined(
mapGet(pendingRequests, transactionOrRequestId),
([toClientId, handleResponse]) =>
isUndefined(toClientId) || toClientId == fromClientId
? handleResponse(body, fromClientId)
: /* istanbul ignore next */
0,
);
} else if (message == 2 /* ContentHashes */ && isAutoLoading) {
getChangesFromOtherStore(
fromClientId,
body,
transactionOrRequestId ?? void 0,
)
.then((changes) => {
persisterListener?.(void 0, changes);
})
.catch(onIgnoredError);
} else if (message == 3 /* ContentDiff */ && isAutoLoading) {
persisterListener?.(void 0, body);
} else {
ifNotUndefined(
message == 1 /* GetContentHashes */ &&
(syncing || persister.isAutoSaving())
? store.getMergeableContentHashes()
: message == 4 /* GetTableDiff */
? store.getMergeableTableDiff(body)
: message == 5 /* GetRowDiff */
? store.getMergeableRowDiff(body)
: message == 6 /* GetCellDiff */
? store.getMergeableCellDiff(body)
: message == 7 /* GetValueDiff */
? store.getMergeableValueDiff(body)
: void 0,
(response) => {
sendImpl(
fromClientId,
transactionOrRequestId,
0 /* Response */,
response,
);
},
);
}
});
return persister;
};
export {Message, createCustomSynchronizer};