UNPKG

tinybase

Version:

A reactive data store and sync engine.

540 lines (525 loc) 15.8 kB
const EMPTY_STRING = ''; const T = 't'; const V = 'v'; const UNDEFINED = '\uFFFC'; const strStartsWith = (str, prefix) => str.startsWith(prefix); const isUndefined = (thing) => thing == void 0; const ifNotUndefined = (value, then, otherwise) => isUndefined(value) ? otherwise?.() : then(value); 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 errorNew = (message) => { throw new Error(message); }; const arrayForEach = (array, cb) => array.forEach(cb); const arrayClear = (array, to) => array.splice(0, to); const arrayPush = (array, ...values) => array.push(...values); const arrayShift = (array) => array.shift(); 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 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 jsonString = JSON.stringify; const jsonStringWithUndefined = (obj) => jsonString(obj, (_key, value) => (value === void 0 ? UNDEFINED : value)); 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 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 mapToObj = (map, valueMapper, excludeMapValue, excludeObjValue) => { const obj = {}; collForEach(map, (mapValue, id) => { { const objValue = mapValue; { obj[id] = objValue; } } }); return obj; }; 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 stampNewWithHash = (value, time, hash) => [value, time, hash]; const stampUpdate = (stamp, time, hash) => { if (time > stamp[1]) { stamp[1] = time; } stamp[2] = hash >>> 0; }; 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) { 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 stampNewObjectWithHash = () => stampNewWithHash({}, EMPTY_STRING, 0); const createDurableObjectStoragePersister = ( store, storage, storagePrefix = EMPTY_STRING, onIgnoredError, ) => { const constructKey = (type, ...ids) => storagePrefix + type + slice(jsonStringWithUndefined(ids), 1, -1); const deconstructKey = (key) => { if (strStartsWith(key, storagePrefix)) { const type = slice(key, storagePrefix.length, 1); return type == T || type == V ? [ type, ...JSON.parse('[' + slice(key, storagePrefix.length + 1) + ']'), ] : void 0; } }; const getPersisted = async () => { const tables = stampNewObjectWithHash(); const values = stampNewObjectWithHash(); (await storage.list({prefix: storagePrefix})).forEach( async ([zeroOrCellOrValue, time, hash], key) => ifNotUndefined(deconstructKey(key), ([type, ...ids]) => type == T ? ifNotUndefined( ids[0], (tableId) => { const table = objEnsure( tables[0], tableId, stampNewObjectWithHash, ); ifNotUndefined( ids[1], (rowId) => { const row = objEnsure( table[0], rowId, stampNewObjectWithHash, ); ifNotUndefined( ids[2], (cellId) => (row[0][cellId] = [zeroOrCellOrValue, time, hash]), () => stampUpdate(row, time, hash), ); }, () => stampUpdate(table, time, hash), ); }, () => stampUpdate(tables, time, hash), ) : type == V ? ifNotUndefined( ids[0], (valueId) => (values[0][valueId] = [zeroOrCellOrValue, time, hash]), () => stampUpdate(values, time, hash), ) : 0, ), ); return [tables, values]; }; const setPersisted = async ( getContent, [ [tablesObj, tablesTime, tablesHash], [valuesObj, valuesTime, valuesHash], ] = getContent(), ) => { const keysToSet = mapNew(); mapSet(keysToSet, constructKey(T), [0, tablesTime, tablesHash]); objForEach(tablesObj, ([tableObj, tableTime, tableHash], tableId) => { mapSet(keysToSet, constructKey(T, tableId), [0, tableTime, tableHash]); objForEach(tableObj, ([rowObj, rowTime, rowHash], rowId) => { mapSet(keysToSet, constructKey(T, tableId, rowId), [ 0, rowTime, rowHash, ]); objForEach(rowObj, (cellStamp, cellId) => mapSet(keysToSet, constructKey(T, tableId, rowId, cellId), cellStamp), ); }); }); mapSet(keysToSet, constructKey(V), [0, valuesTime, valuesHash]); objForEach(valuesObj, (valueStamp, valueId) => mapSet(keysToSet, constructKey(V, valueId), valueStamp), ); await storage.put(mapToObj(keysToSet)); }; const addPersisterListener = () => {}; const delPersisterListener = () => {}; return createCustomPersister( store, getPersisted, setPersisted, addPersisterListener, delPersisterListener, onIgnoredError, 2, // MergeableStoreOnly, {getStorage: () => storage}, ); }; export {createDurableObjectStoragePersister};