UNPKG

@dependable/session

Version:

Save and restore @dependable/state to the session storage

353 lines (279 loc) 8.9 kB
import { observable, subscribables, registerInitial } from '@dependable/state'; const objectToJSON = (value, cache) => Object.fromEntries( Object.entries(value).map(([k, v]) => [k, toJSON(v, cache)]) ); const arrayToJSON = (value, cache) => value.map((item) => toJSON(item, cache)); const toJSON = (value, cache) => { if (typeof value === "function") { if (value.kind === "observable") { const id = storeObservableInCache(value, cache); return { $reference: id }; } else { throw new Error( "Observables can only contain JSON serializable data and other observables" ); } } if (value && typeof value === "object") { if (Array.isArray(value)) { return arrayToJSON(value, cache); } else { return objectToJSON(value, cache); } } return value; }; const storeObservableInCache = (observable, cache) => { let id = observable.id || observable.sessionId || cache.ids.get(observable); if (!id) { id = "$" + cache.nextId++; cache.ids.set(observable, id); } if (!cache[id]) { cache.observables[id] = toJSON(observable(), cache); } return id; }; const snapshotFromObservables = (observables, nextId = 0) => { const cache = { nextId, ids: new Map(), observables: {} }; Array.from(observables) .filter((subscribable) => subscribable.kind === "observable") .sort((a, b) => { if (a.id < b.id) return -1; if (a.id > b.id) return 1; return 0; }) .forEach((subscribable) => { storeObservableInCache(subscribable, cache); }); return { nextId: cache.nextId, observables: cache.observables }; }; const processArray = (value, snapshot, observables) => value.map((item) => processValue(item, snapshot, observables)); const processObject = (value, snapshot, observables) => Object.fromEntries( Object.entries(value).map(([k, v]) => [ k, processValue(v, snapshot, observables), ]) ); const processValue = (value, snapshot, observables) => { if (value && typeof value === "object") { if (value.$reference) { return processObservable(value.$reference, snapshot, observables); } else if (Array.isArray(value)) { return processArray(value, snapshot, observables); } else { return processObject(value, snapshot, observables); } } else { return value; } }; const isAnonymous = (id) => id.match(/^\$\d+$/); const processObservable = (id, snapshot, observables) => { if (observables[id]) return observables[id]; const value = processValue(snapshot[id], snapshot, observables); if (isAnonymous(id)) { observables[id] = observable(value); observables[id].sessionId = id; } else { observables[id] = observable(value, { id }); } return observables[id]; }; const observablesFromSnapshot = (snapshot) => { const observables = {}; for (const id of Object.keys(snapshot.observables)) { processObservable(id, snapshot.observables, observables); } return observables; }; const isEqual = (a, b) => { if (a === b) return true; if ((!a && b) || (a && !b)) return false; const aType = typeof a; const bType = typeof b; if (aType !== bType) return false; if (aType === "object") { const aKeys = Object.keys(a); const bKeys = Object.keys(b); if (aKeys.length !== bKeys.length) return false; for (let i = 0; i < aKeys.length; i++) { const key = aKeys[i]; if (!isEqual(a[key], b[key])) return false; } return true; } else { return false; } }; const createArrayUpdates = (current, next, delMarker) => { if (!Array.isArray(current)) return next; const updates = []; const commonLength = Math.min(current.length, next.length); for (let i = 0; i < commonLength; i++) { if ( !next[i] || typeof next[i] !== "object" || typeof current[i] !== "object" ) { updates[i] = next[i]; } else { updates[i] = createUpdates(current[i], next[i], delMarker); } } for (let i = commonLength; i < next.length; i++) { updates[i] = next[i]; } return updates; }; const createUpdates = (current, next, delMarker) => { if (Array.isArray(next)) { return createArrayUpdates(current, next, delMarker); } const cKeys = Object.keys(current); const nKeys = Object.keys(next); const updates = {}; for (const key of nKeys) { if (!isEqual(current[key], next[key])) { if ( !next[key] || typeof next[key] !== "object" || typeof current[key] !== "object" ) { updates[key] = next[key]; } else { updates[key] = createUpdates(current[key], next[key], delMarker); } } } for (const key of cKeys) { if (!(key in next)) { updates[key] = delMarker; } } return updates; }; const delMarkerRegexp = /^\$del(\d+)$/; const findDelMarkerLikeValues = (data) => { if (typeof data === "string" && data.match(delMarkerRegexp)) { return data; } if (data && typeof data === "object") { return Object.values(data).flatMap(findDelMarkerLikeValues).filter(Boolean); } return null; }; const createDelMarker = (data) => { const index = findDelMarkerLikeValues(data) .map((v) => parseInt(v.replace(delMarkerRegexp, "$1"))) .reduce((max, v) => Math.max(max, v), 0); if (!index) { return "$del"; } return "$del" + (index + 1); }; const createPatch = (current, next) => { const delMarker = createDelMarker(next); return { u: createUpdates(current, next, delMarker), d: delMarker }; }; const applyArrayPatchUpdate = (current, update, delMarker) => { const result = current.slice(update.length); for (let i = 0; i < update.length; i++) { result[i] = applyPatchUpdate(current[i], update[i], delMarker); } return result; }; const applyObjectPatchUpdate = (current, update, delMarker) => { const result = { ...current }; for (const key of Object.keys(update)) { const value = update[key]; if (value === delMarker) { delete result[key]; } else { result[key] = applyPatchUpdate(current[key], update[key], delMarker); } } return result; }; const applyPatchUpdate = (current, update, delMarker) => { if ( update && current && typeof update === "object" && typeof current === "object" ) { if (Array.isArray(update)) { return applyArrayPatchUpdate(current, update, delMarker); } return applyObjectPatchUpdate(current, update, delMarker); } return update; }; const applyPatch = (current, patch) => applyPatchUpdate(current, patch.u, patch.d); // TODO reject unsupported values let nextId = 0; /** * Save the current @dependable/state to session storage. */ const saveSession = () => { const snapshot = createSnapshot(nextId); sessionStorage.setItem("@dependable/session", JSON.stringify(snapshot)); }; /** * Restore saved @dependable/state from session storage. * * @throws Error if no session has been stored. */ const restoreSession = () => { const data = sessionStorage.getItem("@dependable/session"); if (!data) { throw new Error("No session to restore"); } sessionStorage.removeItem("@dependable/session"); const snapshot = JSON.parse(data); restoreSnapshot(snapshot); }; /** * Returns a snapshot of the current session. * * @params {number} nextId the id number base to use when generating ids. * @returns {import('./shared').SessionSnapshot} a snapshot of the current session. */ const createSnapshot = (nextId = 0) => { return snapshotFromObservables(subscribables(), nextId); }; /** * Restore the observables for the given snapshot. * * @param {import('./shared').SessionSnapshot} snapshot the snapshot to be restored. */ const restoreSnapshot = (snapshot) => { const observables = observablesFromSnapshot(snapshot); nextId = snapshot.nextId; for (const observable of Object.values(observables)) { registerInitial(observable); } }; /** * Creates a patch between two session snapshots. * * @param {import('./shared').SessionSnapshot} current a snapshot of the current session * @param {import('./shared').SessionSnapshot} updated a snapshot of the updated session * @return {import('./shared').SessionSnapshotDiff} the diff between the snapshots */ const diffSnapshots = (current, updated) => createPatch(current, updated); /** * Applies a snapshot patch to a given session snapshot. * * @param {import('./shared').SessionSnapshot} snapshot a session snapshot to apply the patch to * @param {import('./shared').SessionSnapshotPatch} patch a snapshot patch to apply to the given session snapshot * @return {import('./shared').SessionSnapshot} the resulting session snapshot */ const applySnapshotDiff = (snapshot, patch) => applyPatch(snapshot, patch); export { applySnapshotDiff, createSnapshot, diffSnapshots, restoreSession, restoreSnapshot, saveSession };