UNPKG

tinybase

Version:

A reactive data store and sync engine.

589 lines (575 loc) 17.2 kB
import {Map as Map$1} from 'yjs'; const TINYBASE = 'tinybase'; const EMPTY_STRING = ''; const T = 't'; const V = 'v'; 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 errorNew = (message) => { throw new Error(message); }; const arrayForEach = (array, cb) => array.forEach(cb); const arrayMap = (array, cb) => array.map(cb); const arrayIsEmpty = (array) => size(array) == 0; 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 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 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 mapForEach = (map, cb) => collForEach(map, (value, key) => cb(key, value)); 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 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 DELETE = 'delete'; const getYContent = (yContent) => [yContent.get(T), yContent.get(V)]; const getChangesFromYDoc = (yContent, events) => { if (size(events) == 1 && arrayIsEmpty(events[0].path)) { return [yContent.get(T).toJSON(), yContent.get(V).toJSON(), 1]; } const [yTables, yValues] = getYContent(yContent); const tables = {}; const values = {}; arrayForEach(events, ({path, changes: {keys}}) => arrayShift(path) == T ? ifNotUndefined( arrayShift(path), (yTableId) => { const table = objEnsure(tables, yTableId, objNew); const yTable = yTables.get(yTableId); ifNotUndefined( arrayShift(path), (yRowId) => { const row = objEnsure(table, yRowId, objNew); const yRow = yTable.get(yRowId); mapForEach( keys, (cellId, {action}) => (row[cellId] = action == DELETE ? null : yRow.get(cellId)), ); }, () => mapForEach( keys, (rowId, {action}) => (table[rowId] = action == DELETE ? null : yTable.get(rowId)?.toJSON()), ), ); }, () => mapForEach( keys, (tableId, {action}) => (tables[tableId] = action == DELETE ? null : yTables.get(tableId)?.toJSON()), ), ) : mapForEach( keys, (valueId, {action}) => (values[valueId] = action == DELETE ? null : yValues.get(valueId)), ), ); return [tables, values, 1]; }; const applyChangesToYDoc = (yContent, getContent, changes) => { if (!yContent.size) { yContent.set(T, new Map$1()); yContent.set(V, new Map$1()); } const [yTables, yValues] = getYContent(yContent); const changesDidFail = () => { changesFailed = 1; }; let changesFailed = 1; ifNotUndefined(changes, ([cellChanges, valueChanges]) => { changesFailed = 0; objMap(cellChanges, (table, tableId) => changesFailed ? 0 : isUndefined(table) ? yTables.delete(tableId) : ifNotUndefined( yTables.get(tableId), (yTable) => objMap(table, (row, rowId) => changesFailed ? 0 : isUndefined(row) ? yTable.delete(rowId) : ifNotUndefined( yTable.get(rowId), (yRow) => objMap(row, (cell, cellId) => isUndefined(cell) ? yRow.delete(cellId) : yRow.set(cellId, cell), ), changesDidFail, ), ), changesDidFail, ), ); objMap(valueChanges, (value, valueId) => changesFailed ? 0 : isUndefined(value) ? yValues.delete(valueId) : yValues.set(valueId, value), ); }); if (changesFailed) { const [tables, values] = getContent(); yMapMatch(yTables, void 0, tables, (_, tableId, table) => yMapMatch(yTables, tableId, table, (yTable, rowId, row) => yMapMatch(yTable, rowId, row, (yRow, cellId, cell) => { if (yRow.get(cellId) !== cell) { yRow.set(cellId, cell); return 1; } }), ), ); yMapMatch(yValues, void 0, values, (_, valueId, value) => { if (yValues.get(valueId) !== value) { yValues.set(valueId, value); } }); } }; const yMapMatch = (yMapOrParent, idInParent, obj, set) => { const yMap = isUndefined(idInParent) ? yMapOrParent : (yMapOrParent.get(idInParent) ?? yMapOrParent.set(idInParent, new Map$1())); let changed; objMap(obj, (value, id) => { if (set(yMap, id, value)) { changed = 1; } }); yMap.forEach((_, id) => { if (!objHas(obj, id)) { yMap.delete(id); changed = 1; } }); if (!isUndefined(idInParent) && !yMap.size) { yMapOrParent.delete(idInParent); } return changed; }; const createYjsPersister = ( store, yDoc, yMapName = TINYBASE, onIgnoredError, ) => { const yContent = yDoc.getMap(yMapName); const getPersisted = async () => yContent.size ? [yContent.get(T).toJSON(), yContent.get(V).toJSON()] : void 0; const setPersisted = async (getContent, changes) => yDoc.transact(() => applyChangesToYDoc(yContent, getContent, changes)); const addPersisterListener = (listener) => { const observer = (events) => listener(void 0, getChangesFromYDoc(yContent, events)); yContent.observeDeep(observer); return observer; }; const delPersisterListener = (observer) => { yContent.unobserveDeep(observer); }; return createCustomPersister( store, getPersisted, setPersisted, addPersisterListener, delPersisterListener, onIgnoredError, 1, // StoreOnly, {getYDoc: () => yDoc}, ); }; export {createYjsPersister};