executor-fn
Version:
A lightweight function wrapper with optional state, history, undo/redo, and reset support. Use like a normal function or unlock powerful time-travel features.
437 lines (379 loc) • 14.2 kB
JavaScript
import { useSyncExternalStore } from "react";
// =========================
// IndexedDB Wrapper
// =========================
class IDBStorage {
constructor(dbName = "ExecutorV2DB", storeName = "history") {
this.dbName = dbName;
this.storeName = storeName;
this.db = null;
}
async init() {
if (this.db) return this.db;
return new Promise((resolve, reject) => {
const request = indexedDB.open(this.dbName, 1);
request.onerror = reject;
request.onsuccess = () => {
this.db = request.result;
resolve(this.db);
};
request.onupgradeneeded = (e) => {
const db = e.target.result;
if (!db.objectStoreNames.contains(this.storeName)) {
db.createObjectStore(this.storeName, { keyPath: "_index" });
}
};
});
}
async add(entry) {
const db = await this.init();
return new Promise((resolve, reject) => {
const tx = db.transaction(this.storeName, "readwrite");
const store = tx.objectStore(this.storeName);
const req = store.put(entry);
req.onsuccess = () => resolve(entry);
req.onerror = reject;
});
}
async get(index) {
const db = await this.init();
return new Promise((resolve, reject) => {
const tx = db.transaction(this.storeName, "readonly");
const store = tx.objectStore(this.storeName);
const req = store.get(index);
req.onsuccess = () => resolve(req.result);
req.onerror = reject;
});
}
async getAll() {
const db = await this.init();
return new Promise((resolve, reject) => {
const tx = db.transaction(this.storeName, "readonly");
const store = tx.objectStore(this.storeName);
const req = store.getAll();
req.onsuccess = () => resolve(req.result);
req.onerror = reject;
});
}
async clear() {
const db = await this.init();
return new Promise((resolve, reject) => {
const tx = db.transaction(this.storeName, "readwrite");
const store = tx.objectStore(this.storeName);
const req = store.clear();
req.onsuccess = () => resolve();
req.onerror = reject;
});
}
}
// =========================
// Circular buffer
// =========================
class CircularHistory {
constructor(maxSize = 1000) {
this.maxSize = maxSize;
this.data = [];
}
push(entry) {
this.data.push(entry);
if (this.data.length > this.maxSize) this.data.shift();
}
getAll() {
return [...this.data];
}
clear() {
this.data = [];
}
get length() {
return this.data.length;
}
get(index) {
return this.data[index];
}
}
// =========================
// Main ExecutorV2
// =========================
export default function ExecutorV2(callback, options = {}) {
const {
storeHistory = true,
initialArgs = [],
callNow = false,
metadataFn,
maxHistory = 1000,
noDuplicate = false,
equalityFn = (a, b) => JSON.stringify(a) === JSON.stringify(b),
onError,
historyStep = 1,
groupBy,
useIndexedDB = true,
} = options;
const memoryHistory = new CircularHistory(maxHistory);
const subscribers = new Set();
let historyPointer = -1;
let paused = false;
let batchDepth = 0;
let pendingNotify = false;
let callCount = 0;
let value;
let initialValue;
const idb = useIndexedDB ? new IDBStorage() : null;
if (useIndexedDB) idb?.init();
const smartClone = (obj) => {
if (typeof obj !== "object" || obj === null) return obj;
if (Array.isArray(obj)) return [...obj];
return { ...obj };
};
const notifySubscribers = () => {
if (paused || batchDepth > 0) return;
if (!pendingNotify) {
pendingNotify = true;
requestAnimationFrame(() => {
subscribers.forEach((cb) => cb());
pendingNotify = false;
});
}
};
const saveHistory = async (entry) => {
if (noDuplicate && memoryHistory.getAll().some((e) => equalityFn(e.value, entry.value))) return;
memoryHistory.push(entry);
if (useIndexedDB) await idb.add(entry);
historyPointer = memoryHistory.length - 1;
notifySubscribers();
};
const execute = async (...args) => {
callCount++;
try {
value = await callback(...args);
} catch (err) {
if (onError) onError(err);
return value;
}
if (callCount % historyStep === 0 && storeHistory) {
const entry = {
value: smartClone(value),
meta: metadataFn ? metadataFn(value) : undefined,
group: groupBy ? groupBy(value) : undefined,
_index: (memoryHistory.length ? memoryHistory.getAll()[memoryHistory.length - 1]._index + 1 : 0),
_time: Date.now(),
};
await saveHistory(entry);
}
if (initialValue === undefined) initialValue = smartClone(value);
return value;
};
if (callNow) execute(...initialArgs);
// =========================
// Core methods
// =========================
const instance = execute;
instance.value = () => value;
instance.initialValue = () => initialValue;
instance.getHistory = async () => (useIndexedDB ? await idb.getAll() : memoryHistory.getAll());
instance.clearHistory = async () => {
memoryHistory.clear();
historyPointer = -1;
if (useIndexedDB) await idb.clear();
notifySubscribers();
return value;
};
instance.undo = async (steps = 1) => {
const entries = await instance.getHistory();
historyPointer = Math.max(0, historyPointer - steps);
value = entries[historyPointer]?.value ?? value;
notifySubscribers();
return value;
};
instance.redo = async (steps = 1) => {
const entries = await instance.getHistory();
historyPointer = Math.min(entries.length - 1, historyPointer + steps);
value = entries[historyPointer]?.value ?? value;
notifySubscribers();
return value;
};
instance.jumpTo = async (index) => {
const entries = await instance.getHistory();
if (index < 0 || index >= entries.length) return undefined;
historyPointer = index;
value = entries[historyPointer].value;
notifySubscribers();
return value;
};
instance.replaceAt = async (index, newValue) => {
const entries = await instance.getHistory();
if (index < 0 || index >= entries.length) return undefined;
entries[index].value = newValue;
notifySubscribers();
return newValue;
};
instance.insertAt = async (index, newValue) => {
const entries = await instance.getHistory();
if (index < 0 || index > entries.length) return undefined;
entries.splice(index, 0, { value: newValue, _index: index, _time: Date.now() });
historyPointer = index;
notifySubscribers();
return newValue;
};
instance.removeAt = async (index) => {
const entries = await instance.getHistory();
if (index < 0 || index >= entries.length) return undefined;
const removed = entries.splice(index, 1)[0];
historyPointer = Math.min(historyPointer, entries.length - 1);
notifySubscribers();
return removed.value;
};
// =========================
// Serialization & files
// =========================
instance.serializeHistory = async () => JSON.stringify(await instance.getHistory());
instance.deserializeHistory = async (data) => {
memoryHistory.clear();
data.forEach((d) => memoryHistory.push(d));
historyPointer = memoryHistory.length - 1;
notifySubscribers();
};
instance.exportHistory = async () => JSON.stringify(await instance.getHistory());
instance.importHistory = async (json) => {
try {
const data = JSON.parse(json);
await instance.deserializeHistory(data);
} catch { }
};
instance.exportHistoryToFile = async (filename = "executor_history.json") => {
const blob = new Blob([await instance.exportHistory()], { type: "application/json" });
const link = document.createElement("a");
link.href = URL.createObjectURL(blob);
link.download = filename;
link.click();
};
instance.importHistoryFromFile = async () =>
new Promise((resolve, reject) => {
const input = document.createElement("input");
input.type = "file";
input.accept = "application/json";
input.onchange = async (e) => {
const file = e.target.files[0];
const text = await file.text();
await instance.importHistory(text);
resolve(value);
};
input.onerror = reject;
input.click();
});
// =========================
// Advanced history ops
// =========================
instance.copy = async (histories) => {
const newExec = ExecutorV2(callback, options);
for (const hist of histories) {
for (const h of hist) await newExec.insertAt((await newExec.getHistory()).length, h.value);
}
return newExec.value();
};
instance.merge = async (histories, opts = {}) => {
const { position = "end", overwrite = false } = opts;
for (const hist of histories) {
for (const entry of hist) {
const idx = position === "start" ? 0 : (await instance.getHistory()).length;
await instance.insertAt(idx, entry.value);
}
}
return instance.value();
};
instance.sort = async (orderOrFn = "default") => {
const entries = await instance.getHistory();
const compareFn =
typeof orderOrFn === "function"
? orderOrFn
: orderOrFn === "asc"
? (a, b) => (a.value > b.value ? 1 : -1)
: orderOrFn === "desc"
? (a, b) => (a.value < b.value ? 1 : -1)
: (a, b) => 0;
entries.sort(compareFn);
memoryHistory.clear();
entries.forEach((e) => memoryHistory.push(e));
notifySubscribers();
return instance.value();
};
instance.split = async (...ranges) => {
const entries = await instance.getHistory();
const result = {};
ranges.forEach((r, i) => {
const [start, end] = Array.isArray(r) ? r : [r, r];
result[`split_${i}`] = ExecutorV2(
callback,
options
);
for (let j = start; j <= end && j < entries.length; j++) {
result[`split_${i}`].insertAt(j, entries[j].value);
}
});
return result;
};
// =========================
// Subscriptions & batch
// =========================
instance._subscribe = (cb) => subscribers.add(cb);
instance._unsubscribe = (cb) => subscribers.delete(cb);
instance.batch = (callback) => {
batchDepth++;
callback();
batchDepth--;
if (batchDepth === 0) notifySubscribers();
};
instance.pauseHistory = () => (paused = true);
instance.resumeHistory = () => {
paused = false;
notifySubscribers();
};
// =========================
// Utilities
// =========================
instance.log = async () => console.log(await instance.getHistory());
instance.reset = async () => {
value = smartClone(initialValue);
notifySubscribers();
return value;
};
return instance;
}
// =========================
// React hook integration
// =========================
export function useExecutor(executor) {
if (!executor || typeof executor !== "function") {
throw new Error("useExecutor: must receive a valid Executor instance");
}
const value = useSyncExternalStore(
(cb) => {
executor._subscribe(cb);
return () => executor._unsubscribe(cb);
},
() => executor.value()
);
return value;
}
// =========================
// ExecutorGroup
// =========================
export function combineExecutors(...executors) {
return {
undo: async () => Promise.all(executors.map((e) => e.undo())),
redo: async () => Promise.all(executors.map((e) => e.redo())),
reset: async () => Promise.all(executors.map((e) => e.reset())),
clearHistory: async () => Promise.all(executors.map((e) => e.clearHistory())),
export: async () => Promise.all(executors.map((e) => e.exportHistory())),
importAll: async (dataArr) => {
await Promise.all(dataArr.map((json, i) => executors[i].importHistory(json)));
},
};
}
// ✅ What this gives you
// Full Executor API: all core + advanced methods
// Memory buffer: recent N entries in memory
// IndexedDB: persistent history, scales to millions of entries
// React support: useExecutor hook
// Batching / pause / resume
// Serialization & file import/export
// ExecutorGroup for combining multiple executors