UNPKG

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
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