@dependable/session
Version:
Save and restore @dependable/state to the session storage
353 lines (279 loc) • 8.9 kB
JavaScript
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 };